Questionnaires — break-time symptom checkpoints & end-of-session (#20)¶
A participant stares at provocative flicker for a whole session. There has always been an always-available abort, but until now nothing structured asked them how they were doing. That is issue #20, and it is a prerequisite for the ethics-amendment requirement — "max session duration + mandatory breaks + symptom checkpoints" before multi-condition sweeps can be approved — being honest rather than aspirational.
This doc is the operator SOP and the design rationale. The instruments themselves come from the deployment's own ethics description of the questionnaires, which is lab-side and not shipped.
A third built-in instrument, ssvep.runtime.questionnaire.demographics() (#290), is not one of
these two and lives outside this doc's mechanism. It is the background questionnaire — closed-choice
age range and gender — and it is never attached to a protocol group or an end-of-session slot the way
the two below are: nothing in this toolbox administers it yet. Building the instrument and the
participants.tsv/.json columns its answers land in is #290's own scope; the participant-facing
screen that actually asks it, holding the answers in memory alongside the safety statements until they
pass, is separate work (#286 P4). See docs/BIDS.md ("Demographics") for the columns and the
multi-session rule.
1. The two instruments¶
| Questionnaire 1 — post-condition | Questionnaire 2 — end-of-session | |
|---|---|---|
| When | After every experimental condition | Once, at the very end |
| Attached to | A protocol group | The protocol |
| Items | 11: four engagement (Interest, Enjoyment, Challenged, Stimulated) + seven adverse (Headache, Lightheaded, Dizziness, Nausea, Disoriented, Mental fatigue, Visual fatigue) | 2: a recommendation rating + an optional free-text comment |
| Scale | 5-point: Very slightly or not at all · A little · Moderately · Quite a bit · Extremely | 5-point, endpoint-anchored: Not at all likely … Extremely likely |
| Gating | Every item required, then it finishes on its own | The rating is required; the comment is not. Finishes once the rating is given |
| After | A self-paced rest break | Nothing — the session is over |
Both are shipped verbatim as ssvep.runtime.questionnaire.post_condition() and
end_of_session(). They are what the REB read — the items, the scale, and the wording. What
changed under #97 is only the software mechanism for finishing: there is no longer a participant-facing
"Confirm & Continue" / "Finish" button to click (ssvep.ui.questionnaire_dialog.QuestionnaireScreen
finishes itself the instant every required item has an answer). Nothing about which questions are
asked, their order, their scale, or their thresholds moved — that would be an amendment matter, and
none of this is one. If a participant wants to add the (optional) end-of-session comment, they need to
type it before giving the required rating, since the rating is what completes that instrument — noted
here because it is the one place the new one-required-item-only instrument gives them less time than
the old button did.
A group is the right home for the checkpoint because a group is one experimental condition — the thing the approved instrument is administered per. Nothing has to interpret "condition" at run time; the protocol already says where the boundaries are.
2. Authoring — the Build Protocol tab¶
Every new group gets the checkpoint automatically. That is the whole design decision: a symptom check that depends on someone remembering to add it is a symptom check that will be missing from the session where it mattered. The end-of-session questionnaire is likewise on by default.
- Checkpoint… (with a group selected) → edit the questions, or remove the checkpoint.
- End of session → Edit questions… in the protocol metadata box.
- The tree's third column shows each group's checkpoint, and marks it
(edited)when it is no longer the approved instrument. - The resolution preview shows checkpoints in the running order, flagged
⚑.
Removing one is deliberate and possible on purpose: this toolbox will serve groups under many ethics approvals — including deployments with none configured — and a protocol that does not require these instruments must be buildable. Where a deployment's own approval requires the checkpoint, removing it is a decision for that approval, not for this dialog.
Editing the questions¶
The editor pop-out changes the prompt, the scale anchors, the items, which items are adverse, and each one's flag threshold. It carries a standing warning, which is the honest position:
For a protocol running under a governing approval configured on this machine, changing the wording, the scale, or which symptoms are asked about is an amendment matter — check with your ethics board before editing. A protocol with no governing approval configured (a teaching or demo session) can be edited freely.
Restore built-in defaults puts back the built-in instrument exactly as shipped.
Protocols authored before #20¶
Untouched. Opening one in Build Protocol offers to add the defaults; nothing is written until you say yes and save. That matters because a protocol's fingerprint is stamped into every recording made under it — editing the file changes the fingerprint, so it has to be a decision, and it should come with a version bump.
3. Running — the Run Session tab¶
The session checklist is the literal running order: runs and checkpoints interleaved, a ⚑ row
after each group and the end-of-session instrument last. The checklist itself lives on Run
Session, alongside the QA check (a collapsible pane below it) — moved there from Set up Session in
126. The session's Purpose, loading/resolving the protocol and the 🔒 Lock live on **Set up¶
Session**: #126 moved all of it to Run Session, and #147 moved the load/resolve/lock half back next to the identity fields the lock freezes, leaving only the checklist and the Start button on Run Session.
# Group Item Status
1 band-high ssvep-high-a ✓ recorded
2 band-high ssvep-high-b ✓ recorded
— band-high ⚑ post-condition ✓ answered
3 band-med ssvep-med-a ○ pending
…
— — ⚑ end-of-session ○ pending
A checkpoint follows its run on its own. When a run completes, the checkpoint that comes next is administered immediately — nobody has to press anything. The checkpoint belongs to the run that just ended: waiting on an operator click puts a person-shaped delay between the flicker and the symptom question meant to catch it, and pushes the break it precedes later and later into the session.
Two cases where it deliberately does not fire, and the button is left for the operator:
- The run was aborted or errored. Its recording is partial and the operator is mid-problem. The checklist steps over partial runs, so without this exception an abort would ask the participant about a block that did not happen — and would mark done the checkpoint belonging to the re-run.
- The run has no session position. Recording is protocol-only since #115, so in practice this
is only a pre-#115 recording made outside a protocol (
session_position: {"ad_hoc": true}).
In those cases the next-action button says what pressing it will do — Prep selected run ("Start next run" until #147, then "Start selected run"; it prepares the highlighted run, and Start recording on the live-EEG pane is what actually begins one) or Run questionnaire (one button since #126; it used to be a separate "Record next run" / "Re-run selected…" pair). An operator about to hand the mouse to a participant should not have been told "record". Once every run and questionnaire is done — and nothing is deliberately selected to redo — that button says Session complete and disables itself instead of staying up as a click that has nothing to do (#125) — it used to stay enabled/labelled "Record next run" after the session's last item was already recorded.
What happens, pressed or automatic:
- The questionnaire opens fullscreen on the participant's monitor — the highest-refresh display, the same rule the stimulus renderer picks by, so it lands on the screen the flicker was on. It is made application-modal before that first show (not after), which is what actually blocks the operator's own window for as long as it is up — a subtlety that matters, because setting modality after a widget's first show is a Qt no-op, and that gap is exactly what let an operator interact with the main window during a live questionnaire before this was fixed (#97).
- The participant answers, and there is no separate submit/continue step. The instant every
required item has an answer, the screen finishes itself and control returns — nothing is left for
the participant to press. Before #97 this was a "Confirm & Continue" / "Finish" button the
operator had no way to know was still outstanding, so they could (and did) try to start the next
run while the participant was still looking at an enabled button nobody had told them about. Esc
does nothing; the operator's way out is operator: end, which records
declined— its confirmation dialog is parented to the operator's own window, never shown on the participant's screen. - Responses are written (§4). The questionnaire screen itself is then always closed and released
— in a
finally, regardless of how the administration ended — so it can never linger or reappear between later runs the way it did before this was fixed (#122). - The operator sees a popup reporting this checkpoint's scores every single time it completes —
not only when something is flagged (#97). Before, a dialog only ever appeared for the alarming
case, so the ordinary one gave the operator nothing to look for and no reliable way to know the
participant was actually done. When something is flagged, the popup keeps its warning styling and
must be acknowledged before the checklist moves on (§5); the responses were already written in
step 3 — participant first, operator interruption second — so the acknowledgement itself has
nowhere to land unless something writes it back afterward:
ssvep.runtime.questionnaire.record_acknowledgmentstamps anssvep_ack: {operator, acknowledged_at}block onto the already-saved_beh.jsonthe moment the operator dismisses the dialog. Before this existed the acknowledgement happened but left no record at all — bench testing turned up a real flagged checkpoint the operator did check in on, with nothing on disk to show it. - If the questionnaire declares
rest_after, a self-paced rest screen appears. No countdown, and nothing about its pacing changed here — see the callout below. It always ends with a reminder that the participant can stop the flickering at any time by pressing Esc (questionnaire_dialog.ESC_REMINDER), because participants are told they will be reminded at every break. A break given its own message keeps the reminder. - When the participant ends their own break, the operator is told. The break screen is fullscreen on the participant's monitor, so "I'm ready to continue" is invisible from the operator's station — without this the session sits finished-and-waiting until somebody happens to look. The prompt names what is next and offers Set up next, which prepares that run (locks its manifest, expands the QA-check pane). Starting it stays a deliberate act on the Run Session tab (#86).
- While a questionnaire or the break is up, the operator's window says so (#565). An amber banner above every tab names which screen it is. During the questionnaire the toolbox is blocked anyway (the screen is modal), and the banner explains why. During the break the toolbox stays usable: the checklist has already moved on to the next run, and preparing it and checking impedance are fine. Start recording is refused until the participant ends the break. Preparing a run is also refused while a run is recording or an impedance check is running. A re-prepare rebuilds the impedance pane, and doing that under a live check aborts the process — the likeliest cause of the GUI crash in the 2026-10-21 pilot.
RestScreendid not change. #97/#122/#125 are all about the questionnaire screen and the operator's side of the handoff — the mandatory rest break that can follow it (step 5 above) is a separate class (ssvep.ui.questionnaire_dialog.RestScreen) and REB Appendix B describes it as self-paced: the participant decides when it ends, with no countdown. Making it operator-timed instead would be a new constraint on the participant nobody described to them or to the REB, so it was deliberately left untouched by this fix and stays exactly as tested before.
Re-administering. Click the checkpoint in the checklist (it becomes the highlighted row) and press Prep selected run — a confirmed overwrite, same as re-recording a run (one button since #126; it used to be a separate "Re-run selected…"). A questionnaire can go wrong in ways the checklist cannot see (answered by the operator by mistake, ended too early), and overwriting a real person's answers should be a decision.
A confirmed re-administration replaces this checkpoint's own answers only. If the file at that path is another protocol's (#279), the row reads ✗ holds «X»'s data, the button says Blocked, and pressing it explains that the checkpoint has not been administered and how to go on (a different session label); nothing is asked and nothing is written. A checkpoint found foreign after the lock is not auto-administered after its run either — the operator is told instead, at the moment it was due. The checkpoint that comes up after a run is always the one placed after that run, whatever state other items earlier in the session are in. And when a run's data had to be saved beside an existing file (.rescued-NN), the run still happened: the operator is told its checkpoint has not been asked and is offered it straight away, before any re-run.
Turning them off — a bench session¶
A session whose Purpose is Bench (no person in the chair, #286) skips every checkpoint. It replaced the old "Administer questionnaires" checkbox: there is nobody to ask, and a session with a person in it always asks, an observer-only one included.
Two things happen, and the second is the point:
- Checkpoints show as skipped in the checklist rather than disappearing, so nobody has to wonder later whether the protocol had any.
- Every run recorded that session is stamped
session_position.questionnaires: "skipped". A protocol that declares none stamps"none"; a normal session stamps"administered".
An absent response file has to mean exactly one thing ("not asked yet") for the checklist to resume correctly. So the reason there will never be one gets written down, not left to be inferred. A rig test and a session whose responses were lost must not read the same way six months later — that is the same data-review lesson applied one artifact over.
The switch is disabled for a protocol that declares no questionnaires; there is nothing to skip.
4. Where the answers go¶
sourcedata/sub-XXX/ses-YYY/beh/ — the BIDS beh datatype, a sibling of the eeg/ folder the
runs already write into, so one session directory describes itself:
sourcedata/sub-XXX/ses-YYY/
├── eeg/
│ ├── sub-XXX_ses-YYY_task-ssvep_run-001_eeg.xdf
│ └── sub-XXX_ses-YYY_task-ssvep_run-001_eeg.session.json
└── beh/
├── sub-XXX_ses-YYY_task-postcondition_run-001_beh.tsv # after group 1
├── sub-XXX_ses-YYY_task-postcondition_run-001_beh.json
├── sub-XXX_ses-YYY_task-postcondition_run-002_beh.tsv # after group 2
├── sub-XXX_ses-YYY_task-postcondition_run-002_beh.json
├── sub-XXX_ses-YYY_task-endofsession_beh.tsv
└── sub-XXX_ses-YYY_task-endofsession_beh.json
- One pair per administration, never appended to. File-exists is then the completion test —
exactly as
*.session.jsonis for a run — so an abandoned session resumes at the right point and nothing is rewritten mid-session. There is still no separate progress file to desync from the data. - …and the file must be this protocol's (#279). The task labels above are fixed, so a second protocol that reuses a participant code writes to these very paths — even if it gave its runs a distinct task label. Counted by existence, the first study's answers made the second study's symptom checkpoints and end-of-session questionnaire read already answered: never presented, with no message. Now the
_beh.json's ownsession_positionhas to carry this protocol's fingerprint. Anything else there — another protocol's answers, or a file that names no protocol — is foreign: shown as such in the checklist, never counted as answered or stepped over, never saved over (save_responseraises and writes nothing), and a reason the lock refuses the session label, with a message naming the protocol it belongs to and telling the operator to choose a different session label.docs/PROTOCOLS.md("A session label another protocol already used") has the whole rule, including why a file that names no protocol is treated as foreign. run-NNNis the administration ordinal, which is precisely what BIDSrunmeans: repeated acquisitions of the same task. A once-per-session instrument gets norunentity.- The
.tsvis the data: a header of item names, one row of answers,n/afor unanswered. - The
.jsonis the BIDS sidecar (per-columnDescription+Levels) and carries the whole instrument, the responses, the flags the operator was shown, the timestamps (date-shifted like every time the toolbox writes, never the real date:DATA_GOVERNANCE.md), and the samesession_positionblock a recording gets. The file can say what it asked without anyone having to go and find the protocol version. Because each item is a top-level block of that same file, the sidecar's own keys are reserved item names:consent,data_use,session_position,provenance, and anything startingssvep_. A questionnaire using one fails validation (an item namedconsentwould read as a legacy consent record and hold the session back from push and export for good). - Free text is verbatim in the JSON only. In the TSV, tabs and newlines are flattened — a raw tab in a cell would silently shift every column after it, which is worse than an escaped newline.
Declined is recorded, not absent. A refusal writes a file with ssvep_state: "declined". A
participant who could not finish and a checkpoint that never happened must not look the same.
Compliance¶
- Responses are participant data: under
sourcedata/, so git-ignored (R1), Canada-only (R3), and pushed only under consent (R5). - Observer-only: nothing is written at all. The questionnaire is still administered and the operator still sees any flags — the person under the flicker is real whether or not their data is kept, so the safety check is not theirs to lose. Only the file is withheld.
- The free-text box carries an on-screen note asking for nothing identifying (R2). It is the one place a participant can type anything at all, and the only place that rule can act is before the typing.
- Responses are not copied into the BIDS dataset proper yet — the converter reads
sub-*/ses-*/eeg/*_eeg.xdfand does not seebeh/. A follow-up.
5. Flags — what makes this a safety measure¶
Only adverse items can flag, and only at or above their own threshold:
| Items | Flags at |
|---|---|
| Headache, Lightheaded, Dizziness, Nausea, Disoriented | 3 — "Moderately" |
| Mental fatigue, Visual fatigue | 4 — "Quite a bit" |
| Interest, Enjoyment, Challenged, Stimulated | never |
The split is deliberate. An hour of flicker makes moderate visual and mental fatigue an ordinary outcome; an alert that fires at every single break is one an operator learns to dismiss without reading. Engagement items never flag — a participant who is bored is not a safety event.
The thresholds live in the protocol, not in the code, so a study that wants different ones sets them in its own protocol rather than needing a change here.
When something flags, the operator gets a dialog naming the symptom, the response, and the threshold, and must acknowledge it before the checklist offers the next item. Nothing stops the session automatically. Whether to continue, shorten, or stop is a judgement about a person in the room, and the toolbox is not in a position to make it. What it can guarantee is that the operator saw.
6. Where the code is¶
| Piece | Where |
|---|---|
Model, REB defaults, flagging, the beh/ writer (pure, Qt-free) |
ssvep.runtime.questionnaire |
| Protocol blocks + resolution into checkpoints + the checklist | ssvep.runtime.session |
Schema (questionnaire on a group, end_questionnaire on the protocol) |
schemas/session_protocol.schema.json (v1.1) |
| Participant screen, rest screen, operator acknowledgement, editor | ssvep.ui.questionnaire_dialog |
| Design-tab authoring | ssvep.ui.session_builder |
| Run-tab checklist + administration | ssvep.ui.session_record |
A protocol carrying questionnaires stamps protocol_version: "1.1"; one without them round-trips
byte-identically as "1.0", fingerprint unchanged. Loading a pilot protocol to look at it does not
re-version it.
7. Not yet, and why¶
- In the BIDS view.
beh/lives insourcedataonly; the converter does not carry it into the BIDS dataset proper.sourcedatais exempt from BIDS validation, so nothing is wrong — it is just not done. An export does carry the ratings: it writes them tophenotype/with a data dictionary, and never a free-text answer (docs/EXPORT.md§9). - A minimum break duration. The approved description says "mandatory breaks"; the ethics appendix says the break is
self-paced. A countdown that locks the Continue button would be a new constraint on the participant
that nobody has described to them or to the ethics board. If the approval comes back requiring a number, it goes in the
questionnaire block beside
rest_after. - Cross-session symptom trends. Each administration stands alone. Comparing a participant's checkpoints across a session (or across visits) is an analysis question, and nothing reads these files yet.
- Per-item scale editing in the GUI. The editor changes the questionnaire-wide scale; a per-item override (Questionnaire 2's endpoint-anchored one) is preserved but must be edited in JSON.