ssvep.runtime — sessions and acquisition¶
Part of the API reference, generated from the docstrings in src/ssvep.
Run plan¶
ssvep.runtime.run_plan ¶
Run plan: expand a run manifest into a deterministic, ordered list of trials + markers.
The runtime runs ONLY built, saved, versioned protocols. Given a manifest, this derives the
condition × block × trial structure, assigns a cued target (frequency) per trial, and produces
the marker sequence — all reproducibly from a seed (recorded in provenance). The runner
attaches real LSL timestamps when each marker fires.
Condition order is the manifest's, never drawn here (#31). Blocks play in the order
design.trial_order declares (generated at design time by :mod:ssvep.stim.trial_order and
fingerprinted with the manifest); a manifest that declares none plays its conditions in
design.conditions order, blocked, as every run always has. The seed only shuffles which target
each trial inside a block cues.
RunPlan
dataclass
¶
Source code in src/ssvep/runtime/run_plan.py
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | |
is_free
property
¶
True for a free-choice run: no trial has a cued target or a label (#268).
played_order ¶
The order this plan plays, for the sidecar (#31): the scheme and one row per trial.
Each row is position (1-based in the run), condition, block (the condition's own
1-based block number), block_position (1-based position of that block in the run),
trial (1-based within the block), target_id and target_freq_hz. n_trials
keeps only the first that many — the trials a run stopped early actually played.
Source code in src/ssvep/runtime/run_plan.py
estimated_duration_range_s ¶
(shortest, longest) the run can take (s). Equal for a fixed-length run.
With early stopping (#23) the longest is :meth:estimated_duration_s (every trial runs to
stim_s; what a progress bar plans for, so it finishes early rather than late) and the
shortest has every trial stop at its first look, min_window_s. The shortest is a floor
the run cannot reach: the flicker also runs on for the stop latency after each decision.
Source code in src/ssvep/runtime/run_plan.py
markers_for_trial ¶
cue → stim_on → stim_off markers for one trial (block_start/end added by the runner).
Source code in src/ssvep/runtime/run_plan.py
early_stop_lead_in_s ¶
The visual-latency lead-in (s) a run that stops early trims before its first look (#23):
design.timing.onset_offset_s, or :data:ssvep.DEFAULT_ONSET_OFFSET_S when the design does
not state one - as the offline batch reads it, so the live looks and the replay start their
growing windows on the same sample.
Source code in src/ssvep/runtime/run_plan.py
presented_stim_s ¶
How long one trial flickers when it runs to its end (s), before rounding to frames.
A fixed-length run presents stim_s plus the onset_offset_s the design itself states (0
for one that predates the field), exactly as it always has. A run that may stop early (#23)
presents :func:ssvep.classification.decoding.early_stop_span_s: the lead-in its decoder trims,
the maximum window and a small guard, so a trial that runs to its maximum always holds the
samples its last look needs (:func:ssvep.runtime.presentation.stim_frames then rounds UP).
Source code in src/ssvep/runtime/run_plan.py
plan_summary ¶
The sidecar's plan block for plan: size, seed, duration, conditions, and the play order.
trial_order (#31) is the order the runtime played, trial by trial
(:meth:RunPlan.played_order), with the scheme the manifest declared — so a recording says
which condition each trial was and where in the run it fell, without re-deriving it. A run
stopped early played the first provenance.trials_recorded of these. A resting run has no
conditions to order and gets no trial_order. Shared with the synthetic generator so the two
sidecars cannot drift (#348).
Source code in src/ssvep/runtime/run_plan.py
Session protocol and resolver¶
ssvep.runtime.session ¶
The session level: a protocol (template) → a concrete, ordered, frozen set of runs (#43).
Terminology (AGENTS.md §2.1): a run is one continuous recording (one run manifest, planned by
:mod:ssvep.runtime.run_plan); a session is an ordered set of runs; a protocol is the
instructions for running a session on a participant. This module is the protocol layer that #42
unblocked — it reuses the module name freed when the old session.py (a run planner) became
run_plan.py.
A :class:SessionProtocol is a template: authored once, instantiated per participant. It
contains its run manifests and groups them; each group declares how its runs are ordered for
a given participant. :func:resolve_session turns (protocol, sub-XXX, ses-YYY) into an ordered
list of :class:ResolvedRun — each carrying the frozen run manifest, a derived run_index,
and the position block stamped into its recording's sidecar. The order is derived and recorded,
never inferred from files on disk (the manifest-spine rule, one level up). A protocol never redefines
a run's design.
Hardware is the session's, not a run's (#484). A protocol carries one acquisition block
(schema 1.7, :attr:SessionProtocol.acquisition) and :func:resolve_session stamps it into every
resolved run, replacing whatever acquisition block an embedded run manifest still carries — so
everything downstream that reads run.manifest["acquisition"] (impedance, preview, decoder,
recorder, sidecar) reads the session's hardware, whichever run it is looking at. A protocol from
before 1.7 has no block of its own: its runs' blocks are lifted when they all agree, and when they
disagree the session has no hardware and cannot be locked (:attr:ResolvedSession.acquisition_problem).
A protocol with no hardware anywhere takes the hardware Set up Session states for it
(resolve_session(..., session_hardware=...)): the operator chooses it once, for the whole
session, which is the same decision the protocol would otherwise have carried.
To run the same design on different hardware, record another session.
Protocol schema v1.2 (#115) embeds each run manifest in the protocol; before it, a protocol held
a path + pinned fingerprint and the resolver loaded the file, hard-stopping on drift. Embedding makes
a protocol one self-contained artifact — copy it to an acquisition PC and the whole design goes with
it — and means a saved protocol always runs, where before it could be silently broken by anyone
editing a run manifest it pointed at. What was lost with the resolve-time drift stop is regained by
:meth:SessionProtocol.source_report, which asks the same question at design time and can be
acted on. Pre-1.2 protocols still resolve exactly as they did, drift stop included.
The core (protocol model + ordering + resolver) is pure and unit-tested: it scans no sourcedata,
and for an embedded protocol it touches no filesystem at all. The helpers :func:next_session /
:func:session_exists are separate, so the resolver stays pure.
ProtocolDriftError ¶
Bases: RuntimeError
A referenced run manifest's on-disk fingerprint no longer matches the protocol's pin.
Raised by :func:resolve_session (unless allow_drift): the design a participant would run has
silently diverged from the design the protocol was authored against. A design change must be a
deliberate new protocol version, never a side effect of editing a referenced run manifest — so
this is a hard stop, not a warning (AGENTS.md §2: declared, never inferred).
Source code in src/ssvep/runtime/session.py
RunRef
dataclass
¶
One run of a group: the run manifest itself, plus where it was copied from.
Since protocol schema v1.2 (#115) the manifest is embedded and authoritative — the resolver
runs :attr:manifest and never reads the store. That makes a protocol a single self-contained
file (copy it to an acquisition PC and the whole design travels with it) and makes a saved
protocol un-breakable by someone editing a run manifest it happens to point at.
:attr:run_manifest / :attr:fingerprint survive as the source pointer: where this copy came
from and what it fingerprinted at embed time. Nothing at record time reads them. They exist so
:meth:source_status can answer "has the file I copied this from moved on?" at design time,
which is where a design question belongs — the previous scheme could only answer it at resolve
time, i.e. by refusing to start a session with the participant already in the chair.
A pre-1.2 protocol has no :attr:manifest. Those still load, and :func:resolve_session still
resolves them the old way — store lookup plus a fingerprint check that hard-stops on drift.
Source code in src/ssvep/runtime/session.py
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | |
pin
classmethod
¶
Embed the run manifest at store_dir / run_manifest, recording where it came from.
The manifest is loaded through :func:ssvep.io.manifest.load_manifest, so it is canonicalized
to v1.3 shape and schema-validated here — an unloadable or invalid run manifest fails while
someone is authoring, not while someone is recording.
Source code in src/ssvep/runtime/session.py
resolve_manifest ¶
This run's manifest and its fingerprint — embedded copy, or the legacy store lookup.
Embedded (v1.2+): returned as-is. There is nothing to verify, because there is nothing else it could have come from; the protocol is the design.
Legacy (v1.0/v1.1): loaded from store_dir and re-fingerprinted, raising
:class:ProtocolDriftError on a mismatch unless allow_drift.
Source code in src/ssvep/runtime/session.py
source_status ¶
Whether the file this run was embedded from still matches it — a design-time question.
One of :data:SOURCE_CURRENT / :data:SOURCE_DRIFTED / :data:SOURCE_MISSING /
:data:SOURCE_UNKNOWN. Never raises: an unreadable or invalid source file is missing,
because the answer this feeds is a report, and a report that throws is a report nobody sees.
Source code in src/ssvep/runtime/session.py
SessionProtocol
dataclass
¶
Source code in src/ssvep/runtime/session.py
343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 | |
has_participant_features ¶
True once any 1.5 feature is in use — i.e. this file needs a 1.5 reader (#286): a
data_use, safety_screen or start_questionnaire block. A closed-choice item can
only be in the start questionnaire (validation refuses one anywhere else), so it needs no
separate check.
Source code in src/ssvep/runtime/session.py
has_embedded_runs ¶
True once any run carries its manifest inline — i.e. this file needs a 1.2 reader (#115).
has_per_session_fpvs ¶
True once any embedded run is an FPVS design whose item order is drawn per session
(design.fpvs.realise: "per_session", #530) — i.e. this file needs a 1.8 reader. A legacy
path reference is not inspected: its manifest lives in the store, not in this file.
Source code in src/ssvep/runtime/session.py
source_report ¶
Every run paired with whether its source file still matches it (:meth:RunRef.source_status).
The design-time replacement for the old resolve-time drift stop: authoring can ask "what have I fallen behind on?" and act on it, instead of finding out when a session refuses to start.
Source code in src/ssvep/runtime/session.py
duplicate_run_labels ¶
Runs in this protocol that share a run.name but are different designs (#467).
The name identifies a run; a description need not be unique (two variants of one design
legitimately share it), so a shared description is no longer reported. Two runs with one name
and different designs is the copy-paste that was never updated for the run it landed on —
what happened in the 2026-08-28 pilot, where runs at three frequency bands were all cloned
under one name. The same design twice under one name (one run repeated on purpose) is not a
collision. Prose differences (run.description) do not make a design different
(:func:ssvep.io.manifest.design_fingerprint). The same check runs earlier, at Save in
Build run (:func:ssvep.io.manifest.name_clashes); this one catches a pair that was
never saved side by side.
Returns one entry per colliding name — {"field": "name", "value": ..., "runs":
[(group_label, run_label), ...]} — worst (most runs sharing it) first. A run whose manifest
can't be resolved (a legacy reference with no store, or a source file that has moved) is
skipped rather than raising.
Source code in src/ssvep/runtime/session.py
run_labels ¶
Every run's label, in protocol order — computed exactly as :func:resolve_session does.
Used to check contrasts[*].levels name real runs (:meth:contrast_problems). A run whose
manifest can't be resolved (a legacy reference with no store, or a source file that has moved)
is skipped rather than raising, the same convention as :meth:duplicate_run_labels.
Source code in src/ssvep/runtime/session.py
contrast_problems ¶
Human-readable problems with this protocol's contrasts block. Empty ⇒ fine.
Checked here, at design time, rather than only when an analysis tries to run a contrast: a
typo'd run label or a copy-pasted duplicate name is something the Build Protocol tab's
"Validate protocol…" action flags while someone is authoring (:meth:_validate_clicked in
ssvep.ui.session_builder), the same way :meth:duplicate_run_labels already does for
runs. Schema-shaped problems (unknown key, bad name pattern, wrong level count, which fields
a design requires/forbids) are the schema's job; what's checked here is cross-item and
cross-object — things a JSON Schema item definition cannot express, because they depend on
this protocol's own runs (run/levels naming a real run label) or on values the
schema has no opinion on (between.column being reserved, between.levels differing).
Source code in src/ssvep/runtime/session.py
run_acquisition_blocks ¶
(run label, acquisition block) for every run whose own manifest still carries one.
Read only to migrate a protocol from before 1.7 (#484): such a block is never used for a
recording once the protocol has its own. A run that cannot be resolved is skipped, the
convention of :meth:run_labels.
Source code in src/ssvep/runtime/session.py
session_acquisition ¶
The hardware this protocol's sessions are recorded on, or None if it has none (#484).
Its own block when it has one. Otherwise (a protocol from before 1.7) the block its runs
carry, lifted, when every run that carries one carries the same one; None when none does
or they disagree — :meth:acquisition_problem says which.
Source code in src/ssvep/runtime/session.py
acquisition_problem ¶
Why this protocol has no session hardware, in words an operator can act on, or None.
schema_version ¶
The schema version this protocol is, not the one it was loaded as.
Derived, not stored: a protocol carrying questionnaire blocks uses 1.1 features and says so, while one without them round-trips byte-identically as whatever it already was. The stamp then means "this file needs a 1.1 reader", never "a 1.1 build opened it" — so loading a pilot protocol to look at it does not rewrite its version.
Everything that reports a schema version must come through here. It did not, once: the file
said 1.1 and every recording made from it said 1.0, because to_dict derived the value and
the sidecar stamp read the stale protocol_version attribute off an in-memory object that
had been loaded as 1.0 and had questionnaires added since (caught on a hardware
check, 2026-08-01). One protocol, two answers, depending on which artifact you read.
Highest feature in use wins: an embedded FPVS run whose item order is drawn per session
(1.8, #530: an older build would play the design's sample order to everyone, so it must
refuse the file) outranks a session acquisition block (1.7, #484), which outranks a
study (1.6, #396), which outranks a participant-facing block
(1.5: data_use, safety_screen, start_questionnaire, the one slot a choice item
may be in), which outranks a between/mixed contrast (1.4), which outranks a within-only contrasts block (1.3),
which outranks embedded runs (1.2), which outranks questionnaires (1.1), since each reader
necessarily understands every version below it. A protocol whose contrasts are all
"within" therefore stays 1.3 — adding stage 2 (#269) must not silently bump every
existing within-only protocol, including the teaching protocol, to a version they don't use.
Source code in src/ssvep/runtime/session.py
validate ¶
Raise if this protocol is not one a session can run: the schema and its cross-field rules
(:func:ssvep.io.protocol.validate_protocol), then each questionnaire's own checks
(:meth:Questionnaire.validate, e.g. two options of one choice item with the same value),
so a broken instrument is caught at design time rather than at administration.
Source code in src/ssvep/runtime/session.py
fingerprint ¶
Semantic fingerprint of the protocol itself — its identity in each recording's sidecar.
ResolvedCheckpoint
dataclass
¶
One questionnaire administration in a resolved session (#20).
A checkpoint sits between runs — after a group's last run, or after the whole session — so
nothing is streaming while it happens and there are no markers to write, only a timestamp. It is
resolved rather than improvised for the same reason a run's run_index is: where in the session
a participant was asked is part of the record, not something to reconstruct from file times.
Source code in src/ssvep/runtime/session.py
ResolvedSession
dataclass
¶
Source code in src/ssvep/runtime/session.py
sequence ¶
Runs and checkpoints interleaved into the literal running order of the session.
A checkpoint lands immediately after the run whose index it follows, so this is what the operator's checklist shows and what "the next thing to do" means.
Source code in src/ssvep/runtime/session.py
RunStatus
dataclass
¶
Source code in src/ssvep/runtime/session.py
ItemStatus
dataclass
¶
One row of the session checklist — a run or a checkpoint, with its state derived from disk.
Source code in src/ssvep/runtime/session.py
fpvs_session_seed ¶
The seed one session's FPVS item order is drawn from (design.fpvs.realise: "per_session", #530).
The randomized-group recipe (:func:_rng) with the session and the run in the key: a one-way
SHA-256 of (protocol seed, sub-XXX, ses-YYY, group label, run label), so every participant,
every visit and every FPVS run of a session draws its own order, and the same inputs always give
the same seed (a relock or a resumed session plays the order it started with; nothing is drawn
at lock). occurrence (2, 3, ...) enters the key only when one run label appears more than
once in a group (the same design repeated on purpose), so the repeats differ too.
It carries nothing beyond its inputs (AGENTS.md §4): the subject and session codes are the
de-identified sub-/ses- labels already in every file name and in session_position;
no date, time, operator or other identifier enters it. It is a function of the codes, so it is
only as de-identified as they are (docs/FPVS.md says what that means for an export that ever
re-codes participants). Truncated to 52 bits so it is exact in any JSON reader.
Source code in src/ssvep/runtime/session.py
realise_run_for_session ¶
manifest with a "per_session" FPVS run's item order drawn from seed (#530), as a
new dict; any other manifest is returned as it is. The drawn order is then checked by the rules
the loader applies to every FPVS block (the #529 sequence, adjacency and shuffled-cycle checks),
so a session is never locked on an order the loader would refuse.
Source code in src/ssvep/runtime/session.py
order_indices ¶
This participant's ordering of n runs in a group, plus the counterbalance arm (or None).
Pure and deterministic in (order, n, subject, seed, label). fixed keeps authored order;
counterbalanced picks a Williams sequence by subject_number mod #sequences; randomized
shuffles with a per-group seed.
Source code in src/ssvep/runtime/session.py
locate_in_store ¶
Where a run reference points: run_manifest under store_dir.
store_dir is one directory — the path is simply joined, exactly as it always was — or an
ordered search path of them: the run-manifest store plus a site's extra design directories
(paths.designs, #298). Against a search path the reference must name exactly one file:
present in two directories, it is refused rather than resolved to whichever is listed first,
because a pre-1.2 run is loaded from here and silently picking the wrong copy is choosing a
different design. Present in none, the first directory's path is returned, so the caller's own
missing-file error reads as it always did.
Source code in src/ssvep/runtime/session.py
acquisition_summary ¶
One line naming a hardware block: amplifier, carrier (with its wiring hash), rate, prep.
Source code in src/ssvep/runtime/session.py
with_session_hardware ¶
manifest with its acquisition block replaced by the session's (#484), as a new dict.
A run's own block, if it still has one, is dropped rather than merged: hardware is not a run fact, and a merge is how a run's default carrier survived under the session's amplifier. With no session hardware the run carries none at all, so nothing downstream can fall back on the run's.
Source code in src/ssvep/runtime/session.py
resolve_session ¶
resolve_session(protocol, subject, session, store_dir=None, *, allow_drift=False, session_hardware=None)
Instantiate protocol for one participant's session into an ordered, frozen run list.
Inputs are the template, the participant sub-XXX, and the visit ses-YYY. Groups run in
authored order; within each group the runs are reordered per its scheme (derived from the
participant), then flattened and stamped with run_index 1..N.
store_dir is needed only for a pre-1.2 protocol, whose runs are paths relative to the
run-manifest store: those are loaded and re-fingerprinted, raising :class:ProtocolDriftError on
a mismatch unless allow_drift. A v1.2 protocol carries its manifests, so store_dir is
unused and may be omitted — the whole resolution is then pure in-memory, which is the point: no
file outside the protocol can change what a participant runs.
session_hardware is Set up Session's hardware, used only when the protocol has none of its
own and none of its runs carry any (:func:_session_hardware);
:attr:ResolvedSession.acquisition_from_session then says so.
The resolver never scans sourcedata and never assigns ses-YYY — that is the caller's, kept
out so this stays a pure function of its inputs.
Source code in src/ssvep/runtime/session.py
815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 | |
checkpoint_position ¶
The session_position block to save with one checkpoint's answers: the resolved position.
Whether the session was a pilot is no longer written here (#161 wrote pilot: true): the
answer carries the session's data-use stamp instead (#286 P3, data_use.purpose), which
:func:ssvep.io.bids.is_pilot_session reads. The answer still carries it itself rather than
relying on a recording beside it, for the reason #372 found: a questionnaire given before an
aborted first run has no recording to speak for it.
Source code in src/ssvep/runtime/session.py
eeg_stall_phrase ¶
EEG stopped for 42 s / EEG stopped 3 times, 58 s in all (longest 42 s) from the sidecar's
eeg_stalls (#486). Sidecars written before total_s existed say stopped 3 times (longest
42 s): the longest gap is never presented as if it were each gap's length.
Source code in src/ssvep/runtime/session.py
session_progress ¶
Which runs of a resolved session are already recorded — derived from disk, no progress file.
Three states, because two were not enough. A run is pending when its *.session.json
sidecar is absent, so a crash before saving is correctly offered again. It is done when the
sidecar shows every planned trial was recorded and the run was not stopped early. In between it
is partial: the sidecar exists, but the run was aborted (Abort or Esc) or lost its stream
part-way.
That middle state is the lesson from #86 (docs/PROTOCOLS.md). An 11-second abort still saves (deliberately — partial
data is data), and when "the sidecar exists" was the whole test, the checklist called that run
recorded and moved on. The operator, who could see perfectly well that it had not been, worked
around the checklist by hand-numbering the next run — which took it out of the protocol entirely
and cost it its fingerprint pin. Showing partial is what removes the reason to improvise.
The recordings remain the state; there is still no second artifact to desync (a lesson from the same review, one artifact over) — completeness is read out of the sidecars themselves.
A sidecar is read before it is counted (#279). The path is predicted from
sub/ses/task/run alone, so a second protocol that reuses a participant code — and a
run task label, which ssvep as the Build run default makes the usual case — finds the first
protocol's sidecars at its own paths. One whose session_position does not carry this protocol's
fingerprint is foreign: never done, never partial (so never offered for a confirmed
overwrite), and the lock refuses the session label while it is there. See
:func:ssvep.runtime.provenance.foreign_file for the rule, including why a sidecar that names no
protocol at all is foreign too.
(An observer-only session persists nothing, so every run reads pending here — the record UI runs those forward-only rather than resuming, since there is nothing on disk to resume from.)
Source code in src/ssvep/runtime/session.py
next_pending ¶
The first unrecorded run (lowest run_index with no sidecar), or None if none is left.
Deliberately skips partial runs rather than re-offering them: something is on disk for those, and re-recording would overwrite it. They are shown as partial in the checklist and re-run through the confirmed path, so replacing collected data stays a decision rather than a default.
A foreign run is not skipped: it is where this session stops, because nothing of this protocol's has been recorded there and nothing can be (#279). Stepping over it is how another protocol's data used to read as this session being further along — or complete.
Source code in src/ssvep/runtime/session.py
questionnaire_mode ¶
Which stamp this session's runs carry. Declared-and-off is not the same as never-declared.
Absence of a response file has to mean one thing for the checklist to resume correctly ("not asked yet"), so the reason there will never be one gets written down instead of inferred. A rig test with no participant and a session whose responses were lost must not read the same way later — that ambiguity is the same lesson, one artifact over.
Source code in src/ssvep/runtime/session.py
refresh_ack_check ¶
refresh_ack_check(resolved, advertised_hz, *, screen_index=None, n_screens=None, tol=REFRESH_ACK_TOL)
Compare the stimulus screen's advertised mode rate against every resolved run's design rate.
Pure — the caller supplies advertised_hz (or None when the screen could not be queried),
so this is testable offscreen with a mocked rate and never touches a real pyglet display itself
(#314). screen_index / n_screens are carried through only to be stamped (finding 4,
352 review) — they say which screen this compared, on a multi-monitor rig, without changing¶
the comparison itself.
What a mismatch actually breaks — not the presented frequency: the renderer evaluates the
ideal waveform at t = frame / clock with clock = the screen's advertised mode (it is
never re-pointed at the design's refresh_hz — see ssvep.stim.renderer.FlickerRenderer,
flicker.py's module docstring), so the mean presented frequency is exactly the declared one
regardless of which mode the panel is actually in. What is lost is frame-exactness: the
design's frames-per-cycle arithmetic, its frame-exactness and photosensitivity checks were all
computed for the design's refresh_hz and describe nothing about a run on a different clock. A
square-wave target that was an exact, jitter-free divisor at the design's rate (e.g. 240/10 = 24
frames/cycle at 240 Hz) can become a non-divisor at the screen's real rate (144/10 = 14.4, so its
cycles alternate 14 and 15 frames — interval jitter the design never validated). A driver that
misreports the panel's real rate (a 59.94 Hz mode advertised as 59) does not shift the
presented frequency (the flicker follows time, #544) but makes it repeat a sample about once a
second to stay on time — that is invisible to this check, which only ever compares two
advertised numbers; the flik-o-meter (photodiode) measures the real, physical frequency and is
the only thing here that would catch it.
FPVS runs (#494) are not a mismatch on a panel whose rate their base rate divides: their
design is declared in Hz, seconds and items, and the FPVS renderer rebuilds the frame schedule
for the panel and re-checks every rule there (:func:ssvep.stim.fpvs.clock_for_panel), so
nothing this acknowledgement warns about is lost. One the panel cannot play is still listed.
Returns {"status", "advertised_hz", "mismatched_runs", "undeclared_runs", "screen_index",
"n_screens"}. status is one of :data:REFRESH_MATCH / :data:REFRESH_MISMATCH /
:data:REFRESH_UNKNOWN. mismatched_runs is [(run_index, design_hz), …] for every run
whose display.refresh_hz differs from advertised_hz by more than tol (fractional).
undeclared_runs is [run_index, …] for every run with no positive display.refresh_hz to
compare at all — schema requires the key, so this should not happen in practice, but a run this
check silently skipped must never let the overall status read as "match" for it (finding 5, #352
review): with no mismatches but at least one undeclared run, status is "unknown", not "match".
Source code in src/ssvep/runtime/session.py
1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 | |
refresh_ack_stamp ¶
The block :func:recorder_for stamps into every run's session_position (#314).
So a run recorded under a screen-mode mismatch stays describable from its own sidecar without
depending on the renderer's after-the-fact provenance.advertised_refresh_hz (#230) — that is
a per-run measurement, taken during the run itself; this is the lock-time check plus what the
operator was told and did about it, decided once for the whole session. acknowledged only
means anything for a "mismatch" status: there was nothing to acknowledge for "match" or "unknown",
so it is omitted there rather than stamped False. screen_index / n_screens are
included only when known, so a stamp made before #352's fix (or in a test that never supplied
them) keeps comparing equal to its old, shorter shape. undeclared_runs is included only when
non-empty — the run(s), if any, this check could not compare at all (finding 5).
Source code in src/ssvep/runtime/session.py
carrier_confirm_stamp ¶
The :data:CARRIER_CONFIRM_KEY block: the operator confirmed, at lock, that the carrier
named here is the one on the participant (#195).
carrier and carrier_hash identify the carrier the session records (the as-run one, which
labels the channels); differs_from_protocol says it is not the protocol's. operator is the
operator id provenance already records (the typed name, on a profile with no whitelist); at must be date-shifted
(:func:ssvep.runtime.dates.stamp), a real date is never written.
Source code in src/ssvep/runtime/session.py
session_continuation_stamp ¶
The :data:SESSION_CONTINUATION_KEY block (#490): this session was unlocked and locked again
while it already held recordings, and what differed from the lock those recordings were made under.
kind is "continued" when the operator was asked at Lock and chose to carry on (the
protocol was the same; the purpose / data-use option differed), or "hardware_changed" when
only the as-run hardware differed, which keeps the session without asking. changes says what
differed, in words; at must be date-shifted (:func:ssvep.runtime.dates.stamp).
Source code in src/ssvep/runtime/session.py
recorder_for ¶
recorder_for(run, resolved, *, operator, data_use, out_dir, device=None, notes=None, seed=0, questionnaires=None, acquisition_override=None, asset_dir=None, refresh_ack=None, approval=None, flicker_ack=None, carrier_confirm=None, session_continuation=None)
Build a :class:RunRecorder for one resolved run — the only place resolver meets recorder.
run_index, session, and the position block all come from the resolved run/session, so
they are never hand-set at record time (the whole point of #43). data_use (the session's
purpose and tier, :class:ssvep.runtime.data_use.DataUse, or None for observer only) is
passed per session by the caller: it is decided at lock. Returns an un-run recorder; the record
path drives it.
questionnaires (see :func:questionnaire_mode) rides in the position block rather than in the
protocol, because whether they were administered is a fact about this session, decided at the
bench — not part of the design. The resolver stays pure and unaware of it.
acquisition_override is the same kind of fact: what headset / electrode type / skin prep
physically ran, which the protocol cannot know because electrodes get swapped at the bench.
It was threaded through the ad-hoc record path only, so until #115 a run recorded from a
protocol — the path the lab actually uses — silently dropped it, which is the wet/dry
electrode-metadata gap reappearing one level up. Removing the ad-hoc path made fixing this compulsory.
asset_dir is the locked protocol's asset bundle, where the run's image targets' pictures are;
the recorder copies them beside the recording when it saves (#258).
refresh_ack (see :func:refresh_ack_stamp) is the same kind of session-level fact as
questionnaires: whether the stimulus screen's mode matched this session's designs at lock
time, and what the operator did about it — decided once, at the bench, not part of the protocol
(#314). None only for a recorder built with no lock-time check at all (should not happen from
the GUI lock path; kept so a caller that never checked still gets a sidecar, just without the stamp).
approval (see :func:ssvep.runtime.approval.stamp) is the approval envelope the session was
locked under (#161): its id, version, reference and document hash. Absent when none gated the
lock — no envelope, or a profile that is not a lab one — which is itself the statement that
none did; the sidecar's site_profile stamp says which kind of deployment it was.
flicker_ack (see :func:ssvep.runtime.flicker_risk.ack_stamp) is the session's highest
flicker-risk grade as the operator was shown it at lock, and, for a high grade with a human
present, their acknowledgement (#286 P5). Session-level, like refresh_ack; absent when no run
presents flicker.
carrier_confirm (see :func:carrier_confirm_stamp) records that the operator, at lock, chose
the carrier physically on the participant and it was the session's (#195). Session-level, like
refresh_ack; absent only for a recorder built with no lock-time confirmation.
session_continuation (see :func:session_continuation_stamp) records that this lock carried
on a session that already held recordings, and what changed since (#490). Absent for a fresh
session and for an abort-and-retry relock where nothing changed.
A pilot session is declared by data_use.purpose (#286 P3), not by session_position.pilot
(#161's stamp, no longer written; still read, only to restrict). Every between-condition contrast
excludes such a recording, and the BIDS converter mirrors it as session_type = pilot.
Source code in src/ssvep/runtime/session.py
1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 | |
session_checklist ¶
The whole session as an ordered to-do list — runs and checkpoints interleaved.
Completion is derived from disk for both kinds, by the same rule and for the same reason: a run
is done when its *.session.json exists (and reports every planned trial), a checkpoint when
its _beh.json exists — and, for both, when that file's own session_position says it is
this protocol's (#279). There is still no separate progress file to desync from the data.
The last clause is the symptom checkpoints' safeguard. Their built-in instruments use fixed task
labels (postcondition, endofsession), so a second protocol reusing a participant code
predicts the first one's answer files even when it gave its runs a distinct task label. Counted by
existence, those read as already answered and were never presented. A file that is not this
protocol's is foreign instead: not done, not skipped over by :func:next_pending_item, and a
reason the lock refuses the session label. Foreign outranks skipped — switching questionnaires
off does not make another protocol's data this session's.
questionnaires=False (the operator's "don't run questionnaires" switch — a rig test with no
participant) marks checkpoints skipped rather than hiding them: they stay visible, so nobody
has to wonder later whether the protocol had any.
Source code in src/ssvep/runtime/session.py
next_pending_item ¶
The next thing to do: the first item that is neither done, partial, nor skipped.
Partial runs are stepped over for the same reason :func:next_pending steps over them — something
is on disk, so replacing it stays a deliberate re-run rather than the default.
A foreign item is not stepped over (#279): the session stops there, and says why. Walking past it is precisely how a symptom checkpoint went unasked, and how a session holding nothing but another protocol's data read "Session complete".
Source code in src/ssvep/runtime/session.py
foreign_items ¶
The items whose path already holds another protocol's file — what the lock refuses on (#279).
session_dir_foreign_files ¶
Every foreign or unreadable file anywhere under this resolved session's own directory (#279).
:func:foreign_items (via :func:session_checklist) only looks at the paths this protocol
would itself predict, from its own run and questionnaire task labels. A visit that ran a
different protocol under this same sub/ses label may have used entirely different task
labels for both, so its files sit at paths this protocol never predicts and never sees — the gap
"one session folder = one protocol" closes: not just the paths a protocol cares about, but the
directory it would be locked into.
Every *.session.json (a run's sidecar) and *_beh.json (a checkpoint's) anywhere under
sourcedata/sub-XXX/ses-YYY/ is read and judged the same way any single one would be at its own
predicted path (:func:ssvep.runtime.provenance.foreign_file, against this resolved session's own
fingerprint) — the same identity rule, and the same fail-closed treatment of a file that names no
protocol or cannot be read. An .xdf with no sidecar beside it counts too, for the same reason
:func:ssvep.runtime.runner.foreign_run_output already treats one that way at a predicted path:
nobody has shown it belongs to any protocol, let alone this one.
A file that is this protocol's own (own fingerprint, including one it rescued earlier) is not
returned. Returns [] if the directory does not exist yet — a brand new session label.
The stimuli/ subtree (:data:ssvep.io.assets.STIMULI_DIR) is skipped for the two JSON globs:
it holds copied pictures, named by the design (pins puts no extension restriction on a pinned
file), not run/checkpoint sidecars — a picture that happens to be named e.g. pic.session.json
is not one, and judging it as one only produces an unreadable-file refusal nobody can act on.
Source code in src/ssvep/runtime/session.py
session_recording_count ¶
How many recordings (run sidecars, and XDFs with no sidecar) a session folder holds (#490).
Counts files on disk whatever protocol they belong to: it answers "is there anything here a
relock could mix with", not "how far has this protocol got" (that is :func:session_progress).
Zero for a session folder that does not exist yet and for an observer-only session, which writes
nothing.
Source code in src/ssvep/runtime/session.py
session_exists ¶
True if a recording session dir already exists at all.
Not wired to the GUI, and not what protects a session's files: an existing directory is the
normal state of a session being resumed. What the lock checks instead is whose files sit at the
paths this protocol would write (:func:foreign_items, #279), and whose files sit anywhere else
in the directory too (:func:session_dir_foreign_files) — one session folder holds one
protocol's data.
Source code in src/ssvep/runtime/session.py
next_session ¶
Suggest the next ses-NNN for a participant by scanning existing session dirs (ses-001 if none).
A convenience for prefilling the operator's session field — deliberately separate from the pure
resolver, which takes ses-YYY as an input rather than deriving it from disk.
Source code in src/ssvep/runtime/session.py
Session purpose and data-use tier¶
ssvep.runtime.data_use ¶
Session purpose and data-use tier: the one policy every gate reads (#286 P3, D4/D5/D11).
A recording's fate (whether it is kept, mirrored to lab storage, converted, pooled into a contrast, or exported) depends on two facts stamped into it at lock: why the session was run (its purpose) and what the participant agreed to (its tier). This module holds both vocabularies, the single matrix that maps them to what may happen, and the fail-closed reader that turns a sidecar's stamp back into a decision. It is pure and stdlib-only on purpose, so every gate can import it and none can drift from another by re-deriving the rule.
Purposes (D5, D11): research (participant consent), pilot (a team-member volunteer, no
consent screen, the operator attests), teaching (a class; a fixed course-use-only tier) and
bench (no human present). Which ones a deployment offers depends on its site profile's kind
(:data:PURPOSES_BY_KIND).
Tiers (D4) are cumulative: 0 observer only (the participant takes part, and nothing from the
session is saved), 1 this study only, 2 this study and future research by this team, 3 share openly. Only research asks for one of these; the other purposes
have one fixed tier each (:data:TIERS_BY_PURPOSE), plus 0 so that "keep nothing" exists everywhere.
Tier 0 permits nothing at all: under it nothing is ever written, not even temporarily.
Unstamped means refused (D11), with one exception (#286 Q1): a recording with no stamp counts as
teaching on a teaching machine, unless it says it was made on a lab machine. That is the most
restrictive purpose (never pushed, never exported), so inferring it widens nothing. There is no migration: an older sidecar's three consent
flags are never translated into a tier, because "public yes, future no" has no tier and inventing one
would be consent nobody gave.
The matrix and the reader are the policy; the session lock, the writers and the push, export and conversion gates are where it is applied.
Purpose ¶
Bases: str, Enum
Why a session was run. Set at lock, stamped into every file the session writes (D5).
Source code in src/ssvep/runtime/data_use.py
Action ¶
Bases: str, Enum
Something that may or may not happen to a recording.
Source code in src/ssvep/runtime/data_use.py
PurposeRules
dataclass
¶
What a session of one purpose involves before anything is recorded.
Source code in src/ssvep/runtime/data_use.py
WordingChoice
dataclass
¶
The wording a research session will use, or why it can't be locked (wording is None).
Source code in src/ssvep/runtime/data_use.py
DataUse
dataclass
¶
One recording's purpose and tier, as stamped (or, for Q1 only, inferred).
mode, wording ({"source", "sha256"}), offered and minimum describe a
research session's consent and are absent otherwise; attestation is a pilot session's two
booleans and absent otherwise. inferred is true only for an unstamped recording read as
teaching under the Q1 rule; it is never written (:meth:to_stamp refuses one).
Source code in src/ssvep/runtime/data_use.py
to_stamp ¶
The block a writer puts under :data:STAMP_KEY (absent fields left out).
Raises ValueError for an inferred record: an inference about an unstamped recording is
not a stamp, and writing it as one would turn a guess into a durable claim.
Source code in src/ssvep/runtime/data_use.py
Reading
dataclass
¶
What :func:data_use_of made of a sidecar: a record, or why it has none (refused).
Source code in src/ssvep/runtime/data_use.py
LockDecision
dataclass
¶
What a session lock decided about data use: a stamp, "write nothing", or a refusal.
recordset: the session is recorded, and every file it writes carriesrecord.to_stamp().observer: the session runs (the person under the flicker is real, so the safety checks and questionnaires still happen) but nothing is written, not even temporarily, and no stamp is ever built (tier 0, D4/D7).refused: the lock must not happen, and this says why in the operator's words.
Source code in src/ssvep/runtime/data_use.py
describe ¶
One line for the lock status: the purpose and tier, or that nothing is saved.
Source code in src/ssvep/runtime/data_use.py
purposes_for ¶
allowed ¶
The matrix cell for (purpose, tier): every :class:Action it allows (empty = none).
An unknown purpose, or a tier that purpose never carries, allows nothing. True is not the
tier 1 (True == 1 in Python): only a real int is a research tier.
Source code in src/ssvep/runtime/data_use.py
wording_sha256 ¶
SHA-256 of a wording block in canonical JSON (sorted keys, no whitespace, UTF-8).
What a research recording is stamped with, so the words the participant was shown can be identified later without copying them into every file.
Source code in src/ssvep/runtime/data_use.py
wording_problems ¶
Cross-field problems with a data_use block that JSON Schema cannot express. Empty = fine.
The schema checks each field's shape; this checks they agree: the minimum is one of the tiers
offered, every offered tier has its text and no other tier does, and an on_screen protocol
carries the form it shows. :func:ssvep.io.protocol.validate_protocol runs it on load and save.
Source code in src/ssvep/runtime/data_use.py
is_builtin_wording ¶
Whether block is a built-in generic wording, unchanged: the same canonical form, and so
the same :func:wording_sha256, as one in :data:KNOWN_BUILTIN_SHA256. False for anything
that cannot be hashed.
Source code in src/ssvep/runtime/data_use.py
protocol_wording_problems ¶
:func:wording_problems, plus the one rule only a protocol's own block is held to: it is
not the built-in generic wording, unchanged (#548).
Build Protocol's editor starts a new block from the built-in wording, so an author who ticks
"declares its own wording" and changes nothing would otherwise declare it as the protocol's
own. The stamp would then say source: "protocol", a claim that someone approved those words
for this study, and a lab machine, which refuses a research session without wording of the
protocol's own, would run one under the toolbox's generic text. The built-in wording is used
by leaving the block out, on a machine that allows it, and is stamped builtin there.
:func:ssvep.io.protocol.validate_protocol (every load and save) and :func:research_wording
(the lock) run this. The comparison is exact (:func:is_builtin_wording, the stamp's own
canonical form): it stops the untouched pre-filled wording, or an exact copy of it, being
declared as the protocol's own. A copy with any edit is the protocol's own wording, and the
stamp's hash identifies exactly which words those were.
Source code in src/ssvep/runtime/data_use.py
research_wording ¶
Which wording a research session on a kind machine uses, given its protocol's block (Q5).
A protocol's own block always wins, once it passes :func:protocol_wording_problems (which
refuses the built-in wording declared, unchanged, as the protocol's own). Without one, a lab machine refuses the lock (a lab's
research is run under wording someone approved, and the toolbox has no business guessing it); a
generic machine falls back to :data:BUILTIN_GENERIC_WORDING, stamped as built-in with its
hash. A teaching machine never offers research at all.
Source code in src/ssvep/runtime/data_use.py
tier_choice ¶
Classify a research tier against the wording's offered tiers and minimum.
Source code in src/ssvep/runtime/data_use.py
stamp_problems ¶
Everything wrong with a stamp as read from disk. Empty = a stamp this toolbox can act on.
Strict in the fail-closed direction: an unknown key (a newer toolbox's stamp), a value of the
wrong JSON type ("2" for 2, true for 1), a tier the purpose never carries, tier 0, a
research tier below the minimum it was stamped with, or a pilot attestation that is not exactly
two true booleans each make the stamp unreadable, and an unreadable stamp allows nothing.
Tier 0 and a tier below the minimum are refused for the same reason: under either, nothing is written, so a recording that carries one should not exist, and what it says cannot be trusted.
Source code in src/ssvep/runtime/data_use.py
data_use_of ¶
The purpose and tier of the recording this sidecar describes, or why it has none. Fail-closed.
active_kind is the kind of the machine doing the reading (the site profile in force).
- A
data_usestamp is read strictly (:func:stamp_problems); a malformed one is refused and never inferred around. Its purpose must be one the recording's own kind offers, when the recording says which kind it was made under. - No stamp: refused (D11), unless the recording counts as teaching (#286 Q1). On a teaching
machine (
active_kindteaching) it does when its ownsite_profile.kindisteaching,genericor absent:genericis what :func:ssvep.runtime.site.recording_stampwrites when the profile does not resolve, which is how a class recording made with the profile unset reads, and a copy made before the kind was stamped names none. A recording whose own kind islabis research data under its own rules and stays refused, wherever it is read. Off a teaching machine, only a recording whose own kind isteachingcounts. - A legacy observer-only flag refuses either way, and a legacy
pilot: trueblocks the teaching inference and contradicts any non-pilot stamp.
Source code in src/ssvep/runtime/data_use.py
research_needs_screens ¶
Why operator entry is not honest for this research session, or None when it is. One rule
for both reasons: the site has the participant choose (tier_entry participant, or
anything unreadable: :data:PARTICIPANT_ENTRY, which the participant screens answer), or the
protocol shows its consent form on screen under operator entry
(:data:ON_SCREEN_NEEDS_PARTICIPANT_ENTRY, which is refused: no screens run for it).
Source code in src/ssvep/runtime/data_use.py
lock_decision ¶
lock_decision(kind, purpose, *, protocol_block=None, tier=None, attestation=None, tier_entry=DEFAULT_TIER_ENTRY, participant_tier=None)
Turn the operator's choices at lock into a :class:LockDecision. Pure; fail-closed.
kind is the active site profile's kind, purpose the one chosen, protocol_block the
loaded protocol's data_use block (or None), tier the participant's stated tier as
the operator entered it (research: an int; teaching: :data:TIER_COURSE_USE_ONLY or 0), and
attestation a pilot session's {"team_member": bool, "oral_consent": bool}, and
tier_entry the site profile's data_use.tier_entry (default participant, fail-closed),
and participant_tier the option the participant chose on the participant screens (#286 P4),
or None when they have not run.
- A purpose this kind does not offer is refused (D11).
- Research: the wording is the protocol's block, else built-in on a generic machine, else the
lock is refused (#286 Q5). Then :func:
research_needs_screens: on a site whosetier_entryisparticipant(:data:PARTICIPANT_ENTRY) the option is the participant's own from the screens: withoutparticipant_tierthe decision is refused withscreens_pendingset (the operator'stieris ignored, never stamped as the participant's), and with it the stamp saysentered_by: "participant". Under operator entry a protocol whoseconsent_modeison_screenis refused outright (:data:ON_SCREEN_NEEDS_PARTICIPANT_ENTRY); otherwise the operator'stieris used, stampedentered_by: "operator"(#286 Q4), and aparticipant_tieris refused (nobody asked). The tier must be one the wording offers. Below the minimum the participant did not consent: refused, and nothing runs (D7). Tier 0 at a minimum of 0 (the built-in wording's) is observer only: the session runs and nothing is saved. - Teaching: course use only, or 0 (observer only, #286 Q6);
tier_entrydoes not apply. The lock still shows the student the safety screen (and only that) before it freezes this decision (Aaron, #560); nothing about it is stamped. - Pilot: both attestation booleans must be true (D5), or refused. No name is ever taken.
- Bench: nothing to ask (#286 Q3).
- Pilot and bench take no tier; one passed in is refused (a confused caller).
Source code in src/ssvep/runtime/data_use.py
739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 | |
lower_targets ¶
The options a locked session may be lowered to, highest first, 0 always last. Pure.
Lowering is downward only and never raises an option on anyone's behalf. A research session may
go to any offered option below its current one that is still at or above the minimum it was
stamped with (an option below the minimum means "did not consent", and that is option 0: delete
what was saved). Every lowerable purpose may go to 0 (:data:LOWERABLE_PURPOSES). An
observer-only session, a refused one, a bench one or no decision at all has nothing to lower to;
whether an observer-only session still has saved files to delete is a question about the disk,
not about this policy.
Source code in src/ssvep/runtime/data_use.py
lowered ¶
The lock decision after lowering decision to tier. Raises ValueError otherwise.
tier must be one of :func:lower_targets. 0 gives an observer-only decision: from now
on nothing more is written (the caller deletes what was already saved). Any other tier keeps
every other field of the stamp (wording, offered, minimum, entered_by) and changes the tier
alone, so a lowered stamp is exactly the stamp the lock would have made for that option.
Source code in src/ssvep/runtime/data_use.py
lowered_stamp ¶
stamp (as read from a file) lowered to research option tier, or None when it is
already at or below it. Pure; never raises an option.
Raises ValueError when the stamp cannot be lowered to tier: it is unreadable, it is not
a research stamp, or tier is not an option it can carry (not offered, below its minimum, or
0, which is deletion and never a stamp).
Source code in src/ssvep/runtime/data_use.py
may ¶
Whether action may happen to the recording record describes. None = never.
POOL also needs active_kind: a contrast on that machine pools only
:data:POOL_PURPOSE_BY_KIND's purpose.
Source code in src/ssvep/runtime/data_use.py
refusal ¶
Why action may not happen to this recording, or None when it may. Fail-closed.
Source code in src/ssvep/runtime/data_use.py
pool_purpose ¶
The one purpose a contrast pools (#286 Q7): :data:POOL_PURPOSE_BY_KIND's for this machine,
or :data:SYNTHETIC_POOL_PURPOSE on a synthetic dataset.
Source code in src/ssvep/runtime/data_use.py
pool_refusal ¶
Why this recording may not be pooled into a contrast, or None when it may. Fail-closed.
A contrast pools exactly one purpose (:func:pool_purpose). On a real dataset that is the
matrix's POOL action (:func:refusal). On a synthetic dataset it is a readable bench stamp: the
matrix never pools bench, because a real bench recording (a phantom, a photodiode) beside
participants' recordings is not one of them, but a synthetic dataset holds nothing else.
Source code in src/ssvep/runtime/data_use.py
Session lifecycle: lowering and deletion¶
ssvep.runtime.lifecycle ¶
The session lifecycle on disk: lowering a session's option, and deleting it (#286 P3c, D7).
A participant may lower their data-use option before they leave, never raise it, and never after
(:mod:ssvep.runtime.data_use's :func:~ssvep.runtime.data_use.lower_targets is the policy). This
module is what that decision does to the files the session has already written:
- Lowering a research option (say 3 to 1) rewrites the tier in every stamp this
sub/sescarries: each run sidecar (a rescued one included), each questionnaire answer and each impedance-log entry. Nothing else in a file changes, every file is checked before any is written, and the new files replace the old ones only once all of them are staged (:func:lower_session). The tier lives only in these stamps (the BIDS view, the derivatives and every gate read it back from them), so nothing derived needs rewriting. - Switching to option 0 (observer only) deletes the whole
sub/sesoutput: thesourcedatasession folder (recordings, sidecars, answers, impedance log, rescued files, declarations, stimulus copies), its BIDS view, its per-session derivatives, its row in the participant's sessions table, every contrast report that pooled it (each report lists whom it pooled), any other derived page that names it, and its lines in any log or table (the rest of which, other participants' records, is kept). An older report nothing can rule out is named for a check by hand. A participant with nothing left anywhere loses theirparticipants.tsvrow too (:func:delete_session).
Both check the subject and session labels first (:func:checked_session), and refuse, before
touching anything, to work through a symlink or junction: following one could rewrite or delete
files that belong to someone else (the same rule as push, #341). Neither reaches
copies outside this data root: a file already pushed to lab storage, or an export made earlier.
That is why a session whose option can still be lowered pushes nothing until it ends
(:data:ssvep.runtime.data_use.PUSH_AT_SESSION_END).
LifecycleError ¶
LowerResult
dataclass
¶
What :func:lower_session did: the files rewritten, and the ones it left as they were.
Source code in src/ssvep/runtime/lifecycle.py
DeleteResult
dataclass
¶
What :func:delete_session removed, and anything worth telling the operator.
Source code in src/ssvep/runtime/lifecycle.py
session_dir ¶
<out_dir>/sourcedata/sub-XXX/ses-YYY: where a session's own files live.
is_within ¶
Whether path is folder or inside it, compared as written (normalised, never resolved,
case-insensitively where the platform is).
Source code in src/ssvep/runtime/lifecycle.py
stamped_files ¶
Every JSON file in a session's eeg/ and beh/ folders: where stamps are written.
Source code in src/ssvep/runtime/lifecycle.py
lower_session ¶
Lower every research stamp of this sub/ses to option tier (1 or above).
All or nothing, checked first. Every stamp-bearing file is read and its new content worked out
before any is written; a stamp that cannot be lowered to tier (unreadable, another purpose,
or tier not an option it can carry) refuses the whole change with :class:LifecycleError,
naming the file, and leaves every file as it was. A stamp already at or below tier stays as
it is (lowering never raises one). A file with no stamp at all is left alone and listed: it is
already held back by every gate, which is stricter than any option.
The new files are staged beside the old ones and then swapped in. If a swap itself fails, the files already swapped carry the lower option and the error names the rest; running the same lowering again finishes it.
Source code in src/ssvep/runtime/lifecycle.py
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | |
checked_session ¶
(root, subject, session, session folder) once the labels are ones the recorder itself
would write, or :class:LifecycleError. Every entry point here goes through this first: an empty
or .. label would otherwise turn "this session" into the participant's every session, or the
whole data root.
Source code in src/ssvep/runtime/lifecycle.py
session_protocols ¶
The protocols a sourcedata session's run sidecars name (session_position.protocol):
the only ones a contrast can have pooled it into. None when that cannot be known (no run
sidecar, or one that cannot be read), so the caller cannot rule out any older report.
Source code in src/ssvep/runtime/lifecycle.py
lowest_research_tier ¶
The lowest research option any file of this sub/ses already carries, or None.
A lock reads it so that a re-lock never stamps later files above earlier, lowered ones.
Source code in src/ssvep/runtime/lifecycle.py
deletion_targets ¶
The folders :func:delete_session removes whole, those that exist: the sourcedata
session, its BIDS view, and its folder in each derivatives pipeline. What a confirmation names.
Refuses labels that are not a session's (:func:checked_session).
Source code in src/ssvep/runtime/lifecycle.py
delete_session ¶
Delete everything this data root holds of one sub/ses (option 0 after saving).
Removes :func:deletion_targets; the session's row in sub-XXX/sub-XXX_sessions.tsv; the
dataset-level derived files that carry it (:func:_plan_derived: every contrast report that
pooled it, any other page that names it, and its lines in any log or table, which are kept for
everyone else); the participant's now-empty folders; and, when the participant has no session
left anywhere here, their participants.tsv row. Then rebuilds the derivatives index, if
there is one. A contrast report deleted here is rebuilt by running the contrasts again. A file
nothing here can rule out is not kept silently: the result names it for a check by hand.
The labels are checked first (:func:checked_session), and every folder is checked for links:
a bad label or a link anywhere refuses the whole deletion with :class:LifecycleError before
anything is removed. The sourcedata session goes last, so a deletion interrupted part-way
leaves the session to be deleted again rather than derived files of a session that is gone.
Copies outside this data root (lab storage, an earlier export) are not reachable from here.
Source code in src/ssvep/runtime/lifecycle.py
523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 | |