Skip to content

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 of sub-XXX mod the number of Williams sequences (n for even group size, 2n for 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.
  • source records 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.3 experimentβ†’run change 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):

  1. Walks groups in order; within each group derives this participant's ordering from the scheme.
  2. 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=True overrides (and the recording still freezes what actually ran).
  3. Flattens across groups and assigns run_index 1..N.
  4. Returns ResolvedRun descriptors β€” each with the frozen (canonicalized v1.3) run manifest, its derived run_index, group label, arm, and the position block.

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 next ses-NNN by 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-manifest file 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 is SessionRecordPanel.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/_resolved state; 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.json sidecar β€” 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 via session.recorder_for (frozen manifest + derived run_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-YYY prefills via next_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's SignalPreview, 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:

  1. An abort still saves β€” deliberately; partial data is data. An 11-second abort wrote a complete XDF + sidecar.
  2. Progress was "the sidecar exists", so that 11-second run read as recorded.
  3. _on_session_done cleared the pending protocol recorder on every outcome, abort included.
  4. The Setup tab stayed fully armed β€” locked manifest, subject, session, Run # β€” and looked identical.
  5. Start silently built a form recorder: run_index from the spinbox, no position.

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.tsv surfacing β€” the protocol/arm is authoritative in the sidecar; surfacing it into the BIDS sessions.tsv/_eeg.json is a later io.bids concern (see docs/BIDS.md).