BIO-TG-00 (#827) review evidence pack: ThinkGear first-device safety contract¶
Status: RESEARCH DRAFT, REVIEW PENDING. This is a review aid for the G0 decision on #827. It decides nothing. It does not accept, revise or reject ADR-v2-155, it does not change the status of ADR-v2-152, and it authorizes no runtime code. Date: 2026-11-06 Code baseline: main @ 8a72bbc6. Every file:line below refers to that tree. Read for this pack: issues #826–#833 (GitHub REST API, read 2026-11-06); ADR-v2-155; the qualification spec; ADR-030 (2026-11-01 amendment); src/yazses/biosignal/{thinkgear,integrity,pipeline,runner,cli_impl,fieldtest}.py; tests/test_biosignal_thinkgear.py. Related (not duplicated): the 2026-11-03 roadmap and coding-agent handoff plan G0–G4; this pack only checks the G0 clauses against code. The handoff still names the deleted branch (design/biosignal/2026-11-03-coding-agent-handoff.md:5; see H2). Nothing was executed apart from git/grep/gh api reads. No test, hardware or device was run.
0. Housekeeping findings the reviewer should know first¶
These are facts about the review package itself, not about the design.
| # | Finding | Evidence | Consequence for G0 |
|---|---|---|---|
| H1 | The ADR under review is ADR-v2-155, not ADR-v2-154. #826 and #827 still say "Proposed ADR-v2-154". On main, ADR-v2-154 is a different decision (EEG/EOG research evidence gates, #696). | design/adr/adr-v2-154-eeg-eog-evidence-gates.md:1; design/adr/adr-v2-155-thinkgear-transport-safety.md:1; renumbering commit a86176ee; ADR index rows design/adr/README.md:246-247 | The decision annotation must go on v2-155. The issue texts need their ADR number corrected (owner action; this pack edits no issue). |
| H2 | Every "review branch" link in #826–#833 is dead. research/thinkgear-first-device-2026-11-03 returns HTTP 404 on GitHub; it was merged as PR #856. | gh api repos/MSKazemi/yazses/branches/research/thinkgear-first-device-2026-11-03 → 404 (2026-11-06); merge commit 400908f1 | Review the files on main instead: the three documents are at the paths above. |
| H3 | No overlapping runtime fix exists. No ThinkGear runtime commit in the last 300 commits on main besides the docs merge; the CHANGELOG [Unreleased] section does not mention ThinkGear, poor_signal, IntegrityGuard or field-test parity; no local branch matches thinkgear/tg. | git log --oneline -300, CHANGELOG.md [Unreleased] grep (2026-11-06) | #827 acceptance item "inspect latest main for overlapping fixes" is satisfied as of 8a72bbc6. |
| H4 | ADR-v2-155 is already in the generated ADR index as Proposed. | design/adr/README.md:247 | After a decision, re-run uv run python scripts/gen-adr-index.py; do not hand-edit the index. |
1. Clause → current code evidence¶
"GAP" means the clause describes behaviour that main does not have. A GAP is expected — the ADR is a proposal for new behaviour — so the column tells the reviewer how big each implementation step is, not that anything is broken today in the shipped default (the whole feature is off by default: src/yazses/config.py:1074, enabled: bool = False).
1.1 ADR-v2-155 decision clauses¶
| Clause (ADR-v2-155) | Current code at 8a72bbc6 | Verdict |
|---|---|---|
D1 Raw 0x80 is the only detector input; eSense/attention/meditation/vendor blink never exposed | The parser returns only raw and poor_signal per packet (thinkgear.py:128-137, built at :258); vendor scores are skipped and a test pins it (tests/test_biosignal_thinkgear.py:93, test_esense_attention_meditation_are_never_exposed). | MET (already true; keep) |
D2a Read all POOR_SIGNAL reports in wire order; any nonzero taints the whole read | read() overwrites a single attribute with each report, so only the last survives: thinkgear.py:439-442. A 0 → 200 → 0 read ends with poor_signal == 0 and nothing else records the 200. | GAP (the core finding of #826 gap 1, confirmed) |
| D2b Any checksum / length / malformed frame taints the whole read | Rejections are counted (thinkgear.py:146-148, incremented at :195, :203, :208) and logged once per read (:443-447), but the emitted SampleBlock carries no taint and nothing downstream reads the counter. | GAP (counting exists; veto does not) |
| D2c Unknown contact (no report yet) is untrusted | poor_signal starts as None (thinkgear.py:390, reset at :422) but nothing consults it; raw blocks are emitted before any 0x02 report (:448-454). | GAP |
| D2d Stale contact report (>3 s) is untrusted | No contact timestamp is kept anywhere in thinkgear.py. The only time-based guard is generic stream stall (integrity.py:710-721), which is about blocks, not contact reports. | GAP |
| D2e Health kept separate from samples; batch with no raw may update health but emit no event | A read with no raw rows returns None (thinkgear.py:448-449) after updating poor_signal (:441-442). So the shape is right, but a 200 in a raw-less read is visible only as a mutable attribute that no gate reads. | PARTIAL |
| D3a Event gate requires source-health AND stream integrity AND waveform quality | Integrity + waveform are composed today: IntegrityGate.update ORs the guard's per-block verdict with the inner monitor (integrity.py:826-844), and the pipeline closes on BAD/suppress_events/bad_during_update (pipeline.py:769-788). There is no third (source-health) input. | PARTIAL (2 of 3 legs) |
| D3b Missing/unrecognised provenance is untrusted | Already the integrity guard's rule: gate_closed_for returns True for a block not in its bounded journal (integrity.py:680-686; depth _PROVENANCE_DEPTH = 8 at :162; journal at :625, filled at :652-656). | MET for integrity; reusable seam for health |
| D3c A health veto releases a held activation, suppresses a pending double blink, and nothing fires on recovery | A closed quality gate already drops events and releases a held squeeze (integrity.py:800-807 docstring; gate read at pipeline.py:629, release at :670-675). Detector reset on a stream gap exists in the runner (runner.py:391-399, pipeline.reset(keep_governor_state=True)). There is no reset tied to a health transition, because there is no health transition. | PARTIAL |
D4 Fixed constants: first explicit 0 required; != 0 unhealthy; TTL 3.0 s; 2 fresh zero reports + ≥1.0 s clean raw; completing batch still vetoed | None of these constants exist in code (grep for TTL/rearm in src/yazses/biosignal/ finds none tied to ThinkGear). | GAP (needs decision Q3 first) |
D5 Rejections create an uncertainty epoch; never synthesize samples or invent gaps; label lost count unknown | The "never fabricate" half is already true: t0 = emitted / fs (thinkgear.py:451-453), no padding, and the warning text says "no samples were substituted" (:445-446); a test pins it (tests/test_biosignal_thinkgear.py:261). The epoch half (invalidating gestures across the boundary, excluding exposure) does not exist. Note: skipped_bytes (thinkgear.py:149, :182, :185, :217) is not part of rejected_packets (:152-153), so a resync is not even logged as a loss. | PARTIAL (no-fabrication MET; epoch GAP; resync uncounted — see Q8) |
| D6 One shared live-policy construction for daemon, monitor, field-test | Daemon: guard + gate built inline (runner.py:853, :918-919, :923-924, live from scheme at :862). Monitor: a second hand-written copy (cli_impl.py:753-757). Field-test: no guard, no gate — it opens the raw source (cli_impl.py:1678), builds the plain pipeline (:1690-1691), and feeds raw blocks (:1725, :1735). | GAP (#826 gap 3 / #830 problem confirmed) |
D6b check-source adds a ThinkGear protocol-health line without claiming support | conformance.py has no ThinkGear-specific code (grep thinkgear\|poor → none; entry point conformance.py:203). | GAP (small; diagnostics only) |
| D7a Missing pyserial / port / disappearance cannot crash dictation or cause an activation | Lazy import with a clear ImportError (thinkgear.py:266-287); open failure and read failure degrade to unavailable no-op (:410-417, :433-435, _fail at :464-470); tests at tests/test_biosignal_thinkgear.py:283, :295, :305. | MET |
| D7b A generic quality-gate exception must not silently disable the health veto | Two existing fail-open paths a health leg would inherit unless designed around: (1) IntegrityGate drops a raising inner monitor and continues on integrity alone (integrity.py:829-834) — fine for integrity, but waveform quality is then silently gone for the session; (2) if the whole parts.quality object raises, the pipeline disables the quality gate and returns open (pipeline.py:773-777). A health veto placed inside parts.quality would be switched off by path (2). | GAP with a design trap (see Q4/Q9) |
| D8 Off by default; no network; local hardware only; aggregate-only reports | Default off (config.py:1074); ADR-030 amendment requires a default daemon to load none of the code (adr-030-agent-era-direction.md:175-176, enforced by tests/test_biosignal_default_stays_out.py). Field-test docstring promises counts only (cli_impl.py:1658-1663). No ThinkGear module opens a socket (it opens only serial.Serial, thinkgear.py:290-293). | MET (re-verify with TG-17/TG-18 tests in #831) |
Boundary No change to SampleBlock, its t0 contract, event vocabulary, action mapping or blink thresholds | Nothing in the proposal requires one if the health snapshot rides the guard's existing identity journal (integrity.py:625, :652-656, :680-686). | Feasible as written |
1.2 Spec state-table rows that depend on wiring, not on the source¶
| Spec row / test | Current evidence | Verdict |
|---|---|---|
| TG-11: stop/start must not carry trust | start() rebuilds the parser and resets poor_signal/counters (thinkgear.py:418-423), but returns early when a port is already open (:403-404), so a start() without stop() keeps everything. IntegrityGuard.start() clears its journal (integrity.py:629-637). | PARTIAL — fine on stop→start; a double-start keeps state |
| TG-15: held action released once across fault/pause/watchdog | Watchdog thread + release path exist (runner.py:320-356, :434-451, :665-671; DEFAULT_WATCHDOG_SECONDS = 1.5 at :96). | MET for stream silence; health-driven release is new |
| TG-16: untrusted exposure excluded, overlapping cues voided | The accounting already exists: untrusted blocks are not added to usable (fieldtest.py:947-948) and overlapping cues are voided (:953-954), driven by _trust(status) (:893, :1054-1060). What is missing is any untrusted signal reaching that status, because field-test has no guard (row D6). | Accounting MET; input GAP |
| "Failure without block" (health packet with no raw, then silence) | Raw-less read returns None (thinkgear.py:448-449); the guard then runs only its stall check (integrity.py:644-646); the runner resets detectors after a gap > watchdog (runner.py:391-399). A pending blink candidate is therefore cleared only by time, not by the contact fault. | GAP (bounded by watchdog 1.5 s, per current code) |
2. Decisions for the maintainer (yes/no)¶
Each item restates one #827 question (Q1–Q6) or a question this pack surfaced (Q7–Q10) as a yes/no decision, gives the evidence that bears on it, and names what the answer unblocks. The pack expresses no preference. Where the ADR proposes an answer, it is labelled "ADR proposes".
| ID | Decision (answer yes or no) | Evidence that bears on it | A "yes" unblocks | A "no" means |
|---|---|---|---|---|
| Q1 | Does any nonzero POOR_SIGNAL anywhere in one serial read veto the whole read, even if a later report in the same read is 0? (ADR proposes: yes.) | D2a GAP at thinkgear.py:439-442; there is no per-sample timestamp to localise the bad report (thinkgear.py:59-63 docstring; ADR-v2-155 §Context). | #828 (TG-03, TG-04) | #828 needs a written alternative that is "demonstrably causal and equally safe" (#827 Q1) before coding. |
| Q2 | Does a parser reject mean an uncertainty epoch with lost-sample count recorded as unknown, never an estimate? (ADR proposes: yes.) | D5: no-fabrication already holds and is tested (thinkgear.py:445-453; test :261); ThinkGear has no sequence number (thinkgear.py:59-61). | #828 (TG-05, TG-06), #833 audit item "record explicit unknown sample loss" | #828 must specify a loss estimator, which ADR-v2-155 §Alternatives already rejects. |
| Q3a | Accept CONTACT_TTL_S = 3.0, REARM_GOOD_REPORTS = 2, REARM_CLEAN_SAMPLE_S = 1.0, "zero only is healthy", and "completing batch still vetoed" as experimental engineering constants, hard-coded, not user-configurable? | D4 GAP (none exist). No hardware measurement exists (spec §0 table "Hardware: not yet available"; ADR-v2-155 last line). | #828 (TG-07, TG-08) | #828 waits for revised constants. |
| Q3b | Is "the device is unusably restrictive under these constants" an acceptable, reportable G3 outcome (INCONCLUSIVE/FAIL) rather than a reason to loosen the gate during the pilot? (ADR proposes: yes, ADR-v2-155 §Consequences bullet 1.) | Spec §10 open question 2; #832 Outcomes "INCONCLUSIVE ... quality permanently untrusted". | #832 (its outcome definitions), #833 option ⅔ | The pilot needs a pre-registered loosening rule before it runs (spec §10 forbids tuning on scored data). |
| Q4 | Carry the per-read health snapshot through IntegrityGuard via its existing identity journal (a read-only health_for(block) next to gate_closed_for), with no SampleBlock change? | D3b: journal and fail-closed lookup already exist (integrity.py:625, :652-656, :680-686); sanitized copies are produced by checker.check and the emitted block is what is journaled (:651-656), so the seam sees both inner and outer block. | #829 (TG-12, TG-13) | #829 needs a different provenance design, likely touching SampleBlock (forbidden by ADR boundary) → would need a revised ADR. |
| Q5 | Factor one guarded-source + gated-pipeline constructor used by daemon, monitor and field-test, with no change to config keys, CLI flags or IPC? | D6: three call sites, two hand-written copies (runner.py:918-924, cli_impl.py:753-757) and one missing (cli_impl.py:1678-1735). No new public surface is needed to share them; live is already derived from the spec scheme in both copies (runner.py:862, cli_impl.py:755). | #829 (constructor), #830 (TG-14, TG-16) | #830 must define per-caller differences and document why (its DoD already allows "documented and justified" differences). |
| Q6 | Is this work a narrow hardening inside the existing ADR-030 biosignal exception, not a new capability or device family? | ADR-030 amendment: exception covers the finished ADR-v2-152 pipeline; "further biosignal capabilities and new device families" stay frozen (adr-030-agent-era-direction.md:162-178). ThinkGear is already a shipped source (sources.py:1414-1416, boards.py:269-276). ADR-v2-155 §Scope calls itself "correctness hardening ... not a new capability". | The whole epic: #828–#833 | Under ADR-030 §3 the epic is frozen; #828–#831 stay blocked and #832 cannot start. |
| Q7 | Is fixing the field-test parity gap (field-test measures on an ungated stream) in scope for this epic even for non-ThinkGear sources? | D6: run_field_test uses no guard for any source (cli_impl.py:1678-1735), so this is a generic evidence-parity defect, not ThinkGear-specific. ADR-v2-155 D6 and #830 frame it inside this epic. | #830 can fix it generically in one PR | Split: a generic parity fix as its own issue (it is a correctness fix, arguably outside the freeze) and a ThinkGear-only health leg in #830. |
| Q8 | Does a parser resync (skipped_bytes increase with no checksum/length/malformed reject) also veto the read? (Spec §3 says "a lone extraneous framing byte need not equal a dropped sample but may conservatively veto" — undecided.) | skipped_bytes is counted (thinkgear.py:149, :182, :185, :217) but excluded from rejected_packets (:152-153) and from the loss warning (:443-447). A test already treats leading extra SYNC bytes as tolerated (tests/test_biosignal_thinkgear.py:195). | #828 (TG-06 expected values) | #828 must define a "significant resync" threshold — a number the spec does not give. |
| Q9 | Must the health veto sit outside the parts.quality exception path, so that a raising waveform monitor (or a raising gate object) can never reopen the gate for a ThinkGear source? | D7b: pipeline.py:773-777 disables the whole quality gate and returns open on an exception; integrity.py:829-834 drops only the inner monitor. ADR-v2-155 D7 last sentence requires fail-closed for this path. | #829 (its step 3 "even if a generic quality monitor degrades on failure") | #829 needs an explicit statement of which exception reopens what, and a test pinning it. |
| Q10 | Is a contact fault in a raw-less read allowed to reset a pending blink only at the next block (or after the 1.5 s watchdog gap), rather than immediately? | §1.2 "failure without block": no synthetic empty block is allowed (spec §4 caveat); reset today is time-driven (runner.py:391-399, DEFAULT_WATCHDOG_SECONDS = 1.5 at :96). | #829 (TG-09, TG-15 edge case "health update with no raw samples followed by silence") | #829 needs a new non-block signal path from source to pipeline — a larger change than the ADR describes. |
Status-hygiene decisions (procedural, from #827 acceptance list):
| ID | Decision | Unblocks |
|---|---|---|
| P1 | Record the outcome (Accepted / revised / rejected, with date) on ADR-v2-155, and re-run scripts/gen-adr-index.py. ADR-v2-152 status unchanged. | #828–#831 READY labels |
| P2 | Correct the ADR number (154 → 155) and the dead branch links in #826–#833 (H1, H2). | Reviewers and cloud agents following the issue links |
3. Unblock matrix¶
Which decisions each downstream issue needs before it can be marked READY. "—" means the issue does not depend on that decision.
| Issue | Phase | Hard prerequisites (decision IDs) | Soft (affects tests / wording only) | Also blocked by |
|---|---|---|---|---|
| #828 pure health state + fake wire | G1 | Q1, Q2, Q3a, Q6, Q8, P1 | Q4 (shape of snapshot type) | — |
| #829 gate composition + shared constructor | G1 | Q4, Q5, Q6, Q9, Q10, P1 | Q7 | #828 |
| #830 daemon/monitor/field-test parity | G2 | Q5, Q6, Q7 | Q3b (report wording) | #829 |
| #831 diagnostics + privacy-safe docs | G2 | Q6 | Q2 (how "unknown loss" is worded), Q3b, Q8 | #829, #830 |
| #832 first physical device | G3 | Q3a, Q3b, Q6 | — | #828–#831 merged; hardware available |
| #833 evidence review / release decision | G4 | Q2, Q3b | all (its evidence matrix maps every ADR-v2-155 clause) | #832 result or explicit BLOCKED |
Reading the matrix: Q6 is the gate on everything — a "no" freezes the epic under ADR-030 §3. After Q6, Q1/Q2/Q3a/Q8 are the minimum for G1 to start (#828); Q4/Q5/Q9/Q10 are the minimum for #829.
4. What this pack does not establish¶
- It does not show that any proposed constant is right. No device was observed; the 3 s / 2 report / 1 s values are from ADR-v2-155 and the spec, unmeasured (ADR-v2-155 last line).
- It does not verify the NeuroSky protocol claims; those are cited by ADR-v2-155 to the vendor page
https://developer.neurosky.com/docs/doku.php?id=thinkgear_communications_protocol(vendor documentation; not re-read for this pack). - Line numbers are valid only at
8a72bbc6; re-check before coding (spec §0 item 1). - It does not modify #826–#833, ADR-v2-155, ADR-v2-152 or any runtime file.