From f45a80eff1cd5fccfa95bfc5bff15ce321171fc4 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Mon, 7 Sep 2026 02:54:26 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs(tauri):=20the=20Windows=20portable=20I?= =?UTF-8?q?S=20built=20=E2=80=94=20correct=20the=20stale=20"deferred"=20se?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src-tauri/README.md` said Windows packaging was deferred and "no Windows artifact is produced here". That has not been true since the release workflow grew its `windows-latest` leg: it builds `gridfpv-desktop.exe`, smoke-tests that it starts, and uploads it as `gridfpv-windows-portable`. The same README's own build section already listed the `.exe` output path, so the file contradicted itself. Replaced with what actually happens, and — since the question comes up every time someone looks at building locally — WHY no cross-compile is attempted: building the exe from Linux is feasible, but it cannot *launch* the result, and the startup smoke-test is the point (#58 is done when the binary launches on all three OSes, not when it compiles for them). Same note for the macOS leg, which stays on Apple hardware for licensing reasons. Also drops the "(deferred — not yet built)" qualifier from the Windows row of the data-dir table, which was stale for the same reason. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011XCMrqJjroyaZ7M2gF6JhU --- src-tauri/README.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src-tauri/README.md b/src-tauri/README.md index 1a6c1db1..cfd75e44 100644 --- a/src-tauri/README.md +++ b/src-tauri/README.md @@ -63,7 +63,7 @@ mount, or `current_exe()` can't be resolved), the app falls back to the OS per-u | OS | Fallback path | | ------- | ------------------------------------------------------------ | | Linux | `~/.local/share/org.gridfpv.desktop/` | -| Windows | `%APPDATA%\org.gridfpv.desktop\` (deferred — not yet built) | +| Windows | `%APPDATA%\org.gridfpv.desktop\` | | macOS | `~/Library/Application Support/org.gridfpv.desktop/` | On startup the app prints one of: @@ -161,7 +161,21 @@ wins; this never overwrites it. ## Windows -Windows packaging is **deferred**. The crate is Windows-ready in principle (the -`windows_subsystem = "windows"` attribute is set for release builds), but no Windows artifact -is produced here and **no cross-compile is attempted** — it needs a Windows build host with -the WebView2 runtime and the MSVC toolchain. +The Windows portable **is** built — by the `windows-latest` leg of +`.github/workflows/release-builds.yml`, which produces the +`gridfpv-windows-portable` artifact (`src-tauri/target/release/gridfpv-desktop.exe`) +and smoke-tests that it starts. Windows needs none of the Linux system deps above: +it builds against the MSVC toolchain and loads the **bundled WebView2 runtime**, +which ships with Windows 11. + +**No cross-compile is attempted, and that is deliberate.** Building the `.exe` from +Linux (`cargo-xwin` and friends) is feasible — WebView2 is a runtime COM dependency, +not a build-time one — but it cannot *launch* the result, and the startup smoke-test +is the point: #58 is done when the single binary launches on all three OSes, not when +it compiles for them. So a Windows artifact comes from a Windows host, which in +practice means the CI leg. + +Same for the **macOS** leg (`macos-latest`, Apple Silicon): Apple's licensing keeps +macOS builds on Apple hardware, and the maintainer has no Mac — so CI's smoke-test is +the only thing standing between that artifact and shipping unverified. Both legs stay +in Actions. From 00417e0e74a74e818d2695e18e3d37e4aee2e955 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Mon, 7 Sep 2026 02:54:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(fold):=20gate=20bounce=20is=20its=20ow?= =?UTF-8?q?n=20rule=20=E2=80=94=20a=20tunable=20per-timer=20same=5Fpass=5F?= =?UTF-8?q?window=20(#517)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bouncy gate put a removal row between every pair of real laps in Marshaling: ∅ crossing at 1:02.4s — under min lap, auto-removed [Restore] three or four of them per pass, so the laps an RD came to marshal were the minority of the list. The cause was that ONE concept had TWO owners and neither was the fold: the console suppressed reflection tones with a hardcoded 1s constant the server knew nothing about (#503), while the fold lumped every too-close crossing under `UnderMinLap` — conflating a competition rule ("too soon to count as a lap") with a hardware fact ("that did not happen twice"). Because they were the same disposition on the wire, the console could not collapse a reflection burst without also hiding a genuinely short lap. So the fold now decides, once, and both consumers read it. * `Timer::same_pass_window_micros` — timer-level, because bounce is a property of the GATE (antenna, RSSI thresholds, placement), identical across every round run on it. `RoundDef::min_lap_secs` stays where it is; that one is a racing rule. Default 1s, hard-capped at 2s: no lap is that short, so the setting can only ever collapse repeat detections of one physical pass. * `VoidReason::SamePassBounce` / `CrossingDisposition::RejectedSamePass` — checked BEFORE the floor (it is the narrower claim), measured from the last SURVIVING pass so a burst collapses against one anchor instead of ratcheting forward off its own echoes, and honouring the same marshal exemption. * Marshaling collapses a burst to one muted line, expanding to the ordinary rows with per-crossing Restore. No bulk restore — restoring a whole burst is never wanted and one misclick would add that many phantom laps. * `CROSSING_TONE_COOLDOWN_MICROS` is deleted, not aliased. `useCrossingTones` reads the disposition instead. Behaviour is unchanged: the fold measures from the last surviving pass exactly as the cooldown measured from the last sounded tone, and a suppressed crossing extends neither. ## Pinned at the arm, so a timer edit cannot re-judge a finished race `min_lap_secs` freezes once a round has raced, precisely so results cannot shift under it. Timers have no such freeze — they are edited freely — so a timer-level fold input read live would re-score every past race on that timer. `Event::HeatDetectionPinned` records the window in force when the heat arms, and the fold reads it from the log. Same shape as `HeatStarting` / `HeatFinalizing` / `RaceExpired`: the runtime resolves a value once, at emission time, and logs it as a fact. `(Staged, Start) -> Armed` is the only arm in the heat FSM, so every run pins exactly once and a re-arm re-pins. Verified live: with a race finished, turning the timer's window off left the lap list byte-identical. The general rule is #518. The pin is included in the heat and class windows BY TAG and un-gated — it is appended at the arm and `current_run_start` opens the window at Running, so a gated scan would drop it and the live view would then disagree with the lap list about a suppressed pass. That is the D26 failure of #409; `live_crossings.rs` now asserts it cannot recur. `docker/rotorhazard/race_day.py` gains a `bouncy` scenario — echo bursts per pass plus one genuine sub-floor crossing — which is the fixture this needs. Verified against dockerized RH: 11 RejectedSamePass, 1 RejectedTooShort, 8 counted laps, and the two sitting side by side in one pilot's list. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011XCMrqJjroyaZ7M2gF6JhU --- bindings/CreateTimerRequest.ts | 8 +- bindings/CrossingDisposition.ts | 4 +- bindings/Event.ts | 11 +- bindings/Timer.ts | 28 +- bindings/UpdateTimerRequest.ts | 8 +- bindings/VoidReason.ts | 2 +- crates/adapters/tests/rh_live.rs | 1 + crates/app/src/source.rs | 5 + crates/app/src/source/failover.rs | 1 + crates/app/src/source/rh_connections.rs | 2 + crates/app/tests/rh_connect_live.rs | 3 + crates/app/tests/rh_no_plugin_live.rs | 1 + crates/app/tests/rh_restart_live.rs | 1 + crates/app/tests/rh_tune_write_live.rs | 1 + crates/events/src/lib.rs | 31 ++ crates/projection/src/lib.rs | 327 ++++++++++++++++-- crates/server/src/app.rs | 37 ++ crates/server/src/control_handler.rs | 73 +++- crates/server/src/events.rs | 2 + crates/server/src/live_state.rs | 12 +- crates/server/src/round_engine.rs | 1 + crates/server/src/timers.rs | 110 ++++++ crates/server/tests/live_crossings.rs | 165 ++++++++- crates/server/tests/multi_round_event_live.rs | 1 + docker/rotorhazard/race_day.py | 34 ++ .../rd-console/src/lib/lapCallouts.svelte.ts | 55 ++- .../rd-console/src/screens/Marshaling.svelte | 178 ++++++++-- .../src/screens/TimerManager.svelte | 58 +++- .../rd-console/tests/MarshalingScreen.test.ts | 69 ++++ .../apps/rd-console/tests/TimersPage.test.ts | 22 +- .../tests/lapCallouts.svelte.test.ts | 77 +++-- xtask/src/race_day.rs | 4 + 32 files changed, 1180 insertions(+), 152 deletions(-) diff --git a/bindings/CreateTimerRequest.ts b/bindings/CreateTimerRequest.ts index 9ffc58b4..0e8d70ea 100644 --- a/bindings/CreateTimerRequest.ts +++ b/bindings/CreateTimerRequest.ts @@ -32,4 +32,10 @@ node_count?: number, * The new timer's **available channels** in raw MHz (race redesign Slice 4a). Optional; * defaults to empty (none configured). */ -available_channels?: Array, }; +available_channels?: Array, +/** + * The new timer's **gate-bounce window** in µs (#517) — see + * [`Timer::same_pass_window_micros`]. Optional; omit it (or send `0`) for no bounce rule. + * [`DEFAULT_SAME_PASS_WINDOW_MICROS`] is what the console offers, not what the server assumes. + */ +same_pass_window_micros?: number, }; diff --git a/bindings/CrossingDisposition.ts b/bindings/CrossingDisposition.ts index ff3863c4..1c06eb35 100644 --- a/bindings/CrossingDisposition.ts +++ b/bindings/CrossingDisposition.ts @@ -10,6 +10,6 @@ * So a disposition is a **position in the corrected pass chain**, or the removal record the fold * already keeps — never a new logged fact. * - * The two removal-side variants map 1:1 onto the only two [`VoidReason`]s that exist. + * Every removal-side variant maps 1:1 onto the [`VoidReason`] that produced it. */ -export type CrossingDisposition = "Holeshot" | "Counted" | "RejectedTooShort" | "VoidedByMarshal" | "RejectedAfterRaceEnd"; +export type CrossingDisposition = "Holeshot" | "Counted" | "RejectedTooShort" | "RejectedSamePass" | "VoidedByMarshal" | "RejectedAfterRaceEnd"; diff --git a/bindings/Event.ts b/bindings/Event.ts index 000194b8..a18b476e 100644 --- a/bindings/Event.ts +++ b/bindings/Event.ts @@ -112,7 +112,16 @@ heat: HeatId, * unbounded grace (the engine's `GraceWindow::UntilScored`): the heat then closes only * on the all-crossed rule or the RD's `ForceEnd`. */ -deadline?: number, } } | { "DetectionVoided": { target: LogRef, } } | { "LapInserted": { adapter: AdapterId, competitor: CompetitorRef, at: SourceTime, +deadline?: number, } } | { "HeatDetectionPinned": { +/** + * The heat this config was pinned for (it is entering `Armed`). + */ +heat: HeatId, +/** + * The **gate-bounce window** (µs) in force for this run: two lap-gate crossings by the + * same competitor closer together than this are one physical pass. `None` for no rule. + */ +same_pass_window_micros?: number, } } | { "DetectionVoided": { target: LogRef, } } | { "LapInserted": { adapter: AdapterId, competitor: CompetitorRef, at: SourceTime, /** * The heat the inserted lap belongs to. Unlike a raw [`Pass`] (an untagged wire * observation attributed positionally), an insertion is an RD statement **about a diff --git a/bindings/Timer.ts b/bindings/Timer.ts index c3b1f9dc..a17d0617 100644 --- a/bindings/Timer.ts +++ b/bindings/Timer.ts @@ -186,4 +186,30 @@ calibration: Array, * an empty record is omitted entirely, so a pre-#413 `timers.json` restores with none and a * console (or a test fixture) written before this existed still parses a `Timer`. */ -node_channels?: Array, }; +node_channels?: Array, +/** + * The **gate-bounce window** (µs): two lap-gate crossings by the same competitor closer + * together than this are **the same physical pass**, detected twice (#517). + * + * A quad in the gate's near field fires the detector several times per crossing — antenna + * reflections milliseconds apart. That is a property of *this gate*: its antenna, its RSSI + * thresholds, where it sits on the track. So it lives on the timer, not on the round. + * + * **Not the same thing as the min-lap floor.** `RoundDef::min_lap_secs` is a *competition + * rule* — "a lap shorter than this does not count" — and it is the round's to set. This is a + * *hardware fact* — "that was not a second crossing at all". Conflating them is what made a + * reflection burst indistinguishable from a genuinely short lap in the marshaling list. + * + * `None` (the default, and what an older `timers.json` restores with) means no bounce rule: + * every crossing stands on its own and only the round's floor judges it. + * + * Capped at [`MAX_SAME_PASS_WINDOW_MICROS`] and normalized so `0` reads as `None`. The cap is + * what makes the setting safe to change: no real lap can be that short, so the window can only + * ever collapse a multi-detection of one pass, never eat a lap somebody flew. + * + * **Read at the arm, not at the fold.** The value in force when a heat arms is stamped onto + * the log ([`Event::HeatDetectionPinned`](gridfpv_events::Event::HeatDetectionPinned)) and the + * corrected fold reads it from there — so editing this never re-judges a race that has already + * run. See #518 for the general rule. + */ +same_pass_window_micros?: number, }; diff --git a/bindings/UpdateTimerRequest.ts b/bindings/UpdateTimerRequest.ts index 0416a8f5..9f1394eb 100644 --- a/bindings/UpdateTimerRequest.ts +++ b/bindings/UpdateTimerRequest.ts @@ -32,4 +32,10 @@ node_count?: number, * A new **available-channels** set in raw MHz (race redesign Slice 4a), or `None` to leave it * unchanged. */ -available_channels?: Array, }; +available_channels?: Array, +/** + * A new **gate-bounce window** in µs (#517), or `None` to leave it unchanged. Send `0` to turn + * the bounce rule off — unlike the node-count override this needs no separate clear route, + * because zero already spells "no window" (the same idiom `RoundDef::min_lap_secs` uses). + */ +same_pass_window_micros?: number, }; diff --git a/bindings/VoidReason.ts b/bindings/VoidReason.ts index c556800a..95c0b7b6 100644 --- a/bindings/VoidReason.ts +++ b/bindings/VoidReason.ts @@ -3,4 +3,4 @@ /** * Why a pass sits on the removal record instead of the lap chain. */ -export type VoidReason = "Marshal" | "UnderMinLap" | "AfterRaceEnd"; +export type VoidReason = "Marshal" | "UnderMinLap" | "SamePassBounce" | "AfterRaceEnd"; diff --git a/crates/adapters/tests/rh_live.rs b/crates/adapters/tests/rh_live.rs index 84a3a401..55517a1e 100644 --- a/crates/adapters/tests/rh_live.rs +++ b/crates/adapters/tests/rh_live.rs @@ -58,6 +58,7 @@ fn event_kind(e: &Event) -> &'static str { Event::HeatStarting { .. } => "HeatStarting", Event::HeatFinalizing { .. } => "HeatFinalizing", Event::RaceExpired { .. } => "RaceExpired", + Event::HeatDetectionPinned { .. } => "HeatDetectionPinned", Event::DetectionVoided { .. } => "DetectionVoided", Event::LapInserted { .. } => "LapInserted", Event::LapAdjusted { .. } => "LapAdjusted", diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 99115de1..ef6ab65d 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -2328,6 +2328,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); // The timer reports four nodes; the RD has switched off the third one ("Node 3" = index 2). @@ -2440,6 +2441,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); // Select only the RotorHazard timer for Practice. @@ -2493,6 +2495,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); registry @@ -3530,6 +3533,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() .id @@ -3619,6 +3623,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() .id; diff --git a/crates/app/src/source/failover.rs b/crates/app/src/source/failover.rs index ba05d351..54d013f9 100644 --- a/crates/app/src/source/failover.rs +++ b/crates/app/src/source/failover.rs @@ -92,6 +92,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); let event = EventId( diff --git a/crates/app/src/source/rh_connections.rs b/crates/app/src/source/rh_connections.rs index 3aa35c4a..8e2f3286 100644 --- a/crates/app/src/source/rh_connections.rs +++ b/crates/app/src/source/rh_connections.rs @@ -735,6 +735,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("timer created") .id @@ -1150,6 +1151,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("timer created") .id; diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs index 600d5ada..00442a6d 100644 --- a/crates/app/tests/rh_connect_live.rs +++ b/crates/app/tests/rh_connect_live.rs @@ -156,6 +156,7 @@ async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_throu channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer"); // A freshly-configured RH timer rests at `Configured` until the Director connects it. @@ -550,6 +551,7 @@ async fn director_fails_over_from_a_dropped_rh_primary_to_a_mock_alternate() { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer"); @@ -718,6 +720,7 @@ async fn editing_a_live_timers_url_in_place_re_dials_without_a_restart() { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer at the wrong URL"); registry diff --git a/crates/app/tests/rh_no_plugin_live.rs b/crates/app/tests/rh_no_plugin_live.rs index 6c595a56..f4a0a0f3 100644 --- a/crates/app/tests/rh_no_plugin_live.rs +++ b/crates/app/tests/rh_no_plugin_live.rs @@ -194,6 +194,7 @@ async fn a_plugin_less_rotorhazard_connects_but_grid_refuses_to_race_it_and_says channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create the RH timer"); diff --git a/crates/app/tests/rh_restart_live.rs b/crates/app/tests/rh_restart_live.rs index ec5478e6..1e607970 100644 --- a/crates/app/tests/rh_restart_live.rs +++ b/crates/app/tests/rh_restart_live.rs @@ -143,6 +143,7 @@ async fn restarting_a_timer_reexecutes_rotorhazard_and_the_director_reconnects_a channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer"); registry diff --git a/crates/app/tests/rh_tune_write_live.rs b/crates/app/tests/rh_tune_write_live.rs index c8e39d51..4b821c57 100644 --- a/crates/app/tests/rh_tune_write_live.rs +++ b/crates/app/tests/rh_tune_write_live.rs @@ -142,6 +142,7 @@ async fn bench(port: u16) -> Bench { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer") .id; diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 6ab4727a..9c7c8342 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -833,6 +833,37 @@ pub enum Event { #[ts(optional, type = "number")] deadline: Option, }, + /// The **detection config in force for this run**, pinned at the arm (#517). + /// + /// The gate-bounce window (`Timer::same_pass_window_micros`) is *timer* config, and a timer is + /// editable at any time — unlike a round, which freezes the moment it races. Read live, the + /// corrected fold would therefore re-judge finished races every time an RD nudged a slider on + /// the Timers page. So the value in force is written here, **once, when the heat arms**, and + /// the fold reads it from the log instead: an edit changes the next run, never a past one. + /// + /// Same shape as [`HeatStarting`](Event::HeatStarting) / [`HeatFinalizing`](Event::HeatFinalizing) + /// / [`RaceExpired`](Event::RaceExpired) — the runtime resolves a value once, at emission time, + /// and logs it as a fact so a replay reads the same answer instead of re-deriving it. Same idea + /// as [`HeatLayoutSet`](Event::HeatLayoutSet), too, which pins the resolved channel layout at + /// the *stage* for exactly this reason (#478). The general rule is #518. + /// + /// **Why the arm and not the stage:** arming is the moment the gate opens to detections, and it + /// is the last point before a pass can arrive. `(Staged, Start) -> Armed` is the only arm in the + /// heat FSM (`heat::apply`), so every run passes through here exactly once — and a re-arm after + /// an Abort or Restart re-pins, which is correct: it is a new run. + /// + /// **Absent means no bounce rule** — a heat armed before this existed, or one whose timer has + /// no window set. The fold then judges crossings on the round's min-lap floor alone, exactly as + /// it did before, so an older log folds bit-identically. + HeatDetectionPinned { + /// The heat this config was pinned for (it is entering `Armed`). + heat: HeatId, + /// The **gate-bounce window** (µs) in force for this run: two lap-gate crossings by the + /// same competitor closer together than this are one physical pass. `None` for no rule. + #[serde(default)] + #[ts(optional, type = "number")] + same_pass_window_micros: Option, + }, /// Marshaling: void a previously-detected pass, referenced by log offset. The /// projection folds it out as if it never happened — the raw [`Pass`] stays in /// the log untouched. diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 7a7a25ed..0e551b78 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -146,9 +146,23 @@ pub enum VoidReason { #[default] Marshal, /// The corrected fold suppressed it: it would close a lap under the round's minimum lap - /// time (D26 — a gate reflection / double-detection; timers are dumb emitters, GridFPV - /// owns lap semantics). + /// time (D26 — timers are dumb emitters, GridFPV owns lap semantics). A **real** crossing + /// that came too soon to count as a lap — the pilot did cross the gate. UnderMinLap, + /// The corrected fold suppressed it as a **gate bounce** (#517): it landed within the timer's + /// `same_pass_window` of the last surviving crossing, so it is not a second crossing at all — + /// it is the *same physical pass*, detected twice by a gate whose near field caught the quad + /// more than once. + /// + /// **Distinct from [`UnderMinLap`](Self::UnderMinLap) on purpose.** That one is a competition + /// rule ("too soon to count"); this is a hardware fact ("that did not happen twice"). They were + /// one variant until #517, which is exactly why a reflection burst and a genuinely short lap + /// were indistinguishable in the marshaling list — and why the console could not collapse the + /// former without hiding the latter. + /// + /// Checked **before** the floor, because it is the narrower claim. Marshal-restorable like the + /// others; an explicit ruling always outranks the rule. + SamePassBounce, /// The corrected fold suppressed it under the **grace rule** (#505): the competitor had /// already taken their one allowed crossing after the run's `RaceExpired` marker ("finish /// the lap you had started; once you cross after the end-of-race tone, no more laps @@ -458,12 +472,13 @@ where pub fn corrected_passes_with_floor<'a, I>( events: I, min_lap_micros: Option, + same_pass_window: Option, race_expired: Option, ) -> Vec<(u64, Pass)> where I: IntoIterator, { - corrected_and_voided_passes_with_floor(events, min_lap_micros, race_expired).0 + corrected_and_voided_passes_with_floor(events, min_lap_micros, same_pass_window, race_expired).0 } /// The offset of `heat`'s standing **`RaceExpired` marker** within `window` — the grace rule's @@ -487,6 +502,44 @@ where found } +/// The **gate-bounce window** (µs) pinned for `heat`'s current run, or `None` for no bounce rule +/// (#517). +/// +/// Reads the latest [`Event::HeatDetectionPinned`] tagged for this heat: **last one wins**, so a +/// heat aborted and re-armed races under the window in force at the *new* arm, and every earlier +/// pin is dead. A heat that never armed, or one armed before the pin existed, resolves `None` and +/// the fold judges crossings on the round's min-lap floor alone — bit-identical to before. +/// +/// # Why the whole log, not the run window +/// +/// The pin is appended at the **arm**, and `current_run_start` opens a run's window at `Running` — +/// so the pin sits *below* the window and a window-scoped scan would never see it. That is fine, +/// because this resolves a **value**, not a boundary: unlike [`race_expired_offset`], whose answer +/// *is* a log position and therefore has to come from the window, a window here would be an +/// obstacle rather than a scope. +/// +/// So it is resolved the way the D26 floor is resolved — at the call site, from the full log, and +/// handed to the fold as a parameter (`min_lap_micros_of` ∘ `round_def_of_heat` does exactly this). +/// One resolver, every scope, same answer. +pub fn same_pass_window_of_heat<'a, I>(events: I, heat: &gridfpv_events::HeatId) -> Option +where + I: IntoIterator, +{ + let mut found = None; + for event in events { + if let Event::HeatDetectionPinned { + heat: h, + same_pass_window_micros, + } = event + { + if h == heat { + found = *same_pass_window_micros; + } + } + } + found.filter(|w| *w > 0) +} + /// One removed pass as the fold emits it: /// `(pass offset, restore-target offset, pass, why)`. pub type VoidedEmit = (u64, u64, Pass, VoidReason); @@ -730,6 +783,11 @@ where /// After the marshaling corrections fold, each competitor's surviving chain is walked /// chronologically: /// +/// - **The bounce window** (#517): a **raw, unruled** pass landing within `same_pass_window` of +/// the last surviving crossing is AUTO-SUPPRESSED as [`VoidReason::SamePassBounce`] — not a +/// crossing that came too soon, but the *same physical pass* seen twice. Checked **before** the +/// floor because it is the narrower claim, and reported separately so the console can collapse a +/// reflection burst without also hiding a genuinely short lap. /// - **The floor**: a **raw, unruled** pass that would close a lap shorter than /// `min_lap_micros` is AUTO-SUPPRESSED — moved onto the removal record with /// [`VoidReason::UnderMinLap`] (its restore target is itself; a marshal re-time exempts it). @@ -748,6 +806,7 @@ where pub fn corrected_and_voided_passes_with_floor<'a, I>( events: I, min_lap_micros: Option, + same_pass_window: Option, race_expired: Option, ) -> (Vec<(u64, Pass)>, Vec) where @@ -759,7 +818,8 @@ where let pairs: Vec<(u64, &Event)> = events.into_iter().collect(); let (surviving, mut voided) = corrected_and_voided_passes(pairs.iter().copied()); let floor = min_lap_micros.filter(|f| *f > 0); - if floor.is_none() && race_expired.is_none() { + let bounce = same_pass_window.filter(|w| *w > 0); + if floor.is_none() && bounce.is_none() && race_expired.is_none() { return (surviving, voided); } @@ -795,9 +855,19 @@ where let mut post_expiry_taken = false; for (offset, pass) in chain { let ruled = exempt.contains(&offset); - let too_close = floor.is_some_and(|floor| { - last_kept.is_some_and(|prev| pass.at.micros.saturating_sub(prev.micros) < floor) - }); + // Both rules measure from the LAST SURVIVING crossing, never from the previous + // observation: a suppressed pass does not extend the window it was suppressed by, so a + // burst collapses against one anchor instead of ratcheting forward off its own echoes. + let since_kept = last_kept.map(|prev| pass.at.micros.saturating_sub(prev.micros)); + // Bounce first — it is the narrower claim, and a pass inside the bounce window is not a + // short lap to be judged by the floor, it is not a separate crossing at all. + let bounced = bounce.is_some_and(|w| since_kept.is_some_and(|since| since < w)); + if bounced && !ruled { + voided.push((offset, offset, pass, VoidReason::SamePassBounce)); + continue; + } + let too_close = + floor.is_some_and(|floor| since_kept.is_some_and(|since| since < floor)); if too_close && !ruled { voided.push((offset, offset, pass, VoidReason::UnderMinLap)); continue; @@ -843,21 +913,23 @@ pub fn lap_list_marshaled<'a, I>(events: I) -> LapList where I: IntoIterator, { - lap_list_marshaled_with_floor(events, None, None) + lap_list_marshaled_with_floor(events, None, None, None) } -/// [`lap_list_marshaled`] under a round's **auto-suppression rules**: the minimum-lap floor -/// (D26) and the grace rule against `race_expired` (#505) — suppressed passes land on each -/// competitor's removal record with [`VoidReason::UnderMinLap`] / [`VoidReason::AfterRaceEnd`]. +/// [`lap_list_marshaled`] under a run's **auto-suppression rules**: the gate-bounce window +/// (#517), the minimum-lap floor (D26) and the grace rule against `race_expired` (#505) — +/// suppressed passes land on each competitor's removal record with +/// [`VoidReason::SamePassBounce`] / [`VoidReason::UnderMinLap`] / [`VoidReason::AfterRaceEnd`]. pub fn lap_list_marshaled_with_floor<'a, I>( events: I, min_lap_micros: Option, + same_pass_window: Option, race_expired: Option, ) -> LapList where I: IntoIterator, { - CorrectedWindow::of(events, min_lap_micros, race_expired).into_lap_list() + CorrectedWindow::of(events, min_lap_micros, same_pass_window, race_expired).into_lap_list() } /// One window's **correction fold, folded once** — the shared input to both views of it: the @@ -881,9 +953,15 @@ pub struct CorrectedWindow { } impl CorrectedWindow { - /// Fold `events` (a window of `(offset, event)` pairs) under the D26 floor and the #505 - /// grace rule (`race_expired` — the run's marker offset, [`race_expired_offset`]). - pub fn of<'a, I>(events: I, min_lap_micros: Option, race_expired: Option) -> Self + /// Fold `events` (a window of `(offset, event)` pairs) under the #517 bounce window + /// (`same_pass_window` — the run's pinned value, [`same_pass_window_of_heat`]), the D26 floor, + /// and the #505 grace rule (`race_expired` — the run's marker offset, [`race_expired_offset`]). + pub fn of<'a, I>( + events: I, + min_lap_micros: Option, + same_pass_window: Option, + race_expired: Option, + ) -> Self where I: IntoIterator, { @@ -898,6 +976,7 @@ impl CorrectedWindow { let (surviving, voided) = corrected_and_voided_passes_with_floor( pairs.iter().copied(), min_lap_micros, + same_pass_window, race_expired, ); Self { @@ -975,7 +1054,7 @@ fn lap_list_of_corrected( /// So a disposition is a **position in the corrected pass chain**, or the removal record the fold /// already keeps — never a new logged fact. /// -/// The two removal-side variants map 1:1 onto the only two [`VoidReason`]s that exist. +/// Every removal-side variant maps 1:1 onto the [`VoidReason`] that produced it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export, export_to = "bindings/")] pub enum CrossingDisposition { @@ -986,10 +1065,19 @@ pub enum CrossingDisposition { /// A crossing that **closed a lap** (chain position `n >= 1` closes lap `n`). Counted, /// The corrected fold **auto-suppressed** it under the round's minimum-lap floor (D26 — - /// [`VoidReason::UnderMinLap`]): a gate reflection / double-detection. It records no lap and - /// today reaches no live consumer at all, which is the gap #397 exists to close — a - /// too-sensitive gate is as broken as an insensitive one, and nothing surfaces it live. + /// [`VoidReason::UnderMinLap`]): a real crossing that came too soon to close a lap. It records + /// no lap, but it still **tones** (#397) — a too-sensitive gate is as broken as an insensitive + /// one, and a table of laps will never show an RD the difference. RejectedTooShort, + /// The corrected fold **auto-suppressed** it as a **gate bounce** (#517 — + /// [`VoidReason::SamePassBounce`]): the same physical pass, detected twice. + /// + /// This is the one disposition that is deliberately **silent**. Every other crossing tones, + /// because every other crossing is a thing that happened; a bounce is the gate stuttering over + /// a thing that happened once. Before #517 the console suppressed these with its own private + /// timer (`CROSSING_TONE_COOLDOWN_MICROS`) — one concept, two owners, and no way for the + /// marshaling list to agree with the speaker. Now the fold decides once and both read it. + RejectedSamePass, /// A marshal explicitly removed it after the fact ([`VoidReason::Marshal`]). It was a real /// observed crossing when it happened; the removal is a later ruling over it. VoidedByMarshal, @@ -1006,6 +1094,7 @@ impl CrossingDisposition { match reason { VoidReason::Marshal => Self::VoidedByMarshal, VoidReason::UnderMinLap => Self::RejectedTooShort, + VoidReason::SamePassBounce => Self::RejectedSamePass, VoidReason::AfterRaceEnd => Self::RejectedAfterRaceEnd, } } @@ -1066,12 +1155,13 @@ pub struct DispositionedPass { pub fn dispositioned_passes<'a, I>( events: I, min_lap_micros: Option, + same_pass_window: Option, race_expired: Option, ) -> Vec where I: IntoIterator, { - CorrectedWindow::of(events, min_lap_micros, race_expired).crossings(None) + CorrectedWindow::of(events, min_lap_micros, same_pass_window, race_expired).crossings(None) } impl CorrectedWindow { @@ -2239,6 +2329,172 @@ mod marshaling_tests { assert!(cl.voided.is_empty()); } + /// A pin event for `heat`, as the arm appends it (#517). + fn pinned(heat: &str, window: Option) -> Event { + Event::HeatDetectionPinned { + heat: gridfpv_events::HeatId(heat.into()), + same_pass_window_micros: window, + } + } + + #[test] + fn the_pinned_window_is_the_latest_one_for_that_heat() { + // Last one wins, per heat. A heat armed, aborted, the RD retuned the gate, re-armed: the + // run races under the window set for THAT arm, and the earlier pin is dead. A pin for a + // different heat never leaks across. + let heat = gridfpv_events::HeatId("q-1".into()); + let log = [ + pinned("q-1", Some(1_000_000)), + pinned("q-2", Some(2_000_000)), + pinned("q-1", Some(400_000)), + ]; + assert_eq!(same_pass_window_of_heat(log.iter(), &heat), Some(400_000)); + } + + #[test] + fn an_unpinned_heat_resolves_to_no_bounce_rule() { + // Three ways to have no rule, all of which must read the same: never armed, armed before + // #517 existed (no pin on the log at all), and armed on a timer with the setting unset + // (a pin carrying `None`). A `0` is normalized away for the same reason. + let heat = gridfpv_events::HeatId("q-1".into()); + assert_eq!(same_pass_window_of_heat([].iter(), &heat), None); + assert_eq!( + same_pass_window_of_heat([pinned("q-2", Some(1_000_000))].iter(), &heat), + None, + "another heat's pin is not this heat's" + ); + assert_eq!( + same_pass_window_of_heat([pinned("q-1", None)].iter(), &heat), + None + ); + assert_eq!( + same_pass_window_of_heat([pinned("q-1", Some(0))].iter(), &heat), + None + ); + } + + #[test] + fn the_bounce_window_labels_a_reflection_burst_apart_from_a_short_lap() { + // #517, and the whole reason the window exists. The RD runs a 10s floor and a 1s bounce + // window. Two suppressions land in the same run and they are NOT the same thing: + // + // * a 132ms echo — the gate stuttering over one physical pass; + // * a 3s crossing — the pilot really did cross the gate again, far too soon to score. + // + // Before this both read `UnderMinLap`, so the console could not collapse the first without + // also hiding the second. Now the fold says which is which. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), // offset 0 — holeshot, kept + pass("rh", "A", 1_132_000, Some(2)), // offset 1 — +132ms: bounce + pass("rh", "A", 4_000_000, Some(3)), // offset 2 — +3s: real, but under the floor + pass("rh", "A", 21_000_000, Some(4)), // offset 3 — +20s: a genuine lap + ]; + let (surviving, voided) = corrected_and_voided_passes_with_floor( + tagged(&events), + Some(10_000_000), + Some(1_000_000), + None, + ); + // Only the holeshot and the genuine lap survive; both suppressions measured from the + // holeshot, because a suppressed pass never becomes the anchor. + assert_eq!( + surviving.iter().map(|(o, _)| *o).collect::>(), + vec![0, 3] + ); + assert_eq!( + voided + .iter() + .map(|(offset, _, _, reason)| (*offset, *reason)) + .collect::>(), + vec![ + (1, VoidReason::SamePassBounce), + (2, VoidReason::UnderMinLap), + ], + "the echo is a bounce; the 3s crossing is a real pass under the floor" + ); + } + + #[test] + fn the_bounce_window_measures_from_the_last_surviving_pass_not_the_last_crossing() { + // A burst walks forward in ~0.9s steps. Measured crossing-to-crossing every one would be + // "within 1s of the previous" and the whole burst would collapse forever. Measured from the + // last SURVIVING pass — the rule the audio's cooldown already used, anchoring on the last + // *sounded* tone — the third step is 1.8s off the anchor and stops being a bounce. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), // offset 0 — kept, the anchor + pass("rh", "A", 1_900_000, Some(2)), // offset 1 — +0.9s: bounce + pass("rh", "A", 2_800_000, Some(3)), // offset 2 — +1.8s from the ANCHOR: not a bounce + ]; + let (_, voided) = + corrected_and_voided_passes_with_floor(tagged(&events), None, Some(1_000_000), None); + assert_eq!( + voided + .iter() + .map(|(offset, _, _, reason)| (*offset, *reason)) + .collect::>(), + vec![(1, VoidReason::SamePassBounce)], + "an absorbed crossing must not ratchet the window forward off its own echo" + ); + } + + #[test] + fn the_bounce_window_works_with_no_min_lap_floor_at_all() { + // The floor is a per-round competition rule and an RD may simply not set one. The bounce + // window is hardware truth and must still apply — this is what lets the console stop + // keeping its own private cooldown, which used to be the ONLY bounce rule in that case. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), // offset 0 — kept + pass("rh", "A", 1_050_000, Some(2)), // offset 1 — 50ms echo + ]; + let (surviving, voided) = + corrected_and_voided_passes_with_floor(tagged(&events), None, Some(1_000_000), None); + assert_eq!(surviving.len(), 1); + assert_eq!(voided.len(), 1); + assert_eq!(voided[0].3, VoidReason::SamePassBounce); + } + + #[test] + fn a_marshal_ruling_exempts_a_pass_from_the_bounce_window() { + // Same exemption the floor honours: an explicit ruling outranks an automatic rule. An RD + // who re-times a pass into the bounce window has decided it is a real crossing, and the + // fold does not get to overrule them. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), // offset 0 — kept + pass("rh", "A", 1_100_000, Some(2)), // offset 1 — would bounce… + adjusted(1, 1_100_000), // offset 2 — …but the RD re-asserted it + ]; + let (surviving, voided) = + corrected_and_voided_passes_with_floor(tagged(&events), None, Some(1_000_000), None); + assert_eq!(surviving.len(), 2, "the ruled pass stands"); + assert!(voided.is_empty()); + } + + #[test] + fn no_bounce_window_folds_bit_identically_to_before() { + // The additive guarantee: a run with no pinned window (every heat armed before #517, and + // every timer with the setting unset) must fold exactly as it always did. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), + pass("rh", "A", 1_004_000, Some(2)), + pass("rh", "A", 30_000_000, Some(3)), + ]; + let with_none = + lap_list_marshaled_with_floor(tagged(&events), Some(10_000_000), None, None); + let with_zero = + lap_list_marshaled_with_floor(tagged(&events), Some(10_000_000), Some(0), None); + assert_eq!(with_none, with_zero, "0 normalizes to no rule"); + let reasons: Vec = with_none + .competitors + .iter() + .flat_map(|c| c.voided.iter().map(|v| v.reason)) + .collect(); + assert_eq!( + reasons, + vec![VoidReason::UnderMinLap], + "without a window the echo is still judged by the floor alone, as before" + ); + } + #[test] fn min_lap_floor_suppresses_the_phantom_double_detection() { // The live bug (Audit Shakedown): every pilot got TWO passes 4ms apart at race start — @@ -2251,7 +2507,7 @@ mod marshaling_tests { pass("vd", "A", 7_208_000, Some(3)), // offset 2 — real lap 1 pass("vd", "A", 13_500_000, Some(4)), // offset 3 — real lap 2 ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -2273,7 +2529,7 @@ mod marshaling_tests { }] ); // No floor ⇒ bit-identical to the plain fold (rounds predating the setting). - let unfloored = lap_list_marshaled_with_floor(tagged(&events), None, None); + let unfloored = lap_list_marshaled_with_floor(tagged(&events), None, None, None); let plain = lap_list_marshaled(tagged(&events)); assert_eq!(unfloored, plain); assert_eq!(plain.competitors[0].laps.len(), 3); @@ -2297,7 +2553,7 @@ mod marshaling_tests { // The marker is resolved from the window exactly as the server call sites do. let marker = race_expired_offset(tagged(&log), &heat); assert_eq!(marker, Some(2)); - let result = lap_list_marshaled_with_floor(tagged(&log), None, marker); + let result = lap_list_marshaled_with_floor(tagged(&log), None, None, marker); let cl = result .competitors .iter() @@ -2319,7 +2575,7 @@ mod marshaling_tests { ); // No marker ⇒ bit-identical to the plain fold (a run that never expired). assert_eq!( - lap_list_marshaled_with_floor(tagged(&log), None, None), + lap_list_marshaled_with_floor(tagged(&log), None, None, None), lap_list_marshaled(tagged(&log)) ); } @@ -2338,7 +2594,7 @@ mod marshaling_tests { // spend A's one allowed crossing (that was offset 4). inserted("rh", "A", 10_000_000), ]; - let result = lap_list_marshaled_with_floor(tagged(&events), None, Some(3)); + let result = lap_list_marshaled_with_floor(tagged(&events), None, None, Some(3)); let a = result .competitors .iter() @@ -2375,7 +2631,8 @@ mod marshaling_tests { pass("rh", "A", 35_193_000, Some(5)), // offset 4 — reflection: UnderMinLap pass("rh", "A", 52_000_000, Some(6)), // offset 5 — real next lap: AfterRaceEnd ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(10_000_000), Some(1)); + let result = + lap_list_marshaled_with_floor(tagged(&events), Some(10_000_000), None, Some(1)); let cl = result .competitors .iter() @@ -2432,7 +2689,7 @@ mod marshaling_tests { pass("vd", "A", 9_000_000, Some(3)), // offset 2 adjusted(1, 3_000_000), // offset 3 — marshal: "that 2s lap is real" ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -2458,7 +2715,7 @@ mod marshaling_tests { pass("vd", "A", 10_000_000, Some(2)), // offset 1 inserted("vd", "A", 2_500_000), // offset 2 — a 1.5s lap, by ruling ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -2479,7 +2736,7 @@ mod marshaling_tests { pass("vd", "A", 1_030_000, Some(4)), // echo — suppressed pass("vd", "A", 8_000_000, Some(5)), // real — kept (7s from last kept) ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -2506,7 +2763,7 @@ mod marshaling_tests { pass("vd", "A", 8_000_000, Some(3)), // offset 2 — real lap voided(0), // offset 3 ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -3845,7 +4102,7 @@ mod marshaling_tests { log.push(voided(10)); let floor = Some(5_000_000); - let window = CorrectedWindow::of(tagged(&log), floor, None); + let window = CorrectedWindow::of(tagged(&log), floor, None, None); let full = window.crossings(None); assert!( full.len() > 8, @@ -3877,7 +4134,7 @@ mod marshaling_tests { assert!(window.crossings(Some(0)).is_empty()); // And the free function still answers the whole run. - assert_eq!(dispositioned_passes(tagged(&log), floor, None), full); + assert_eq!(dispositioned_passes(tagged(&log), floor, None, None), full); } /// **One fold, two views.** The lap list and the crossing feed are now read off a single @@ -3896,14 +4153,14 @@ mod marshaling_tests { pass("rh", "node-0", 60_000_000, None), ]; let floor = Some(5_000_000); - let window = CorrectedWindow::of(tagged(&log), floor, None); + let window = CorrectedWindow::of(tagged(&log), floor, None, None); assert_eq!( window.crossings(None), - dispositioned_passes(tagged(&log), floor, None) + dispositioned_passes(tagged(&log), floor, None, None) ); assert_eq!( window.into_lap_list(), - lap_list_marshaled_with_floor(tagged(&log), floor, None) + lap_list_marshaled_with_floor(tagged(&log), floor, None, None) ); } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index dce008ad..4e5cc67c 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -2571,6 +2571,15 @@ pub(crate) fn class_window_offsets(events: &[Event], class: &ClassId) -> Vec<(u6 window.push((offset, event.clone())); } } + // The pinned detection config (#517), by TAG rather than by the positional cursor. + // It would land here anyway — the pin is appended while its heat is active — but + // leaning on that would make a fold input depend on cursor bookkeeping, which is the + // shape of bug `heat_window_offsets`'s pass-tagging rule exists to end. + Event::HeatDetectionPinned { heat, .. } => { + if class_heats.contains(heat) { + window.push((offset, event.clone())); + } + } // Untagged passes and adjudications belong to whichever heat is currently active. _ if active => window.push((offset, event.clone())), _ => {} @@ -2638,6 +2647,10 @@ async fn snapshot_heat( HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled_with_floor( heat_offsets.iter().map(|(o, e)| (*o, e)), min_lap_micros, + // The bounce window (#517): the value pinned at THIS heat's arm. Resolved from the + // full log rather than the window — the pin is appended at the arm, which sits below + // `current_run_start`. Never read live off the timer: that is the whole point. + gridfpv_projection::same_pass_window_of_heat(&events, &heat), // The grace rule's boundary (#505): the heat's standing RaceExpired marker, // resolved from the same window the fold reads — like the floor, it must reach // the laps, live, and result folds identically or the surfaces disagree. @@ -2814,6 +2827,14 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, active = h == heat; active } + // The run's pinned detection config (#517): by tag, and deliberately WITHOUT the + // `run_start` gate. The pin is appended at the ARM and the window opens at `Running`, + // so it sits below `run_start` by construction — gating it would drop the very thing + // the fold needs, and the heat-scope live view would then disagree with the lap list + // about a suppressed pass. `same_pass_window_of_heat` takes the LAST one, so an + // abandoned run's pin is superseded by the re-arm's rather than competing with it. + // (Same shape as `HeatScheduled` above, which is also in-by-tag and un-gated.) + Event::HeatDetectionPinned { heat: h, .. } => h == heat, // Heat-tagged marshaling events: by tag, never by position. Event::HeatVoided { heat: h } | Event::PenaltyApplied { heat: h, .. } @@ -2886,6 +2907,10 @@ pub(crate) fn score_heat_window( let corrected = gridfpv_projection::corrected_passes_with_floor( heat_offsets.iter().map(|(o, e)| (*o, e)), min_lap_micros, + // The bounce window (#517) applies to the SCORE too — the scored chain and the marshaling + // list must be the same chain. Resolved from the log's pin, so re-scoring a finished heat + // (a standings recompute, a seeding draw) can never pick up a since-edited timer setting. + gridfpv_projection::same_pass_window_of_heat(events, heat), // The grace rule (#505) applies to the SCORE exactly as to the lap list: the pass // chain the scorer ranks is the one the marshaling view shows, marker rule included. gridfpv_projection::race_expired_offset(heat_offsets.iter().map(|(o, e)| (*o, e)), heat), @@ -4626,6 +4651,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); @@ -4702,6 +4728,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() } @@ -4803,6 +4830,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); registry @@ -4989,6 +5017,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); let (status, _) = post_timer_connection(registry.clone(), &rh.id.0, "restart").await; @@ -5599,6 +5628,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); let (status, bytes) = post_calibration( @@ -6194,6 +6224,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); let (status, bytes) = @@ -6469,6 +6500,7 @@ mod tests { }), node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); registry @@ -6778,6 +6810,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }; let (status, raw) = post_timer(registry.clone(), &body, None).await; assert_eq!(status, StatusCode::OK); @@ -6801,6 +6834,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }; let (status, _) = post_timer(registry, &body, None).await; assert_eq!(status, StatusCode::UNAUTHORIZED); @@ -6858,6 +6892,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() } @@ -7036,6 +7071,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }; let (_, raw) = post_timer(registry.clone(), &body, None).await; let extra: Timer = serde_json::from_slice(&raw).unwrap(); @@ -7130,6 +7166,7 @@ mod tests { channel_capability: None, node_count: Some(0), available_channels: None, + same_pass_window_micros: None, }; let (status, _) = post_timer(registry, &body, None).await; // A 0-node timer caps every heat to no pilots — rejected as a 400, not silently created. diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 1568c976..1c6fdfb4 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -320,10 +320,11 @@ pub fn apply_command_in_event( // `Start` is the **arm** (it opens the gate to detections). It is the last moment Grid can // refuse before RotorHazard is driving a live race, so it carries the GridFPV-plugin // backstop (#405) — see [`refuse_arm_without_plugin`]. - Command::Start { heat } => match refuse_arm_without_plugin(registry, event_id) { - Some(err) => CommandAck::failed(err), - None => apply_command(state, Command::Start { heat }), - }, + // `Start` is the **arm** (it opens the gate to detections). It is the last moment Grid can + // refuse before RotorHazard is driving a live race, and — for the same reason — the last + // moment to pin the detection config this run will be judged by (#517). Both need the + // registry, so it runs here rather than in the log-only `apply_command`. + Command::Start { heat } => apply_start_heat(registry, event_id, state, heat), // `ScheduleHeat` also needs the event meta + timer registry (the channel cap + assignment), // so it is handled here rather than in the log-only `apply_command`. Command::ScheduleHeat { @@ -1300,6 +1301,63 @@ fn apply_stage_heat( } } +/// Handle [`Command::Start`] — the **arm**, and the moment the gate opens to detections. +/// +/// Two things have to happen here and nowhere else. +/// +/// **The plugin backstop (#405).** Arming is the last point Grid can refuse before RotorHazard is +/// driving a live race — see [`refuse_arm_without_plugin`]. +/// +/// **The detection pin (#517).** The gate-bounce window is *timer* config, and a timer stays +/// editable forever, unlike a round — which freezes the moment it races precisely so results cannot +/// shift under it. Read live by the fold, an RD nudging the Timers page would silently re-judge +/// every race that timer ever ran. So the value in force is written to the log **here**, once, and +/// the corrected fold reads it from there: an edit changes the next run and never a past one. +/// +/// `(Staged, Start) -> Armed` is the only arm in the heat FSM ([`heat::apply`]), so every run +/// passes through this exactly once — and a re-arm after an Abort or Restart re-pins, which is +/// right: that is a new run, and it should race under the window set for it. +/// +/// Mirrors [`apply_stage_heat`], which pins the resolved channel layout at the *stage* for exactly +/// the same reason (#478). The general rule is #518. +fn apply_start_heat( + registry: &EventRegistry, + event_id: &EventId, + state: &AppState, + heat: HeatId, +) -> CommandAck { + if let Some(err) = refuse_arm_without_plugin(registry, event_id) { + return CommandAck::failed(err); + } + let _guard = state.command_guard(); + // Validate the transition FIRST: an illegal arm must leave the log completely untouched, so a + // refusal can never leave a pin behind on a heat that did not arm. + let transition = match heat_transition(state, heat.clone(), HeatCommand::Start) { + Ok(event) => event, + Err(err) => return CommandAck::failed(err), + }; + // The pin goes down BEFORE the transition, so the arm is never observable without the config it + // armed under. An unresolvable timer pins `None` — no bounce rule, the pre-#517 behaviour — + // rather than failing the arm: a race must not be blocked because a setting could not be read. + let window = registry + .meta_of(event_id) + .and_then(|meta| crate::round_engine::assignment_timer(&meta, ®istry.timers())) + .and_then(|timer| timer.same_pass_window_micros); + if let Err(err) = state.append( + Event::HeatDetectionPinned { + heat: heat.clone(), + same_pass_window_micros: window, + }, + None, + ) { + return CommandAck::failed(err); + } + match state.append(transition, None) { + Ok(_offset) => CommandAck::ok(), + Err(err) => CommandAck::failed(err), + } +} + /// The layout id [`apply_stage_heat`] should stamp onto `heat`, or `None` when there is nothing to /// stamp — see that function's doc for the three cases (already bound, no layout, no round). /// @@ -5503,6 +5561,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }; let (registry, event_id, round) = event_with_timer_and_round(timer_req, &["alpha", "bravo"]); @@ -5554,6 +5613,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: None, + same_pass_window_micros: None, }; let (registry, event_id, round) = event_with_timer_and_round(timer_req, &["alpha", "bravo"]); @@ -5598,6 +5658,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(2), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }; let (registry, event_id, round) = event_with_timer_and_round(timer_req, &["a", "b", "c", "d"]); @@ -5640,6 +5701,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }; let (registry, event_id, round) = event_with_timer_and_round(timer_req, pilots); let meta = registry.meta_of(&event_id).unwrap(); @@ -5883,6 +5945,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }) .unwrap(); let class = registry @@ -6055,6 +6118,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }) .unwrap(); let class = registry @@ -6981,6 +7045,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); // Selected while healthy — a legitimate selection under the #405 gate. diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 945bcc2b..7feb812d 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -4410,6 +4410,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); rh_id = rh.id.clone(); @@ -5954,6 +5955,7 @@ mod tests { channel_capability: None, node_count: Some(4), available_channels: None, + same_pass_window_micros: None, }) .unwrap(); reg.set_timers(event, vec![timer.id.clone()]).unwrap(); diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index bb9ba366..51bdafde 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -573,7 +573,17 @@ fn live_state_core( // of the window, so a Restarted heat can never inherit a stale marker). let race_expired = gridfpv_projection::race_expired_offset(run_window.iter().copied(), ¤t_heat); - let corrected = CorrectedWindow::of(run_window.iter().copied(), min_lap_micros, race_expired); + // The bounce window (#517): the value pinned at this heat's arm. Resolved over the WHOLE log, + // not `run_window` — the pin is appended at the arm and `current_run_start` opens the window at + // Running, so a window-scoped scan would never find it. Unlike the marker above, this resolves + // a value rather than a boundary, so a wider scan costs nothing and a narrower one loses it. + let same_pass_window = gridfpv_projection::same_pass_window_of_heat(events, ¤t_heat); + let corrected = CorrectedWindow::of( + run_window.iter().copied(), + min_lap_micros, + same_pass_window, + race_expired, + ); // The crossing feed (#397) — the same run window, read as *crossings* rather than *laps*, so // the holeshot and a floor-rejected pass (neither of which derives a lap) are visible live. // Bounded to the most recent `MAX_LIVE_CROSSINGS`: the tail is kept and the head dropped, so diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 6b8043dc..19888d8a 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -2614,6 +2614,7 @@ mod tests { manual_connect: false, calibration: Vec::new(), node_channels: Vec::new(), + same_pass_window_micros: None, } } diff --git a/crates/server/src/timers.rs b/crates/server/src/timers.rs index 2800c2ab..9814242c 100644 --- a/crates/server/src/timers.rs +++ b/crates/server/src/timers.rs @@ -72,6 +72,23 @@ pub const DEFAULT_NODE_COUNT: u32 = 8; /// connect spends the count back to zero. pub const CONNECT_ATTEMPT_CAP: u32 = 3; +/// The **default gate-bounce window** (µs) offered for a new timer: 1 second (#517). +/// +/// Comfortably over a reflection burst's spread (tens to low hundreds of milliseconds) and +/// comfortably under any lap anybody has ever flown, so it collapses a multi-detection without +/// coming near a real crossing. It is only a *default* — the RD tunes it per gate, which is the +/// whole point of the setting living on the timer. +pub const DEFAULT_SAME_PASS_WINDOW_MICROS: i64 = 1_000_000; + +/// The **hard cap** on [`Timer::same_pass_window_micros`] (µs): 2 seconds (#517). +/// +/// This cap is what makes the window safe to edit at all. A lap under two seconds is not a lap — +/// no course is that short and no quad is that fast — so whatever the RD types here, the rule can +/// only ever collapse repeat detections of one physical pass. It can never swallow a lap somebody +/// actually flew, which is what lets the setting stay editable instead of freezing like the +/// round's min-lap floor does (`events.rs`, the raced-round freeze). +pub const MAX_SAME_PASS_WINDOW_MICROS: i64 = 2_000_000; + /// The file name (under the data dir) the timer registry is persisted to (issue #73). pub const TIMERS_FILE: &str = "timers.json"; @@ -615,6 +632,34 @@ pub struct Timer { #[serde(default, skip_serializing_if = "Vec::is_empty")] #[ts(as = "Option>", optional)] pub node_channels: Vec, + /// The **gate-bounce window** (µs): two lap-gate crossings by the same competitor closer + /// together than this are **the same physical pass**, detected twice (#517). + /// + /// A quad in the gate's near field fires the detector several times per crossing — antenna + /// reflections milliseconds apart. That is a property of *this gate*: its antenna, its RSSI + /// thresholds, where it sits on the track. So it lives on the timer, not on the round. + /// + /// **Not the same thing as the min-lap floor.** `RoundDef::min_lap_secs` is a *competition + /// rule* — "a lap shorter than this does not count" — and it is the round's to set. This is a + /// *hardware fact* — "that was not a second crossing at all". Conflating them is what made a + /// reflection burst indistinguishable from a genuinely short lap in the marshaling list. + /// + /// `None` (the default, and what an older `timers.json` restores with) means no bounce rule: + /// every crossing stands on its own and only the round's floor judges it. + /// + /// Capped at [`MAX_SAME_PASS_WINDOW_MICROS`] and normalized so `0` reads as `None`. The cap is + /// what makes the setting safe to change: no real lap can be that short, so the window can only + /// ever collapse a multi-detection of one pass, never eat a lap somebody flew. + /// + /// **Read at the arm, not at the fold.** The value in force when a heat arms is stamped onto + /// the log ([`Event::HeatDetectionPinned`](gridfpv_events::Event::HeatDetectionPinned)) and the + /// corrected fold reads it from there — so editing this never re-judges a race that has already + /// run. See #518 for the general rule. + // A window is capped at 2s, so it is bounded far below 2^53: render it as a plain TS `number` + // rather than a `bigint`, the same call `Pass::sequence` and `RaceExpired::deadline` make. + #[serde(default)] + #[ts(optional, type = "number")] + pub same_pass_window_micros: Option, } /// The words one Tune write's refusals use — the only thing that differs between the three @@ -1140,6 +1185,12 @@ pub struct CreateTimerRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub available_channels: Option>, + /// The new timer's **gate-bounce window** in µs (#517) — see + /// [`Timer::same_pass_window_micros`]. Optional; omit it (or send `0`) for no bounce rule. + /// [`DEFAULT_SAME_PASS_WINDOW_MICROS`] is what the console offers, not what the server assumes. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub same_pass_window_micros: Option, } /// The body of `PUT /timers/{id}` — the editable fields of a timer (issue #73). @@ -1173,6 +1224,12 @@ pub struct UpdateTimerRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub available_channels: Option>, + /// A new **gate-bounce window** in µs (#517), or `None` to leave it unchanged. Send `0` to turn + /// the bounce rule off — unlike the node-count override this needs no separate clear route, + /// because zero already spells "no window" (the same idiom `RoundDef::min_lap_secs` uses). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub same_pass_window_micros: Option, } /// The body of `PUT /events/{id}/timers` — the timer ids an event selects (issue #73), and @@ -2171,6 +2228,9 @@ impl TimerRegistry { manual_connect: false, calibration: Vec::new(), node_channels: Vec::new(), + // No bounce rule: the Mock synthesizes clean passes, so there are no reflections to + // collapse and a window here would only ever be a lie about a gate that does not exist. + same_pass_window_micros: None, }; timers.insert(sim.id.clone(), sim); @@ -2266,6 +2326,7 @@ impl TimerRegistry { manual_connect: false, calibration: Vec::new(), node_channels: Vec::new(), + same_pass_window_micros: normalize_same_pass_window(request.same_pass_window_micros), }; reg.timers.insert(id, timer.clone()); reg.persist()?; @@ -2293,6 +2354,12 @@ impl TimerRegistry { return Err(TimerError(refusal)); } } + if let Some(window) = request.same_pass_window_micros { + // #517: refused here, with the same before-any-write discipline as the width override. + if let Err(refusal) = validate_same_pass_window(window) { + return Err(TimerError(refusal)); + } + } if let Some(name) = &request.name { let trimmed = name.trim(); if !trimmed.is_empty() { @@ -2322,6 +2389,11 @@ impl TimerRegistry { if let Some(available) = &request.available_channels { timer.available_channels = available.clone(); } + if let Some(window) = request.same_pass_window_micros { + // `0` spells "no bounce rule" and normalizes to `None`, so the one field both sets and + // clears the window — see [`UpdateTimerRequest::same_pass_window_micros`]. + timer.same_pass_window_micros = normalize_same_pass_window(Some(window)); + } let updated = timer.clone(); reg.persist()?; Ok(updated) @@ -3316,6 +3388,36 @@ pub const MAX_MOCK_LAPS: u32 = 1000; /// [`MAX_MOCK_LAPS`] (a runaway sim). `node_count` is passed in so the merged value can be checked /// on a partial edit; `None` (#412 — follow whatever the timer reports) is always fine, since a /// discovered width can never be zero. +/// Refuse a **gate-bounce window** outside its allowed range (#517). +/// +/// Negative is meaningless (a window is a duration) and above +/// [`MAX_SAME_PASS_WINDOW_MICROS`] is refused rather than clamped: silently accepting a number +/// and then applying a different one is how a setting stops meaning what it says. `0` is legal +/// and spells "no bounce rule" — [`normalize_same_pass_window`] turns it into `None`. +/// +/// Mirrors `events::validate_min_lap`, deliberately: the two settings are siblings, and an RD who +/// mistypes one should get the same shape of answer from the other. +pub fn validate_same_pass_window(micros: i64) -> Result<(), String> { + if micros < 0 { + return Err("same_pass_window_micros cannot be negative".to_string()); + } + if micros > MAX_SAME_PASS_WINDOW_MICROS { + return Err(format!( + "same_pass_window_micros must be at most {MAX_SAME_PASS_WINDOW_MICROS} \ + ({}s) — a longer window could suppress a lap somebody actually flew", + MAX_SAME_PASS_WINDOW_MICROS / 1_000_000 + )); + } + Ok(()) +} + +/// `0` (and anything below it, which [`validate_same_pass_window`] has already refused) means +/// **no bounce rule**, stored as `None` so the fold's "is there a window at all" test is a plain +/// `Option` check rather than a magic zero. Same normalization `RoundDef::min_lap_secs` applies. +pub fn normalize_same_pass_window(micros: Option) -> Option { + micros.filter(|m| *m > 0) +} + pub fn validate_timer_config(kind: &TimerKind, node_count: Option) -> Result<(), String> { if node_count == Some(0) { return Err( @@ -3397,6 +3499,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("timer created") .id; @@ -3772,6 +3875,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("timer created") .id; @@ -4040,6 +4144,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, } } @@ -4052,6 +4157,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, } } @@ -4386,6 +4492,7 @@ mod tests { channel_capability: Some(fixed.clone()), node_count: Some(4), available_channels: Some(vec![5658, 5695, 5732, 5769]), + same_pass_window_micros: None, }) .unwrap(); assert_eq!(created.channel_capability, fixed); @@ -4456,6 +4563,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() } @@ -4496,6 +4604,7 @@ mod tests { channel_capability: None, node_count: Some(8), available_channels: None, + same_pass_window_micros: None, }) .unwrap(); reg.set_reported_nodes(&rh.id, 4); @@ -4672,6 +4781,7 @@ mod tests { channel_capability: None, node_count: Some(8), available_channels: None, + same_pass_window_micros: None, }) .unwrap(); reg.set_reported_nodes(&rh.id, 4); diff --git a/crates/server/tests/live_crossings.rs b/crates/server/tests/live_crossings.rs index 5353f84d..16b7c7cc 100644 --- a/crates/server/tests/live_crossings.rs +++ b/crates/server/tests/live_crossings.rs @@ -88,12 +88,25 @@ fn shape(state: &LiveRaceState) -> Vec<(u64, CrossingDisposition, Option)> /// A heat running with `passes` already on the log, plus the offset the first pass sits at. fn running_heat(passes: Vec) -> (Vec, u64) { + running_heat_pinned(passes, None) +} + +/// As [`running_heat`], with a gate-bounce window pinned at the arm (#517) — the shape the real +/// `Start` command writes: the pin lands immediately before the `Armed` transition, which is +/// *below* the run window's start, so this also exercises the window-inclusion rule. +fn running_heat_pinned(passes: Vec, window: Option) -> (Vec, u64) { let mut events = vec![ scheduled("q-1", &["A", "B"]), changed("q-1", HeatTransition::Staged), - changed("q-1", HeatTransition::Armed), - changed("q-1", HeatTransition::Running), ]; + if let Some(w) = window { + events.push(Event::HeatDetectionPinned { + heat: HeatId("q-1".into()), + same_pass_window_micros: Some(w), + }); + } + events.push(changed("q-1", HeatTransition::Armed)); + events.push(changed("q-1", HeatTransition::Running)); let first_pass_offset = events.len() as u64; events.extend(passes); (events, first_pass_offset) @@ -149,6 +162,7 @@ fn counted_crossings_carry_the_lap_number_the_lap_list_gives_them() { with_offsets(&events).iter().map(|(o, e)| (*o, e)), None, None, + None, ); let a = laps .competitors @@ -479,3 +493,150 @@ fn the_bound_never_re_announces_a_crossing_that_fell_out_of_the_window() { "every crossing announced once, none re-announced after the window slid past it" ); } + +// --- The gate-bounce window (#517) --------------------------------------------------------- + +/// **A bounce is its own disposition, and it reaches the live feed as one.** +/// +/// The whole point of #517: before it, this crossing and a genuinely short one were both +/// `RejectedTooShort`, so the console could not tell a reflection burst from a lap the pilot +/// really flew too fast. Under a pinned 1s window the 61ms echo is `RejectedSamePass` and the 3s +/// crossing — a real pass, under the 10s floor — is still `RejectedTooShort`. +#[test] +fn a_bounce_and_a_short_lap_reach_the_feed_as_different_dispositions() { + let (events, base) = running_heat_pinned( + vec![ + pass("A", 0), + pass("A", 61_000), // +61ms — the gate stuttering: a bounce + pass("A", 3 * SECOND), // +3s — a real crossing, but under the floor + ], + Some(SECOND), + ); + let state = folded_with_floor(&events, Some(10 * SECOND)); + + assert_eq!( + shape(&state), + vec![ + (base, CrossingDisposition::Holeshot, None), + (base + 1, CrossingDisposition::RejectedSamePass, None), + (base + 2, CrossingDisposition::RejectedTooShort, None), + ], + "the echo is a bounce; the 3s crossing is a real pass the floor refused" + ); +} + +/// **The pin is read from the log, so the heat-scope window must carry it.** +/// +/// `current_run_start` opens a run's window at `Running`, and the pin is appended at the *arm* — +/// below that boundary. If the window dropped it, the live view would apply no bounce rule while +/// the lap list applied one, and the two surfaces would disagree about a suppressed pass. That is +/// exactly the D26 class of bug (#409); this asserts it cannot recur for the bounce window. +#[test] +fn the_arm_time_pin_survives_the_run_window_and_the_lap_list_agrees() { + let (events, base) = running_heat_pinned( + vec![pass("A", 0), pass("A", 61_000), pass("A", 30 * SECOND)], + Some(SECOND), + ); + let state = folded_with_floor(&events, None); + assert_eq!( + shape(&state), + vec![ + (base, CrossingDisposition::Holeshot, None), + (base + 1, CrossingDisposition::RejectedSamePass, None), + (base + 2, CrossingDisposition::Counted, Some(1)), + ], + "the pin sits below run_start; the live fold must still see it" + ); + + // And the lap projection, read over the same log, reaches the same verdict. + let laps = lap_list_marshaled_with_floor( + with_offsets(&events).iter().map(|(o, e)| (*o, e)), + None, + gridfpv_projection::same_pass_window_of_heat(events.iter(), &HeatId("q-1".into())), + None, + ); + let a = laps + .competitors + .iter() + .find(|c| c.competitor.competitor == CompetitorRef("A".into())) + .expect("A has laps"); + assert_eq!( + a.laps.len(), + 1, + "one counted lap, holeshot to the 30s crossing" + ); + assert_eq!( + a.voided.iter().map(|v| v.reason).collect::>(), + vec![gridfpv_projection::VoidReason::SamePassBounce] + ); +} + +/// **A re-arm re-pins, and the latest pin is the one that governs.** +/// +/// Abort, retune the gate, re-arm: the new run must race under the window set for *it*. The old +/// pin also sits below `run_start`, so "last one wins" is the only rule that can separate them. +#[test] +fn a_re_arm_races_under_the_window_pinned_at_the_new_arm() { + let mut events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Staged), + Event::HeatDetectionPinned { + heat: HeatId("q-1".into()), + same_pass_window_micros: Some(SECOND), + }, + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + pass("A", 0), + changed("q-1", HeatTransition::Aborted), + changed("q-1", HeatTransition::Staged), + // The RD decided the gate was over-filtering and dialed the window right down. + Event::HeatDetectionPinned { + heat: HeatId("q-1".into()), + same_pass_window_micros: Some(100_000), + }, + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + ]; + let base = events.len() as u64; + // 400ms apart: a bounce under the FIRST window, a real crossing under the second. + events.extend(vec![pass("A", 0), pass("A", 400_000)]); + + let state = folded_with_floor(&events, None); + assert_eq!( + shape(&state), + vec![ + (base, CrossingDisposition::Holeshot, None), + (base + 1, CrossingDisposition::Counted, Some(1)), + ], + "the second arm's 100ms window governs — the abandoned run's 1s pin is dead" + ); +} + +/// **No pin folds exactly as before.** Every heat armed before #517 existed, and every timer with +/// the setting unset, must be judged by the round's floor alone. +#[test] +fn an_unpinned_run_is_bit_identical_to_the_pre_bounce_fold() { + let passes = vec![pass("A", 0), pass("A", 61_000), pass("A", 30 * SECOND)]; + let (unpinned, _) = running_heat(passes.clone()); + let (pinned_off, _) = running_heat_pinned(passes, Some(0)); + let floor = Some(10 * SECOND); + // Dispositions, not offsets: the pinned log carries one extra event, so every append offset + // shifts by one. What must be identical is the VERDICT on each crossing. + let verdicts = |events: &[Event]| -> Vec<(CrossingDisposition, Option)> { + shape(&folded_with_floor(events, floor)) + .into_iter() + .map(|(_, d, lap)| (d, lap)) + .collect() + }; + assert_eq!( + verdicts(&unpinned), + verdicts(&pinned_off), + "a pinned 0 is no rule at all, exactly like no pin" + ); + assert!( + verdicts(&unpinned) + .iter() + .any(|(d, _)| *d == CrossingDisposition::RejectedTooShort), + "and without a window the echo is still judged by the floor, as it always was" + ); +} diff --git a/crates/server/tests/multi_round_event_live.rs b/crates/server/tests/multi_round_event_live.rs index 6a108547..3193d0e4 100644 --- a/crates/server/tests/multi_round_event_live.rs +++ b/crates/server/tests/multi_round_event_live.rs @@ -576,6 +576,7 @@ async fn build_event( channel_capability: Some(ChannelCapability::Flexible), node_count: Some(CALLSIGNS.len() as u32), available_channels: Some(MEMBER_CHANNELS.to_vec()), + same_pass_window_micros: None, }, ) .await; diff --git a/docker/rotorhazard/race_day.py b/docker/rotorhazard/race_day.py index 64fcc027..ab7ef0ca 100644 --- a/docker/rotorhazard/race_day.py +++ b/docker/rotorhazard/race_day.py @@ -91,6 +91,39 @@ def scenario_messy(nodes, rng): return out +def scenario_bouncy(nodes, rng): + """A GATE THAT RE-DETECTS (#517): every real crossing is followed by a burst of echoes. + + This is the failure the same-pass window exists for. A quad sitting in the gate's near field + trips the detector several times per pass — reflections tens to a couple of hundred + milliseconds apart, at a lower peak than the real crossing. RotorHazard reports all of them + (GridFPV neutralises RH's own min-lap filter on purpose), so without a bounce rule the + marshaling list gets three or four removal rows between every pair of real laps. + + Node 0 is the worst offender (3 echoes per pass), node 1 bounces occasionally, and the rest of + the field is clean — so one screen shows the collapsed burst, the untouched laps, and the + difference between them. One pilot also gets a genuine sub-floor crossing 3 s after a lap: far + outside any sane bounce window, so it must stay a full `under min lap` row and keep its tone. + """ + out = [] + for i, n in enumerate(nodes): + laps = _laps(n, 4, 7.0, 0.05, STRONG, rng=rng) + out += laps + if i == 0: + # The bad gate: three echoes after every crossing, 60–260 ms out. + for t, node, _peak in laps: + for gap in (0.061, 0.193, 0.254): + out.append((t + gap, node, FALSE_PEAK + rng.randint(-8, 8))) + elif i == 1: + # An intermittent one: a single echo after the holeshot and after lap 2. + for t, node, _peak in (laps[0], laps[2]): + out.append((t + 0.088, node, FALSE_PEAK)) + # …and a REAL crossing 3 s after lap 1 — the pilot cut the course. Under a 10 s floor + # this is `under min lap`, NOT a bounce: it keeps its own row and it still tones. + out.append((laps[1][0] + 3.0, node, STRONG - 10)) + return out + + def scenario_pack(nodes, rng): out = [] base_pace = 6.5 @@ -105,6 +138,7 @@ def scenario_pack(nodes, rng): "clean": scenario_clean, "varied": scenario_varied, "messy": scenario_messy, + "bouncy": scenario_bouncy, "pack": scenario_pack, } diff --git a/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts b/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts index 32dcbba0..688bd603 100644 --- a/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts +++ b/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts @@ -147,14 +147,6 @@ export function useLapCallouts( }); } -/** - * How long after a competitor's SOUNDED tone their further crossings are absorbed (#503), in - * source-clock µs. Well under any real lap (the min-lap floor's field default is 10s) and well - * over a reflection burst's spread (tens to hundreds of ms), so it can only ever collapse a - * multi-detection of one physical pass, never silence a genuine next lap. - */ -export const CROSSING_TONE_COOLDOWN_MICROS = 1_000_000; - /** * **New-crossing detection** for the per-crossing tone (#397) — the sibling of * {@link useLapCallouts} above, and the reason this module is no longer only about laps. @@ -171,18 +163,28 @@ export const CROSSING_TONE_COOLDOWN_MICROS = 1_000_000; * too-sensitive gate, and it is exactly what a table of laps will never show them. Nothing here * filters toward "meaningful" crossings — but repeats are rate-limited, see the cooldown below. * - * ── The cooldown: one physical pass is ONE tone (#503) ─────────────────────────────────────── + * ── One physical pass is ONE tone — and the FOLD decides that (#503, then #517) ───────────── * A quad sitting in the gate's near field fires the detector several times per pass (antenna * reflections milliseconds apart), and on the field that rendered as a pip storm per lap — the - * tone stopped answering "did the gate see me?" and started drowning the RD. So: a crossing by - * the SAME competitor within {@link CROSSING_TONE_COOLDOWN_MICROS} of the last one *sounded* for - * them is absorbed. Measured from the last sounded tone (an absorbed crossing does not extend - * the window), keyed **per competitor** — two pilots crossing near-simultaneously must both - * tone, that is the gate telling the RD it saw both. The window is source-clock (`at`), the axis - * bursts are adjacent on; a crossing carrying an *older* source time than the last sounded one - * (a marshal insert) is never absorbed. Absorbed crossings still advance the watermark — they - * are seen, just not sounded — and the min-lap floor still voids them on its own axis; this - * cooldown only de-duplicates the NOISE of them. + * tone stopped answering "did the gate see me?" and started drowning the RD. + * + * #503 fixed that here, with a private 1s per-competitor cooldown. It worked, but it made this + * module a second owner of a rule the server also had an opinion about: the same physical fact — + * *the gate stutters over one pass* — was a hardcoded client constant AND (conflated with the + * min-lap floor) a server-side suppression, with no way for the two to agree. The marshaling list + * could not collapse a reflection burst without also hiding a genuinely short lap, because by the + * time it saw them they were both `RejectedTooShort`. + * + * So #517 moved the rule to the one place that already judges crossings: the corrected fold. The + * window is per-timer config (`Timer.same_pass_window_micros`), pinned onto the log at the arm so + * a later edit cannot re-judge a finished race, and a crossing inside it folds to the disposition + * `RejectedSamePass`. This detector simply does not sound that one. **Behaviour is unchanged** — + * the fold measures from the last *surviving* pass exactly as the cooldown measured from the last + * *sounded* tone, and a suppressed crossing extends neither — but there is now one window, one + * owner, and the speaker and the lap list can no longer disagree. + * + * Everything else still tones, including a crossing on a seat nobody is flying: a bounce is the + * gate stuttering over something that happened once, and that is the ONLY thing being filtered. * * ── The watermark: fire on IDENTITY, never on a frame arriving ─────────────────────────────── * Every `LiveCrossing` carries `pass_ref` — its **global append offset**, stable across every @@ -232,9 +234,6 @@ export function useCrossingTones( let scopeKey: string | undefined; let baselined = false; let watermark = -1; - // The source time (µs) of the last crossing SOUNDED per competitor — the cooldown's anchor - // (#503). Absorbed crossings never land here, so the window measures from the last tone. - const lastTonedAt = new Map(); $effect(() => { const scope = getScope(); @@ -248,7 +247,6 @@ export function useCrossingTones( scopeKey = scope; baselined = false; watermark = -1; - lastTonedAt.clear(); } // First sight of this scope BASELINES: retire everything on offer, announce none of it. const announce = baselined && audible; @@ -261,15 +259,10 @@ export function useCrossingTones( if (crossing.pass_ref <= previous) continue; if (crossing.pass_ref > next) next = crossing.pass_ref; if (!announce) continue; - // The cooldown (#503): absorb a same-competitor crossing inside the window of their last - // SOUNDED tone. A negative delta (a marshal insert carrying an older source time) is a - // different case entirely and always tones; exactly-at-the-boundary tones too. - const last = lastTonedAt.get(crossing.competitor); - if (last !== undefined) { - const since = crossing.at - last; - if (since >= 0 && since < CROSSING_TONE_COOLDOWN_MICROS) continue; - } - lastTonedAt.set(crossing.competitor, crossing.at); + // The bounce (#517): the fold already decided this crossing was the same physical pass as + // the last surviving one, so there is nothing here for the RD to hear. It still advances the + // watermark — it was seen, just not sounded — exactly as the old client-side cooldown did. + if (crossing.disposition === 'RejectedSamePass') continue; onCrossing(crossing); } watermark = next; diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 34840439..67bb90ea 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -34,6 +34,7 @@ Pilot, PilotProgress, SignalTraceView, + VoidedPass, VoidReason } from '@gridfpv/types'; import { formatMicros, Select, toast } from '@gridfpv/components'; @@ -442,9 +443,9 @@ } /** RESTORE a removed pass. A marshal-voided pass is undone by void-the-void (targeting the - * standing removal event); an AUTO-suppressed pass (UnderMinLap, D26; AfterRaceEnd, #505) is - * BLESSED by an AdjustLap re-asserting its own raw instant — an explicit ruling outranks - * both rules, so the fold exempts it and the pass returns to the chain. */ + * standing removal event); an AUTO-suppressed pass (SamePassBounce, #517; UnderMinLap, D26; + * AfterRaceEnd, #505) is BLESSED by an AdjustLap re-asserting its own raw instant — an explicit + * ruling outranks all three rules, so the fold exempts it and the pass returns to the chain. */ function doRestorePass(v: { void_ref: number; pass_ref: number; @@ -461,10 +462,16 @@ }); } - /** The removal record's row text: WHY the pass is off the chain, by its `VoidReason`. */ + /** The removal record's row text: WHY the pass is off the chain, by its `VoidReason`. + * + * Every arm is spelled out rather than leaning on the `default:` — a new reason landing here + * silently would read as "stays removed", which is a different claim about a different kind of + * removal, and the RD would have no way to tell it was wrong. */ function voidedRowLabel(v: { at: number; reason: VoidReason }): string { const at = `${formatMicros(v.at)}s`; switch (v.reason) { + case 'SamePassBounce': + return `crossing at ${at} — same pass (gate bounce), auto-removed`; case 'UnderMinLap': return `crossing at ${at} — under min lap, auto-removed`; case 'AfterRaceEnd': @@ -474,6 +481,44 @@ } } + /** Is this removal a gate bounce — the fold's "same physical pass, seen twice" (#517)? + * + * The one removal kind that gets COLLAPSED in the lap list rather than given its own row. A + * bouncy gate puts three or four of these between every pair of real laps, and they carry no + * information individually: the RD wants to know a burst happened, not to read it out. Every + * other reason — a real crossing under the floor, a marshal void, a post-race crossing — keeps + * its full row, because each of those is a distinct thing that happened. */ + function isBounce(v: { reason: VoidReason }): boolean { + return v.reason === 'SamePassBounce'; + } + + /** The removals that belong in `lap`'s slot: after the previous lap, before this one. Hoisted + * out of the markup because the burst and the full rows both need it, and `{@const}` cannot + * live everywhere it would have to. */ + function removalsBefore(voided: VoidedPass[], laps: Lap[], lap: Lap): VoidedPass[] { + return voided.filter( + (v) => v.at < lap.at && !laps.some((o) => o.number < lap.number && o.at > v.at) + ); + } + + /** The removals after the LAST lap — and, for a competitor with no laps at all, their whole + * record. Both cases need the burst indicator: a run whose every crossing bounced is exactly + * the finding an RD came to marshaling to see. */ + function removalsAfterLast(voided: VoidedPass[], laps: Lap[]): VoidedPass[] { + return voided.filter((v) => laps.length === 0 || v.at >= laps[laps.length - 1].at); + } + + /** Which lap rows have their bounce burst expanded. Keyed by the burst's owning lap `end_ref` + * (and `-1` for a burst that trails the last lap), so it survives a refold — a re-fold keeps + * each pass's offset, which is exactly why the removal record is keyed on offsets too. */ + let expandedBursts = $state(new Set()); + + function toggleBurst(key: number): void { + const next = new Set(expandedBursts); + if (!next.delete(key)) next.add(key); + expandedBursts = next; + } + /** Remove (void) a lap straight from its row — the one-click removal on the lap list. */ function doVoidLap(competitor: CompetitorRef, lap: Lap): Promise { return submitCorrection(async () => { @@ -1285,25 +1330,60 @@

No laps yet.

{:else} {@const voidedSorted = [...(cl.voided ?? [])].sort((a, b) => a.at - b.at)} + {#snippet removalRow(v: VoidedPass)} +
  • + + {voidedRowLabel(v)} + {#if canCorrect} + + {/if} +
  • + {/snippet} + + {#snippet bounceBurst(bounces: VoidedPass[], key: number)} + {#if bounces.length > 0} +
  • + + +
  • + {#if expandedBursts.has(key)} + {#each bounces as v (v.pass_ref)} + {@render removalRow(v)} + {/each} + {/if} + {/if} + {/snippet}
      {#each cl.laps as lap (lap.end_ref)} - {#each voidedSorted.filter((v) => v.at < lap.at && !cl.laps.some((o) => o.number < lap.number && o.at > v.at)) as v (v.pass_ref)} -
    1. - - {voidedRowLabel(v)} - {#if canCorrect} - - {/if} -
    2. + {@const slot = removalsBefore(voidedSorted, cl.laps, lap)} + {#each slot.filter((v) => !isBounce(v)) as v (v.pass_ref)} + {@render removalRow(v)} {/each} + {@render bounceBurst(slot.filter(isBounce), lap.end_ref)}
    3. - {/if} -
    4. + + {#each removalsAfterLast(voidedSorted, cl.laps).filter((v) => !isBounce(v)) as v (v.pass_ref)} + {@render removalRow(v)} {/each} + {@render bounceBurst( + removalsAfterLast(voidedSorted, cl.laps).filter(isBounce), + -1 + )}
    {/if} {#if canCorrect} @@ -2078,6 +2150,36 @@ list-style: none; padding: var(--gf-space-1) 0; } + /* The collapsed gate-bounce burst (#517). Deliberately the QUIETEST thing in the lap list: it + is an annotation on the run, not an event in it, and the whole point of collapsing was that + these were drowning the laps. No strike-through — nothing here is a removed lap; it is a + count of detections that were never separate crossings in the first place. */ + .bounce-row { + display: flex; + align-items: center; + gap: var(--gf-space-2); + color: var(--gf-text-faint); + list-style: none; + padding: 0; + font-size: var(--gf-font-size-sm); + } + .bounce-toggle { + background: none; + border: 0; + padding: var(--gf-space-1) 0; + color: inherit; + font: inherit; + cursor: pointer; + text-align: left; + } + .bounce-toggle:hover, + .bounce-toggle:focus-visible { + color: var(--gf-text); + text-decoration: underline; + } + .bounce-row .chev { + margin-left: var(--gf-space-1); + } /* Region heads: the two scopes of this page (pilot vs heat), RD-requested after the lap-editor consolidation made the seam invisible. The divider is deliberately HEAVY — sunlit-laptop legible — and the heat region reads as a distinct zone, not more pilot UI. */ diff --git a/frontend/apps/rd-console/src/screens/TimerManager.svelte b/frontend/apps/rd-console/src/screens/TimerManager.svelte index 165a1a8d..732e6d08 100644 --- a/frontend/apps/rd-console/src/screens/TimerManager.svelte +++ b/frontend/apps/rd-console/src/screens/TimerManager.svelte @@ -322,6 +322,18 @@ let saving = $state(false); let formError = $state(undefined); + /** + * The gate-bounce window this form OFFERS a new timer, and the ceiling it accepts — in seconds + * (#517). + * + * **The server is authoritative** (`timers::MAX_SAME_PASS_WINDOW_MICROS`, and it refuses rather + * than clamps); these are the field's affordances — a spinner that stops in the right place and + * a hint that says why — exactly as `maxNodeCount` is for the width. They are not on the wire, so + * a change to the Rust cap must be echoed here; the refusal message is the backstop if it is not. + */ + const DEFAULT_SAME_PASS_WINDOW_SECS = 1; + const MAX_SAME_PASS_WINDOW_SECS = 2; + // ── Channel config (race redesign Slice 4b) ────────────────────────────────── // The capability (Fixed | Flexible), node count, and the chosen available channels. The chosen set // is held as a `Set` of raw MHz (catalog picks + custom entries); for a Fixed timer it is @@ -339,6 +351,14 @@ * live in the Nodes dialog, which is the surface that can express them. */ let seededNodeCount = $state(String(DEFAULT_NODE_COUNT)); + /** + * The gate-bounce window, in **seconds** for the field (the wire is µs) — #517. + * + * Seconds because that is how an RD thinks about it and how the sibling setting (a round's min + * lap) is already expressed; the conversion happens once, on submit. `'0'` is a real value here, + * not an empty field: it spells "no bounce rule", which is what a timer that never bounces wants. + */ + let formSamePassWindow = $state('0'); let formChannels = $state>(new Set()); // A Fixed timer's built-in allowed set (its physically-supported channels); the picker offers // exactly these, and `formChannels` is the subset the RD makes available. Empty ⇒ all catalog. @@ -368,6 +388,12 @@ formChannels = new Set(timer?.available_channels ?? []); formFixedAllowed = fixedAllowed(timer?.channel_capability); formCustomMhz = ''; + // A NEW timer is offered the 1s default; an existing one shows what it actually has, including + // `0` for a timer the RD deliberately left without a rule. Unlike the node-count override there + // is nothing to disambiguate — the field always sends, because `0` is a meaningful answer. + formSamePassWindow = timer + ? String((timer.same_pass_window_micros ?? 0) / 1_000_000) + : String(DEFAULT_SAME_PASS_WINDOW_SECS); } export function openAdd() { @@ -545,6 +571,14 @@ // Only carried when the RD actually set a width — see `seededNodeCount`. Omitted otherwise, so // an unrelated edit never pins an override the RD did not ask for. const node_count = String(formNodeCount) === seededNodeCount ? undefined : nodeCount; + // Seconds on screen, µs on the wire. Always sent: unlike the width override, `0` here is a real + // answer ("this gate does not bounce"), so there is no "untouched" state to preserve. + const windowSecs = Number(formSamePassWindow); + if (!Number.isFinite(windowSecs) || windowSecs < 0 || windowSecs > MAX_SAME_PASS_WINDOW_SECS) { + formError = `Same-pass window must be between 0 and ${MAX_SAME_PASS_WINDOW_SECS} seconds (0 turns it off).`; + return; + } + const same_pass_window_micros = Math.round(windowSecs * 1_000_000); saving = true; formError = undefined; try { @@ -554,7 +588,8 @@ kind: built.kind, channel_capability, node_count, - available_channels + available_channels, + same_pass_window_micros }; const updated = await session.updateTimer(editing.id, req); if (!updated) { @@ -568,7 +603,8 @@ kind: built.kind, channel_capability, node_count, - available_channels + available_channels, + same_pass_window_micros }; const created = await session.createTimer(req); if (!created) { @@ -855,6 +891,24 @@ + + + + + { expect(sendSpy).toHaveBeenCalledWith({ AdjustLap: { target: 13, at: 43_000_000 } }); }); + it('collapses a gate-bounce burst to one line, and expands to per-crossing Restore (#517)', async () => { + // The complaint this issue came from: a bouncy gate put a row per reflection between every + // pair of real laps, and the laps became the minority of the list. Collapsed, the burst is + // one muted line; the individual crossings — and their Restores — are one click away. + const bouncy: LapList = { + competitors: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + laps: [ + { number: 1, duration_micros: 41_000_000, at: 41_000_000, start_ref: 10, end_ref: 12 } + ], + voided: [ + { at: 41_061_000, pass_ref: 13, void_ref: 13, reason: 'SamePassBounce' as const }, + { at: 41_193_000, pass_ref: 14, void_ref: 14, reason: 'SamePassBounce' as const }, + { at: 41_254_000, pass_ref: 15, void_ref: 15, reason: 'SamePassBounce' as const } + ] + } + ] + }; + const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: bouncy }); + render(Marshaling, { session }); + + // Collapsed: one summary, and none of the three crossings has a row of its own. + expect(screen.getByText(/3 same-pass crossings/)).toBeInTheDocument(); + expect(screen.queryByText(/crossing at 41\.061s/)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Restore removed pass at 41\.061s/ }) + ).not.toBeInTheDocument(); + + // Expanded: the ordinary removal rows, each with its own Restore. No bulk action exists. + await fireEvent.click(screen.getByRole('button', { name: /3 same-pass crossings/ })); + expect( + screen.getByText(/crossing at 41\.061s — same pass \(gate bounce\), auto-removed/) + ).toBeInTheDocument(); + await fireEvent.click( + screen.getByRole('button', { name: /Restore removed pass at 41\.061s/ }) + ); + expect(sendSpy).toHaveBeenCalledWith({ AdjustLap: { target: 13, at: 41_061_000 } }); + }); + + it('a bounce burst does NOT collapse a genuinely short crossing beside it (#517)', async () => { + // The distinction the whole issue turns on. Both were `UnderMinLap` before #517, so the + // console could not hide one without hiding the other. The 3s crossing is a real pass the + // pilot made — it keeps its own row and its own Restore, at full prominence. + const mixed: LapList = { + competitors: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + laps: [ + { number: 1, duration_micros: 41_000_000, at: 41_000_000, start_ref: 10, end_ref: 12 } + ], + voided: [ + { at: 41_061_000, pass_ref: 13, void_ref: 13, reason: 'SamePassBounce' as const }, + { at: 44_000_000, pass_ref: 14, void_ref: 14, reason: 'UnderMinLap' as const } + ] + } + ] + }; + const { session } = makeTestSession({ live: liveRunning, laps: mixed }); + render(Marshaling, { session }); + expect(screen.getByText(/1 same-pass crossing\b/)).toBeInTheDocument(); + expect( + screen.getByText(/crossing at 44\.000s — under min lap, auto-removed/) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Restore removed pass at 44\.000s/ }) + ).toBeInTheDocument(); + }); + it('Restore on a removed pass sends void-the-void at the STANDING removal event', async () => { const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: voidedLapList }); render(Marshaling, { session }); diff --git a/frontend/apps/rd-console/tests/TimersPage.test.ts b/frontend/apps/rd-console/tests/TimersPage.test.ts index e1ec8a49..1f17c59c 100644 --- a/frontend/apps/rd-console/tests/TimersPage.test.ts +++ b/frontend/apps/rd-console/tests/TimersPage.test.ts @@ -39,6 +39,16 @@ const RH: Timer = { disabled_nodes: [] }; +/** + * The gate-bounce window the add/edit form sends (#517), in µs. + * + * The field always sends — unlike the node-count override there is no "the RD did not touch this" + * state to preserve, because `0` is itself a real answer ("this gate does not bounce"). A NEW + * timer is offered the 1s default; the fixtures here have the setting unset, so an EDIT of one + * sends `0`. Both are covered below. + */ +const SAME_PASS_DEFAULT_MICROS = 1_000_000; + describe('TimersPage (app-level timer registry)', () => { it('lists the registry on mount, with the built-in Mock undeletable', async () => { const listTimersImpl = vi.fn(async () => [MOCK, RH]); @@ -113,7 +123,8 @@ describe('TimersPage (app-level timer registry)', () => { kind: { Mock: { laps: 3, lap_ms: 30000 } }, channel_capability: 'Flexible', node_count: undefined, - available_channels: [] + available_channels: [], + same_pass_window_micros: SAME_PASS_DEFAULT_MICROS }, 'tok' ); @@ -149,7 +160,8 @@ describe('TimersPage (app-level timer registry)', () => { kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, channel_capability: 'Flexible', node_count: undefined, - available_channels: [] + available_channels: [], + same_pass_window_micros: 0 // the fixture timer has no window set; the field sends 0 = no rule }, 'tok' ); @@ -208,7 +220,8 @@ describe('TimersPage (app-level timer registry)', () => { channel_capability: 'Flexible', node_count: 6, // Catalog channels in catalog order, then the custom MHz. - available_channels: [5658, 5800, 5685] + available_channels: [5658, 5800, 5685], + same_pass_window_micros: SAME_PASS_DEFAULT_MICROS }, 'tok' ); @@ -261,7 +274,8 @@ describe('TimersPage (app-level timer registry)', () => { channel_capability: { Fixed: { channels: [5658, 5695] } }, // Untouched, so the width override is left exactly as it was (#412). node_count: undefined, - available_channels: [5658, 5695] + available_channels: [5658, 5695], + same_pass_window_micros: 0 // the fixture timer has no window set; the field sends 0 = no rule }, 'tok' ); diff --git a/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts b/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts index c02cedfc..6adc4718 100644 --- a/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts +++ b/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts @@ -408,24 +408,29 @@ describe('useCrossingTones', () => { }; } - it('tones on EVERY crossing — holeshot, counted, rejected-too-short and marshal-voided alike', () => { + it('tones on every crossing EXCEPT a fold-declared bounce', () => { + // #397's rule and #517's single exception, in one assertion. Everything the gate reports is + // audible — a holeshot, a counted lap, a pass under the floor, a marshal-voided one, and a + // crossing after the race ended — because each of those is a thing that happened. The one + // silence is `RejectedSamePass`: not a second crossing, the same one seen twice. const h = harness(); const holeshot = crossing(10, 'maverick-1', 'Holeshot'); const counted = crossing(11, 'maverick-1', 'Counted', 1); const rejected = crossing(12, 'maverick-1', 'RejectedTooShort'); const voided = crossing(13, 'goose-2', 'VoidedByMarshal'); + const afterEnd = crossing(14, 'goose-2', 'RejectedAfterRaceEnd'); + const bounce = crossing(15, 'maverick-1', 'RejectedSamePass'); - h.set({ crossings: [holeshot] }); - h.set({ crossings: [holeshot, counted] }); - h.set({ crossings: [holeshot, counted, rejected] }); - h.set({ crossings: [holeshot, counted, rejected, voided] }); + const feed = [holeshot, counted, rejected, voided, afterEnd, bounce]; + for (let i = 1; i <= feed.length; i++) h.set({ crossings: feed.slice(0, i) }); - expect(h.toned).toEqual([holeshot, counted, rejected, voided]); + expect(h.toned).toEqual([holeshot, counted, rejected, voided, afterEnd]); expect(h.toned.map((c) => c.disposition)).toEqual([ 'Holeshot', 'Counted', 'RejectedTooShort', - 'VoidedByMarshal' + 'VoidedByMarshal', + 'RejectedAfterRaceEnd' ]); h.cleanup(); }); @@ -570,45 +575,61 @@ describe('useCrossingTones', () => { h.cleanup(); }); - it('absorbs a same-competitor reflection burst into ONE tone (#503)', () => { + it('stays silent on a reflection burst the fold called RejectedSamePass (#503, #517)', () => { // One physical pass, five detections milliseconds apart (a quad in the gate's near field). - // The field rendered this as a pip storm per lap; the tone must answer "did the gate see - // me?" once. Explicit `at` values — the cooldown runs on the source clock, not offsets. + // The field rendered this as a pip storm per lap; the tone must answer "did the gate see me?" + // once. #503 did that here with a private 1s timer; #517 moved the judgement into the fold, + // so this detector now reads the disposition rather than re-deriving the rule from `at`. const h = harness(); const burst = [ { ...crossing(80, 'maverick-1', 'Counted', 1), at: 10_000_000 }, - { ...crossing(81, 'maverick-1', 'RejectedTooShort'), at: 10_061_000 }, - { ...crossing(82, 'maverick-1', 'RejectedTooShort'), at: 10_193_000 }, - { ...crossing(83, 'maverick-1', 'RejectedTooShort'), at: 10_254_000 }, - { ...crossing(84, 'maverick-1', 'RejectedTooShort'), at: 10_487_000 } + { ...crossing(81, 'maverick-1', 'RejectedSamePass'), at: 10_061_000 }, + { ...crossing(82, 'maverick-1', 'RejectedSamePass'), at: 10_193_000 }, + { ...crossing(83, 'maverick-1', 'RejectedSamePass'), at: 10_254_000 }, + { ...crossing(84, 'maverick-1', 'RejectedSamePass'), at: 10_487_000 } ]; h.set({ crossings: burst }); expect(h.toned).toEqual([burst[0]]); - // The next genuine lap — 17s later, far past the cooldown — tones again. + // The next genuine lap tones again — the bounce absorbs the burst, not the gate. const nextLap = { ...crossing(85, 'maverick-1', 'Counted', 2), at: 27_500_000 }; h.set({ crossings: [...burst, nextLap] }); expect(h.toned).toEqual([burst[0], nextLap]); h.cleanup(); }); - it('measures the cooldown from the last SOUNDED tone — an absorbed crossing does not extend it', () => { + it('STILL tones a genuinely short crossing — RejectedTooShort is not a bounce', () => { + // The distinction #517 exists to make, and the one the old client-side cooldown could not: + // a pass 3s after the last one, under a 10s floor, is a real crossing the pilot really made. + // It scores nothing, but the RD must hear that the gate fired — that is the whole of #397. + // Under the pre-#517 cooldown this depended on `at` arithmetic; now the fold has already + // ruled, and a crossing it did NOT call a bounce is audible whatever its spacing. + const h = harness(); + const holeshot = { ...crossing(100, 'maverick-1', 'Counted', 1), at: 10_000_000 }; + const tooShort = { ...crossing(101, 'maverick-1', 'RejectedTooShort'), at: 13_000_000 }; + const bounce = { ...crossing(102, 'maverick-1', 'RejectedSamePass'), at: 13_090_000 }; + h.set({ crossings: [holeshot] }); + h.set({ crossings: [holeshot, tooShort] }); + h.set({ crossings: [holeshot, tooShort, bounce] }); + expect(h.toned).toEqual([holeshot, tooShort]); + h.cleanup(); + }); + + it('a bounce still advances the watermark — it was seen, just not sounded', () => { + // The absorbed crossing must not be re-offered later. Silence is not the same as unseen: if a + // bounce left the watermark behind, a re-push of the same frame would sound it. const h = harness(); - const t0 = { ...crossing(90, 'maverick-1', 'Counted', 1), at: 10_000_000 }; - const absorbed = { ...crossing(91, 'maverick-1', 'RejectedTooShort'), at: 10_800_000 }; - // 1.2s after t0 but only 0.4s after the absorbed crossing: the window anchors on the TONE, - // so this fires. (A storm that never pauses must not silence the gate indefinitely.) - const clear = { ...crossing(92, 'maverick-1', 'RejectedTooShort'), at: 11_200_000 }; - h.set({ crossings: [t0] }); - h.set({ crossings: [t0, absorbed] }); - h.set({ crossings: [t0, absorbed, clear] }); - expect(h.toned).toEqual([t0, clear]); + const kept = { ...crossing(90, 'maverick-1', 'Counted', 1), at: 10_000_000 }; + const bounce = { ...crossing(91, 'maverick-1', 'RejectedSamePass'), at: 10_800_000 }; + h.set({ crossings: [kept, bounce] }); + h.set({ crossings: [kept, bounce] }); + expect(h.toned).toEqual([kept]); h.cleanup(); }); - it('cools down PER COMPETITOR — two pilots crossing near-simultaneously both tone', () => { - // The gate telling the RD it saw both pilots is the point of the feature; only repeats of - // the SAME competitor are noise. + it('two pilots crossing near-simultaneously both tone', () => { + // The gate telling the RD it saw both pilots is the point of the feature. The fold keys the + // bounce window per competitor for the same reason, so neither of these is ever absorbed. const h = harness(); const mav = { ...crossing(95, 'maverick-1', 'Counted', 1), at: 10_000_000 }; const goose = { ...crossing(96, 'goose-2', 'Counted', 1), at: 10_050_000 }; diff --git a/xtask/src/race_day.rs b/xtask/src/race_day.rs index 7879c185..ed309224 100644 --- a/xtask/src/race_day.rs +++ b/xtask/src/race_day.rs @@ -44,6 +44,10 @@ const SCENARIOS: &[(&str, &str)] = &[ "messy", "marshaling practice: a missed lap, a false/extra pass, and a DNF", ), + ( + "bouncy", + "a gate that re-detects: echo bursts per pass, plus one genuine sub-floor lap (#517)", + ), ( "pack", "a tight field crossing close together (close finishes)",