Session protocols β composing runs into a counterbalanced session (#43)¶
A run manifest describes one continuous recording; a session protocol describes the whole
visit β which runs, in what order, and what is manipulated across them (CLAUDE.md Β§2.1). This is the
level that used to live in a side document and a hand-typed run_index. It now has a first-class,
declared, reproducible form: schemas/session_protocol.schema.json + ssvep.runtime.session.
The contract + resolver landed first; building a protocol and driving a recording session from it in the GUI followed, and has since shipped (#57, #115, #126, #147) β see The GUI.
The model¶
A SessionProtocol is a template β authored once, instantiated per participant. It contains
its run manifests; it never redefines a run's design. To run the same design on different hardware,
add two run manifests (build one at each hardware setting) β there are no per-run overrides, so
"what design ran" is always answerable from the protocol alone.
SessionProtocol
βββ name, version, seed, description # description carries the labels-only record of what's manipulated
βββ groups[] # FIXED sequence β group order is never reordered
βββ Group{ label, order, runs[] }
βββ RunRef{ manifest (the run manifest, embedded), source{run_manifest, fingerprint}, label? }
- Groups run in authored order. Only runs within a group are reordered. So "resting β 4 counterbalanced SSVEP runs β resting β 4 more" is four groups; resting stays pinned to its slots.
- Each group declares its own
order: fixedβ authored order, every participant.counterbalancedβ a balanced Williams Latin square over the group's runs (each run preceded by every other equally often β the right within-subject counterbalance where fatigue/adaptation carry over). The participant's arm = numeric part ofsub-XXXmod the number of Williams sequences (nfor even group size,2nfor odd). Sequential enrolment (901, 902, 903β¦) cycles the arms and comes out balanced, with no external state.randomizedβ a per-participant shuffle seeded by(protocol seed, sub-XXX, group label), so two randomized groups reorder independently yet reproducibly.- Runs are embedded (schema v1.2, #115). The manifest is copied into the protocol when the run is added, and that copy is what runs. A protocol is therefore one self-contained file β copy it to an acquisition PC and the whole design goes with it β and it can never be broken, or silently changed, by an edit to a file it merely pointed at.
sourcerecords where the copy came from: the store-relative path and the manifest's fingerprint at embed time. It is provenance only β nothing reads it at record time. It exists so the Build Protocol tab's Check sourcesβ¦ can answer "has the file I built this from moved on?", at design time, where the answer is actionable. The fingerprint is rename-stable (the pre-1.3experimentβrunchange doesn't alter it, Β§BIDS/#42).
Why embedded, and what replaced the drift stop¶
Before #115 a protocol held a path plus a pinned fingerprint, and resolve_session re-read the file
and refused (ProtocolDriftError) if the fingerprint no longer matched. The intent was right β
a design change must be deliberate β but the enforcement landed in the wrong place: the failure
surfaced at resolve time, i.e. with a participant already in the chair, and it was caused by an
edit made days earlier in another tab. It also meant a protocol could be rendered unrecordable by
someone tidying run-manifests/. Eight of the seventeen protocols in session-protocols/ were in
exactly that state when this was written.
So the check moved rather than disappeared:
| before (v1.0/v1.1) | now (v1.2) | |
|---|---|---|
| what runs | the file in run-manifests/, re-read every time |
the copy inside the protocol |
| edit a run manifest | the protocol stops resolving | nothing changes; the protocol still runs its copy |
| when you find out | at resolve time, mid-session | at design time, via Check sources⦠|
| adopting the new version | re-pin by hand | Check sourcesβ¦ β Re-embed, which bumps the version |
Pre-1.2 protocols are unchanged: they still reference, still verify, and still hard-stop on drift. Loading one in the builder offers to embed its runs β never does it silently, because embedding changes the protocol's fingerprint and recordings already made from it carry the old one.
Resolving a session¶
from ssvep.runtime import session as S
proto = S.SessionProtocol.load("session-protocols/example_compare_bands.json")
resolved = S.resolve_session(proto, "sub-905", "ses-001", store_dir="run-manifests")
for r in resolved:
print(r.run_index, r.group, r.label) # run_index is DERIVED 1..N, never typed
resolve_session(protocol, sub-XXX, ses-YYY, store_dir):
- Walks groups in order; within each group derives this participant's ordering from the scheme.
- Loads each referenced run manifest and re-fingerprints it. If it no longer matches the pin,
it raises
ProtocolDriftErrorβ the design a participant would run has silently diverged from the design the protocol was authored against. That is a hard stop, not a warning: a design change must be a deliberate new protocol version.allow_drift=Trueoverrides (and the recording still freezes what actually ran). - Flattens across groups and assigns
run_index1..N. - Returns
ResolvedRundescriptors β each with the frozen (canonicalized v1.3) run manifest, its derivedrun_index, group label, arm, and thepositionblock.
The resolver is pure: it reads only the run manifests the protocol references, scans no
sourcedata, and never assigns ses-YYY. Two filesystem helpers sit beside it (and are wired to the
GUI later), deliberately kept out of the pure function:
next_session(out_dir, sub-XXX)β suggests the nextses-NNNby scanning existing session dirs.session_exists(out_dir, sub-XXX, ses-YYY)β for an overwrite warning.
What lands in the recording¶
Each recording's *.session.json sidecar gains a session_position block β the single
authoritative record that these recordings are one session under one protocol (analysis reads it
rather than inferring from filenames; BIDS filenames already carry sub/ses/run):
"session_position": {
"protocol": "example-compare-bands", "protocol_version": "1",
"protocol_fingerprint": "β¦", "subject": "sub-905", "session": "ses-001",
"group": "ssvep", "order": "counterbalanced", "run_index": 3, "arm": 1, "seed": 7,
"label": "alpha"
}
RunRecorder(position=resolved_run.position, run_index=resolved_run.run_index, β¦) stamps it. Every
recording has one: recording is protocol-only since #115. Pre-#115 recordings made outside a protocol
are stamped session_position: {"ad_hoc": true}, which is why that marker still exists in readers.
The tabs are independent (#115), and Run Session is modal once locked (#126)¶
Build run, Build Protocol, Set up Session and Run Session hand off through saved files and nothing else. No tab arms another:
| tab | what it does | how its work gets out |
|---|---|---|
| Build run | build one run manifest; test it with Preview flicker / Preview session | save it into run-manifests/ |
| Build Protocol | compose saved run manifests into a protocol; Set up acquisition hardware for all of them at once | save it into session-protocols/ |
| Set up Session | de-identified subject/session/operator/consent; Load protocolβ¦ / Resolve / refresh / Suggest ses #; the as-run acquisition fields (amplifier/sampling rate/headset/skin prep); Administer questionnaires; π Lock protocol and session details | nothing β the loaded/resolved/locked state is read by Run Session's getters |
| Run Session | the checklist, Start selected run, QA check, live views | the recording |
This was not true before #115. The builder pushed whatever it loaded or saved into the record panel, so opening a protocol to look at it armed it for recording, labelled "from Build Session" β which is how a protocol ends up loaded that nobody loaded. And the Lock button read the Build run form, so it warned about "unsaved changes to the run manifest" on a form the operator had never touched (that tab seeds an unsaved example on startup).
There is no ad-hoc single-run path. Every recording comes from a saved, versioned protocol, so every recording has a fingerprint pin and a place in a session. A one-off is a one-run protocol.
Loading, resolving and locking a protocol live on Set up Session; recording from it lives on Run
Session. They moved from Set up Session to Run Session in #126, and #147 moved the load/resolve/
lock half back β next to the identity/consent fields a lock actually freezes, which is where they
had been asked to go in the first place. ssvep.ui.session_record.SessionRecordPanel is the one
piece of state behind both halves (_proto/_resolved/_presented), exposed as two sub-widgets β
protocol_widget (Set up Session) and recording_widget (Run Session) β so there is exactly one
loaded protocol and one resolved session no matter which tab last touched it, not two copies that
could disagree. See The GUI below for the current shape and why.
Authoring a protocol β the Build Protocol tab¶
The app has a Build Protocol tab beside Build run (the run-manifest form) β two flat,
top-level tabs, both from ssvep.ui.design (#57): compose groups from the run manifests in
run-manifests/, pick each group's ordering from a dropdown
(fixed / counterbalanced / randomized), reorder groups and runs, and save into
session-protocols/. Adding a run embeds it on the spot. A live Preview resolution
panel shows how the protocol resolves for sample participants (sub-901β¦sub-904) so the
counterbalancing is visible before you save.
The tab was called Build Session until #115. It builds a protocol; a session is one participant's visit under one β and "Set up Session", where a session actually is set up, was the very next tab. CLAUDE.md Β§2.1 locks these words as non-interchangeable.
Check sources⦠compares every run against the file it was embedded from and reports what has
changed, what is missing, and what is still a pre-1.2 reference. Nothing is altered unless you accept
Re-embed, which bumps the design version with it β recordings name a protocol by name + version,
so two designs answering to both cannot be told apart by reading one. The tree's third column carries
the same verdict per run (β matches source / β SOURCE CHANGED / β¦) and re-checks on every refresh.
Set up acquisition hardware (#121, renamed and rescoped in #147) sets the acquisition hardware
parameters β amplifier, sampling rate, headset / electrodes, skin prep β on every run in the
protocol, in one step. It opens the shared form
(ssvep.ui.acquisition_form.AcquisitionFormMixin / AcquisitionDialog), prefilled from the
protocol's first run, and on confirm overwrites every run's acquisition block
(SessionProtocolBuilder.apply_acquisition_to_all) and bumps the version, same as any other Build
Protocol edit.
It was called Set acquisition for all runsβ¦ and its help said it set "the acquisition block" β a word this toolbox spends Β§2.1 reserving for a block of trials. A control that teaches the operator the wrong vocabulary for the thing they are about to record is worth renaming.
This is the only place hardware is chosen at design time. Until #147 the Build run tab had its
own Acquisition group, so hardware was a property of each run design and a protocol could vary it
across runs. That is no longer the model, for the reason the bench keeps supplying: hardware does
not change within a session. Comparing a Cyton against an actiCHamp means recording the same
protocol twice, in two sessions, with the amplifier recorded per session β not two runs of one
session claiming two amplifiers between which nothing was re-capped. So a run manifest as authored
now carries no acquisition block at all (it is optional in the schema since #147), the protocol
stamps one into every run it embeds, and the session may override it with what was actually on the
participant's head. A protocol whose runs still have none cannot be locked β lock_blocker() names
it and points back here.
Older protocols that do carry a different acquisition per run still load and still run exactly as
recorded; nothing rewrites them. The field is per-run in the manifest, as it must be for the
recording to describe itself β what changed is who is allowed to set it, and when.
Known, accepted consequence. Every run this action touches now differs from the
run-manifestfile it was originally embedded from β that override is the point β so Check sourcesβ¦ will report it asβ SOURCE CHANGED, permanently, for as long as the protocol exists (re-embedding from source would discard the override you just made, which is not what "fixing" this means). This is expected, not drift to chase down or a bug to fix.
Validate protocol⦠is schema-validate() plus one check Check sources doesn't do: it queries
any runs sharing a run.name or run.description. Distinct runs are supposed to say what they are β
sharing either is a likely copy-paste that was never edited for the run it landed on, exactly what
happened in the 2026-08-28 pilot (Exp1_alpha-med-high, sub-994's protocol): three 31β39 Hz runs and
three 46β54 Hz runs all carried the description "9-class high-frequency SSVEP, 36β44 Hz operating
band", inherited from whichever run they were cloned from and never touched again β a fact a human
reading the saved protocol would have had no way to notice. Save is disabled until Validate has
passed against the protocol exactly as it currently stands β any edit re-locks it, so this is not a
one-time gate you can satisfy once and then ignore for the rest of the session. A duplicate does not
block saving outright (a repeat can be intentional); it puts the collision in front of you and asks
you to continue deliberately, the same "report + let the human decide" shape as Check sources.
SessionProtocol.duplicate_run_labels(store_dir) is the pure model-level check behind the button, in
ssvep.runtime.session next to source_report.
β¦or in Python¶
Equivalently (and what the example script does), build one in Python
(see scripts/build_example_session_protocol.py):
from ssvep.runtime import session as S
store = "run-manifests"
proto = S.SessionProtocol(name="compare-bands", version="1", seed=7, groups=[
S.Group("rest-pre", S.ORDER_FIXED, [S.RunRef.pin("resting_eo_ec.json", store)]),
S.Group("ssvep", S.ORDER_COUNTERBALANCED, [S.RunRef.pin(f, store, label=l) for f, l in RUNS]),
])
proto.validate()
proto.save("session-protocols/compare_bands.json")
RunRef.pin loads the run manifest, embeds it, and records where it came from. Note the resolver
call above passes store_dir only because it also has to handle pre-1.2 protocols β an embedded one
resolves with S.resolve_session(proto, "sub-905", "ses-001") and touches no filesystem at all.
Questionnaires (#20)¶
A group also carries a break-time symptom checkpoint β the REB-approved post-condition
questionnaire, administered after that group's last run β and the protocol carries an
end-of-session instrument, asked once at the very end. Both are attached by default when a
group is created, both are removable, and both are stored inline in the protocol so it can say
exactly what it asked (a change to the questions is a design change, caught by the protocol
fingerprint like any other). They resolve into the running order alongside the runs, and the operator's
checklist is that order. Full SOP, storage layout, and the flag thresholds: QUESTIONNAIRES.md.
The GUI¶
Both halves have shipped (#57), and running a session is now modal to one tab (#126).
- β
Protocol builder β the Build Protocol tab above (
ssvep.ui.session_builder). - β
"Record from protocol" β split across Set up Session and Run Session
(
ssvep.ui.session_record), both reusing Set up Session's own Subject / Session / Operator / Consent / Output-dir fields through getters (a session's participant and consent belong to the visit, not to any one run). Load protocolβ¦, Resolve / refresh, Suggest ses #, Administer questionnaires and π Lock live on Set up Session, next to the identity/ consent fields a lock actually freezes β that isSessionRecordPanel.protocol_widget. The checklist and the one Start selected run button stay on Run Session βSessionRecordPanel.recording_widgetβ because that is where a run is actually recording and where the always-available Abort and live views live. Both tabs show the same loaded-protocol status line, since both widgets read the same underlying_proto/_resolvedstate; there is no second copy of it to disagree with the first. Until #126 load/lock lived on Set up Session; #126 moved both to Run Session so that tab β and the QA check that used to be a separate stop on the way β never needed to be visited on their own; #147 moved load/resolve/lock back to Set up Session, because the lock freezes the identity/consent fields that live there, and a lock button that reads fields on a different tab than the one it is on was the wrong split. Run Session keeps the checklist, the live panes and the Start button β everything that only matters once a session is actually recording. A protocol arrives here only via Load protocolβ¦ β the Build Protocol tab pushes nothing (#115). Loading resolves for the current Subject on the spot, so the run checklist appears immediately rather than only after Resolve; a drift or unset subject leaves a status line, not a modal. The session appears as a checklist whose progress is read from disk, in three states (session.session_progress): pending (no.session.jsonsidecar β a crash before saving is correctly offered again), done (the sidecar shows every planned trial recorded), and partial β the sidecar exists but the run was aborted or lost its stream part-way, shown asβ INCOMPLETE (n/N trials) β re-run. One button, "Start selected run" (named "Start next run" until #147 β renamed because the operator can select a different row to redo it, so the label should say what it acts on; #126 replaced the old "Record next run" + "Re-run selectedβ¦" pair with it) acts on whatever is highlighted β the checklist auto-highlights the first pending item (deliberately skipping partial ones: something is on disk for those, so replacing it is a decision, not a default) on every refresh, and clicking a different, already-done-or-partial row re-stages that one instead, with the same named, confirmed, reason-logged overwrite "Re-run selectedβ¦" used to ask for. Each run is handed to the normal record path viasession.recorder_for(frozen manifest + derivedrun_index+positionβ never hand-typed). Consent is captured once per session; an observer-only session persists nothing, so it runs forward-only (resume/re-run disabled).ses-YYYprefills vianext_session. - β
QA check folded in (#126) β the impedance check (
ImpedanceCheckWidget) that used to be its own QA check tab is now a collapsible pane below the protocol checklist on Run Session. It starts collapsed; pressing Start selected run expands it (and the operator can expand/collapse it manually any time by clicking its title). Confirm nothing else in the app still expected QA check as a standalone tab before relying on this β it is gone, not hidden. - β
Unified live-EEG pane (#126) β below the QA-check pane and the always-visible Abort, one
_LiveEegPlot(ssvep.ui.run.LiveEegPane) is now used both for the pre-run signal check and for in-run viewing, instead of two separate plot instances (the old QA check tab'sSignalPreview, a shorter-window plot of its own, plus Run Session's own recording-time plot). Two buttons at its top: Preview EEG toggles a passive preview stream (nothing recorded β the sub-902 lesson: you couldn't see the signal until stimulation started); Start recording starts stimulus presentation (what used to be "Start session"), auto-starting the preview first if it wasn't already running so there is always at least a glance of live signal on the way in. The amp is still single-client β starting a recording still releases any in-process preview before the recording bridge subprocess opens the board, exactly as before (MainWindow._release_preview, unchanged logic, re-hosted target). Below the live-EEG pane, the live-output view (live decoding / resting alpha reactivity) is unchanged, just re-hosted in the same place it always was.
π Lock protocol and session details¶
The button on Set up Session, below the protocol/acquisition fields β moved off Set up Session onto Run Session in #126, then back onto Set up Session in #147. It freezes both halves of what a recording is β which design (the loaded protocol) and whose session (Subject / Session / Operator / Consent / Output dir) β so it can be started safely, and it now sits next to every field it actually freezes rather than reading them across a tab boundary. The QA-check pane's montage comes from the first resolved run, so impedance is usable straight after locking.
Until #115 only the first half was locked: subject, session and consent stayed editable underneath a
live checklist, so a run could be ordered under one identity and recorded under another with
nothing saying so. The acquisition-as-run fields (amplifier / sampling rate / headset / skin prep /
note β dropdowns since #147, defaulting to the protocol's hardware) stay open on purpose: they
record what was physically on the participant's head and get corrected at the bench, which is the
whole reason they exist. Electrode type is no longer among them because it is no longer asked:
the headset already states whether the electrodes are dry, gold-cup or active, so #147 derives it
(builder.headset_electrode_type) rather than offering a second field that could disagree with the
first. It is still recorded, and the live mains threshold and the Cyton-dry impedance zones still
read it. Reference and ground are derived the same way (builder.headset_reference_ground):
on the free-electrode array they are the white SRB and black BIAS leads on the left and right
earlobes β a fact about the montage, not a session choice β so a recording made from a protocol
saved before that was settled still gets them right. See Electrode montage. Locking does not disable Build Protocol or Set
up Session by itself β an operator can still author the next protocol during a break, or correct the
as-run acquisition fields between runs. What does close every other tab off is a run actually
recording (see below) β that is deliberately a narrower condition than "locked", so those two
uses keep working.
Before the lock the checklist is a preview; Start selected run is disabled. After it, the protocol can no longer be swapped (Load protocolβ¦, Resolve, Clear protocol are disabled) β unlock to change either half.
If it refuses, it names the missing thing (MainWindow.lock_blocker, pure and unit-tested): no
protocol loaded, a protocol that will not resolve for this participant (quoting the reason, e.g.
drift on a pre-1.2 protocol), a resolved protocol whose runs have no acquisition hardware set at all
(possible since #147, because Build run no longer sets it β it names the runs and points back to
Build Protocol's "Set up acquisition hardware"), or a missing Subject / Session / Operator.
Runs can only be started from the checklist. Pressing Start with nothing prepared is refused, and there is no fallback to fall back to β see Β§"What sub-002 cost us" below (#86).
Tab-switch-mid-run (#124) now has a structural guard, not only a reactive one. For as long as a
run is actually recording, MainWindow._set_other_tabs_enabled(False) disables every tab but Run
Session outright β a tab-bar click leads nowhere else for that whole window, closing the gap the
older reactive guard (_on_tab_changed, reverting a switch after the fact) could only catch after it
happened. The reactive guard stays anyway, as defense-in-depth: no traceback has ever been captured
for the crash it guards against, and Qt does not block a disabled tab being reached via
setCurrentIndex called from code rather than a click. #124 stays open, referenced rather than
closed β the root cause of the original crash is still unknown; this hardens the one reachable path
(the tab bar) considerably, it does not prove the underlying bug fixed.
Under the panels, the load-bearing logic is pure and unit-tested: session_progress /
next_pending (derive-from-disk), recorder_for (resolver β recorder), and run_output_base (the
one shared BIDS-stem function, so "where a run's sidecar is" and "where RunRecorder writes" can't
drift apart). Only the amp+display record loop itself is hardware-gated.
What sub-002 cost us (#86)¶
Two runs of sub-002/ses-001 were recorded outside the protocol that was driving the session, and
nothing at the time said so. The chain is worth keeping, because every link looked reasonable alone:
- An abort still saves β deliberately; partial data is data. An 11-second abort wrote a complete XDF + sidecar.
- Progress was "the sidecar exists", so that 11-second run read as recorded.
_on_session_donecleared the pending protocol recorder on every outcome, abort included.- The Setup tab stayed fully armed β locked manifest, subject, session, Run # β and looked identical.
- Start silently built a form recorder:
run_indexfrom the spinbox, noposition.
An operator worked around a checklist that was telling them something false, using the only affordance left β a hand-typed run number β and that took the run out of its protocol. The recovered run was the right condition at the right index; what it lost was the fingerprint pin and the drift check, which is precisely the guarantee #43 exists to give.
The same investigation surfaced a hazard that had not fired yet: run_output_base maps run_index
straight into the filename and save_outputs had no existence check, so the accidental path was
also the only path with no overwrite protection. It now refuses (check_output_clear before the amp
is touched), and if a clash is somehow only discovered at save time the run is written to a
.rescued-NN sibling rather than dropped β refusing an overwrite must never cost a participant's
session.
The lesson generalises past this bug: a state the operator can see but the software cannot express will be worked around, and the workaround is where provenance dies.
86 closed step 5 by refusing while a protocol was loaded. That left Clear protocol as a way¶
back to the same recording, one confirm away β the road was still there, with a gate on it. #115
removed the road: there is no form recorder to fall back to in any state, and a one-off recording is
a one-run protocol. It also fixed something #86 did not reach β the as-run acquisition fields
(headset / electrode type / prep / note) were threaded through the ad-hoc recorder only, so every
run recorded from a protocol, i.e. every real session, silently dropped them. That is the sub-902
wet/dry metadata gap reappearing one level up, and removing the ad-hoc path is what made it
impossible to leave unfixed (session.recorder_for(..., acquisition_override=β¦)).
Not yet, and why¶
- Structured
factorsβ v1 records the manipulation as labels (group/run labels +description), which satisfies "declared, not inferred". A machine-readable factors block waits for the results-KB consumer (#37) to shape it rather than guessing now. - BIDS
sessions.tsvsurfacing β the protocol/arm is authoritative in the sidecar; surfacing it into the BIDSsessions.tsv/_eeg.jsonis a laterio.bidsconcern (seedocs/BIDS.md).