Skip to content

Architecture

Guiding shape: "A now, C-shaped boundary"

We start as one native desktop application (fastest path to increment 1, the stimulus builder + runtime), but we adopt the boundaries of a more distributed design from day one so nothing becomes a dead end:

  • The timing-critical stimulus + LSL engine is an isolated "agent" — a component with a narrow, message-based control surface, so UI work can never stall the render loop, and so it can later run as a separate process or be reimplemented in another engine.
  • All decoding/IO logic lives in a UI-agnostic core (plain functions over arrays + manifests). No GUI import ever reaches into it.

Consequence: adding a browser-based analysis frontend later (served from the Canadian compute server for remote users) is additive — it consumes the same core + manifest. So is swapping the renderer for a game engine (see "Engine portability").

Why not fuller web-first or two-frontends now: both add real infra (web server, container, protocol) before the first timing-critical deliverable exists. We buy their optionality cheaply via the boundary instead of paying their cost up front. Revisit when remote browser analysis becomes a priority.

The manifest is the spine

 ┌─────────────┐   emits    ┌──────────────────────┐
 │ stim builder│──manifest─▶│ run manifest         │◀─ single source of truth
 └─────────────┘            │ (schemas/*.json)     │
                            └──────────┬───────────┘
        stamps into each recording     │        reads directly
                 ┌─────────────────────┼─────────────────────┐
                 ▼                                            ▼
        ┌────────────────┐  LSL (EEG + markers)      ┌──────────────────┐
        │ runtime/runner │─────────────────────────▶ │ analysis + report│
        │ record → XDF   │                            │ metrics.json →   │
        └────────────────┘                            │ figures/report   │
                 │ save/push (Canada only)            └──────────────────┘
                 ▼
         local disk → lab storage server → compute server

Every value analysis would otherwise reverse-engineer (frequencies, montage, trial/block structure, render method, marker schema) is a manifest lookup. This deletes the back-filling that dominated the pilot.

Hardware is the session's (#484)

A run design does not choose an amplifier or a headset. The protocol carries one acquisition block for the whole session (protocol schema 1.7, set by Build Protocol's Set up acquisition hardware), and ssvep.runtime.session.resolve_session stamps it over every resolved run, replacing any block an older run manifest still carries. Everything downstream keeps reading manifest["acquisition"] and so reads the session's hardware, whichever run is current: the impedance montage and colour zones, the live preview, the decoder plan (spatial.occipital_decode_plan), the early-stop CPU estimate, the approval envelope's board and headset checks, the recorder's LSL/XDF channel labels and the sidecar montage. BIDS conversion, contrasts and carrier provenance (#419) read the sidecar, which carries the as-run block.

Set up Session prefills its hardware fields from the protocol on every load. The operator may correct them; the correction is merged in by runner.merge_acquisition_override (the one place a design and an as-run override combine), lasts for the whole session, and is never reset by a run being prepared. At lock the session's carrier is drawn and the operator picks, from the list, the one on the participant (nothing is pre-selected); a carrier that differs from the protocol's is said in that same dialog (#195). A protocol from before 1.7 lifts its runs' block when they agree and cannot be locked when they disagree.

Components (src/ssvep/)

Package Responsibility Depends on Notes
classification/ Validated engine: CCA/FBCCA/TRCA, PSD/SNR, metrics, viz. Pure (n_ch, n_times) array API. numpy, scipy, scikit-learn, matplotlib This is ssvep_core from the handoff, lifted as-is (renamed from the too-generic core). Same code offline + online.
io/ manifest ⇄ (arrays, labels) adapter; XDF read (pyxdf/mne-lsl); BIDS conversion (mne-bids); provenance stamping; consent-flag model. mne, mne-bids, pyxdf, jsonschema The thin adapter that replaces the pilot's study-specific glue.
stim/ pyglet flicker renderer (vsync-locked, gamma/LUT-aware, dropped-frame detection); builder data-model that validates renderability against display.refresh_hz; emits manifest. Runs as the stimulus agent. Integrates the flik-o-meter photodiode for timing validation (USB capture + analysis in stim/photodiode.py; --measure-flicker presentation; LSL-sync link still to come). pyglet, pyserial Timing-critical. Validate every build; photosensitivity warnings here.
runtime/ Session runner: resolve/verify LSL streams (OpenBCI Cyton 8-ch via BrainFlow/OpenBCI→LSL), push structured markers, record EEG+markers → XDF, stamp manifest, save + push to the lab's storage server (push.py, SFTP via the system OpenSSH client — opt-in, consent-gated, copy-never-move). Always-available abort; break-time adverse-symptom checkpoints. pylsl, mne-lsl Only runs built, saved, versioned protocols.
analysis/ Offline pipelines (epoch → decode → sweep windows), canonical metrics.json, report generation — including a reproducible stimulus description (screen-layout figure + trial-timeline diagrams, derived from the manifest) — and the derivatives layer (derivatives.py): where reports live (sub-XXX/ses-YYY/), the subject→session→run index rebuilt from disk, and the derivatives dataset_description.json. classification, io One metrics.json feeds every table/figure/number (report principle). The index is derived, never accumulated — see docs/BIDS.md.
ui/ PySide6 unified GUI with Design / Run / Analyze modes; pyqtgraph real-time plots. Identical behaviour for live vs file input. PySide6, pyqtgraph Talks to the stim agent over its control surface; never imports render internals.

Real-time path

classification.decoding.classify / cca_scores / fbcca_scores already take a single (n_ch, n_times) window — the online loop calls them per LSL buffer; calibration-free methods need no warm-up. TRCA stays gated behind trca_feasible(). The online harness (buffering, windowing, marker sync) is new; the math is the validated handoff code.

Trial timing: phases end on the clock (#457)

A recorded run's schedule (runtime.presentation.build_schedule) is laid out on the frame clock, the display's mode rate, and every phase carries the duration it asks for: cue, stimulation (stim_s plus the onset lead-in), the feedback slots, ITI; for resting, the lead-in and each block. Its frame span is only nominal (rounded up, presentation.phase_frames), which is what run-length estimates and progress read. The renderer ends each phase on the first flip at or after the phase's first flip plus its requested duration plus a 1 ms guard (presentation.PhaseClock, presentation.GUARD_S), timed by its own perf_counter at the top of each draw. That is a draw-start time, not a flip time: with a blocking vsync swap it is the previous flip's return plus the event loop's wake-up jitter, which varies by a few hundred microseconds either way from frame to frame. Without the guard, a panel on which a whole number of frames lands within that jitter of the request (992 frames at 239.62 Hz is 100 µs under 4.14 s) ends a phase one frame short in light. Markers are stamped with an LSL time read beside that perf_counter, before any other work in the frame, so marker spacing is the spacing the clock decided on rather than that plus the draw's own variable delay. So a phase is never shorter than asked, in light or in marker spacing, overshoots by less than the frame interval that crossed the line plus the guard (at most one extra frame on a panel within 1 ms of an exact multiple), and does not depend on any measurement of the refresh. A panel faster than the mode holds the phase's last scheduled frame until its time is up; a slower one, or a dropped frame, moves to the next phase as soon as it is. The guarantee assumes a blocking vsync swap. The session window is not exclusive fullscreen: it is a borderless window at the desktop's mode (pyglet's set_fullscreen with no display mode), which Windows 11 may compose through the desktop window manager or promote to independent flip, and vsync=True alone does not make every SwapBuffers wait: the GL driver queues the swap and returns. Every renderer therefore calls glFinish() right after each flip (stimwindow.finish_after_flip, #544), as PsychoPy and Psychtoolbox do, so the next draw starts after a completed flip; a run logs what that wait cost (glFinish after each flip: …) and records it with the swap interval under presentation_timing.display_sync. A fullscreen run is refused before anything is recorded if the window's swap interval reads 0 (vsync off) or its refresh measures more than 1 % above the frame clock (frames outpacing the panel; stimwindow.startup_problem). Windowed, DWM-composited or triple-buffered presentation does not block on vblank: after a stall the queued frames drain with draws back to back, their spacing is not frame spacing, and a phase decided on such a catch-up draw can be short in light by up to the queue depth (one or two frames). A windowed run is a preview, not timing. A live early stop that arrives while the stim phase is holding on its last scheduled frame (it has already drawn its nominal span and ends on the clock within a frame) is refused and counted stale, so the trial is recorded as having run to its maximum, never as both cut and overrun. What a frame shows follows time, not the frames drawn (#544). Each stim frame's luminance is computed from the index of the refresh it is shown at, counted from the phase's first flip on the frame clock (presentation.RefreshIndex, ScheduleCursor.refresh_index), about round((flip time − phase start flip time) × mode rate). A missed refresh skips a sample, so the waveform is not left a sample behind for the rest of the trial (counted by frames drawn, a 43 Hz trial that drew 55 of its 994 refreshes short averaged about 40.6 Hz and ended about 10 cycles off); an extra frame, drawn with no refresh since the last one, repeats a sample instead of pushing the waveform ahead. The index moves to the clock only when it is clearly off the next refresh (half a refresh behind, three quarters ahead), so draw-start jitter never moves it and a panel slightly off its mode corrects in single steps. The flicker's phase still resets at stim_on (index 0 on the stim's first flip), so phase-coded targets keep their exact phase at onset; markers go out on the flips that start the phases they mark, and the final block_end on the flip that ends the last ITI. Esc and the GUI Abort are untouched.

Before this, every phase was counted as round(seconds × measured refresh) frames, the measurement a mean over about a second. One stall in that second read a 239.6 Hz panel as 230 Hz and made every phase of that run 4 % short, and rounding to the nearest frame could land a trial one EEG sample short of its window: at 250 Hz, 4.0 s of stimulation plus the 0.14 s lead-in is exactly 1035 samples, 1000 after the trim, so one trial at 1034 dropped the whole run's analysis window from 4.0 to 3.5 s. A recording made since reaches its window: every epoch is at least stim_s + onset_offset_s long. Analysis is unchanged (trial_dur_s is still the requested span), and recordings made before still load and analyse as they did.

  • The measured refresh is report-only. It is one over the median frame interval (stimwindow.measured_hz), so a stall does not move it, and it times nothing. A measurement more than 1 % from the frame clock is warned about (display_report.refresh_warning): in the renderer's log, and in the Run tab as the run starts. Durations are unaffected; what it changes is every flicker frequency, which runs at the panel's real rate. The not-presenting refusal (#521) still uses the mean, the frames actually delivered per second.
  • What is recorded. The renderer adds presentation_timing to its display report and the recorder writes it into the sidecar's provenance (absent from older sidecars): per trial the presented seconds of each phase (flip to flip), the stim phase's frames and its overshoot past the request; per run how many phases ended on the clock, the end rule's guard (guard_s), their overshoot (min, mean, max) and how many were short (0 by construction, there to be checked). A resting run records each block and the lead-in the same way. A trial a live early stop cut short is marked stopped_early, and its stim_off marker's presented_s is now the flip-to-flip time.
  • Display stalls are recorded (#539). The phase clock also keeps, per phase, its longest flip-to-flip interval and the refreshes its intervals skipped (presentation.missed_refreshes: round(interval × frame clock) − 1 per interval, so a panel a few tenths of a percent off its mode adds nothing). Each trial's row gains stim_expected_frames, stim_dropped_frames, stim_max_gap_s and max_gap_s; each resting block the same unprefixed; an FPVS run, which is frame-locked and so is only watched by a clock with no durations (stim.fpvs.sequence_clock), records each sequence's frames, nominal and presented seconds and stretch_s. The recorder also writes provenance.refresh_check (frame_qc.refresh_check: mode, measured median, deviation %, and the warning when there was one). runtime.frame_qc.assess reads both back and reports three separate things for the Run tab's end-of-run note, the per-run report and the derivatives index. A stall: more than 2 refreshes skipped, or one interval over 3 frame periods, from the recorded intervals. Missed refreshes / extra frames: per phase, refreshes elapsed = round(presented_s × mode) (the vblank clock; on the one rig measured, markers sat on that grid exactly, which is an observation about that rig and not a guarantee), deficit = frames drawn − refreshes elapsed, flagged when |deficit| > max(1, 1% of the refreshes elapsed) so a panel a few tenths of a percent off its mode is not flagged over a long block, negative for repeated frames and positive for more frames than refreshes (presentation not vsync-locked: VRR, dynamic refresh, a non-blocking swap). The interval count alone is blind to a loop that ran uniformly at another rate (every interval rounds to one period), hence both. The measured refresh is the unreliable number and is not a basis for any of this; it feeds only the refresh warning. A stalled trial is reported as a stall only. A sidecar from before #539 has no intervals, so it can show only missed or extra frames (the same count test, so live and offline agree) and never a stall. It never excludes, and recomputes the refresh warning from the provenance rates.
  • FPVS stays frame-locked. An FPVS sequence is a whole number of items at the base rate, frame-exact by design (fpvs.clock_for_panel); ending a sequence or gap on the clock would cut or stretch an item cycle and break the base and oddball rates the analysis tags. Its schedule is laid out at the frame clock it plays on and is not affected by the refresh measurement, so the failure this fixes does not arise there.
  • The flik-o-meter captures a frame-counted window of flicker to measure its frequency; that is a measurement window, not a timed phase, and its length is unchanged. What it shows follows the refresh clock as a session's stim does (#544), so it measures what a run presents.
  • FPVS items follow the refresh clock too (#544). The stream stays frame-locked (every item the same whole number of frames at the frame clock), and each drawn frame shows the schedule frame of the refresh it lands on, counted from the stream's first flip: a missed refresh skips one schedule frame instead of shifting every later item, marker and sequence. The resting renderer has no flicker; its eyes-open flash and end fade are still counted in frames drawn, which is cosmetic.

Windowing convention, locked (#176): an SSVEP trial's samples are selected inclusive of both ends (t0 <= t <= t1), via the one shared ssvep.windowing.inclusive_window_indices that both runtime.online.LiveDecoder (live) and analysis.epoching.build_epochs (offline) call — match this in any third trial-epoching path that turns a marker span into an array window. (Scoped to that pair: analysis.resting_paf's block-windowing is a separate marker-span-to-array path with no live counterpart, still half-open, and out of scope for #176.)

Participant feedback (#268, experimental)

A run can show the participant a verdict after each trial: a check mark or cross drawn on the cued target (design.feedback, manifest v1.6; absent means none, and a design without it keeps its fingerprint). The stimulus window is an isolated subprocess and the live decoder is in the operator GUI's process, so the answer comes back over a second small LSL stream and nothing else crosses the boundary: the renderer imports no decoder and no Qt, and the GUI imports no renderer. The pure parts are in runtime/feedback.py.

  • Route. record_run opens an ssvep_feedback string outlet (type Feedback, a per-run source_id like the other streams) before it launches the renderer, and passes --feedback-stream / --feedback-source-id only for a manifest that declares feedback. After the GUI's own marker handler has produced a live-decode result, FeedbackRelay sends one JSON message for it (condition, block, trial, decoded, predicted_target_id, predicted_freq_hz, score). The relay never feeds the decoder, so the GUI's marker handling is unchanged. The renderer polls its inlet once per frame with timeout=0.0, so frame timing and the always-available Esc abort are untouched.
  • A fixed slot, never a wait. The schedule stays precomputed and frame-locked: after stim_off come a blank decide phase (wait_s, default 0.3 s, a budget for the answer to travel) and a feedback phase (show_s, default 1.0 s), then the ITI. The stimulus window decides what to draw on the first frame of the feedback phase. An answer that has not arrived shows a grey clock and is recorded late; one that arrived undecodable is undecided; an answer for any other (condition, block, trial) is never shown. A run is never lengthened for a late answer. Correctness is judged at the frequency level (the decoder predicts a frequency, and the cued frequency is compared with it), so the builder refuses feedback when two targets share a frequency, as phase-coded sets do, and when a condition lists a frequency that is not a stimulus frequency.
  • The record. The renderer emits a feedback marker at the frame the display appears (shown: correct / incorrect / late / undecided, and latency_s from stim_off to the answer's arrival). It is the record of what the participant saw; the back-channel itself is not recorded. The sidecar's live_decode.trials[] records feedback_sent_at (LSL clock) per trial. Analysis epochs on stim_on/stim_off only, so the extra marker rows are inert there; BIDS events.tsv gains shown and latency_s columns only for a run that has feedback rows.
  • A lost back-channel is not fatal. If the stream disappears mid-run the renderer keeps drawing; the failed pulls are counted and the remaining trials record late.
  • It refuses rather than lies. A manifest that declares feedback and cannot answer it does not start: record_run raises FeedbackUnavailable before the amp is touched when no live decoder is attached, and the renderer exits non-zero before opening its window if it cannot resolve the back-channel.
  • Display. Drawn line strokes (no font, no image; the line shapes are created once when the window opens and only repositioned), static for the whole slot: a check mark (blue, two strokes), a cross (orange, two strokes), or a clock (grey: a ring and two hands) meaning no decision in time. The three differ in shape, not only colour. The clock shows when the decoder's answer did not arrive in time (late) or it could not decide from that trial (undecided); a trial that merely ran to its maximum window still shows a check or cross if its answer arrives in time (#469; the earlier short dash read as a flat grey rectangle). Their colours are deliberately not the default cue green; the builder warns when a target's own cue colour is close to the check mark's.
  • What to tell the participant (level 1). After each flicker you will briefly see one mark on the target you were asked to look at: a blue check mark means the system picked that target; an orange cross means it picked a different one; a grey clock means the system had no answer ready in time, or could not read one from that trial. The clock is not a mistake by you.
  • Reports and contrasts. A run's report header and metrics.json (feedback_mode) state the mode, read from the manifest. A contrast that pools runs differing in feedback proceeds and records a caveat in its notes, because feedback changes what the participant does.

Participant-chosen targets (#268 level 2, experimental)

A run can let the participant choose where to look (design.cue_mode: "free", manifest v1.7; absent means cued, and a design without it keeps its fingerprint). A free run records no intended target, so it has no ground truth and is never scored. That is a property of the manifest, and every consumer of a trial's label asks ssvep.io.manifest.cue_mode / is_cued rather than guessing from a missing field.

  • What the participant sees. The cue slot shows every target outlined in grey (nothing is highlighted), all targets flicker, and after the fixed decide slot a light frame goes around the target the live decoder picked (feedback.mode: "selection", no verdict). A late or undecodable answer shows the clock, as in level 1. trials_per_block counts choices per block.
  • Free choice needs selection feedback. The schema (if/then) and the builder refuse free with feedback none (nothing for the participant to do and nothing to analyse) or correctness (no cued target to judge against), and refuse it on a resting run. selection is also valid on a cued run, where it frames the decoded target with no verdict (which may differ from the cued one).
  • No label, never a sentinel. Every marker of a free run carries cued: false and no target_freq_hz or target_id; the marker schema requires target_freq_hz on every marker that is not cued: false, and refuses a cued: false marker that carries a label. The feedback marker records shown: "selected" and shown_target_id.
  • What analysis does. epoching reads a free trial's label as None (an Epochs is labelled only when every trial has one), and everything that scores (run_analysis, run_by_condition, group_by_condition, decode_at_window, decode_dynamic) raises UnlabelledEpochs rather than decode against a missing label. The batch dispatches on the manifest's cue_mode to ssvep.analysis.free_choice, which reports the offline decoder's pick per trial, the choice distribution, and the agreement between the live and the offline decoder over the trials both decoded (a consistency check, not an accuracy). The record carries cue_mode: "free" and scored: false, and has no accuracy, ITR, chance, window sweep or confusion matrix. The single-recording debug script refuses a free run.
  • It never enters an accuracy contrast. Exclusion.UNSCORED_FREE_CHOICE leaves a free run out of every contrast, decided from the run's own manifest before any metrics are opened (see docs/CONTRASTS.md). The synthetic generator refuses to plant an effect in a free-choice protocol.
  • Live panel. The operator's live decode log lists a free trial's pick as unscored; its running accuracy is not updated (a free trial's correct is None, not a miss).

Live early stopping (#23, experimental)

A run can declare that each trial's flicker ends as soon as the live decoder is confident (design.early_stop, manifest v1.8; absent means every trial lasts stim_s, and a design without it keeps its fingerprint). The rule is the one the offline replay uses (decoding.EarlyStopRule): a growing window from stim_on plus the onset lead-in, looked at after min_window_s and then every step_s, the last look always at stim_s, stopping at the first look whose rho_margin reaches confidence_margin.

  • The block. statistic (rho_margin), method (the live decoder's, fbcca; margins are per method), min_window_s, step_s, confidence_margin, and calibration: source noise_default (the method's shipped, noise-calibrated threshold, and then confidence_margin must be that value) or study (chosen for this study on separate, fixed-length runs, never on the runs it is applied to) with a free-text basis. There is no max_window_s field: the maximum is design.timing.stim_s, one fact in one place. The threshold is per study and frozen in the manifest; per-participant thresholds would need an as-run override and are not built. Every consumer reads the block through ssvep.io.manifest.early_stop_config, which supplies the maximum, and never infers early stopping from short trials.
  • Refused at build and at load. The builder and validate_manifest refuse a schedule decoding.check_decision_schedule refuses (a minimum longer than stim_s, a zero step, a threshold outside (0, 1)) and a study source with no basis. A noise_default source with another threshold is refused where a design is authored or used for a new recording (the builder, and early_stop.run_refusal at the lock and the pre-run check), not at load: a recording made under an older shipped default stays readable, and Build run shows such a file's value against the current default and asks which to keep rather than rewriting it; the builder also refuses a method other than the live decoder's, a resting run, and targets that share a frequency (the decoder names a target by frequency, so two that share one are never told apart).
  • Allowed with every feedback mode and both cue modes, and warned about. With feedback correctness or selection the feedback shows the decision that stopped the trial. With feedback none the builder warns that a trial ending sooner is itself feedback, and the reports will say so. A free-choice run is the classic "select when confident" loop. The builder also prints ssvep.analysis.pipeline.calibration_caveats for the design, the words the analysis report will use, when the schedule, threshold, candidate count or (for CCA) an alpha-band candidate is outside the false-stop calibration.
  • Per-look CPU. When the manifest names its hardware, ssvep.runtime.early_stop.cpu_caveat estimates the largest look's CPU from the channels the Run tab decodes (spatial.occipital_decode_plan) and the sampling rate, at the per-channel-sample cost measured on a development laptop, and warns (never refuses) when it would take more than the stop-latency budget leaves for the look (150 ms in all, less one bridge chunk, the pump and two frames). An all-channel decode of a 16-channel montage at 1000 Hz is warned about; the occipital-subset decode of a large montage is not.
  • Run length. RunPlan.estimated_duration_range_s gives the longest run (every trial to stim_s; what a progress bar plans for) and the shortest (every trial stopped at its first look; a floor, since the flicker runs on for the stop latency). Build run shows the range, and the sidecar's plan records estimated_min_duration_s beside estimated_duration_s for such a run only.
  • The stop. The live decoder evaluates the rule as EEG arrives (LiveDecoder(early_stop=...), built by the Run tab from runtime.early_stop.live_decoder_options, on the LSL clock). The moment a look is confident while the trial still flickers, it appends a stop to LiveDecoder.stops; FeedbackRelay.after_eeg (wired after the GUI's on_eeg by orchestrator.wire_backchannel) sends it as a "kind": "stop" message on the participant-feedback stream, ssvep_feedback, which a run that stops early opens whether or not it declares feedback. The stop carries the answer's fields too, so the feedback slot shows the decision that stopped the trial and no second answer is sent; a trial that runs to its maximum is answered as before.
  • A trial that runs to its maximum is always decided. Its flicker is classification.decoding.early_stop_span_s: the lead-in, the maximum window (stim_s) and a 25 ms guard. It ends on the clock like every phase since #457, never short of it (its nominal span rounded up, presentation.stim_frames), so the maximum look always finds its samples inside [stim_on, stim_off]. Rounding the lead-in plus window to the nearest frame, as a fixed-length run did before #457, can fall short of it (248 frames = 4.133 s at 60 Hz for 4.14 s), which would leave every such trial undecided. The maximum look stays exactly stim_s. The stop decision itself is unchanged: a stop still ends the flicker on the next frame boundary.
  • The renderer ends the trial on the next frame boundary. The schedule stays the precomputed one; presentation.ScheduleCursor walks it, and without a stop it moves exactly as the old frame counter did. Each frame the renderer drains the back-channel (timeout=0.0), and feedback.StopGate hands a stop to the cursor before the frame's markers: if it names the trial flickering now, the cursor jumps to the end of that stim phase, so the frame being drawn is the first after the stim and carries stim_off, and every later phase and marker comes that many frames sooner. A stop for any other trial, or arriving in a cue, slot or ITI or after the flicker already ran to its maximum, is counted stale and changes nothing: a stop can only shorten the current flicker, never lengthen a phase, skip a slot or start flicker. Esc and the GUI Abort are untouched; an abort mid-window drops the open trial, as it always has.
  • What is recorded. For a run that declares early stopping only, every stim_off marker carries presented_s (flip to flip on the stimulus window's clock, lead-in included) and stopped (early or max), and an early one adds decided_window_s, margin and stop_latency_s (arrival at the frame poll minus the decision's send time, both on the LSL clock). No latency is compensated for: the flicker runs on for the stop latency, and that light is in presented_s. The sidecar's live_decode adds stop_sent_at per trial and an early_stop run summary (trials stopped early, mean decision time, median lag from data to decision, and whether that lag exceeds the step). The live pane shows "stopped at 1.5 s (margin 0.52)" or "ran to 4 s" per trial, a running mean decision time, and a warning while the decoder lags.
  • What is refused. A threshold for a method the live decoder does not decode (margins are per method) is refused at lock and before the amplifier is touched (EarlyStopUnavailable), as is a run with no early-stopping decoder fed EEG. The stimulus window refuses to start a session whose back-channel it cannot reach. At lock, the per-look CPU estimate is shown for the as-run manifests, which carry the hardware a Build run design does not; it is a warning. Bench checks of the real stop latency per amplifier are pending.
  • The analysis (ssvep.analysis.live_stop, called by the batch for any run whose manifest declares design.early_stop; never inferred from short trials). The scored decisions are the live ones: live_stop.replay_config reads how the live decoder scored (method, harmonics, sub-band setting, mains guard, re-reference, decode channels, lead-in) from the sidecar's live_decode.decoder and refuses a sidecar that lacks any of it or whose rule is not the manifest's (LiveReplayUnavailable, which fails that run's analysis by name). live_stop.replay_run re-runs the rule offline on each trial with decoding.early_stop_decode(..., replay=True), which accepts a trial shorter than the maximum when its stim_off says stopped: early, and records per trial whether the replay agrees (same target, and a window within one step), stop_not_applied (the decoder stopped the trial but the flicker ran to its maximum) and elapsed_s from the run's first marker. A disagreement is reported, never re-scored. The candidates are the run's whole target set, as the live decoder chose among, so the live accuracy's chance is one over that count.
  • What changes in the metrics of a live-stopped run. decision_rule is the live rule with applied: "live" (absent means "offline"). The fixed-window sweep and the operating window stop at the first look, min_window_s, the only windows every trial has (window_sweep_truncated says why: past it only the trials the rule did not stop remain). Target SNR and its spectrum are measured on that same common window, every trial cut to its first min_window_s after the lead-in (snr_window_s), so trials of different lengths are not mixed and none falls under the short-trial floor. live_stop holds the live decisions' accuracy, the ITR charged at the mean presented time (presented_s: lead-in, deciding window and stop latency, plus the ITI, the sweep's own convention) and, beside it as a bound, the latency-free ITR at the mean decided window; the decision-time distribution; the live/replay agreement; and, for a run without declared feedback, the note that trial length itself was feedback. trial_outcomes gain elapsed_s; time_in_run stays the ordinal position. A free-choice run gets the same live_stop block without an accuracy. A fixed-length run's metrics are byte-for-byte unchanged.
  • Pooling. A live-stopped run and a fixed-length run are different data and are never pooled: contrast.decision_rule_key ends applied=live for a live rule, so a pair refuses, and contrast refuses a contrast whose subjects mix the two stopping modes even when each pair matches. The one exception is a contrast whose factor is stopping: it compares the two modes, reads accuracy at its declared window from each run's sweep (which for a live run ends at its first look), and records a caveat giving each mode's mean decision time and ITR under its own rule.
  • BIDS. A run whose stim_off markers say how the flicker ended gets presented_s, stopped, decided_window_s, margin and stop_latency_s columns in events.tsv (on stim_off rows; n/a elsewhere), described in that run's own *_events.json, so every other dataset's task-level file is unchanged. The stim_on row's duration is the same presented interval on the EEG clock. The export allow-list takes the five columns (numbers and the closed early/max).

LSL stream identity and instance isolation (#394)

LSL discovery is multicast: every stream on the subnet is visible to every machine on it, and every instance of the toolbox advertises its EEG and markers under the same two names (ssvep-toolbox-eeg, ssvep_markers). Assume rigs share a subnet (a lab's and a teaching room's acquisition PCs can), and assume two copies of the toolbox can run on one PC. So nothing here resolves a stream it records by name alone.

  • Our own streams carry a per-run token. For each run orchestrator.record_run draws a random token and passes ssvep-toolbox:<eeg|markers>:<hostname>:<token> as the source_id to the EEG bridge (--source-id) and to the renderer's marker outlet (--markers-source-id). The recorder resolves each stream by that exact source_id (lsl_io.resolve_own_stream), and also checks its name, type and hostname. A stream that only has the right name belongs to another rig, another instance, or a stale bridge from an earlier run, and is refused with a message listing the hosts and source_ids it saw. The marker wait keeps waiting past such a stream (ours may simply not be up yet) and names it if the wait runs out.
  • External sources need exactly one match. A stream this toolbox did not create, such as a vendor's own LSL app (the actiCHamp connector, OpenViBE for amps without a Python driver), cannot carry our token. lsl_io.resolve_external_stream takes it by its configured name, never by type, and only if it is the only stream of that name: after the first match it keeps looking for a second, and two or more are refused with each candidate's host and source_id. A stream whose source_id starts ssvep-toolbox: is another instance's own and is never taken as external. RunRecorder.preflight uses this rule. No recording path in the GUI reads an external stream yet; any that is added must use this function.
  • The identity is recorded. Each recorded stream's name, type, source_id, hostname, uid, session_id, created_at, channel_count and nominal_srate go into its XDF stream header as the standard LSL info fields (as LabRecorder writes them), with desc/stream_origin saying this-instance or external, and into the sidecar's lsl_streams block (lsl_io.stream_identity). A cross-bound recording is then detectable afterwards: a hostname that isn't the rig's, or EEG and markers from different hosts or tokens. created_at is LSL's clock, seconds since the machine booted, not a date (docs/DATA_GOVERNANCE.md). The recorded stream names (EEG, ssvep_markers) are unchanged, so live and offline read recordings the same way.
  • One recording per data root. record_run holds a QLockFile on the data root (<root>/.ssvep-toolbox.lock) from before its first check until its files are saved (runtime/instance_lock.py). A second instance recording to the same root is refused before anything starts; instances on different data roots are unaffected. A root that doesn't exist yet, or no root at all, gets a per-user lock instead. A lock left by a crashed process on the same machine clears itself; one left by another machine on a shared drive is named in the refusal, with how to remove it.
  • The renderer's manifest copy is unique per run (tempfile.mkstemp) and removed afterwards. It used to be one fixed %TEMP%/ssvep_run_manifest.json, which two instances on one PC shared.

Auth / access model (remote-only)

  • Local acquisition app: no live auth; operator selects their name from the admin whitelist (config file, not GUI-editable). That operator id is stamped into provenance so we track who collected each dataset. Works fully offline.
  • Remote analysis (future web frontend): GitHub-ID OAuth checked against the whitelist, served only from the Canadian compute server behind TLS + CA/US IP allowlist. This sits on top of the lab's existing server security, never weakening it — the general layering principle is in docs/DATA_GOVERNANCE.md.

Engine portability (Unity / Godot — future, optional)

The stim/ agent is the only engine-specific layer. Its contract to the rest of the system is exactly: consume a manifest, render the specified flicker with correct timing, emit the structured LSL markers. Any future renderer that honours that contract drops in without touching core/io/runtime/analysis.

  • Unity: not used here — proprietary/paid-tier licensing conflicts with the commercial-free requirement, it offers no SSVEP timing advantage over a tight pyglet loop, and no one on the build team knows it.
  • Godot (MIT): the license-clean candidate if a rich game engine ever becomes core to Gambit. A Godot stimulus front-end would implement the same manifest+marker contract.

Data flow & residency (summary; mechanism in docs/DATA_GOVERNANCE.md; each deployment binds that mechanism to its own institutional rules)

Acquire on the Windows PC → write XDF locally → (optionally) BIDS-convert → push to the lab's storage server (Canada) → available to the compute server via NFS for remote offline analysis. No step touches a foreign/US cloud, and git never holds data.