diff --git a/CLAUDE.md b/CLAUDE.md index 8af738d3..b5d41ca3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,9 @@ Example: `Arena Editor v2 | 2026-01-16 14:30 ET · GitHub` **IMPORTANT**: Always include timestamp in Eastern Time (ET) to distinguish multiple updates per day. Update the timestamp whenever the page is modified. -**To get current time**: Run `TZ='America/New_York' date "+%Y-%m-%d %H:%M ET"` in Bash to get the actual current time. Never guess or make up timestamps. +**To get current time**: on macOS/Linux run `TZ='America/New_York' date "+%Y-%m-%d %H:%M ET"` in Bash. **On Windows do NOT use that** — Git Bash has no tzdata, so it silently prints UTC labelled "ET" (this stamped #190's footer four hours off). Use PowerShell instead: +`[System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId((Get-Date),'Eastern Standard Time').ToString('yyyy-MM-dd HH:mm') + ' ET'`. +Never guess or make up timestamps. ## Design System diff --git a/docs/development/analog-input-plan.md b/docs/development/analog-input-plan.md index f52a5bc8..da331e1d 100644 --- a/docs/development/analog-input-plan.md +++ b/docs/development/analog-input-plan.md @@ -261,6 +261,31 @@ Instruments DAQ**. This is a separate, later step; nothing in §3–§4 depends Automate T2/T4 with the AD3 (`instruments` skill) once the panel exists; the pytest suite can sample 0xA4 directly for T3. +#### Bench results 2026-09-10 — first reworked 12-18 arena (MAC `04:E9:E5:1F:D1:72`, fw main + #48, no calibration record) + +Studio v0.74 (Pages) Console → Analog In; nominal scale, pre-F1 ADC. Two sweep runs per channel. + +| Test | AI1 | AI2 | Read | +|---|---|---|---| +| T1 open | ≈ +10.0 V | ≈ +5.5 V, noisy | AI2 is not reaching the pull-up value | +| T1 ground cap | +300 mV | **−2150 mV** | AI1 = ordinary offset; AI2 is not saturated (rework applied) but far off | +| T6 loopback slope (0–5 V) | 1.056 / 1.055 → `check` | 0.819 / 0.803 → `fail` | repeatable, linear on both channels | + +Diagnosis: back out V_adc from the AI2 readings (reading = 6·V_adc − 10 V): ground → 1.31 V, open +→ 2.58 V, versus nominal 1.67 V / 3.33 V. Both are low by the **same factor 0.79**, and open/ground += 1.97 ≈ 2 as nominal, so stage 1 and the pull-up are fine and the AI2 error is a **single stage-2 +gain error: divider ≈ 0.26 instead of 1/3**. The −2.15 V "offset" is that gain error seen through +the −10 V shift, not a second fault. Most likely cause: the old resistor left in parallel with the +new one on the lower leg (10k ∥ 20k = 6.7k → ratio 0.25 → slope ≈ 0.75–0.8) or a wrong value. +**Action: inspect R179/R181 on this board** (target R181 = 20k upper, R179 = 10k lower, nothing +else across them), then repeat T1/T6. The noise on the open reading fits a marginal joint on the +same node. AI1 (+5.5 % gain, +300 mV) is tolerance-class and is what F2's two-point calibration is +for; do not calibrate AI2 until the hardware is corrected. Course-bench 10-10 controllers are +un-reworked: sweep gives slope 0.0056 · offset 9980 mV → `fail` with the LAB-209 text (T1 signature). + +UI note from this session: the panel says `fail` for both a saturated board and a linear-but-wrong +channel; `summarizeSweep` should name the case ("saturated — LAB-209" vs "gain error N %"). + ### 5.2 Calibration (needs F2) - C1 Two-point procedure per channel; then DMM-verified −5 V and +5 V → error target diff --git a/docs/development/codex-review-2026-09-09.md b/docs/development/codex-review-2026-09-09.md new file mode 100644 index 00000000..3fb137c1 --- /dev/null +++ b/docs/development/codex-review-2026-09-09.md @@ -0,0 +1,147 @@ +# Codex cross-review of the pending merge-plan work — 2026-09-09 + +**Scope (four diffs, eight Codex passes, all completed):** + +| Diff | Files / lines | Reviewers | +|---|---|---| +| Run-log stack: #186 Studio v0.72 + #188 readers v0.73 vs main | 24 / 3758 | Claude (independent), Codex GPT-5.5 standard, Codex GPT-5.5 adversarial | +| Analog stack: #190 Analog In S1 v0.74 + #191 calibration S2 v0.75 vs main | 8 / 1714 | same | +| #193 gzip-aware `build-runlog-index.py` (merged, `7b4e533`) | 3 / 241 | same | +| Firmware F1 #46 + F2 #47 (`feat/ai-calibration`) vs main | 9 / 705 | same | + +Method: Claude wrote an independent review of each diff BEFORE Codex ran (files listed +in § 5), then reconciled. Rule from Michael: **true bugs get fixed, suggestions get +discussed.** § 1 is fixed and pushed; § 2–3 are for discussion; nothing else was changed. + +## 1. Fixed and pushed (true bugs — agreement, or a Codex claim Claude verified in code) + +| # | Issue | Who flagged | Where fixed | +|---|---|---|---| +| 1 | `js/runlog-replay.js` lost strict mode: the new `const Fmt` declarations pushed `'use strict'` out of the directive prologue (Prettier's `('use strict');` was the tell) — the whole replay IIFE ran in sloppy mode | Claude · Codex-std · Codex-adv | #188 branch | +| 2 | **Loopback sweep recorded readings against unapplied AO levels.** The Console `send()` helper returns the reply frame even on a non-ok status (it logs the reason), so `if (!ok)` only caught transport failures; with Analog Out in `frame_number` mode every `SET_AO_VOLTAGE` is refused and the sweep would still "succeed" | Codex-std (blocking) · Codex-adv; verified by Claude against `send()` | #190 branch (decode status, abort with a hint naming the AO mode; report a refused/unknown restore) | +| 3 | Analog In poller ignored the Console's own FicTrac closed loop (`bridge.apply`, 0x70 at ~100 Hz outside the runner) — a 10 Hz read interleaved into that stream adds frame-latency jitter | Claude (closed loop) · Codex-std (Console trials, see § 2) | #190 branch (gate on `bridge.apply`) | +| 4 | Firmware: a mis-sampled two-point record with a small span implies hundreds of volts; `GET_ANALOG_IN` cast that to `int16` and **wrapped** | Codex-std · Codex-adv (Claude noted the marginal-span case) | fw #47 (clamp to the field) | +| 5 | Firmware: `SET_ANALOG_CAL` ran while the display was active — sampling the input Mode 4 follows, mutating the record the control loop reads every sample, writing EEPROM + SD. Every other SD-writing command refuses unless stopped | Claude · Codex-std · Codex-adv | fw #47 (`CE_DISPLAY_ACTIVE` guard + HIL test) | +| 6 | `scripts/controller_info.py` decoded capability bits 0–4 only (missing `io_ext`, `ai_cal`) | Codex-std | fw #47 | +| 7 | Dashboard hover called `text.length` "bytes" | Claude · Codex-std | #188 branch (≈, annotated) | + +#191 was rebased onto the fixed #190 (footer conflict resolved); full suites green on +#188 and #190; both firmware environments build. **Everything in § 1 still needs the +bench gates (B1–B4) — none of it has run on hardware.** + +### 1b. Discussion items Michael chose to do (2026-09-09, second pass) + +| Item | Change | Where | +|---|---|---| +| 4 | Loopback verdict also requires \|offset\| ≤ 150 mV (a linear reading 1 V high is `check`, not `ok`); test added | #190 | +| 6 | Calibration commands use a 2 s timeout; one calibration action at a time (a second click is refused with a note). Read-before-write backup of the old record: not done (nice-to-have) | #191 | +| 7 | Mode 4 frame skipping in O(1): whole part of the accumulator as one modular step, bounded, non-finite guard — same final frame as the old one-per-iteration loops | fw #47 | +| 8 | Calibration points checked at sample time — open ≥ 3072 counts, ground within 1024..3072 — and refused with a reason instead of stored | fw #47 | +| 9 | Index builder marks damaged `.jsonl.gz` rows (`error`: `gzip truncated` / `gzip unreadable`) + a WARNING line; plain rows unchanged (byte-identical on real folders). Merged as #194 and installed in the course repo | main | +| 10 | `test_deadband_round_trip` gated behind `AI_CAL_DESTRUCTIVE` | fw #47 | + +Not taken up: 1 (large-commit retry — revisit before the first multi-hour run), 2 (ack +correlation), 3 (auto-switch AO mode — abort-with-hint kept), 5 (Console-trial poller gate), +11 (wording / safe-mode policy for the sweep), 12 (structural). + +## 2. For discussion — Codex raised, not changed + +### Run-log stack +- **`directCommitLarge` has no retry on a non-fast-forward ref update** [all three]. Another rig + committing between the ref read and the PATCH fails the >30 MiB commit (blob/tree/commit + become harmless orphans; the log stays on the bridge). Small fix: re-read ref, rebuild + tree/commit with the same blob, retry ≤3, plus a mocked test. Only multi-hour or `full` + logs take this path. +- **Ack not correlated to its request** [adv]: `log_control_ack` resolves every waiter; a + late ack from a previous enable, or a slow bench exceeding the 800 ms wait, can label a + run with the wrong `run_metadata.log_format` (the file's `frame_schema` stays right). A + request id in `log_control` (bridge + client) would close it. +- **"Flipping the default to v2/.gz before a bench run"** is Codex-adv's strongest argument + against merging. That is exactly gate B1 in the merge plan: #186 + #188 merge together only + after a real run round-trips through the bridge, the commit path, the dashboard and the + replay viewer. +- Browser-side truncated-gzip prefix inflation is untested (Node path is) [Claude · adv]; + `readRunlogPrefixText` returns `''` on failure, which reads as "no metadata" rather than + "cannot inflate here" [adv]. B1's catalog check covers it; a console warning is cheap. +- No 100 MiB preflight before base64-encoding a huge blob [adv]; several full-size copies + in memory during a large commit [std · adv]. Fine for behavior logs; note for `full`. +- The vendored decoder copy is a standing maintenance tax [adv] — known, test-enforced. + +### Analog stack +- **Switch AO to programmable for the sweep and restore the mode afterwards** [std · adv] + versus the abort-with-hint that was implemented. The rig-I/O code already clears + `frame_number` before 0xA0; doing it inside the sweep silently changes rig state — your call. +- **Sweep verdict ignores offset** [adv]: a perfectly linear AI reading AO + 1000 mV is "ok". + For a loopback, |offset| is diagnostic; adding `|offset| ≤ 150 mV` to the ok criterion is + one line. +- **Console-started Mode 2/4 trials don't pause the poller** [std]. The link is idle during + those (controller-timed), so it is harmless, but a console-trial flag would match the + stated "a run owns the link" rule. +- Calibration writes: default 500 ms timeout vs ~10–50 ms of firmware work [std] (fine + unless the SD is slow; rig-I/O uses 2000 ms); no double-click lock; no read-before-write + backup of the previous record so "Clear" is only reversible to nominal [adv]. +- Release note says calibration is "recorded in the run log"; it is `bridge.log` only while + bridge logging is active [std] — wording, or also write to `activeRunLog`. +- `caisweep` is `guardDestructive`'d but not in `SAFE_BLOCKED_CMDS` although it flashes the + LED [adv] — policy decision. +- Architecture: analog I/O ownership is now split across the I/O panel, the Analog In panel + and rig-I/O apply [adv]. + +### #193 index builder +- Codex-std's "blocking" — *the dashboard still filters `.jsonl` only* — was reviewed against + main. #188 replaces those filters with `isRunlogName` (`.jsonl|.jsonl.gz`); it confirms + the must-land-together rule and is harmless today (no `.gz` runs exist yet). +- **Corrupt gzip is silently indexed as an "incomplete" run** [std · adv · Claude minor]: + `inflate()` returns `''` on error. Add an `error` field to the row (and a stderr line) so a + damaged upload is visible — small, worth doing. +- Whole-file inflate of a huge `.gz` in the Action [std · adv]: a 1 h run is ~4 MB; a + multi-hour `full` log could be ~30 MB → ~300 MB of text. A size cap is cheap. +- No per-file isolation in the `--github` loop; `_put_index` retries with a fresh sha but + stale content (pre-existing) [adv]; `format_version` unchanged although `file` may end in + `.gz` and `size` is compressed [adv] — consumers handle it. + +### Firmware +- **Mode 4 catch-up `while` loops are unbounded** and the 100× gain makes bursts larger + [std · adv]. The loops pre-date F1; at unity gain ±1000 fps and a 2 ms tick is ≤ 2 + iterations, but a stalled loop (SD read) catches up in a burst. Bounded modulo arithmetic + is a small change with no effect in normal operation — recommend; discuss timing. +- **Tighten `ainCalValidate`** (require `raw_open` near the top of range, e.g. ≥ 2048) so a + wrong physical state cannot produce a "valid" record [std · adv · Claude]. The Studio warns + at < 3000 counts; the firmware could refuse. +- **The G3-faithful 100× gain change is a one-way door** [adv]: decided 2026-09-07 in the + analog plan; Codex's point stands that once users retune gains, reverting breaks the other + way. Bench CL2 confirms the number; no G6 Mode 4 protocols exist in the field yet. +- `0xA4` growing 4 → 5 bytes [adv]: verified no sibling repo (pyDisplayTools, maDisplayTools, + Modular-LED-Display, G4.1 controller) consumes it; accepted. +- `test_deadband_round_trip` writes EEPROM + SD on every default run [std · adv]; SD mirror + is remove-then-write (not atomic) [adv]; EEPROM address 0 has no allocation registry [adv]; + exact-length enforcement for 0xA5/0xA6/0xA7 [std minor]. + +## 3. Where Codex's two passes disagree + +- **Run-log stack:** standard → merge with the small fixes; adversarial → block flipping the + producer default until ack correlation, retry and one bench round trip are proven. + Tradeoff = gate B1 (already the plan). +- **Analog stack:** standard → one blocking bug (fixed) plus fixes; adversarial → don't merge + the calibration layer as designed (state ownership, no backup, offset-blind verdict). + Tradeoff: S2 is inert without `ai_cal` firmware; gate B4 exercises it on the bench first. +- **Firmware:** standard → fixable in place; adversarial → split into raw-read/capability, + calibration, and gain-change PRs with a version boundary. Tradeoff: F1/F2 are both + unbenched; gates B3/B4 decide before anything ships to a rig. + +## 4. Open questions for Michael + +1. Sweep: keep abort-with-hint, or auto-switch AO to programmable and restore the mode? +2. Add |offset| to the loopback verdict? +3. Bound the Mode 4 catch-up loop now (tiny) or after bench B3? +4. Tighten `ainCalValidate` in firmware (raw_open ≥ 2048)? +5. `directCommitLarge` retry — now, or when a >30 MiB run first appears? +6. #193: mark corrupt gzip rows with an `error` field (small) — do it? +7. Gate `test_deadband_round_trip` behind the destructive flag? + +## 5. Raw outputs (scratch worktrees, not committed) + +Per diff: `.codex-review/claude-analysis-.md`, `codex-diff-review-*/standard.md`, +`adversarial.md`, `diff.patch`, `meta.json`, under the review worktrees `cr-runlog`, +`cr-analog`, `cr-idx`, `cr-fw` in this session's scratchpad. Codex ran `gpt-5.5`; every +pass exited 0. diff --git a/docs/development/controller-telemetry-ring-buffer-proposal.md b/docs/development/controller-telemetry-ring-buffer-proposal.md new file mode 100644 index 00000000..116fb835 --- /dev/null +++ b/docs/development/controller-telemetry-ring-buffer-proposal.md @@ -0,0 +1,396 @@ +# Controller telemetry ring buffer — feasibility + test proposal + +Drafted 2026-09-07 17:38 ET (Michael + Claude). Status: **proposal, no code**. Companion to +`docs/development/analog-acquisition-handover.md` (§ 3.2 F3 / § 3.3 S4, the analog block +stream) and to Linear **LAB-149** ("controller and panel diagnostics with a retrievable +event log"). This document answers one question — *can the G6 controller keep an event log +without touching the SD card, and can the host drain it live?* — and lays out the bench +experiments that would settle it before anyone writes production firmware. + +## 0. TL;DR + +- **Yes, it is reasonable — and the RAM/CPU worries do not apply once the log never + touches the SD card.** A RAM ring buffer that the host drains every 50–100 ms needs a + few KB, not a 40-minute archive. Worst-case event traffic (500 fps frames + 100 Hz + commands + 1 kHz two-channel analog) is 12.6 KB/s; a 5 s cushion is 62 KB. The Teensy + has ~456 KB of unused OCRAM and ~132 KB of free DTCM. SD read+write concurrency never + arises because nothing is written to the SD. +- **The one real obstacle is the wire protocol, not the controller.** Framed replies are + capped at 197 B (one-byte length, 200 B response buffer). Draining 12.6 KB/s means + either ~66 small framed replies per second or reusing the existing raw "bulk response" + path that 0x84/0x8A use — and that path is the one macOS Chrome loses bytes on (#153). + The proposal makes the drain **lossless regardless** (ack-cursor, sequence numbers, + re-send) and tests both framings. +- **Yoke analog and frames, as Michael suggested.** When analog acquisition is on, one + fixed-rate *tick* record carries `[frame_index, ain1, ain2]`; frame-change events are + suppressed. When analog is off, the log is sparse frame-change + command + state events. + One stream, one controller clock, both cases. +- **What it buys beyond analog:** exact display timing of every frame in Mode 2/4 (today + the host knows nothing about what the controller showed when), controller-side receive + time of every command (host→controller latency for Mode 3, cf. LAB-164), SD frame-load + time per frame, and the PE/CE/reset/retry record LAB-149 asks for. +- **Recommendation:** run the six bench experiments in § 5 (about two bench days on a + test firmware branch, no production code) and take the numbers to the group. +- **Two files first, timestamps everywhere, latency is measurable (§ 7–9, added after + Michael's questions):** phase 1 writes a binary sidecar next to the run log and + integrates later; every record carries controller µs and every block carries a + controller↔host clock pair (a per-run *fit*, since crystal drift is 50–150 ms over + 40 min); the ring makes the host→controller→display segments of the FicTrac loop + observable in every run, and two bench experiments (L1/L2) validate them physically. + Only the camera segment needs its own one-time measurement (L3). + +## 1. What exists today (facts, with sources) + +Firmware (`LED-Display_G6_Firmware_Arena`, branch `arena-2x10-local`, PRs #46/#47 stacked +off main; survey 2026-09-07): + +- `loop()` is a flat cooperative pipeline (`main.cpp:110-133`): USB service → command → + `serviceDisplay()` → download/upload/archive service → flush. No RTOS. Ethernet is + compiled out on this branch; USB CDC is the only transport. +- Exactly two ISRs, both trivial: `SpiManager::refreshISR()` sets a flag + counter + (`SpiManager.cpp:14-19`), `dmaISR()` sets a flag. **Nothing touches SD or SPI data in + interrupt context**; IRQ priorities are set so SD reads stay in the main loop + (`main.cpp:161-168`). +- **One choke point for "a frame was displayed":** `transmitOnRefresh()` → + `SpiManager::transferFrame()` (`CommandProcessor.cpp:1219-1224`, `SpiManager.cpp:175-268`, + `++frames_sent_` at `:184`). `cur_frame_index_` is written in four places (0x70 handler + `:1097`, open loop `:1486`, closed loop `:1529/1534`, `enterPatternMode`). +- **One choke point for commands:** `processCommand()`. +- SD: Arduino `SD.h` over SDIO; `readFrame()` is a blocking seek+read+CRC in loop + context, called only on index change (`SdManager.cpp:316-347`, `loadFrame()` + `CommandProcessor.cpp:1541-1565`). GS16 frame = 4064 B, GS2 = 1064 B. Every existing SD + *write* path refuses unless the display is stopped (`CE_DISPLAY_ACTIVE`). There is no + claim anywhere that concurrent SD read+write is safe — the firmware's stance is "don't". +- Reply framing: `sendResponse()` writes a **one-byte length**, `RESP_BUF_SIZE = 200` + → max framed payload **197 B** (`SerialManager.cpp:240-253`, `constants.h:129`). No + 16-bit-length reply exists. Long replies use `sendRaw()`: a framed header carrying a + byte count, then unframed bytes chunked to `Serial.availableForWrite()` with a 2 s + stall abort (`SerialManager.cpp:190-238`). `flushResponses()` never blocks. +- USB CDC measured throughput ~1.37 MB/s (firmware README:153); pyserial sustains ~8 MB/s + downloads on the same hardware (#153). +- Memory (perf build, `arm-none-eabi-size`): DTCM `.data`+`.bss` 163 KB, ITCM 218 KB → + **~132 KB free in RAM1**; OCRAM `.bss.dma` 56 KB → **~456 KB free in RAM2** + (addressable with `DMAMEM`). No `EXTMEM`/PSRAM on the Teensy (the "PSRAM" in this + codebase is panel-side). +- Time: `micros()` everywhere (1 µs resolution, 32-bit wrap at 71.6 min — must be + handled); no host↔controller clock sync opcode. +- Telemetry today: `frames_sent_` (0x33/0x34), `isr_count_` (debug only), + `GET_FRAME_POSITION` 0x72. No timestamps, no counters for drops/errors, no event log. +- Analog after F1/F2: 12-bit, 16× averaging, **~40 µs blocking per `analogRead`**; Mode 4 + samples at 500 Hz in the main loop with EWMA + deadband; calibration `ainMv()`. + +Studio / log pipeline (webDisplayTools): + +- `ArenaLink.send()` is single-flight, echo-correlated, 500 ms timeout; `sendBulkRead()` + handles the 0x84-style header-then-raw path (`js/arena-link.js`). `port.open` still + passes no `bufferSize` (line 162) — the #153 mitigation is not on main. +- Every command the browser sends is already logged host-side as an `arena_command` echo + with browser `t` and bridge `rx_ms` (`js/arena-session.js:311`); in `behavior_v2` that + becomes the compact `["a", …]` row. Runner semantic events (`phase`, condition names) + go to the bridge log too. +- The bridge is a pass-through writer for browser `{type:"log"}` objects; it calls + `setdefault` on the parsed message, so a bare JSON **array** from the browser would + currently be rejected — a one-line bridge change if we want compact array rows written + verbatim (`fictrac-bridge/bridge.py:289-300`). +- Format invariants (runlog-behavior-v2 plan, PRs #183/#186/#188): uniform NDJSON, one + `frame_schema` line, tagged compact arrays expanding losslessly to a canonical object, + offsets from `t0`, converter + corpus gate, one shared `js/runlog-format.js`. + +## 2. Why the SD-card event log was the wrong shape + +The earlier idea (LAB-149 as originally imagined: controller writes a log file, host +fetches it at run end) has three costs that the ring buffer removes: + +| Concern | SD event log | RAM ring + live drain | +|---|---|---| +| Storage for 20–40 min | must fit on SD or in RAM (15–30 MB) | a few KB; the host is the archive | +| SD concurrency | new *write* path racing the frame *read* path in the same loop; every existing write refuses while displaying, for this reason | none — the SD is never touched | +| Loss of the run on crash/power | whole log lost if the file isn't closed | at most the last drain interval (≤ 100 ms) | +| Merging with host/behavior logs | separate file, separate clock, post-hoc | rows land in the same JSONL as the FicTrac frames and arena echoes, stamped with both clocks | +| Performance | file writes cost ms and jitter the frame loop | recording a record is a few dozen cycles at the choke point | + +## 3. Proposed design (for discussion — details are what the tests decide) + +### 3.1 On the controller + +**Ring buffer.** One byte ring in `DMAMEM` (OCRAM), 64 KB default (≈ 5 s of worst-case +traffic; ≥ 50 s of a typical Mode 2 run). Single producer (loop + analog ISR — see +below), single consumer (the drain handler). Records are variable-length with a type +byte; a running 32-bit **record sequence number** and a **drop counter** live beside the +ring. When the ring is full the producer drops the *newest* record and bumps the counter +(the host sees the gap in `seq` and the count in the next block header; nothing is +silently lost — it is explicitly counted). + +**Record types (compact binary, little-endian).** + +| Type | Bytes | Fields | When | +|---|---|---|---| +| `TICK` block | 8 + n×2 or n×6 | `t0_us u32, rate_hz u16, n u16`, then n × `idx u16` (+ `ain1 i16, ain2 i16` when analog on) | fixed-rate, produced by the sampling ISR; **the yoked record** | +| `FRAME` | 10 | `t_us u32, idx u16, pattern u16, sd_load_us u16` | at `transmitOnRefresh` when the displayed index changes; **suppressed while TICK runs** | +| `CMD` | 8 + ≤8 | `t_us u32, cmd u8, status u8, len u8, payload[0..8]` | in `processCommand`, after dispatch (controller receive time + result) | +| `STATE` | 8 | `t_us u32, kind u8, code u8, arg u16` | trial start/stop/mode change, PE/CE codes, resets, SPI retries, ring overrun, SD read > threshold — the LAB-149 payload | + +Yoking rule: when analog acquisition is enabled the tick period (1 ms at 1 kHz) already +bounds frame timing to within one tick, and the refresh timer runs at 300 Hz (GS16) / +1000 Hz (GS2), so a `FRAME` event per change adds nothing but bytes; TICK carries the +index instead. When analog is off, no ticks are produced and `FRAME` events give exact +µs display times. A 1000 Hz GS2 refresh can alias a 1 kHz tick — sample at 2 kHz or keep +`FRAME` events on for GS2; test T3 decides. + +**Sampling ISR.** An `IntervalTimer` at the tick rate reads the ADC(s) and appends a +tick. Concern: F1's 16× averaging makes `analogRead` block ~40 µs per channel, so 1 kHz × +2 ch ≈ 8 % of CPU inside an ISR and up to 80 µs of added latency to the refresh ISR. +Options to measure in T2: 4× averaging (10 µs), one channel per alternate tick, or +hardware-triggered ADC with DMA (ADC_ETC) — deferred unless T2 forces it. **Mode 4 should +consume the same samples** (one sampling path; its EWMA/deadband stay in the loop on the +latest tick) — open question 6 in the handover, recommended answer here. + +**Opcodes.** Firmware PR #47 fixed the `0xA_` allocation rule (A0–A3 analog out, A4–A9 +analog in, AA–AF digital; set/get pairs on adjacent even/odd opcodes) and **reserved 0xA8 / +0xA9 for the sampled block stream** — which is this telemetry stream. `GET_CLOCK` goes in +the `0xC_` controller-info block next to 0xC2. + +- `SET_TELEMETRY` **0xA8** — `[len, A8, flags, rate_lo, rate_hi]`: flags bit0 events on/off, + bit1 analog ticks on/off, bit2 raw-vs-calibrated analog; rate in Hz (0 = tick off). + Sampling and event recording run in any state, so the Console scope works outside trials. +- `GET_TELEMETRY_BLOCK` **0xA9** — `[len, A9, ack_seq u32, max_bytes u16]`: the controller frees + everything up to `ack_seq`, then returns the next records from its read cursor **without + freeing them** (freed only by the next ack). Header: `t_now_us u32, first_seq u32, + n_records u16, dropped u32, more u8`. This makes a lost or timed-out reply harmless: the + host simply re-asks with the same `ack_seq` and gets the same bytes again. +- `GET_CLOCK` — returns `micros()` and `millis()`; the host logs one pair per trial for + wall-clock alignment (every block header also carries `t_now_us`, and the host stamps + `rx_ms`, so a continuous pairing is available too). +- Capability bit 7 `telemetry` in the 0xC2 bitmap; the Studio greys the feature without it. + +**Reply framing — the decision the tests make.** Two candidates, both to be implemented +on the test branch: + +- **(A) Chunked framed.** Each `GET_TELEMETRY_BLOCK` reply is a normal `[len, status, + echo, header, records…]` frame ≤ 197 B payload (≈ 180 B of records); `more=1` tells the + host to ask again immediately. Worst case 12.6 KB/s → ~66 round trips/s; typical Mode 2 + without analog → 5–10/s. Uses the proven single-flight path, immune to #153, no new + parser code. Cost: round-trip latency per chunk (expected 1–3 ms on USB CDC — T1 + measures it). +- **(B) Bulk.** Header via `sendResponse`, records via `sendRaw` (as 0x84 does); one + transaction per poll of any size. Faster, but it is the exact path on which macOS Chrome + loses bytes (#153) — though the losses were observed 5–20 KB into 21 KB bursts, and + these blocks are ~0.6–1.3 KB. The ack-cursor makes a lost block recoverable; T1 tells us + how often that would happen. + +Prior: (A) as default, (B) only if T1 shows (A) cannot keep up with analog on. A third +option — a new 16-bit-length reply frame type — is not worth a parser change when (A) +exists. + +### 3.2 On the host (Studio) + +- A **telemetry poller** (pattern: `StudioAnalogIn.createPoller` — single-flight, gated on + connection + capability, quiet) at 10–20 Hz, using `session.send` directly (not the + Console's logging `send()`), looping on `more`. It runs during Mode 2/4 trials (idle + link); in Mode 3 FicTrac trials, where 0x70 saturates the link, it runs with analog + ticks **off** and events on (≈ 1 KB/s → 6 chunks/s), and the UI says so. +- Decoding lives in a new classic dual-export module `js/arena-telemetry.js` (record + parsers, seq/drop accounting, µs-wrap handling, clock pairing) with a Node test; wire + encoders/decoders in `js/arena-wire-g6.js` (export + test each — the missing-export + gotcha). +- Live: ticks feed the oscilloscope as the analog trace set the handover describes; + `FRAME`/`STATE` events feed the run-status pane (drops, PE/CE) and the Console log + (state changes only). +- Run log: the browser expands each block into compact JSON rows and sends them to the + bridge (§ 3.3). The bridge stays the single logger; it needs to accept arrays verbatim + (or the browser wraps them — decide with the format). + +### 3.3 Fit with the run-log format + +One run = one artifact stays an invariant, so rows go into the same JSONL. Following the +`behavior_v2` model (tagged array, offsets from `t0`, lossless expansion in +`runlog-format.js`, converter + corpus gate): + +``` +["ct", t_off, rx_off, t_us, seq, rate_hz, [idx…], [ain1…], [ain2…]] # tick block +["cf", t_off, rx_off, t_us, idx, pattern, sd_load_us] # frame event +["cc", t_off, rx_off, t_us, cmd, status, "hex"] # command (controller side) +["cs", t_off, rx_off, t_us, kind, code, arg] # state / error +``` + +Schema: keep `behavior_v2` and add an optional `streams` object to the `frame_schema` line +(`{"ct": {cols:[…], rate_hz}, "cf": {…}, …}`) rather than bumping the level — readers that +don't know the tags already pass unknown rows through; readers that do get the columns from +the schema. Size estimate at 1 kHz analog + 20 Hz polls: ~15 KB/s of JSON (int arrays gzip +well because `idx` repeats) → roughly 50 MB/h raw, ~8–12 MB/h gzipped, under the 30 MiB +Contents-API path for runs up to ~2 h and always under the 100 MiB hard limit via the Git +Database path. Without analog (events only) the addition is < 1 MB/h. Test T6 measures the +real numbers on synthetic data before the format is frozen. + +Alternative rejected: base64 of the raw binary block inside a row — smaller before gzip, +larger after, and opaque to every reader. + +## 4. Budget (worst case, everything on at once) + +| Source | Rate | Record | B/s | +|---|---|---|---| +| Frames (Mode 2, 500 fps, events mode) | 500/s | 10 B | 5,000 | +| Commands (Mode 3, 0x70 at 100 Hz) | 100/s | 16 B | 1,600 | +| Analog ticks, 2 ch, yoked idx | 1,000/s | 6 B | 6,000 | +| **Total** | | | **12,600 B/s ≈ 45 MB/h** | + +USB CDC link occupancy at 1.37 MB/s: **< 1 %**. Ring holding 5 s: **62 KB** (of ~456 KB +free OCRAM). 40-minute run: 30 MB total drained, held on the host, never on the +controller. Per 50 ms poll: ~630 B → 4 framed chunks under (A), one block under (B). + +## 5. Test plan — what to measure before anyone commits to the design + +All on a **throwaway firmware branch** (`test/telemetry-ring`) with a synthetic producer +and both framings; host side via a Node/pyserial script *and* the Studio (Web Serial is the +production transport and the one with known problems). Instruments: framescan DIO role + +Saleae/AD3 (`instruments` skill), AO `frame_number` mode as an fps meter. + +| # | Experiment | Setup | Pass criterion | +|---|---|---|---| +| **T1 Drain throughput & loss** | Synthetic producer fills the ring at 5 / 12.6 / 50 KB/s; host polls at 10 and 20 Hz with framing (A) and (B); 30 min each; Linux Chrome, macOS Chrome (with and without `bufferSize: 1 MiB`), pyserial as control | 0 unrecovered records (seq continuous after re-sends); measured max sustainable B/s and per-chunk round trip; how often (B) needs a re-send on macOS | +| **T2 Recording overhead / frame jitter** | Mode 2 from SD at 100, 300, 500 fps (GS16 and GS2), telemetry off vs events-on vs events+1 kHz ticks (16× and 4× averaging); framescan pin on the logic analyzer | Frame period jitter and dropped-refresh count unchanged within noise (target: < 50 µs added jitter, 0 extra drops); ISR duration measured | +| **T3 Yoking correctness** | AO in `frame_number` mode looped back into AI1 (the existing Console sweep); Mode 2 at 300 fps with ticks at 1 kHz and 2 kHz; generator sine on AI2 | Each tick's `ain1` matches its own `idx` (AO tracks the index) to within one tick; no aliasing at GS2 1 kHz refresh (or the doc records that GS2 needs 2 kHz / `FRAME` events); sine amplitude + frequency reconstructed from the log | +| **T4 Endurance** | 40-minute Mode 2 protocol on a real 2×10 arena, telemetry on, run through the Studio + bridge | Ring high-water mark; `dropped` stays 0; `micros()` wrap handled; log size raw and gzipped; commit path used | +| **T5 Latency insight (bonus)** | Mode 3 FicTrac closed loop with events on: compare host `t` of each 0x70 echo with the controller `CMD.t_us` and the following `FRAME.t_us` | Distribution of host→controller and command→display latency — the first direct measurement of the Mode 3 pipeline (LAB-164) | +| **T6 Format** | Convert T4's binary blocks to the § 3.3 rows; run the v2 round-trip test, the dashboard corpus parity, and the replay viewer | Lossless expansion; sizes match the § 3.3 estimate; old readers ignore the new rows without error | + +Order: T1 first (it decides the framing and whether macOS is a problem), T2 (decides ISR +averaging and whether ticks are free), then T3/T4 together, T5/T6 opportunistic. Estimated +two bench days plus one day of scripting; no production code changes in any repo. + +## 6. Decisions to bring to the group + +1. Framing (A) chunked vs (B) bulk — after T1. +2. Tick rate default (500 Hz / 1 kHz / 2 kHz) and averaging — after T2/T3. +3. Calibrated mV on the controller vs raw counts + host-side calibration in the log + (handover open question 2). Recommendation: calibrated on the controller, raw as a + flag for diagnostics — one truth, and Mode 4 already uses calibrated volts. +4. Mode 4 consumes the tick samples (one sampling path) — recommended yes. +5. Schema: `streams` field on `behavior_v2` vs a `behavior_v3` level — recommended `streams`. +6. Whether `FRAME` events stay on alongside ticks for GS2 patterns — after T3. +7. Scope of `STATE` events for LAB-149 (which PE/CE/retry paths get instrumented first). + +## 7. One file or two? (Michael's question 1) + +**Recommendation: start with a separate controller-log sidecar; integrate as step 2.** + +- Phase 1: the browser forwards each drained block to the bridge as a `ctl` message and the + bridge writes it to a second file next to the run log, `.ctl.bin` — the **raw binary + blocks verbatim**, each prefixed by a small fixed header `{rx_ms u64, n_bytes u16}` (the + host receive stamp; everything else is already in the block). No JSON, no schema design + yet, ~12 KB/s worst case before gzip. The bridge change is a second file handle keyed by + message type; the Studio commits both files (one Git Database tree with two entries — + `directCommitLarge` builds a one-entry tree today, so a small extension). The dashboard + catalog pairs files by basename. +- Why sidecar first: it decouples this work from the three unmerged `behavior_v2` PRs and + from the format decision (§ 3.3 / decision 5), lets the bench experiments start with a + 10-line decoder, and keeps the controller log crash-safe (the bridge writes as it goes; + browser memory is lost on a tab crash). The **same decoder module** (`js/arena-telemetry.js`) + serves the live scope, the sidecar reader, and — later — the integration converter. +- Phase 2 (after the format settles): a converter merges `.ctl.bin` into the run log as the + § 3.3 compact rows, with the round-trip test and corpus gate; from then on the bridge + writes the rows directly and the sidecar is dropped. Existing sidecar runs are converted + once. Pairing key = the `(t_now_us, rx_ms)` pairs in every block header, so integration + loses nothing that phase 1 recorded. +- Cost of two files: two artifacts per run for the commit path, catalog, and replay until + phase 2. Acceptable for a bench-and-first-release period. + +## 8. Timestamps (Michael's question 2) + +**Yes — every record carries the controller clock, and the drain carries the pairing +needed to map it to wall time.** + +- Events (`FRAME`, `CMD`, `STATE`) carry an absolute `t_us` u32 (`micros()`, 1 µs). `TICK` + blocks carry `t0_us` + `rate_hz`; sample times are implicit (`t0 + i/rate`), which is the + "send deltas" idea taken to its limit — a constant delta costs zero bytes. Recommend + `FRAME` stamp both the start and end of `transferFrame` (`t_us` + `spi_us` u16), since + "frame on the SPI bus" is what the panel latches. +- Deltas on the wire for events: a u16 delta with an escape would save 2 B on a 10 B + record (20 %) — about 1 KB/s at the 500 fps worst case against a link we use < 1 % of. + Not worth the fragility: a ring-overrun drop breaks a delta chain unless each block + restarts absolute. **Keep absolute u32 on the wire**; do the delta compression where it + is free — `t_off` small-int offsets in the JSON rows and gzip on the file. Revisit only + if T1 shows bandwidth matters. +- `micros()` wraps every 71.6 min. The host unwraps using the monotonic record `seq` and + the block header `t_now_us`; a run may cross a wrap and must not care. +- **Clock mapping is a fit, not an offset.** Teensy crystal vs PC clock drift of a few + tens of ppm is 50–150 ms over a 40-minute run — far larger than the latencies in § 9. + Every drain block pairs `t_now_us` with the browser's `performance.now()`-derived + receive time (sub-ms; `Date.now()` is only ms), so the host has ~20 pairs/s and fits a + line per run (offset + rate), refined by the `GET_CLOCK` round trip whose RTT bounds the + one-way uncertainty. The fit residuals are themselves a health metric for the link. + +## 9. Glass-to-glass latency in FicTrac closed loop (Michael's question 3) + +Goal: confirm — before committing to the plan — that the ring buffer plus a bounded bench +effort gives a **robust, repeatable** measurement of the delay from the ball moving to the +panels changing, in real experiments and not only on the bench. + +### 9.1 The chain and its three clocks + +``` +ball moves → [camera exposure/readout] → FicTrac → UDP → bridge → WS → browser → 0x70 → + USB → controller receive → SD loadFrame → wait refresh tick → SPI to panels → LEDs +``` + +| Segment | From → to | Clock(s) | Observable today | With the ring | +|---|---|---|---|---| +| S1 camera + FicTrac | exposure → UDP record out | camera hw clock (`ft`, col 22, ns) → PC | `ft` and bridge `rx` (frame row `ms`) exist, but the **camera↔PC offset is unknown** | unchanged — needs § 9.3 | +| S2 host stack | bridge rx → browser handler → 0x70 written | PC only (same machine) | bridge `ms`, browser `t` on the echo — already logged | add `performance.now()` sub-ms stamps to `_logCommand` | +| S3 USB one-way | 0x70 written → controller `processCommand` | PC → controller | only the RTT (echo receipt − send) | `CMD.t_us` + the § 8 clock fit: direct one-way per command | +| S4 controller | `CMD.t_us` → `FRAME.t_us` (+`spi_us`) | controller only | nothing | **exact, every frame**: SD load time, refresh-phase wait (uniform 0–3.3 ms at 300 Hz, 0–1 ms at 1 kHz), SPI duration | +| S5 panel | SPI end → LEDs at new brightness | external | nothing | one-time constant from § 9.3 (framescan DIO + photodiode) | + +S2–S4 become **fully observable in software for every real run** once the ring exists; S1 +and S5 are hardware constants (plus S1's jitter, which *is* observable as the spread of +`rx − ft`) measured once per hardware configuration on the bench. Glass-to-glass per frame +is then `S1 + S2 + S3 + S4 + S5` with S1's absolute part and S5 from the bench. + +### 9.2 What the ring changes + +Today the only latency number a run yields is the 0x70 RTT. With `CMD` and `FRAME` +records the run log gives, per closed-loop frame, the host→controller one-way, the SD +load, the refresh-phase wait and the SPI time — as a **histogram per run in the dashboard**, +not a one-off measurement. That also exposes the structural term nobody has measured: in +Mode 3 the controller waits for the next refresh tick after a 0x70, adding a uniformly +distributed 0–3.3 ms at GS16. If that matters, "display on receipt" for Mode 3 is a +firmware design question the numbers would justify (not proposed here). + +### 9.3 Bench experiments (L-series, alongside T1–T6) + +| # | Experiment | Setup | Output | +|---|---|---|---| +| **L1 software-chain glass-to-glass** (no camera) | A small Python sender (the FicTrac simulator, `pixi run sim`, extended) emits FicTrac-format UDP frames with a heading step **and toggles an AD3 digital line at the same instant** (`instruments` skill, pydwf). Saleae captures the AD3 line, the controller's `out_debug_framescan` DIO and a **photodiode on one panel**. Mode 3 closed loop through bridge + Studio + ring | Absolute delay UDP→photons, repeat ×200 for a distribution. Compared to `S2+S3+S4` reconstructed from the logs for the same frames → **validates the clock fit and the ring timestamps** to sub-ms | +| **L2 panel constant (S5)** | Framescan DIO vs photodiode edge on the same capture as L1 | S5 mean/spread (expected sub-ms; PWM period sets the floor) | +| **L3 camera + FicTrac (S1)** | IR LED in the camera field of view driven by the AD3; a short Spinnaker/Arena-SDK grab script records each frame's hardware timestamp and whether the LED is lit; FicTrac processing time from its own col 24 / a profiling run. Reuse LAB-164 if Isabel's numbers exist (the Linear issue records none) | S1 absolute = exposure→UDP-out, and the camera↔PC offset method: offset ≈ `min(rx − ft)` over a run, validated against the LED ground truth | +| **L4 true glass-to-glass (gold standard, optional)** | A motorized patterned ball or disk (stepper; step pulse on the Saleae) under the real camera, FicTrac tracking it, Mode 3 closed loop, photodiode on a panel | One end-to-end number per hardware configuration to compare with `L3 + L1`. Do this only if the group wants the physical number rather than the validated sum | + +Order: L1 and L2 come free with T5 (same setup, one extra AD3 line and a photodiode). L3 +is a half day with the camera. L4 is a day and some hardware. + +### 9.4 Can this be done well? — assessment + +- **Yes for S2–S4** (the software and controller chain): every quantity is a timestamp we + control, on clocks we can fit, with L1 as the physical cross-check. Expected precision + ≈ 0.2–0.5 ms per frame, set by the USB RTT asymmetry (T1 measures it). +- **Yes for S5** with one photodiode; it is a constant. +- **S1 is the only segment with a real uncertainty**: the camera clock is unsynchronized + with the PC, so its absolute latency needs L3 (or LAB-164's data) once per camera model + and setting; after that, `rx − ft` gives S1's jitter in every run. The `min(rx − ft)` + offset trick assumes some frames arrive at the minimal latency during a run — true for a + 5–10 minute run, and L3 tells us how far off it is. +- **What would make the plan fail**: (a) T1 showing a Web Serial RTT so variable that + S3 cannot be bounded — unlikely on USB CDC, and the fit residuals would show it; (b) the + refresh-tick wait dominating (0–3.3 ms) and making the question "what is the latency" + ill-posed — that is a finding, not a failure, and it points at the Mode 3 design change. +- Decision gate: run T1 + L1/L2 first (two bench days total). If L1's physical delay + matches the log-reconstructed `S2+S3+S4` within ~1 ms, the method is proven and the rest + is instrumentation of constants. + +## 10. What this does not change + +- The host-side `arena_command` echo logging stays: it is the *host* clock view and the + proof of what was sent; `CMD` events add the controller's view. The bridge remains the + single default-on logger. +- AO `frame_number` output stays for labs recording on a DAQ. +- Nothing here needs the SD card, the ISP path, or the panel firmware. diff --git a/docs/development/lab-pc-handover-2026-09-09.md b/docs/development/lab-pc-handover-2026-09-09.md new file mode 100644 index 00000000..54b66db8 --- /dev/null +++ b/docs/development/lab-pc-handover-2026-09-09.md @@ -0,0 +1,234 @@ +# Lab-PC handover — bench-testing the run-log v2 stack (+ optional analog), 2026-09-09 + +**For a fresh Claude session on the lab Windows PC.** Read this file first, then +`docs/development/merge-bench-plan-2026-09-08.md` (the full gated checklist) and +`docs/development/codex-review-2026-09-09.md` (what was just fixed and what is still open). +The Mac session that wrote this may still be running; treat GitHub as the source of truth for +branch state, not this file. + +## Status after the bench day (2026-09-10 19:13 ET) — read this before § 0 + +| Item | Result | +|---|---| +| Gate B1 (run-log v2) | **passed 1–9** on `rig05-mr` with the sim; #188 merged (`f744e12`), #186 closed; Pages v0.73 | +| Gate B2 (Analog In S1) | **passed** (software) on a course 10-10 controller; #190 merged (`880ffd5`); Pages v0.74 | +| #191 (calibration S2) | rebased onto main, CI green, **held** for gate B4 | +| fw #48 (12-18 variant) | reviewed + approved with nits; **flashed on the 12-18 arena**; LED column sweep confirmed | +| 12-18 analog | AI1 OK (+5.5 % gain, +300 mV offset); **AI2 stage-2 divider wrong (0.79×)** — inspect R179/R181 | +| fw #46/#47, B3/B4 | not started; need #48 merged first (rebase `constants.h`/`README.md`), then the 12-18 board | +| Lab PC | pixi, gh (`mbreiser`), FicTrac (`C:\Lab\GitHub\fictrac`, `pixi-build` branch, Spinnaker 4.4 detected; still needs the rig `config.txt` from the labadmin install), firmware clone + `C:\Lab\GitHub\fw-48` worktree | + +Gotchas learned on this PC (add to § 6 mentally): + +- **Git Bash `TZ='America/New_York' date` prints UTC here** (MSYS has no tzdata) — the CLAUDE.md + recipe is wrong on Windows. Use PowerShell: + `[System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId((Get-Date),'Eastern Standard Time')`. + (#190's footer stamp `16:50 ET` is really 12:50 ET; superseded when #191 lands.) +- **Retargeting a PR base does not trigger CI** (`pull_request: edited`); gate locally with + `pixi run test` on the head sha, or push a commit. +- **Rebasing a child of a squash-merged parent:** `git rebase --onto origin/main `, + not a plain rebase (patch-ids no longer match → add/add conflicts). +- **`pixi run test` needs `PYTHONUTF8=1` on Windows** — the Python tests print `→` and die with + `UnicodeEncodeError` under cp1252. A `[activation.env]` fix is stashed on this checkout + (`git stash list`), destined for its own PR. +- **Firmware upload on Windows:** `scripts/find_teensy.py` is Linux-only → pass + `-- --upload-port COMx`; `teensy_loader_cli` cannot soft-reboot on Win32 and waits — the board + only entered HalfKay once the **arena was powered**. #48 renames the tasks: + `deploy-12-18-performance`, `deploy-10-10-performance` (§ 5 below is stale on this point). +- Windows shows stale "Unknown" COM entries for previously plugged controllers; the live one is + the `Status OK` port (`Get-PnpDevice | ? InstanceId -match VID_16C0`). +- `pixi run ` refuses to start while `pixi.toml` has conflict markers — resolve it with + `.pixi/envs/default/python.exe` directly. + +## 0. Goal for the bench day, in priority order + +1. **Land the run-log v2 + gzip stack** (#186 Studio v0.72, #188 readers v0.73): one real run + through the bridge → `.jsonl.gz` in the data repo → dashboard + replay open it → merge. + Highest priority. Everything else is optional today. +2. Minimal Analog In check (#190) if 10 minutes remain. +3. Firmware F1/F2 + calibration UI (#46/#47/#191): only with a LAB-209-reworked board; skip otherwise. + +## 1. Install on the lab PC (Windows) + +Already there: FicTrac. Add: + +| What | Why | How | +|---|---|---| +| **Git for Windows** | clone both repos | `winget install Git.Git` | +| **pixi** | the ONLY dev tool either repo needs — it fetches Node, Prettier, Python, websockets (web tools) and PlatformIO + the Teensy toolchain (firmware) on first `pixi run` | PowerShell: `iwr -useb https://pixi.sh/install.ps1 \| iex`, then reopen the terminal | +| **Google Chrome** (or Edge) | Web Serial — the Studio talks to the Teensy from the browser; localhost counts as a secure context | winget or installer | +| **GitHub CLI `gh`** (optional) | merging/retargeting PRs from the terminal; the web UI works too | `winget install GitHub.cli` then `gh auth login` | +| **Claude Code** | the new session | desktop app / installer | +| Teensy USB driver | none needed on Windows 10/11 (USB-CDC is in-box); PlatformIO installs the Teensy loader | — | +| Optional, analog only: Digilent WaveForms (AD3), a DMM, a BNC cable | bench gates B2–B4 | Digilent site | + +Repos to clone (any folder; keep them siblings): + +```powershell +git clone https://github.com/reiserlab/webDisplayTools.git +git clone https://github.com/reiserlab/LED-Display_G6_Firmware_Arena.git +``` + +One-time in each: `pixi install` (web tools) — the firmware repo installs on first `pixi run`. +A data-repo token for the Studio's GitHub sign-in: org members use a fine-grained PAT scoped to +`reiserlab/cshl-2026-course`; the shared course guest account needs a CLASSIC token +(`docs/development/data-repo-token-runbook.md`). The bench id is set in the Studio's File ▾ menu. + +## 2. Branch state (as of 2026-09-09 ~09:50 ET; verify with `gh pr list`) + +| PR | Branch | Contains | State | +|---|---|---|---| +| #186 | `claude/runlog-behavior-v2-studio` | Studio v0.72: gzip commits, behavior_v2 default, bridge ack | on main, CI green, **do not merge alone** | +| #188 | `claude/runlog-behavior-v2-readers` | v0.73: `js/runlog-format.js`, dashboard/replay readers, `index.json` filter fix, strict-mode fix — **includes #186's commit** | based on #186's branch; rebased onto main; suites + corpus gates green | +| #190 | `feat/console-analog-in` | Analog In panel S1 v0.74 + sweep refusal fix + closed-loop gating + offset criterion | on main, CI green | +| #191 | `feat/console-analog-cal` | calibration UI S2 v0.75 + 2 s timeouts + click lock | based on #190; rebased | +| fw #46 | `feat/ai-12bit-g3-gain` | F1: 12-bit ADC, G3 gain (100 fps/V at unity), 0xA4 flags | on fw main; never run on hardware | +| fw #47 | `feat/ai-calibration` | F2: EEPROM calibration 0xA5–0xA7 + display-active guard + int16 clamp + O(1) Mode 4 skipping + plausibility-checked points | based on #46; builds; never run on hardware | +| #192 | `claude/data-logging-ring-buffer-9f400b` | docs only: telemetry proposal, merge plan, review, this file | merge any time | + +Already on main: bridge 3.0 (#183, behavior_v2), runlog-index Action (#187) + gzip support +(#193, #194) — the data repo's `.github/scripts/build-runlog-index.py` is a byte-identical copy +of `scripts/build-runlog-index.py` @ main (installed 2026-09-09). + +## 3. Step-by-step: gate B1 (run-log v2 end to end) — ~45 min + +Serve the readers branch locally (Pages serves only main): + +```powershell +cd webDisplayTools +git fetch origin +git checkout claude/runlog-behavior-v2-readers +pixi run serve # http://localhost:8000 (leave running) +``` + +Second terminal, same checkout: + +```powershell +pixi run bridge # WebSocket :8765, FicTrac UDP :60000 +``` + +| # | Do | Pass | +|---|---|---| +| 1 | Open `http://localhost:8000/arena_studio.html`; connect the arena (Web Serial chooser → the Teensy COM port); connect the bridge (`ws://localhost:8765`); sign in to GitHub; set the bench id | Footer says **v0.73**; Console FicTrac panel's log-level mirror reads `behavior_v2` with no ⚠ | +| 2 | Start FicTrac on the rig (or `pixi run sim` in a third terminal if no fly/ball is available) | Bridge stats show frames arriving | +| 3 | Run a short protocol, ≥ 2 conditions, ≥ 60 s, ideally including a Mode 3 (FicTrac) condition | Run banner: `run logging to JSONL (behavior_v2)`; run completes | +| 4 | Watch the commit line at run end | `✓ run log committed: runlogs//.jsonl.gz (X MB → Y KB gzip)`; file visible in the data repo | +| 5 | Data repo → Actions → runlog-index | Green; that folder's `index.json` gained a row with `duration_s` | +| 6 | `http://localhost:8000/dashboard/data-browser/` → browse the repo → open the new run | Listed with start + duration, size column shows `gz`, plots render | +| 7 | Same dashboard: open an older v1 `.jsonl` run | Opens as before | +| 8 | Replay viewer on the new `.gz` (Studio → replay, or `arena_replay_viewer.html`) | Timeline builds | +| 9 | `pixi run bridge -- --convert out.jsonl` then `--convert out.jsonl back.jsonl.gz` | Converter reports identical canonical JSON | + +**Merge (only after 1–9 pass):** + +```powershell +gh pr edit 188 --repo reiserlab/webDisplayTools --base main # retarget; CI starts +gh pr checks 188 --repo reiserlab/webDisplayTools --watch +gh pr merge 188 --repo reiserlab/webDisplayTools --squash --subject "feat(run logs): behavior_v2 + .jsonl.gz — Studio v0.72 commit path + v0.73 readers (#186, #188)" +gh pr close 186 --repo reiserlab/webDisplayTools --comment "Landed via #188 (its branch contained this commit)." +``` + +Why this order: #188's branch already contains #186's commit. Squash-merging #186 first would leave +#188 conflicting (that exact thing happened with #183). Never `--delete-branch` a PR that is +another PR's base. After the merge: hard-refresh Pages (Ctrl+Shift+R) and repeat step 6 from +Pages; every bench then does `git pull` + restarts `pixi run bridge`. + +Skip today: forcing the >30 MiB Git-Database commit path (no UI hook; unit-tested). + +## 4. Minimal Analog In check (#190) — 10 min, any board + +```powershell +git checkout feat/console-analog-in # restart pixi run serve if it caches +``` + +Console → left rail **Analog In**. Pass: live readout at 10 Hz; status says `paused — run +active` during any trial and `live` after; the rail chip updates. With a BNC cable from Analog Out +to Analog In 1: **Run sweep**. On a reworked board expect `ok`; on an un-reworked board expect +`fail` plus the LAB-209 message — both are UI passes. If AO is in `frame_number` mode the sweep +aborts with a hint (that is the new, correct behavior). Then `gh pr merge 190 --squash` and +`gh pr edit 191 --base main` (do not merge #191 until F2 is on hardware). + +## 5. Firmware F1/F2 (only with the reworked board) — gates B3/B4 + +```powershell +cd LED-Display_G6_Firmware_Arena +git fetch origin && git checkout feat/ai-12bit-g3-gain +pixi run deploy-performance # compile + upload (auto-detects the Teensy; close any monitor first) +pixi run test-serial # HIL pytest over USB-CDC (add -- --port COMx if auto-detect fails) +``` + +B3 (F1): 0xA4 is 5 bytes with the 12-bit flag; analog plan § 5.1 T1 ground cap ≈ 0 mV; T2 +linearity −10…+10 V vs a DMM within 30 mV; CL2: Mode 4, gain 10, +1 V → AO `frame_number` +sawtooth = **100 fps**, −1 V reverses, gain 5 → 50 fps. Pass → merge fw #46, retarget #47. + +B4 (F2): `git checkout feat/ai-calibration`, deploy, `pixi run test-serial` +(`AI_CAL_DESTRUCTIVE=1` for the EEPROM-writing tests — bench board only). From the Studio +(#191, advanced mode): two-point calibration per channel — the firmware now REFUSES an implausible +point (open must read near full scale, ground mid-range) and refuses any calibration while the +display runs; ±5 V vs DMM within 20 mV; power-cycle keeps the record; Clear → flags 0; +`/config/analog_cal.json` appears on the card; Mode 4 zero input + deadband → no drift. +Pass → merge fw #47, then #191. + +## 6. Gotchas that bit this week + +- **PR CI runs only when the base is `main`** — retarget, wait for green, then merge. +- **Squash-merging a stacked PR's parent leaves the child conflicting** — rebase the child (git drops + the duplicate commit) rather than merging main in. +- **`pixi.toml`'s `test =` is one line**; every PR that adds a test conflicts with every other — + resolution is always "keep every test". +- **The Studio footer** (`Arena Studio vX | YYYY-MM-DD HH:MM ET · GitHub`) conflicts on every + rebase of #191 — keep v0.75 with a fresh ET timestamp. Never Prettier the HTML. +- A `cmd | tail -1` chain hides a failed `git rebase`; check `git diff --diff-filter=U` before pushing. +- Pages serves main only; bench-test branches via `pixi run serve`. Hard-refresh after deploys + (ES-module cache). +- macOS Chrome loses bytes on bulk SD reads (#153) — Windows Chrome is unaffected. +- The Console `send()` helper returns the reply frame even on a firmware error status — always + decode the status (`Wire.decodeResponse`), never test truthiness. + +## 7. What to report back + +Per gate: pass/fail per row, the run's `.jsonl.gz` path in the data repo, any ⚠ text from the +run banner, and — for analog — the sweep summary line and calibration record. Anything failing: +the Console log (Copy button) and the bridge terminal output. + +## 8. Open items nobody should be surprised by + +`codex-review-2026-09-09.md` § 2: large-commit retry (before the first multi-hour run), ack +correlation, Console-trial poller gate, auto-switch AO mode, safe-mode policy for the sweep. +Telemetry ring-buffer proposal (`controller-telemetry-ring-buffer-proposal.md`) awaits the +group's § 6 decisions; its bench work (T1–T6, L1–L4) starts after that, on a test firmware branch. + +## 9. Time permitting: a tiny bench launcher (so nobody types commands at the rig) + +A small window with a few buttons and a status line, nothing more. Purpose: the experimenter +should never open a terminal to start FicTrac or the bridge. + +Buttons: + +- **Launch FicTrac** — runs the FicTrac executable with the rig's config file. +- **Configure FicTrac** — runs FicTrac's interactive configuration routine (`configGui`, the + click-on-the-ball ROI/mask tool) on the same config file. +- **Launch bridge** — runs `pixi run bridge` from the webDisplayTools checkout (optionally with + the FicTrac port if it differs from 60000). +- **Open Studio** — opens the Studio URL in Chrome (Pages, or `pixi run serve` + localhost when + bench-testing a branch). + +Status line per process: not running / running (pid) / exited with code N, plus the last few +lines of the bridge's output (it prints the log file name and `[cfg] applied …`). Buttons turn +into **Stop** while a process runs; closing the window asks before killing children. + +Keep it boring: + +- Python + tkinter, run from the webDisplayTools pixi environment (`pixi run launcher`), so there is + nothing to install beyond what § 1 already lists. A `.bat` on the desktop can start it. +- One small config file next to it (`launcher.json`): path to the FicTrac executable and config + GUI, path to the rig's FicTrac config, path to the webDisplayTools checkout, FicTrac UDP port, + Studio URL. First run: if a path is missing, a file picker asks once and saves it. +- Subprocesses via `subprocess.Popen`; read their stdout on a thread into the status box; never + block the UI. No auto-restart, no daemons, no tray icon. +- Lives in `fictrac-bridge/launcher.py` (same folder as the bridge, same pixi env) with a pixi task + `launcher = "python fictrac-bridge/launcher.py"`. A short section in `fictrac-bridge/README.md`. +- Windows-first, but nothing Windows-specific in the code beyond the default executable name. + +Not for today unless B1 is done and merged. It changes nothing in the run-log path, so it can +land as its own small PR whenever. diff --git a/docs/development/merge-bench-plan-2026-09-08.md b/docs/development/merge-bench-plan-2026-09-08.md new file mode 100644 index 00000000..6bd22304 --- /dev/null +++ b/docs/development/merge-bench-plan-2026-09-08.md @@ -0,0 +1,198 @@ +# Merge + bench plan, Tue 2026-09-08 – Wed 2026-09-09 + +Written 2026-09-07 18:20 ET after merging #187 (runlog-index Action) and #183 (bridge +`behavior_v2`) to `main` (`bb8078d`). Everything below is a checklist; tick items as they +land. Gates are explicit — a PR merges only when its gate row is green. + +## State at start + +| PR | What | Base | Needs before merge | +|---|---|---|---| +| **#186** Studio v0.72 gzip commits + v2 default | webDisplayTools | main (retargeted; **conflicts** — rebase) | rebase onto main (plan-doc conflict is trivial: #183 squashed the same text), CI green, bench gate B1, merge **together with #188** | +| **#188** readers v0.73 (`js/runlog-format.js`) | webDisplayTools | #186 branch → retarget to main after #186 | rebase (dashboard `app.js`, `index.html`, `tests/test-analysis.js` conflict with #184/#185), CI green, bench gate B1 | +| **#190** Console Analog In S1 v0.74 | webDisplayTools | main | rebase after #186/#188 (Studio HTML, release notes, pixi.toml), bench gate B2 | +| **#191** analog calibration UI S2 v0.75 | webDisplayTools | #190 | retarget after #190; needs fw F2 on hardware; bench gate B4 | +| **#178** data-repo registry | webDisplayTools | main | rebase; **bump v0.72 → v0.76** (collides with #186); rig checklist in the PR | +| **#175** Isabel's closed-loop bias | webDisplayTools | main | Isabel rebases (runner + tests + Studio conflicts, 10 behind); confirm still wanted | +| **fw #46** F1 12-bit ADC + G3 gain | firmware | main | bench gate B3 on a **LAB-209-reworked board** | +| **fw #47** F2 EEPROM calibration 0xA5–0xA7 | firmware | #46 | retarget after #46; bench gate B4 | +| telemetry ring-buffer proposal (docs, branch `claude/data-logging-ring-buffer-9f400b`) | webDisplayTools | main | open the PR; no conflicts (new file) | + +Rules that bit today: **PR CI runs only when the base is `main`** — retarget, then wait for +green, then merge. **Squash-merging a stacked PR's parent leaves the child conflicting** +(its branch carries the pre-squash commit); rebase the child, don't merge-in main. **Never +`--delete-branch` on a PR that is another PR's base** — GitHub closes the child (recovered +today by restoring the ref, reopening #186, retargeting, deleting again). Pages serves +`main` only → bench-test branches by serving a worktree locally +(`python -m http.server 8080`) with that branch's bridge (`pixi run bridge`). + +Prerequisites to line up **before** Tuesday: (1) a controller board with the LAB-209 divider +rework (Frank) — without it every analog test saturates above 0 V and gates B3/B4 cannot run; +(2) a rig with FicTrac + bridge for gate B1 (any course bench, or the office 2×10 with the +simulator `pixi run sim`); (3) the AD3 + a BNC cable for the AO→AI loopback and the Mode 4 +fps meter. + +## Day 1 — Tuesday 2026-09-08: run-log stack, then Studio S1 + +### Morning (desk) + +- [x] ~~Codex cross-review of all pending work~~ — done 2026-09-09 morning, see + `docs/development/codex-review-2026-09-09.md`: 7 true bugs fixed and pushed (#188 strict + mode; #190 sweep refusal + closed-loop gating, #191 rebased; fw #47 calibration guard + + mV clamp + controller_info bits); design suggestions listed for discussion. + Also rebased #186/#188/#190/#191 onto main afterwards: #193 and the two stacks all add + a test to the one-line `test =` task in pixi.toml, so #188 and #190 conflicted with main + until merged by hand (both tests kept). Whichever of #188/#190 merges second will hit + the same one-line pixi.toml conflict once more — the resolution is always "keep every + test". + Second pass (Codex discussion items 4/6/7/8/9/10) done and pushed: #190 (offset + criterion), #191 (2 s cal timeout + click lock, rebased again), fw #47 (O(1) Mode 4 + skipping, plausibility-checked cal points, gated deadband test), #194 merged + installed + (damaged-gz rows carry `error`). +- [ ] **Course benches:** `git pull` + restart `pixi run bridge` (bridge 3.0 is on main; an + old bridge still works with old Studios, the ack just becomes visible). +- [x] ~~Rebase #186 onto main~~ — done 2026-09-07 evening (clean: git dropped the + already-squashed bridge commit); full suite green; force-pushed. +- [x] ~~Rebase #188 onto the rebased #186~~ — done 2026-09-07 evening. Three dashboard + files resolved: kept #184/#185's column registry and per-folder `index.json` lookup, + swapped the `.jsonl` filters for `F.isRunlogName`, ported the readers' gz-aware size + label/hover into the `size` column, kept both test blocks; fresh cache stamps + `?v=20260907-1`. Dashboard test + full suite green, Prettier clean; force-pushed. + #188 still bases on #186's branch — retarget → main after #186 merges. +- [x] ~~Index builder is gzip-blind~~ — fixed 2026-09-07 evening in a follow-up PR to #187 + (`fix/runlog-index-gzip`): `.jsonl.gz` read whole + inflated, head/tail parsing + unchanged; index byte-identical for all 149 v1 logs in the course clone; 9 gz twins of + real logs identical apart from `file`/`size`; 17-check test in `pixi run test`. + Merged as #193 (main `7b4e533`) and installed byte-identical into + `cshl-2026-course/.github/scripts/` (commit `37d003e`); a `workflow_dispatch` full + rebuild ran green on the live repo — every folder "index.json unchanged — skipped", + i.e. the new script reproduces the existing indexes exactly. Gate B1's last checkbox + now only needs a real `.gz` run to appear in an index. +- [x] ~~Corpus gates~~ — run 2026-09-07 evening on the rebased #188 against the + fast-forwarded clone (174 logs now): bridge round trip **174/174**, dashboard parity + **174/174**. The first parity run failed on the 12 `runlogs//index.json` files + (#187's catalogs) because the shared `isRunlogName` accepted bare `.json` and #188 uses + it for directory listings — fixed on the #188 branch (filter is `.jsonl|.ndjson` + `.gz`; + a user-dropped `.json` still loads), vendor copy synced, test updated, suite green. +- [x] ~~Merge #189~~ — merged 2026-09-07 evening. + +### Afternoon (rig) — **gate B1: v2 end to end** + +Serve the #188 worktree locally; bridge from the same worktree. + +**Run 2026-09-10 on the lab PC (Windows), bench `rig05-mr`, sim as FicTrac source — PASSED.** + +- [x] Studio banner names the bridge-acknowledged level `behavior_v2` (bridge 3.0 log: + `[log] writing to arena-log-20260910-111905-238.jsonl (behavior_v2)`). +- [x] `fictrac_direction_test` (28 steps, 330 s, 24 Mode-2 + 3 Mode-3 trials) → 16 514 frame + rows + 4 501 compact `["a", …]` echoes + 96 runner events; `--convert` v2→v1→v2 is + canonical-JSON identical (21 119 lines), and the committed `.gz` equals the bridge's file. +- [x] Commit: `runlogs/rig05-mr/fictrac-direction-test__mreiser__2026-09-10T15-24-35__oaec4gao.jsonl.gz` + (1.06 MB → 323 KB, commit `7d7d2f5`). +- [ ] > 30 MiB path — **skipped by design** (no UI hook; unit-tested). Revisit before the first + multi-hour run (codex-review § 2, `directCommitLarge` retry). +- [x] Dashboard opened the `.gz` from the repo (listed with start + duration, size `gz`, plots + render) and an older v1 `.jsonl` — served from the #188 worktree, then again from Pages. + Local-file open and the v1/v2 P3-parity comparison were **not** exercised (no v1 run of + the same protocol exists). +- [x] Alt replay picker listed `runlogs/rig05-mr/`, fetched + inflated the `.gz`; replay timeline + built and advanced (protocol picked as a local YAML — a site-library protocol is not + auto-bound, only course-repo ones are). +- [x] `runlog-index` Action ran 11 s after the commit (`bbdec9c`): row has `duration_s: 330.291`, + `complete: true`, no `error`. + +Pass → **merged**: #188 squashed as `f744e12` (subject names #186 + #188), #186 closed. Pages +serves v0.73. `feat/console-analog-in` rebased onto main (footer, release notes, `pixi.toml` +one-line test conflicts as predicted), force-pushed, CI green. + +**Gotcha found here:** retargeting a PR's base fires a `pull_request: edited` event, which none of +the workflows listen to — and #188's last commit touched no path-filtered file — so **no CI ran +after the retarget**. The equivalent gate was `pixi run test` + `format-check` locally on the exact +head sha. Push a commit if you need a CI run. + +### Late afternoon — **gate B2: S1 on today's firmware** (any board) + +**Run 2026-09-10 — PASSED (software), on a course-bench 10-10 controller (io_ext firmware, +un-reworked front end).** + +- [x] Analog In panel: live readout, pauses with a visible reason; no log flooding. +- [x] Loopback sweep on the un-reworked board: slope 0.0056 · offset 9980 mV · 6 steps → `fail` + with the LAB-209 text — the expected UI pass. (The 12-18 arena, the only reworked board, + reported *no* io_ext at first — see the firmware section below.) +- [x] `pixi run test` green (with `PYTHONUTF8=1` on Windows — see handover gotchas). + +Pass → **merged**: #190 squashed as `880ffd5` (Studio v0.74; Pages serves it). #191 retargeted to +main and rebased with `git rebase --onto origin/main 9312b56` (a plain rebase replays the parent's +pre-squash commits and produces add/add conflicts), footer v0.75 re-stamped, force-pushed, CI +green — **held open** until gate B4. + +### 12-18 arena bring-up (2026-09-10 evening, fw #48) + +- Firmware PR #48 (`hardware/bring-up-12-18`, Frank) reviewed: approve with nits, both variants + build. Flashed `deploy-12-18-performance` (= main + #48) onto the 12-18 controller (USB serial + 20852340, MAC `04:E9:E5:1F:D1:72`); `GET_CONTROLLER_INFO` → capability `0x23` (g6_mode, + v2_local_storage, io_ext). The column-sweep visual test ran twice: all 240 frames accepted, + human-confirmed smooth P1→P12 — the derived CS map (incl. new D38–D41) is right. +- First real loopback on a reworked board (Studio v0.74 from Pages): **AI1** slope 1.056 / 1.055 + (`check`, +5.5 % — tolerance-class, F2 calibration territory); **AI2** slope 0.819 / 0.803 + (`fail`, linear). T1: AI1 open 10.0 V, ground cap +300 mV; AI2 open ≈ 5.5 V (noisy), ground + cap −2150 mV. Both AI2 numbers are the same 0.79× factor on V_adc ⇒ channel 2's **stage-2 + divider is ≈ 0.26 instead of 1/3** (likely a leftover resistor in parallel with the new one, + or a wrong value) — **inspect R179/R181 on this board** before calibrating it. Details in + `analog-input-plan.md` § 5.1. +- Open: the Studio has no G6 4×12 config / 12-18 rig yet (streaming from the Studio will be + refused with "Bad stream-frame size"); `summarizeSweep` should distinguish "linear gain error" + from "saturated (LAB-209)" instead of one `fail`. + +## Day 2 — Wednesday 2026-09-09: firmware F1/F2, then S2, then cleanup + +### Morning (bench, reworked board, AD3) — **gate B3: F1** + +- [ ] Flash `feat/ai-12bit-g3-gain` (`pio run -e teensy41-performance -t upload`); run the + HIL suite: `pytest tests/test_io_roles.py tests/test_commands.py` — 0xA4 is 5 bytes, + 12-bit flag set. +- [ ] Analog plan § 5.1 T1 ground-cap → ≈ 0 mV; T2 linearity −10…+10 V in 2 V steps (AD3 as + source, DMM as truth) → residual ≤ 30 mV; T3 noise at 12-bit (std of 100 reads ≤ 2 LSB). +- [ ] CL2: Mode 4 with gain 10, +1 V DC → AO `frame_number` sawtooth on the AD3 shows + **100 fps**; −1 V reverses; gain 5 → 50 fps. + +Pass → **merge fw #46**; retarget #47 → main; flash `feat/ai-calibration`. + +### Midday — **gate B4: F2 + S2** + +- [ ] `pytest tests/test_analog_cal.py` (with `AI_CAL_DESTRUCTIVE=1` on the bench board). +- [ ] From the Studio (#191 worktree, advanced mode): C1 two-point per channel → DMM at ±5 V + within 20 mV; C2 power-cycle + SD swap keeps the record; Clear → flags 0; C3 + `/config/analog_cal.json` appears on the card. +- [ ] Mode 4 CL1: zero input, deadband 20 mV → no drift over 60 s. +- [ ] S2 UI: Read table, guided steps, deadband set/clear, `needs ai_cal firmware` text on a + pre-F2 controller. + +Pass → **merge fw #47**, then **merge #191**. + +### Afternoon (desk) + +- [ ] Rebase **#178** onto main, bump to **v0.76**, run its rig checklist items that need no + hardware; merge if green (the course benches keep their stored repo — verify once). +- [ ] Message Isabel about **#175**: rebase needed; confirm the bias feature is still on the + fall plan; offer to pair on the runner conflicts. +- [ ] Open the **telemetry ring-buffer proposal PR** from `claude/data-logging-ring-buffer-9f400b` + (docs only). Ask the group for the § 6 decisions. +- [ ] Release notes: one entry per merged Studio version; footer versions monotonic + (v0.72 #186 → v0.73 #188 → v0.74 #190 → v0.75 #191 → v0.76 #178). +- [ ] Update `CLAUDE.md` gotchas only if something new bit (the three rules above are + candidates). + +## If a gate fails + +- B1 fails on the dashboard → merge #186 anyway is **not** allowed (v0.72 writes `.gz` that + no reader opens); fix #188 first or hold both. +- B3 fails on linearity → likely an un-reworked board (LAB-209), not F1; check T1 first. +- B4 fails on persistence → EEPROM path; the SD mirror is write-only by design and is not + the source of truth. +- Anything on macOS Chrome bulk reads → #153, unrelated to these PRs; note and move on. + +## Not in these two days + +Ring-buffer test firmware (T1–T6, L1–L4) — starts after the group has the proposal; +oscilloscope analog trace set (S4); Isabel's #175 rebase (hers).