Skip to content

Release v0.4.0-alpha.1#377

Merged
ryan-johnson2 merged 644 commits into
mainfrom
devel
Jul 5, 2026
Merged

Release v0.4.0-alpha.1#377
ryan-johnson2 merged 644 commits into
mainfrom
devel

Conversation

@ryan-johnson2

Copy link
Copy Markdown
Contributor

The first alpha release: the full primitives-v1 line — race running end-to-end (lifecycle, timed/laps formats, brackets), marshaling with signal-as-evidence + the shared removal record (D25), native min-lap enforcement (D26), app-wide race audio, the RH start-sync fix, and the v1 console. One product version everywhere (cargo xtask version), /about + build-stamp, and native desktop portables (Linux/Windows/macOS, all with the live RH adapter).

Tag after merge: v0.4.0-alpha.1.

🤖 Generated with Claude Code

ryan-johnson2 and others added 30 commits June 26, 2026 08:30
fix(live): reset lap count on Abort/Restart (scope to current run)
A manually-built heat ("build heat") could not carry a human name: heats
carried no label on the wire, so the console always *derived* the display
name from the round + the heat's position ("‹Round› Heat N" / "A-Main" …).
The RD-entered "heat id" is only the storage handle, so any intended custom
name was silently overridden by the generated convention.

Add an optional `label: Option<String>` that wins over the derived name:

- Event/Command: `Event::HeatScheduled.label` and `Command::ScheduleHeat.label`
  (both additive, `#[serde(default)]`, ts-rs `optional`). Threaded through
  `control_handler` so a manual build-heat persists its label; the generator
  (FillRound) and open-practice paths emit `None`, keeping their auto-names.
  Legacy logs (no label) read back `None`.
- Heat-list payload: `HeatSummary.label`, folded from the latest
  `HeatScheduled` so `GET …/heats` serves it to the console.
- Display: `heatDisplayName` / `heatNameById` prefer a non-blank `label`,
  else fall back to the existing derived convention — so a labelled build-heat
  shows its name everywhere (Rounds & Heats, Live picker, heat sheet) while
  generator heats keep "‹Round› Heat N" / tiers.
- Build-heat form: a "Heat name (optional)" field sends the label on
  `ScheduleHeat`; blank = auto-name (None).

Tests: engine/server round-trip (label persists; legacy reads back None);
frontend label-preference + the form sending the label; the heats contract
asserts the label round-trips on the wire. `cargo xtask ci` green incl.
gen-drift (regenerated bindings/ for the new fields); frontend
build/check/lint/test/contract green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
feat(heats): custom heat label for build-heat (overrides the auto-name)
…name

The build-heat form made the RD hand-type the "Heat id" — but that's the
unique internal handle, not something a human should mint. Remove the field
and auto-generate a round-scoped, collision-safe id in the readable generator
style (`<round>-h-<base36 suffix>`), re-rolling against the round's existing
heats so two quick builds can't collide (the scheduleHeat ack dup-checks too).

Submit now needs only a selected round + a non-empty lineup; the heat name
stays optional. Update the build-heat unit tests (build with no typed id
succeeds, id is non-empty and round-scoped, unique across two builds, label
still sent) and the e2e flow (no id fill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
ux(build-heat): auto-generate the heat id, only ask for the optional name
…+ high rate)

The mock RotorHazard signal was a rigid square peak/nadir envelope —
obviously generated — so the maintainer couldn't judge the marshaling
evidence graph against it. Replace the generator's per-pass shape with a
realistic gate-pass model:

- Per-pass profile: each lap is a low baseline floor plus a Gaussian-ish
  bell centred on the gate crossing (rise -> peak -> fall over the
  approach/depart window), not a square step. The bell peaks ON the
  crossing tick so RH's crossing detection still fires and laps record.
- Baseline noise: a few RSSI units of deterministic per-sample jitter on
  baseline AND peak, from a seeded xorshift64* keyed by (node, sample) —
  no Math.random / wall-clock, so generation stays byte-reproducible.
- Lap-to-lap variation: each pass's peak height drifts a few units
  (seeded by node+lap), so no two passes are identical.
- High sample rate: DEFAULT_TICKS_PER_LAP=48 (a lap is dozens of samples,
  not 6); rh-mock feed --tick now defaults to 0.015s (~67 Hz, in the
  50-100 Hz band) and is documented as the wall-clock RH_UPDATE_INTERVAL.
- Both the coarse streamed node_peak (col 4) and the dense peak/nadir
  history (cols 10/13) now carry the bell+noise, so the LIVE trace and
  the heat-end dense marshal trace both look like real signal.

Scenarios stay meaningful under the model: noisy = marginal peak + a 3x
noise floor (grainy baseline); false-pass = a small low-peak spurious
secondary bump beside a real pass; missed-lap = a pass whose window is
skipped; strong/weak/pack/dnf rescaled to realistic gap densities.
race() seeds each node by index so co-racing nodes get distinct traces.

Tests: adapt the e2e fidelity gate to assert band-membership within the
model (captured == emitted within [baseline-noise, peak+noise]) instead
of a single constant; the dense-history gate prints a dump-style
sparkline of the captured bell trace. Add testkit unit tests for the
bell shape, baseline noise, determinism, lap variation, and per-node
seeding. Verified against real dockerized RotorHazard: all three
rh_signal live tests pass; dense history captured 92 samples as a noisy
rise->peak->fall bell (min 68 / max 157) with laps detecting.

Testkit + xtask signal generator only — no Director runtime / live_state
/ adapter changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
feat(testkit): realistic mock RSSI signal (gate-pass profile + noise + high rate)
…he socket, killing laps)

After #250 activated the heat-end dense marshal pull in the normal flow, the
RH socket flapped and no laps landed on the maintainer's live Director: the
finish dance (ensure_savable_heat -> set_current_heat -> stop_race, driving RH
to DONE so it pulls current_marshal_data/save_laps) re-fired in a loop, creating
heat after heat / round after round, flooding+resetting the socket and stopping
the live race.

Root cause: the once-only guard was a `maintain`-local (`finish_deadline`) while
the `finishing` trigger lived in the *shared* `armed` slot. The dense pull's
burst of emits can itself drop the link; on reconnect the driver re-enters
`maintain` with a fresh local guard but the SAME still-`finishing` slot, so it
re-ran the whole dance — looping indefinitely.

Fix: add a `done` flag to the shared `ArmedHeat` and claim the save on it,
flipping `done` BEFORE any emit. A re-sent DONE, a reconnect, or a maintain
re-entry are now all no-ops — the heat-end dense pull fires EXACTLY ONCE per
arming (reset per fresh arm_heat). A `done`-but-no-local-settle re-entry just
restarts the drain settle (no re-fire) so the slot still clears and any
re-pushed dense history lands.

Verified on a throwaway dockerized RH (rh_connect_live, port 5042): laps land,
the connection stays Connected through the finish (no reconnect storm), and the
RH log shows the save dance once (1x "Current laps saved", 1x "Heat added",
dense 212 samples > 30 coarse). The fire decision is factored into a pure
`claim_finish_flags` helper with a unit test asserting once-only-across-reconnect;
rh_connect_live is extended to assert the save fires once + laps persist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
fix(rotorhazard): dense heat-save fires once (was looping, flapping the socket, killing laps)
Move the two forms in the Rounds & Heats stage out of inline panels and
into the app's existing modal Dialog primitive (backdrop, focus trap,
Esc-to-close), matching the ClassManager/PilotManager pattern: form body
as the Dialog children, the cancel/confirm actions in a {#snippet footer},
submit wired via onclick (the footer sits outside the <form>) while the
form's onsubmit keeps Enter-to-submit working.

- Add round / Edit round: opened by "+ Add round" and the per-round "Edit"
  button (openAdd/openEdit already drive formOpen), closed on submit/cancel.
- Build heat: opened by "+ Build heat", closed on submit/cancel.
- Both Dialogs render at the component root (outside the Cards) so they no
  longer leak option text into the Cards' content for scoped queries.

All field logic, validation, dynamic format params, the auto-generated
heat id, the optional label, submit handlers, toasts, and refresh-after
behavior are unchanged — this is a presentation move. The long round form
scrolls within the dialog body on short screens.

Tests: extend EventRounds unit tests with the modal flow (dialog hidden
until the trigger, opening reveals the form, cancel/submit close it) and
scope the e2e (rounds.spec.ts) to open the dialog before filling, asserting
it closes on a successful add/schedule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
ux(rounds): add-round + build-heat as modal Dialog forms
Two gaps on the RSSI evidence graph closed:

(1) Hover crosshair + readout. Moving over a competitor's trace now shows a
vertical guide at the cursor and a dark, high-contrast chip reading the exact
race-relative time (S.mmm, the same source-clock axis as the samples + lap
markers) and the RSSI sample there. The cursor's pixel-x is mapped back to a
source-time by inverting the marker projection (`timeAt` = inverse of `xOf`),
and the RSSI is the sample nearest that time on the trace's from/period grid.

(2) Add a brand-new lap (not just edit existing ones). Clicking a competitor's
trace adds a lap at the cursor's race-relative time via the existing
InsertLap/insertLapCommand path; an explicit "Add lap here" button at the
crosshair gives a keyboard/trackpad-accessible affordance. A per-competitor
"Add lap" control (pick competitor + typed seconds) makes it work without the
graph (sim heats) and with zero existing laps. All role-gated by `canControl`
like every other correction; a marker click still selects (stops propagation
so it doesn't also add).

Tests: RssiGraph hover reports the right time/RSSI for a fixed trace; a trace
click + the "Add lap here" button call onaddlap with the matching source-time;
read-only hides the add affordance. MarshalingScreen: graph click → InsertLap;
the explicit control adds at a typed time and works with zero laps; read-only
hides it. e2e: add a lap via the control and assert it appears in the lap list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
feat(marshaling): graph hover time/RSSI readout + add-a-new-lap
The realistic gate-pass model (Gaussian bell + baseline noise) looked real
but gave RH's two-state RSSI detector thin/fragile crossing margins: the
crest carried *bidirectional* per-sample noise, so a low ("marginal") peak
could dip below the calibrated `enter` threshold at the very tick the
crossing fires, and the thin-margin scenarios sat only ~3 RSSI units above
enter (MARGINAL_PEAK = enter+5, varied down a few percent lap-to-lap). On
real hardware that means a captured peak that never clearly exceeds enter —
no crossing registers, 0 laps — while the dense trace still captures fine.

Fix the signal model so passes stay realistic AND reliably detect:

- Shape the noise in `pass_value`: full +/- jitter on the off-gate baseline
  (real texture, bounded so it stays clear of `exit` between passes), but
  *rectify the crest noise upward*. `node_peak`/`pass_peak` are running
  maxima on a real timer, so sensor noise around a pass crest only pushes
  the reported peak up, never carves a notch below the true peak. The crest
  is now the unambiguous maximum it physically is, so the captured peak
  clears `enter` by the lap's full peak-to-baseline margin every time.
- Widen MARGINAL_PEAK from enter+5 to enter+15 so even the weakest scenario
  clears enter with real headroom after lap-to-lap variation (still clearly
  below WEAK_PEAK, so strong > weak > marginal ordering holds). This lifts
  marginal/noisy/false-pass off the detection edge.

Verified end-to-end against a dockerized RotorHazard heat (seated pilot,
tick 0.015): clean 0 -> 6 laps, missed-lap skips one, false-pass adds the
spurious bump, noisy still detects. The three docker `rh_signal` tests pass
(detection asserted). New unit tests lock the invariants: the crest never
dips below the nominal peak, and marginal/noisy/weak clear enter by >=8 with
the baseline staying below exit. Deterministic (seeded RNG, no wall-clock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
…wn shows

The Armed-phase "Tone in S.s" countdown (#249) never appeared because
`tone_at` was always `None` on the live state, so the RD console only ever
rendered the generic "Arming… stand by" fallback.

Root cause: `tone_at` is derived in `heat_tone_at` from the heat's
`HeatStarting` event as `recorded_at + delay_ms`. The start driver appends
`HeatStarting` with `recorded_at: None`, and the single append choke point in
`AppState::append` only stamped a server wall-clock `recorded_at` for
`HeatStateChanged` events — so `HeatStarting` stayed untimed and
`heat_tone_at` returned `None` (its documented untimed-entry path). The heat
DID hold in Armed for the randomized delay and DID emit `HeatStarting` with a
real `delay_ms`; only the timing anchor was missing.

Fix: stamp `recorded_at` for `HeatStarting` too (it is the heat's arm instant
the tone countdown anchors to), alongside the existing `HeatStateChanged`
stamping. A caller-supplied timestamp (replay / test) still wins.

This is timer-agnostic: the Armed hold + `HeatStarting` run in the shared
`handle_transition` start driver and the fix is in the shared append path, so
both Mock and RotorHazard heats now surface `tone_at` during Armed. Verified on
throwaway Directors (Mock + a dockerized RH `clean` feed): the heat holds in
Armed for the delay and `tone_at` counts down to zero, clearing on Running.

The frontend already gated the banner correctly (`canControl ? live?.tone_at :
undefined`) and has tests for both the "Tone in S.s" (controlling) and hidden
(read-only) cases; they passed unchanged once `tone_at` is populated.

Tests: two server regression tests prove an untimed `HeatStarting` is stamped
and surfaces `tone_at` while Armed, and that `tone_at` clears on Running.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
fix(testkit): realistic mock signal reliably triggers lap detection
fix(live): Armed tone countdown shows during the start-procedure hold
The Marshaling screen sourced its competitor-name resolver's explicit
`ref → pilotId` map only from `session.liveState.progress` — the GLOBAL
live stream's *current* heat. A node-seeded RH heat carries ref `node-0`
(not a pilot id), so the roster-id fallback misses; and for a finished /
non-current heat the global stream has no progress for it, so the explicit
binding misses too — the resolver fell through to the raw "node-0" label in
the audit trail, ruling/protest/add-lap dropdowns, and lap headings.

The durable `node-0 → pilot` bind lives in the heat's `CompetitorRegistered`
facts. The heat-scope `?projection=live` fold (`snapshot_heat` →
`live_state(&heat_events)`) already materializes that bind on `progress[].pilot`
for ANY heat window — finished, non-current, node-seeded. So pull that
per-heat fold alongside the lap list / audit / signal in `refreshMarshaling`
(new `session.heatLiveState`) and feed ITS progress into the resolver's
`explicitPilotByRef` (global stream merged underneath as a same-heat
fallback, guarded on `current_heat === heat`). No backend change needed —
the durable binding was already exposed; the screen just read the wrong
source.

Adds a `heatLive` test seam + a regression test: a node-seeded heat with a
durable `node-0 → pilot` bind resolves the callsign in the heading, the
dropdowns, and the audit line even with empty global live progress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
…ver raw ids

Codifies the recurring friendly-name-leak fix as a standing rule: anywhere a
value with a human name is shown, render the friendly name via the shared
resolvers (competitorName/heatNameById/...), resolved from a durable source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
docs: CLAUDE.md project rules — always display friendly names
fix(marshaling): resolve pilot callsigns from the durable heat binding (not just live progress)
…get-data only

Make the RotorHazard connector treat RH purely as a start/stop/get-data
recording device: GridFPV's own start procedure (the randomized Armed hold +
the start tone) is the only delay, and RH begins recording the instant Grid
says go — no RH-side staging countdown or tone running on top of Grid's start.

Previously the adapter staged RH lazily at the go instant by emitting a reset
(stop_race + discard_laps) immediately followed by stage_race, which ran RH's
OWN staging sequence (staging tones + a 1-2s random start delay) on top of
Grid's start procedure — two competing start sequences, the root of the
staging-race-eats-laps bug the ~300ms STAGE_RESET_SETTLE band-aid papered over.

Mechanism:
* New transport `prepare_instant_start()` zeroes the CURRENT race format's
  staging over the socket — `alter_race_format` with staging_fixed_tones=0,
  staging_delay_tones=0, start_delay_{min,max}_ms=0, unlimited_time=1 — so
  `stage_race` transitions straight to RACING with no RH staging hold/tones and
  RH never auto-expires the race (Grid owns the stop). The current format id is
  learned from RH's `race_status` stream (`race_format_id`, now also requested
  on connect). Measured on a live RH: stage_race -> RACING drops from ~5.5s
  (stock 3 tones + 1-2s delay) to ~0.9s (only RH's fixed, non-socket-settable
  RACE_START_DELAY_EXTRA_SECS prestage).
* The connection lifecycle now splits across two bridge hooks: at **Staged**
  the bridge prepares each RH connection (zero staging + reset to READY), well
  before Grid's go; at **Running** (the Armed -> Running instant, when Grid's
  tone fires) the driver emits a single `stage_race` so RH starts recording
  immediately — no reset, no settle.
* Because the reset now happens at Staged (seconds before the start emit, never
  the same gevent tick), the reset-vs-staging race is gone and the
  STAGE_RESET_SETTLE band-aid is REMOVED.

Lap-time correctness is preserved: RH timestamps every pass relative to its own
race start and Grid derives lap times as pass-to-pass deltas, so RH's constant
prestage offset cancels — verified live (node-0 laps land near the emulated
0.6s/lap on Grid's clock).

Scope: RH connector only. The Mock/sim source and the generic runtime start
procedure are untouched. Verified on a throwaway dockerized RH: Grid runs the
Armed hold, RH starts on command with no staging hold, passes flow with correct
lap times, stop-on-command + dense data pull still work, the connection stays
stable (no reconnect storm), and the #250 once-only finish guard holds.

`cargo xtask ci` green; rh_signal + rh_connect_live docker tests pass (extended
to assert RH starts promptly on command with no staging-hold gating and that
lap times map correctly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
feat(rotorhazard): Grid owns all start/stop timing; RH is start/stop/get-data only
…attribute (laps)

GridFPV's node→pilot binding lived only in the Director's event log; the RH
adapter tuned node frequencies but never told RotorHazard which pilot sits on
which node. RH then raced a current heat with an empty pilots list, and its pass
gate (`do_pass_record_callback`: a crossing on a node with `pilot_id ==
PILOT_ID_NONE` while a heat is current is dismissed "Pilot not defined") rejected
every clear gate crossing → zero laps even with a pilot bound.

Fix: at Stage (the #263 prepare flow), seat the heat's bound pilots onto their RH
nodes before racing. The bridge resolves the heat's lineup (its durable
HeatScheduled bind — node n runs lineup[n], a pilot ref) to (node_index,
callsign) via the pilot directory and hands it to the connection. The driver,
after the prepare reset, builds a fresh RH heat (`add_heat`), seats each bound
pilot (`add_pilot` + `alter_pilot {callsign}` + `alter_heat {heat, slot_id,
pilot}`), and makes it current (`set_current_heat`) — so RH records AND attributes
passes, and its "Racing heat … pilots:" log names the callsigns. The seated heat
doubles as the marshaling savable heat: the finish-time dense save reuses it
rather than adding a separate empty heat.

Robustness: seating only selects the heat as current when at least one bound
pilot was actually assigned (else falls back to practice mode, which still records
via the `current_heat is HEAT_ID_NONE` gate branch); the heat-slots wait requires
a non-empty node→slot map; the new-pilot wait keys on an id strictly above the
pre-add floor (RH re-broadcasts `pilot_data` on rename); and the seated-heat id is
held outside the reconnect loop so a mid-race reconnect doesn't make the finish
clobber the seated heat with an empty one. Idempotent; the #263 timing flow, the
dense once-only finish, and connection stability are untouched.

Adds `pilot_data` + heat `slots` capture to the adapter; extends `rh_connect_live`
to assert RH's pilots list lists the bound callsign and no crossing is dismissed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
fix(rotorhazard): seat the heat's bound pilots on RH nodes so passes attribute (laps)
Record the maintainer decision (2026-06-26): drop the own-the-stack native
timer-server in favor of a REQUIRED GridFPV RotorHazard plugin as the primary
RH integration.

- decisions.html: mark D15 superseded (text kept as history); add D16 recording
  the required-RH-plugin pivot, rationale, and the revision of D15's
  "never gate on a GridFPV timer" premise.
- roadmap.html: retitle the post-1.0 native-timer-server item to the required
  RH plugin as the integration path; note live-dense-signal + #3 recalc payoff.
- timer-adapters.html: add a direction callout in §8 — RH integration is
  plugin-based (required, guided install); the plugin supersedes the socket
  workarounds; Grid owns start/stop timing, RH = start/stop/get-data.

vision.html / integrations.html contain no own-the-stack / native-timer-server
language, so they need no edits (light touch, no overreach).

Docs only; HTML verified well-formed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
docs: pivot timer strategy — require an RH plugin, retire own-the-stack
The required in-process RH integration that supersedes the socket-API path.
Verified RHAPI surface (floor RHAPI 1.3 / RH v4.3.0+, current 1.4 / v4.4.0):
socket_listen/socket_broadcast channel on RH's own socket.io under a gridfpv_*
namespace (reuses the adapter's existing connection), real race.stage()/stop(),
live dense RSSI via interface.seats Node history, and frequencyset_alter for
calibration. #3 recalc is a re-detection over the stored dense trace, not a
hardware call (detection-fidelity flagged as the load-bearing S4 risk).

Five slices: S0 RH-4.4 mock-node dev harness -> S1 skeleton + gridfpv_hello
handshake + guided-install UX -> S2 live dense RSSI (kills save-then-pull) ->
S3 clean start/stop + per-node passes (deletes alter_race_format/seating/prestage
hacks) -> S4 draggable-threshold recalc. Wired into the docs index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice S0 of the GridFPV RotorHazard plugin (D16, docs/rotorhazard-plugin.html):
the current-RH dev harness that unblocks every later slice. No plugin logic yet —
just a loadable placeholder and the harness to iterate on it in-container.

- docker/rotorhazard: build the image from RotorHazard **v4.4.0 source** (replacing
  the stale cruwaller/rotorhazard:latest = 4.0.0-beta.4, below the RHAPI 1.3 / RH
  4.3.0+ floor). Mock nodes + emulated mock_data CSV. Two 4.4.0 specifics:
  * config.json sets GENERAL.MOCK_NODE_SIGNAL=1 — new in 4.4.0, defaults to 0 (=no
    signal); 1 reads mock_data_{N}.csv (the emulated-signal path the harness needs).
  * run with --data=src/server so DATA_DIR=PROGRAM_DIR (mock CSVs + user plugins/
    resolve in one place; else 4.4.0 chdir's to ~/rh-data and mounts miss).
  Image boots QUIET by default (no baked CSV, no auto-tune) so simulate_lap /
  multi-node live tests aren't polluted; the self-demo signal (auto-tune a CSV-backed
  node) is gated behind GRIDFPV_RH_DEMO=1, set only by docker-compose.

- plugins/gridfpv: placeholder plugin (empty initialize(rhapi) + manifest.json with
  required_rhapi_version 1.3). Loads cleanly into the 4.4.0 container (S1 adds the
  gridfpv_hello handshake).

- testkit RhContainer: point at the locally-built gridfpv-rotorhazard:4.4.0, auto-build
  it if missing (keeps `cargo xtask live` one command), and mount a plugin dir
  (GRIDFPV_RH_PLUGIN / start_with_plugin).

- xtask: `cargo xtask live` boots every live RH against the plugin; `rh-mock feed
  --plugin` and a new `rh-mock plugin-check` (boot RH + plugin, assert it loads).

- adapter fix (4.4.0 wire change): current_laps `lap_time` went string -> number
  (pretty string moved to lap_time_formatted; source/deleted now inline). RawLap typed
  lap_time as Option<String>, which failed the whole snapshot deserialize -> zero laps.
  Parse it permissively (serde_json::Value, advisory/unused) and skip `deleted` laps.
  Adapter reads only lap_number + lap_time_stamp (both stable). Old golden fixture
  still parses.

Gates: cargo xtask ci green; cargo xtask rh-mock plugin-check PASS; all RH-relevant
live targets green on 4.4.0 (rh_live, rh_signal, rh_connect, engine e2e). Pre-existing,
unrelated: gridfpv-server's full_event_live live test doesn't compile (issue #72
router(AppState)->router(EventRegistry) drift; local-only, not in CI) — untouched here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The local-only `gridfpv-server` full_event_live test still drove the pre-#72
single-AppState router: `router(state)`, bare `/control` / `/stream` /
`/snapshot/...` routes, and an ad-hoc `AppState::new(InMemoryLog)`. Since #72 made
the protocol surface event-rooted (`router(EventRegistry)`, routes under
`/events/{event}/...`), this test no longer compiled — and because the live suite
isn't in CI, the rot went unnoticed.

Migrate it to mirror the already-updated `tests/control.rs`: build a fresh
`EventRegistry`, drive the built-in Practice event, root every route + scope under
it. No behavioral change to the assertions (heat loop → live passes → protocol
client → marshaling void → auth gate).

Verified: compiles; `cargo test -p gridfpv-server --features live --test
full_event_live -- --ignored` passes; `cargo xtask ci` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ryan-johnson2 and others added 29 commits July 4, 2026 09:03
Runtime hardening: timed auto-end fallback, per-heat clocks, no-replay startup, atomic writes
Raced-round freeze, run-scoped protests, param + penalty validation (D23/D24)
Console + protocol hardening, durable names, server-anchored staging + the audit docs
…ctions; RSSI zoom

The void/re-detection split-brain (live repro: Audit Shakedown, Quali, Blaze): the
RD removes a valid-crossing-but-not-a-lap detection, and the threshold tuner —
which reads only the RSSI trace — keeps proposing the crossing back as 'a lap to
add'. The removal record and the tuner now share the same data:

- The LapList projection carries each competitor's RD-VOIDED passes
  (CompetitorLaps::voided — at + the voided pass's own offset), folded from the
  same corrected-passes fold that drops them from the laps (void-the-void
  un-records them). The console renders them struck-through in place.
- Re-detection SUPPRESSES any detected crossing within match tolerance of a
  voided instant: never counted as an add, never inserted by a commit, shown as
  'voided by you, stays removed' in the preview and the summary.

The marshaling surface consolidates around the lap-times box (user-specced):

- Tune detection is purely the enter/exit levels + commit; the LAP BOX becomes
  the live detection readout while actively tuning (kept/added/removed/voided
  rows) — and only on explicit intent (a drag or typed level), so a trace whose
  recorded thresholds disagree with the official laps can't hijack the list.
- Each lap row carries Remove directly; selecting a row opens an INLINE editor
  (Split / Edit time / Insert after / Throw out) where the lap is. The separate
  correction and Add-a-lap panels are gone; adding is an inline control in each
  competitor's box (and still available when a pilot has no laps at all).
- The RSSI graph zooms: wheel at the cursor, +/−/Fit buttons, drag-to-pan while
  zoomed; the sample downsampling budget follows the visible window so zooming
  reveals real detail; markers/windows clip to the plot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ag-aware final lock (D25)

The round-2 deep dive over the shared-removal-record work — two adversarial reviews
plus a seeded marshaling combo fuzzer (docs/release-audit-2026-07.html §4):

- Re-detection suppression runs AFTER diff matching: applied before, a voided
  instant could steal a surviving pass's nearest crossing and a commit would void
  the lap the RD kept; a re-added lap fought the stale record forever.
- The removal record carries the RAW instant (the trace knows no re-times) and
  the standing void's offset; the projection's void-the-void resolution walks the
  chain with parity (depth-3 remove/restore/re-remove works); splits resolve
  their source recursively (splitting a split-synthetic used to ack ok and do
  nothing).
- RESTORE is first-class: require_void_target admits a DetectionVoided target
  (void-the-void was unreachable through the command layer — a misclicked Remove
  was permanent) and the struck removal rows carry a Restore button.
- heat_of_offset resolves bridge-stamped passes BY TAG, agreeing with the window
  fold — the positional resolution let rulings on late tagged passes bypass (or
  spuriously hit) the official-result lock.
- Run-scoped + effectively-once command layer: abandoned-run targets rejected
  (stale screens told, not silently no-oped); protests and heat-voids require a
  current run (a pre-run filing was an invisible Finalize wedge); one standing
  throw-out per lap and DQ per pilot/heat (stacked duplicates made reversal a
  silent no-op); voiding a thrown-out lap's pass rejected (dangling ruling);
  insert/adjust/split instants validated — positive, bounded 24h, and never
  colliding into a fuzz-caught ZERO-DURATION lap.
- Run-aware driver rechecks: each runtime clock captures a spawn watermark (the
  heat's latest transition offset) and stands down if ANYTHING moved — same-state
  rechecks couldn't tell a re-run from the run they were spawned for.
- Console: tuning intent keyed per heat+pilot (a reused node seat carried stale
  levels + intent across heats); shared time inputs reset on selection/pilot
  moves; the preview renders once for a dual-adapter pilot; removal rows
  interleave chronologically; graph pan defers pointer capture until a real drag
  so marker clicks work while zoomed.
- Fuzz: seeded 230-300-op command storms incl. hostile targets + a mid-race
  phase — no 5xx, no wedge, every projection invariant held, zero laps lost on
  the live heat under marshaling fire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marshaling: shared removal record, run-scoped rulings, lap-list corrections + RSSI zoom (D25)
…trip, orphan pruning + zoom tests

The release-polish batch over the deferred audit items:

- FINAL FREEZE (audit round-2 §4 deferral, the top remaining integrity item):
  a pass landing at/after a heat's Finalized transition never joins its window
  (heat, class, and live folds) — a delayed RotorHazard catch-up pass, tagged
  or untagged, could silently change an official result with no command and no
  audit entry. Rulings are unaffected: Revert re-opens marshaling, not the
  observation record.
- Version-skew rendering: an unmodeled timer kind now tags/labels/tones as
  unknown with an update nudge (it mislabeled as RotorHazard and field access
  CRASHED the Timers page); an unmodeled result metric labels 'unsupported'
  instead of a DNF-reading em dash.
- Start-procedure round-trip: the rounds form models only the min/max delay but
  the server replaces the round wholesale — editing any round ERASED a stored
  start tone (and would flatten a future non-randomized mode). The stored
  procedure now round-trips verbatim under the form's fields, the same
  preserved-seeding pattern that saved bracket chains.
- Housekeeping: bridge + presence tasks EXIT when their event is deleted (the
  log Arc kept orphans polling forever) and the spawners prune their attached
  sets; the presence reconciler dedups CompetitorSeen within a poll batch.
- Test debt: the RSSI zoom/pan surface gets its suite — wheel/buttons/Fit, and
  the deferred-capture regression (a plain marker click selects while zoomed;
  a real drag captures and pans). Pan/zoom coordinates are NaN-guarded against
  coordinate-less synthetic pointer events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release polish: the Final freeze, skew-safe rendering, tone round-trip, orphan pruning
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Timers are dumb emitters; GridFPV owns lap semantics. The trigger: a start-line
double-detection gave every pilot a phantom 0.004s 'lap 1' — and RotorHazard HAD
MinLapSec=10 set, but its default behavior records-and-highlights rather than
discards, so the sub-min lap reached us with no invalid marker. Enforcement now
lives in the system of record:

- RoundDef.min_lap_secs (optional; the form seeds 5s for new rounds; 0/absent =
  off so pre-existing rounds keep bit-identical results; >600s rejected as a
  typo; FROZEN once the round has raced — it re-scores official heats, D24).
- The corrected-passes fold auto-suppresses a RAW pass that would close an
  under-floor lap (keep-first per burst) — applied identically in the lap list,
  the live view, and every scoring path via corrected_passes_with_floor /
  lap_list_marshaled_with_floor / live_state_over_with_floor, so the score and
  the lap list can never disagree about an echo. The log keeps every raw pass:
  suppression is a view-fold rule, never data destruction.
- The removal record grows a reason (VoidReason::Marshal | UnderMinLap): auto-
  removed crossings render 'under min lap, auto-removed' beside marshal voids,
  and Restore branches — a marshal void restores via void-the-void; a floor
  suppression restores via an AdjustLap re-asserting the raw instant (an
  explicit ruling always outranks the floor, so inserted/split/re-timed passes
  are exempt by construction; a whoop track's genuine 2s laps stay scoreable).
- The pilot ws view (whole-log, cross-round) stays unfloored by design — rounds
  carry different floors; the per-heat views are the authoritative surfaces.
- Rides along: the gridfpv_mock plugin lands node RSSI back at BASELINE after
  each injected pass — parked-at-peak nodes read as 'sitting on the gate' at the
  NEXT race start, which is what fired the phantom double in the first place.

Docs: decisions.html D26 + marshaling.html note. Tests: 5 fold suppression
cases, the scoring seam (a 4ms echo can no longer be a best lap), freeze +
normalization + bounds, console removal-row label + Restore command, rounds-form
round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GridFPV-native minimum lap time (D26): the floor lives in the system of record
…om the console

RD feedback from the field: the countdown is seconds long and nobody races it
with a Skip button ('I usually just hit start anyway'), and Force end read as
an alarming override when it is simply how a practice/timed heat ends early.

- Running exposes Stop (the unchanged ForceEnd wire command) + the off-ramps;
  Armed exposes only Abort/Restart while the countdown runs itself.
- The whole 'override' concept goes with it: tag, styling, isOverride() —
  actions are just actions now, styled by primary/destructive as before.
- SkipCountdown stays a valid wire command (scripts/tests); the console simply
  no longer offers it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Console: one plain Stop button; SkipCountdown + the override tag retired
…tart fixes

Field reports, same session:
1. Callouts only played on the Live Race Control page — the whole audio stack
   (player, speech queue, start/end tones, lap detection) lived in that screen
   and died on navigation. It now lives in an app-shell controller
   (raceDayAudio.svelte.ts) mounted once from App.svelte, consuming the same
   app-wide live stream the global header uses: a running race is audible from
   marshaling, rounds, results — anywhere. The Live page keeps the Callouts
   toggle + control-click unlocks, driving the shared instance. Bonus: page
   navigation no longer remounts the detectors, so returning to Live mid-race
   can never re-buzz the start tone.
2. ~13s of race with no tones or callouts. MEASURED the pipeline first: a
   scratch heat instrumented at 200ms poll showed passes visible in the live
   projection 0.22-0.50s (median 0.33s) after the crossing — the data path is
   sub-half-second. The stall is the browser TTS engine: its first-ever
   utterance can take seconds or die SILENTLY while the backend spins up, and
   the serialized queue dammed every later callout behind the corpse. Two
   fixes: warmUp() speaks a volume-0 utterance on the RD's first gesture (the
   same one-time unlock that resumes the AudioContext), and a per-utterance
   dead-engine WATCHDOG (6s) that cancels + advances so one swallowed utterance
   can never silence the rest of the race.

Noted for a later slice: adapter-appended passes carry no recorded_at (the
latency measurement had to poll live instead of reading the log).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Race-day audio on every page + TTS cold-start warm-up/watchdog
…etry, stale-replay gate

The field report ('first lap reads 7s but arrived 25s into the race') unwound
into three RH-adapter defects, diagnosed by probing the console's actual
websocket path against a live rig:

- prepare_instant_start zeroed the staging delays of the format that was
  current AT STAGE TIME — but seating the heat switches RH's effective format
  to the heat's CLASS format (RHRace's class_format_id override), so the race
  staged with STOCK multi-second staging tones + delays on top of Grid's own
  start procedure. Every race began ~5-7s after Grid's go (a DB-bloated RH
  stretched it past 15s); lap stamps stayed race-relative so results were
  self-consistent and the skew was invisible everywhere except live: laps,
  tones, and callouts all arrived that late. The prepare now re-runs right
  before stage_race, when the effective format is current and RH is READY.
  Measured on the rig: the websocket-path lap latency fell from a constant
  ~7.1s to 0.28-0.37s (median 0.32s).
- A busy RH ('Attempted to stage race while status is not ready') silently
  DROPS a stage — the race never starts and no passes record. The driver now
  re-emits stage_race every 5s inside the settle window until RH confirms
  RACING (paced above RH's stock staging so a retry can never restart a
  countdown in progress).
- Lap records drained BEFORE this arming's RACING confirmation are a stale
  replay — a busy RH re-broadcasts the PREVIOUS race's current_laps snapshot,
  and remapping it contaminated the fresh heat with the last race's laps
  (observed live: a new heat opening on lap 6+). ArmedHeat now carries a
  started flag (set on SessionStarted, shared across reconnects so mid-race
  snapshot replays still dedup) and pre-start passes are dropped.

Operational note: a long-lived test rig accumulates RH heats (one per race,
'Heat 166' by tonight) and slows staging/saves — reset the container's DB
periodically; the retry + gate make the failure mode safe rather than corrupt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RH adapter: races start on Grid's go (effective-format zeroing) + stage retry + stale-replay gate
…, midpoint Split

RD field feedback, third round tonight:

- The time input opens PRE-FILLED with the selected lap's current time and
  'Save time' arms only once the value changes — the RD reads, tweaks, saves.
  The new duration re-times the lap's closing pass (start + duration), so the
  correction is expressed in the unit RDs actually think in, never a free-typed
  absolute instant (which is what the input used to mean).
- Split needs NO time: it halves the lap at its midpoint — the case is a missed
  gate crossing folding two real laps into one double-length lap, and the RD
  tunes either half with Save afterwards if the halves weren't even.
- 'Insert after' is gone; the per-competitor '+ Add lap' footer covers
  insertion.
- Remove vs Throw out spelled out in the tooltips: Remove = this crossing never
  really happened (noise/reflection; the pass is deleted and neighbouring laps
  merge); Throw out = the lap happened but doesn't count (course cut/penalty;
  stays on the clock, excluded from scoring). Remove edits reality; Throw out
  edits the score.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marshaling lap editor: prefilled lap time + Save, midpoint Split, Insert-after retired
…heat rulings

Design call with the RD: protests/rulings STAY on the marshaling page (rulings
belong next to the evidence — a DQ or a protest resolution is made looking at
the laps, the trace, and the audit, and a separate page would make every
adjudication a context switch on the same heat). Revisit a dedicated cross-heat
protest queue if/when pilot-filed protests arrive.

What changes is the SEAM: the per-pilot surfaces (trace, tune, laps &
corrections) and the heat-scoped surfaces (penalties, protests, result
lifecycle, void) now sit under two labeled region heads with a deliberately
heavy divider between them — sunlit-laptop legible, and the heat region reads
as a distinct zone rather than more pilot UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marshaling: two labeled regions — pilot marshaling vs heat rulings & protests
…he settings gear retired

RD-requested v1 simplification:

- The header's '← Switch event' button is gone: the breadcrumb's Home/Events
  crumbs are the way back, and the context header stays purely informational.
- The topbar 'Setup wizard' relaunch button is gone: the wizard still runs
  automatically once when an event is created, and everything it configures
  stays editable on the regular pages — a manual re-run had no use.
- The settings gear (and its dialog) is gone: its only content was the manual
  bearer-token field, which v1 doesn't use — on the loopback console no token
  is needed at all, and when a privileged action ever does need one, the lazy
  TokenDialog prompt (unchanged) asks for it automatically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v1 chrome trim: Switch event, wizard relaunch, settings gear retired
…lds, macOS leg

The alpha release scheme (RD-approved):

- ONE version everywhere: [workspace.package].version = 0.4.0-alpha.1, inherited
  by every spine crate; the standalone src-tauri crate, tauri.conf.json, and the
  console package.json are kept in lock-step by the new 'cargo xtask version'
  command (prints current with no args; refuses malformed or partial bumps).
  Scheme: 0.x milestones, -alpha.N/-beta.N pre-releases; CONTRACT_VERSION stays
  the independent wire-compat integer. (Also: the workspace repository URL still
  pointed at GitLab — now GitHub.)
- GET /about serves { name, version, contract_version } and the console renders
  a fixed build-stamp footer from it (field-support: a bug report quotes the
  version of the rig it came from). Contract-pinned.
- The DESKTOP builds carry the 'live' feature: the product's whole job is
  talking to real timers, and the alpha field test runs against real RH
  hardware. The TLS stack is vendored, so the portable stays one file.
- macOS (Apple Silicon) joins the release matrix beside Linux and Windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v0.4.0-alpha.1: one product version, live desktop builds, macOS release leg
@ryan-johnson2 ryan-johnson2 merged commit b8b9054 into main Jul 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant