From 0e4d0645626ce83d01cd1c313cfe6a7ec22fc4a7 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 21:52:25 +0000 Subject: [PATCH 1/3] wip: min-lap checkpoint (has mangled fixture literals, fixed next) Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 3 + crates/app/tests/race_flow.rs | 7 + crates/projection/src/lib.rs | 264 ++++++++++++++++++++++++++- crates/server/src/app.rs | 90 ++++++--- crates/server/src/control_handler.rs | 5 + crates/server/src/events.rs | 41 +++++ crates/server/src/live_state.rs | 25 ++- crates/server/src/round_engine.rs | 17 +- plugins/gridfpv_mock/__init__.py | 6 +- 9 files changed, 411 insertions(+), 47 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index c27eec7..381ed77 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -2560,6 +2560,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }; registry .add_round(&ScopeEventId(PRACTICE_EVENT_ID.to_string()), req) @@ -2774,6 +2775,7 @@ mod tests { // Zero grace so the fallback deadline IS the window end. grace_window: Some(GraceWindow::Duration { micros: 0 }), protest_window: None, + min_lap_secs: None, }; let round = registry .add_round(&ScopeEventId(PRACTICE_EVENT_ID.to_string()), req) @@ -3010,6 +3012,7 @@ mod tests { protest_window: Some(ProtestWindow::After { micros: window_micros, }), + min_lap_secs: None, }; registry .add_round(&ScopeEventId(PRACTICE_EVENT_ID.to_string()), req) diff --git a/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs index 8ed5ef1..1dfcf20 100644 --- a/crates/app/tests/race_flow.rs +++ b/crates/app/tests/race_flow.rs @@ -339,6 +339,7 @@ async fn round_driven_mock_race_flow_e2e() { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .await; @@ -444,6 +445,7 @@ async fn round_driven_mock_race_flow_e2e() { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .await; @@ -647,6 +649,7 @@ async fn fill_round_rejects_an_oversized_heat_e2e() { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .await; @@ -787,6 +790,7 @@ async fn static_channel_balanced_qual_flow_e2e() { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .await; @@ -1026,6 +1030,7 @@ async fn fast_auto_event( // A short bounded grace so the auto Running→Unofficial fires promptly after the win. grace_window: Some(GraceWindow::Duration { micros: 5_000 }), protest_window: None, + min_lap_secs: None, }, ) .await; @@ -1257,6 +1262,7 @@ async fn open_practice_round_auto_creates_heat_and_time_limit_auto_ends_it_e2e() }), grace_window: None, protest_window: None, + min_lap_secs: None, }; let round: RoundDef = add_round(&app, &event, &token, open_req()).await; let state = registry.resolve(&event).unwrap(); @@ -1470,6 +1476,7 @@ async fn two_open_practice_rounds_in_one_event_get_distinct_heats_e2e() { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }; let round_a: RoundDef = add_round(&app, &event, &token, open_req("Open A")).await; diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 3384cc2..051ca85 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -32,7 +32,7 @@ pub mod recalc; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use gridfpv_events::{ AdapterId, CompetitorRef, Event, HeatId, LogRef, Pass, PilotId, SignalHistory, SourceTime, @@ -128,7 +128,28 @@ pub struct VoidedPass { /// The voided pass's own global log offset (a stable row identity for the UI). pub pass_ref: LogRef, /// The **standing void event's** offset — the target a RESTORE (void-the-void) addresses. + /// For an AUTO-suppressed pass (see [`VoidReason::UnderMinLap`]) this is the pass's own + /// offset: there is no void event, and the restore path is a marshal ruling on the pass + /// itself (an [`Event::LapAdjusted`] re-asserting its raw instant — an explicit ruling + /// always outranks the floor). pub void_ref: LogRef, + /// WHY the pass is off the lap chain — the console labels the row (and picks the restore + /// command) by this. + #[serde(default)] + pub reason: VoidReason, +} + +/// Why a pass sits on the removal record instead of the lap chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum VoidReason { + /// A marshal explicitly removed it ([`Event::DetectionVoided`]). + #[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). + UnderMinLap, } impl CompetitorLaps { @@ -315,8 +336,19 @@ where corrected_and_voided_passes(events).0 } -/// One RD-voided pass as the fold emits it: `(pass offset, standing void event's offset, pass)`. -pub type VoidedEmit = (u64, u64, Pass); +/// [`corrected_passes`] under a round's **minimum-lap floor** (D26) — the scoring-path +/// sibling of [`lap_list_marshaled_with_floor`], so results and the lap list can never +/// disagree about a suppressed pass. +pub fn corrected_passes_with_floor<'a, I>(events: I, min_lap_micros: Option) -> Vec<(u64, Pass)> +where + I: IntoIterator, +{ + corrected_and_voided_passes_with_floor(events, min_lap_micros).0 +} + +/// One removed pass as the fold emits it: +/// `(pass offset, restore-target offset, pass, why)`. +pub type VoidedEmit = (u64, u64, Pass, VoidReason); /// [`corrected_passes`] plus the passes the RD **voided** (and did not un-void), each resolved /// to its concrete pass (re-time applied) with its own offset — the shared removal record the @@ -544,13 +576,87 @@ where if is_voided { let void_ref = void_source.get(offset).copied().unwrap_or(*offset); for (o, p) in scratch.drain(..) { - voided_out.push((o, void_ref, p)); + voided_out.push((o, void_ref, p, VoidReason::Marshal)); } } } (out, voided_out) } +/// [`corrected_and_voided_passes`] with the round's **minimum-lap floor** applied (D26). +/// +/// After the marshaling corrections fold, each competitor's surviving chain is walked +/// chronologically: 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). +/// Marshal-created passes (inserted, split-synthetic) and re-timed passes are NEVER +/// suppressed: an explicit ruling outranks the floor. `None`/`0` floor ⇒ identical to the +/// plain fold, so rounds predating the setting keep their results bit-identical. +pub fn corrected_and_voided_passes_with_floor<'a, I>( + events: I, + min_lap_micros: Option, +) -> (Vec<(u64, Pass)>, Vec) +where + I: IntoIterator, +{ + // The plain fold needs to tell us which surviving passes are raw-and-unruled; re-derive + // that from the events here so the core fold stays untouched. Collect first (two passes + // over the data, but windows are per-heat and small). + let pairs: Vec<(u64, &Event)> = events.into_iter().collect(); + let (surviving, mut voided) = corrected_and_voided_passes(pairs.iter().copied()); + let Some(floor) = min_lap_micros.filter(|f| *f > 0) else { + return (surviving, voided); + }; + + // A pass is EXEMPT from the floor when a marshal shaped it: inserted or split-synthetic + // by construction, or re-timed by a standing (un-voided) adjust. + let mut exempt: BTreeSet = BTreeSet::new(); + for (offset, event) in &pairs { + match event { + Event::LapInserted { .. } | Event::LapSplit { .. } => { + exempt.insert(*offset); + } + Event::LapAdjusted { target, .. } => { + exempt.insert(target.0); + } + _ => {} + } + } + + // Walk each competitor's chain in time order, keep-first: a too-close successor that is + // not marshal-blessed drops to the removal record. + let mut by_competitor: BTreeMap> = BTreeMap::new(); + for (offset, pass) in surviving { + by_competitor + .entry(CompetitorKey::from_pass(&pass)) + .or_default() + .push((offset, pass)); + } + let mut out: Vec<(u64, Pass)> = Vec::new(); + for (_, mut chain) in by_competitor { + chain.sort_by_key(|(offset, p)| (p.at, *offset)); + let mut last_kept: Option = None; + for (offset, pass) in chain { + let too_close = last_kept + .is_some_and(|prev| pass.at.micros.saturating_sub(prev.micros) < floor); + if too_close && !exempt.contains(&offset) { + voided.push((offset, offset, pass, VoidReason::UnderMinLap)); + } else { + last_kept = Some(pass.at); + out.push((offset, pass)); + } + } + } + out.sort_by_key(|(offset, _)| *offset); + (out, voided_out_sorted(voided)) +} + +/// Stable ordering for the removal record (offset order, like the surviving stream). +fn voided_out_sorted(mut voided: Vec) -> Vec { + voided.sort_by_key(|(offset, _, _, _)| *offset); + voided +} + /// Fold a sequence of `(offset, event)` pairs into the lap-list read model, /// applying marshaling adjudications keyed on the target's append **offset** (#31). /// @@ -563,6 +669,15 @@ where /// See [`corrected_passes`] for the adjudications folded, the offset/last-writer-wins /// semantics, and the "void the void" cases. pub fn lap_list_marshaled<'a, I>(events: I) -> LapList +where + I: IntoIterator, +{ + lap_list_marshaled_with_floor(events, None) +} + +/// [`lap_list_marshaled`] under a round's **minimum-lap floor** (D26): auto-suppressed passes +/// land on each competitor's removal record with [`VoidReason::UnderMinLap`]. +pub fn lap_list_marshaled_with_floor<'a, I>(events: I, min_lap_micros: Option) -> LapList where I: IntoIterator, { @@ -570,7 +685,7 @@ where // lives in `corrected_passes`; here we only project it into the lap-list view. Each // pass keeps the global offset that addresses it, so the derived laps carry their // `start_ref`/`end_ref` command targets. - let (surviving, voided) = corrected_and_voided_passes(events); + let (surviving, voided) = corrected_and_voided_passes_with_floor(events, min_lap_micros); let mut by_competitor: BTreeMap> = BTreeMap::new(); for (offset, pass) in surviving { by_competitor @@ -581,7 +696,7 @@ where // The RD-voided passes, grouped the same way — a competitor may have voids but no // surviving laps (every crossing removed), so they seed the map too. let mut voided_by_competitor: BTreeMap> = BTreeMap::new(); - for (offset, void_offset, pass) in voided { + for (offset, void_offset, pass, reason) in voided { by_competitor .entry(CompetitorKey::from_pass(&pass)) .or_default(); @@ -592,6 +707,7 @@ where at: pass.at, pass_ref: LogRef(offset), void_ref: LogRef(void_offset), + reason, }); } @@ -1548,6 +1664,7 @@ mod marshaling_tests { at: SourceTime::from_micros(4_000_000), pass_ref: LogRef(1), void_ref: LogRef(3), + reason: VoidReason::Marshal, }] ); @@ -1564,6 +1681,139 @@ mod marshaling_tests { assert!(cl.voided.is_empty()); } + #[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 — + // the second closed a phantom 0.004s "lap 1" and shifted every real lap's number. + // Under a 5s floor the echo drops to the removal record; the chain reads holeshot → + // real laps, exactly as if the timer had never double-fired. + let events = vec![ + pass("vd", "A", 651_000, Some(1)), // offset 0 — holeshot (kept: first) + pass("vd", "A", 655_000, Some(2)), // offset 1 — the 4ms echo (suppressed) + 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)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + let durations: Vec = cl.laps.iter().map(|l| l.duration_micros).collect(); + assert_eq!( + durations, + vec![6_557_000, 6_292_000], + "holeshot opens the chain; the echo never closes a lap" + ); + assert_eq!( + cl.voided, + vec![VoidedPass { + at: SourceTime::from_micros(655_000), + pass_ref: LogRef(1), + void_ref: LogRef(1), // restore target = the pass itself (a marshal re-time) + reason: VoidReason::UnderMinLap, + }] + ); + // No floor ⇒ bit-identical to the plain fold (rounds predating the setting). + let unfloored = lap_list_marshaled_with_floor(tagged(&events), None); + let plain = lap_list_marshaled(tagged(&events)); + assert_eq!(unfloored, plain); + assert_eq!(plain.competitors[0].laps.len(), 3); + } + + #[test] + fn a_marshal_re_time_exempts_a_pass_from_the_floor() { + // The RESTORE path: the floor suppressed a pass the marshal believes is real. An + // AdjustLap re-asserting its raw instant is an explicit ruling — it outranks the + // floor and the pass returns to the chain (whiff of a whoop track's 2s laps). + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 3_000_000, Some(2)), // offset 1 — 2s lap, under a 5s floor + 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)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!( + cl.laps.iter().map(|l| l.duration_micros).collect::>(), + vec![2_000_000, 6_000_000], + "the blessed pass closes its lap despite the floor" + ); + assert!(cl.voided.is_empty()); + } + + #[test] + fn marshal_created_passes_are_never_floor_suppressed() { + // An inserted pass is a ruling by construction — even one that closes a short lap + // stands (the marshal typed the time; the floor guards raw detections only). + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + 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)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!(cl.laps.len(), 2, "the inserted pass closes its lap"); + assert!(cl.voided.is_empty()); + } + + #[test] + fn a_burst_of_rapid_echoes_all_suppress_against_the_last_kept_pass() { + // Three reflections inside the floor window: each compares against the last KEPT + // pass, so the whole burst drops — not every-other one. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // kept (first) + pass("vd", "A", 1_004_000, Some(2)), // echo — suppressed + pass("vd", "A", 1_009_000, Some(3)), // echo — suppressed + 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)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!(cl.laps.len(), 1); + assert_eq!(cl.laps[0].duration_micros, 7_000_000); + assert_eq!(cl.voided.len(), 3); + assert!( + cl.voided + .iter() + .all(|v| v.reason == VoidReason::UnderMinLap) + ); + } + + #[test] + fn floor_suppression_composes_with_marshal_voids() { + // A marshal void recomputes the chain BEFORE the floor: voiding the first pass makes + // the echo the new chain opener (kept — nothing precedes it), and both removal + // reasons render side by side. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 — marshal-voided below + pass("vd", "A", 1_004_000, Some(2)), // offset 1 — becomes the opener + 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)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!(cl.laps.len(), 1, "opener (echo) -> real pass = one lap"); + assert_eq!(cl.voided.len(), 1); + assert_eq!(cl.voided[0].reason, VoidReason::Marshal); + } + #[test] fn a_depth_three_void_chain_re_voids_the_base_pass() { // void(void(void(P))) — the RD removed, restored, and re-removed: last writer wins, @@ -1590,6 +1840,7 @@ mod marshaling_tests { at: SourceTime::from_micros(4_000_000), pass_ref: LogRef(1), void_ref: LogRef(5), + reason: VoidReason::Marshal, }] ); } @@ -1617,6 +1868,7 @@ mod marshaling_tests { at: SourceTime::from_micros(4_000_000), pass_ref: LogRef(1), void_ref: LogRef(3), + reason: VoidReason::Marshal, }] ); } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index b2ab139..0d70c1a 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -84,7 +84,8 @@ use gridfpv_engine::format::{FormatRegistry, FormatSchema}; use gridfpv_engine::scoring::{HeatResult, WinCondition, score_corrected_with_global_offsets}; use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; use gridfpv_projection::{ - AuditEntry, LapList, lap_list_marshaled, marshaling_log, registrations, signal_trace, + AuditEntry, LapList, lap_list_marshaled, lap_list_marshaled_with_floor, marshaling_log, + registrations, signal_trace, }; use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent}; use serde::Deserialize; @@ -104,7 +105,8 @@ use crate::events::{ SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, }; use crate::live_state::{ - HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing, + HeatSummary, heat_summaries, live_state, live_state_over, live_state_over_with_floor, + with_heat_timing, }; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; use crate::round_engine; @@ -1761,20 +1763,41 @@ async fn snapshot_heat( let heat_offsets = heat_window_offsets(&events, &heat); let heat_events: Vec = heat_offsets.iter().map(|(_, e)| e.clone()).collect(); + // The heat's ROUND config, resolved once for every projection that scores or folds laps: + // the win condition (#45) and the min-lap floor (D26 — the floor must reach the laps, + // live, and result folds identically, or the lap list and the score disagree about a + // suppressed pass). A heat with no round (ad-hoc) keeps the neutral defaults. + let round_def = events + .iter() + .find_map(|e| match e { + Event::HeatScheduled { + heat: h, + round: Some(round), + .. + } if *h == heat => Some(round.clone()), + _ => None, + }) + .and_then(|round_id| { + registry + .meta_of(&event_id) + .and_then(|meta| meta.rounds.iter().find(|r| r.id == round_id).cloned()) + }); + let min_lap_micros = min_lap_micros_of(round_def.as_ref()); + let body = match query.projection { HeatProjection::Live => { // Open-practice overlay (open-practice format, Slice 1): fold the heat's real log window // for a truthful phase/clock, then — when this *is* the active open-practice heat — splice // its in-memory (NOT logged) per-channel laps on top. `merge_into` guards on the heat // matching the accumulator's, so a non-op heat folds its log window unchanged. - ProjectionBody::LiveRaceState( - state - .open_practice() - .merge_into(with_heat_timing(live_state_over(&heat_offsets), &stored)), - ) + ProjectionBody::LiveRaceState(state.open_practice().merge_into(with_heat_timing( + live_state_over_with_floor(&heat_offsets, min_lap_micros), + &stored, + ))) } - HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled( + HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled_with_floor( heat_offsets.iter().map(|(o, e)| (*o, e)), + min_lap_micros, )), HeatProjection::Audit => { // The defensible-results audit panel: fold the heat's rulings into a reverse-chrono @@ -1792,31 +1815,21 @@ async fn snapshot_heat( // `HeatScheduled` tag, then look its `RoundDef::win_condition` up in the event // meta. A heat with no associated round (an ad-hoc / open-practice heat) falls // back to a neutral best-lap qualifying rule, so an un-tagged heat is unchanged. - let win_condition = events - .iter() - .find_map(|e| match e { - Event::HeatScheduled { - heat: h, - round: Some(round), - .. - } if *h == heat => Some(round.clone()), - _ => None, - }) - .and_then(|round_id| { - registry.meta_of(&event_id).and_then(|meta| { - meta.rounds - .iter() - .find(|r| r.id == round_id) - .map(|r| r.win_condition) - }) - }) + let win_condition = round_def + .as_ref() + .map(|r| r.win_condition) .unwrap_or(WinCondition::BestLap); // Score over the heat's FULL adjudicated window via the one shared helper the // round/class standings also use ([`round_engine::completed_heats`] → // [`score_heat_window`]), so the per-heat result and the standings can never // disagree on an adjudicated heat (#226). The helper preserves the window's global // offsets so a `RulingReversed` / `LapThrownOut` resolves to its true `LogRef` (#55). - ProjectionBody::HeatResult(score_heat_window(&events, &heat, win_condition)) + ProjectionBody::HeatResult(score_heat_window( + &events, + &heat, + win_condition, + min_lap_micros, + )) } HeatProjection::Signal => { // The signal-as-evidence trace (marshaling Slice 1): fold the heat window's @@ -1854,6 +1867,10 @@ async fn snapshot_pilot( .collect(); let fallback_ref = CompetitorRef(pilot.0.clone()); + // The pilot view folds the WHOLE log (every heat, every round) — rounds carry different + // min-lap floors, so no single floor applies here; the per-heat views are the floored, + // authoritative surfaces (D26). A pilot may therefore see a raw echo here that the RD's + // heat view suppresses — read-only, never scored. let full = lap_list_marshaled(events.iter().enumerate().map(|(i, e)| (i as u64, e))); let competitors: Vec<_> = full .competitors @@ -2000,14 +2017,19 @@ pub(crate) fn score_heat_window( events: &[Event], heat: &HeatId, win_condition: WinCondition, + min_lap_micros: Option, ) -> HeatResult { let heat_offsets = heat_window_offsets(events, heat); // Fold the marshaling lap corrections (void / insert / adjust / split) into the pass // stream FIRST — scoring raw passes here was the residual #226 split-brain: the marshaling // lap list showed the corrected laps while the result, rankings, standings, and seeding // scored the uncorrected ones. The corrected stream keeps each surviving pass's global - // offset, so a throw-out targeting a lap's end pass still excludes the right lap. - let corrected = gridfpv_projection::corrected_passes(heat_offsets.iter().map(|(o, e)| (*o, e))); + // offset, so a throw-out targeting a lap's end pass still excludes the right lap. The + // round's min-lap floor (D26) applies here too — the score and the lap list must agree. + let corrected = gridfpv_projection::corrected_passes_with_floor( + heat_offsets.iter().map(|(o, e)| (*o, e)), + min_lap_micros, + ); let race_start = corrected .iter() .filter(|(_, p)| p.gate.is_lap_gate()) @@ -2022,6 +2044,15 @@ pub(crate) fn score_heat_window( ) } +/// A round's min-lap floor in MICROSECONDS (D26), `None` when unset/zero — the single +/// conversion every fold call site shares. +pub(crate) fn min_lap_micros_of(round: Option<&crate::events::RoundDef>) -> Option { + round + .and_then(|r| r.min_lap_secs) + .filter(|s| *s > 0) + .map(|s| s as i64 * 1_000_000) +} + /// Render a [`ProtocolError`] as an HTTP error response (protocol.html §9.8): the JSON /// error body under the status its [`ErrorCode`] maps to. The single shared error shape is /// returned uniformly across every snapshot path. @@ -2185,6 +2216,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .expect("round adds (empty classes validate)"); diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 535efa4..2d44c64 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -3435,6 +3435,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -3578,6 +3579,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -3987,6 +3989,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -4165,6 +4168,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -4233,6 +4237,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index a3aba0d..0223e40 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -403,6 +403,16 @@ pub struct RoundDef { /// Additive (`#[serde(default)]`) so a round persisted before this field reads back as `Off`. #[serde(default)] pub protest_window: ProtestWindow, + /// The **minimum lap time** floor, in seconds (D26) — GridFPV-native, because timers are + /// dumb pass emitters and GridFPV owns lap semantics: a raw pass that would close a lap + /// shorter than this (a gate reflection, a double-detection) is AUTO-SUPPRESSED by the + /// corrected-passes fold — visible on the marshaling lap list as a struck removal-record + /// row with a Restore override (marshal-created passes — inserts, re-times — are exempt: + /// an explicit ruling always outranks the floor). `None`/`0` = off (rounds predating the + /// field keep their scored results bit-identical). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_lap_secs: Option, /// The **practice duration** for an open-practice round, in seconds (open-practice refinement). /// When set, the runtime clock **auto-ends the practice** (`Running → Unofficial`) once the /// heat's elapsed running time reaches this limit — independent of any win condition (the time is @@ -840,6 +850,9 @@ pub struct NewRoundReq { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub protest_window: Option, + /// Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + #[serde(default)] + pub min_lap_secs: Option, } /// The body of `PUT /events/{id}/rounds/{round}` — the editable fields of an existing round (race @@ -895,6 +908,9 @@ pub struct UpdateRoundReq { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub protest_window: Option, + /// Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + #[serde(default)] + pub min_lap_secs: Option, } impl EventMeta { @@ -1414,6 +1430,7 @@ impl EventRegistry { None, )?; validate_round_params(&req.format, &req.params)?; + validate_min_lap(req.min_lap_secs)?; // Auto-generate a unique round id within this event: slug(label) + short suffix, retried on // the (astronomically unlikely) collision so the id is always fresh. @@ -1443,6 +1460,7 @@ impl EventRegistry { // The protest window (marshaling Slice 5): omitted ⇒ `Off` (manual finalize only). protest_window: req.protest_window.unwrap_or_default(), // The optional open-practice duration (open-practice refinement): carried through as-is. + min_lap_secs: req.min_lap_secs.filter(|s| *s > 0), time_limit_secs: req.time_limit_secs, }; event.meta.rounds.push(round.clone()); @@ -1538,6 +1556,7 @@ impl EventRegistry { Some(round_id), )?; validate_round_params(&req.format, &req.params)?; + validate_min_lap(req.min_lap_secs)?; // A RACED round's scoring-defining config is FROZEN (user-approved policy): scoring // re-derives from the round's current config, so editing these would silently re-score @@ -1563,6 +1582,11 @@ impl EventRegistry { if effective_channel_mode != existing.channel_mode { frozen.push("channel mode"); } + // The min-lap floor suppresses passes from the scored chain — editing it would + // silently re-score raced heats, so it freezes with the win condition. + if req.min_lap_secs.filter(|s| *s > 0) != existing.min_lap_secs { + frozen.push("min lap time"); + } // Params: only `rounds` (heats per pilot) may change once raced. let differs_beyond_rounds = { let mut a = req.params.clone(); @@ -1602,6 +1626,8 @@ impl EventRegistry { grace_window: req.grace_window.unwrap_or_else(default_grace_window), // The protest window (marshaling Slice 5): omitted ⇒ `Off` (manual finalize only). protest_window: req.protest_window.unwrap_or_default(), + // The min-lap floor (D26): normalized so 0 and omitted are the same OFF. + min_lap_secs: req.min_lap_secs.filter(|s| *s > 0), // The optional open-practice duration (open-practice refinement): replaced wholesale. time_limit_secs: req.time_limit_secs, }; @@ -2339,6 +2365,19 @@ fn validate_round_fields( /// options, a bool must be true/false. Undeclared keys pass through untouched (e.g. the points /// table, which has its own editor). Called from add_round/update_round alongside /// [`validate_round_fields`]. +/// Validate the min-lap floor (D26): 0/omitted is OFF; anything above 10 minutes is a typo +/// (no track has a 10-minute minimum lap), rejected before it can silently eat every lap. +fn validate_min_lap(min_lap_secs: Option) -> Result<(), RoundError> { + if let Some(secs) = min_lap_secs { + if secs > 600 { + return Err(RoundError::Invalid(format!( + "min lap time {secs}s is out of range (0 = off, up to 600s)" + ))); + } + } + Ok(()) +} + fn validate_round_params( format: &str, params: &BTreeMap, @@ -3113,6 +3152,7 @@ mod tests { grace_window: None, protest_window: None, } + min_lap_secs: None, } #[test] @@ -3177,6 +3217,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .expect("an open-practice round with no win condition saves"); diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index a4263c7..550fdac 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -45,7 +45,7 @@ use std::collections::BTreeMap; use gridfpv_engine::heat::{HeatState, heat_state}; use gridfpv_events::{ClassId, CompetitorRef, Event, HeatId, HeatTransition, PilotId, RoundId}; -use gridfpv_projection::{CompetitorKey, lap_list_marshaled, registrations}; +use gridfpv_projection::{CompetitorKey, lap_list_marshaled_with_floor, registrations}; use gridfpv_storage::StoredEvent; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -222,7 +222,7 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { .enumerate() .map(|(i, e)| (i as u64, e)) .collect(); - live_state_core(events, &window) + live_state_core(events, &window, None) } /// Fold a **windowed** log slice with its PRESERVED global offsets — the heat/class-scope @@ -232,17 +232,31 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { /// `0,1,2,…`, so a correction to a heat deep in the log silently missed (or, on coincidence, /// hit the wrong pass) in every heat-scoped live view. `window` must be in log order. pub fn live_state_over(window: &[(u64, Event)]) -> LiveRaceState { + live_state_over_with_floor(window, None) +} + +/// [`live_state_over`] under the current heat's **min-lap floor** (D26): the live lap fold +/// suppresses under-floor raw passes exactly like the laps/result projections, so the race +/// screen's lap counts and the marshaling list can never disagree about an echo. +pub fn live_state_over_with_floor( + window: &[(u64, Event)], + min_lap_micros: Option, +) -> LiveRaceState { // The positional helpers (current heat, phase, lineup, run boundary) read a bare event // slice; the offsets matter only to the marshaling-aware lap fold below. let events: Vec = window.iter().map(|(_, e)| e.clone()).collect(); let pairs: Vec<(u64, &Event)> = window.iter().map(|(o, e)| (*o, e)).collect(); - live_state_core(&events, &pairs) + live_state_core(&events, &pairs, min_lap_micros) } /// The shared fold behind [`live_state`] (full log, positional offsets) and /// [`live_state_over`] (a window with preserved global offsets). `window` is the SAME /// sequence as `events`, paired with each event's global append offset. -fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState { +fn live_state_core( + events: &[Event], + window: &[(u64, &Event)], + min_lap_micros: Option, +) -> LiveRaceState { let Some(current_heat) = current_heat(events) else { return LiveRaceState::default(); }; @@ -266,7 +280,7 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState // boundary, so everything counts (a normally-finalized heat is unaffected). let run_start = current_run_start(events, ¤t_heat); let pass_ceiling = current_run_pass_ceiling(events, ¤t_heat); - let laps = lap_list_marshaled( + let laps = lap_list_marshaled_with_floor( window .iter() .enumerate() @@ -287,6 +301,7 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState } }) .map(|(_, (offset, e))| (*offset, *e)), + min_lap_micros, ); // Per ref: lap count, the last lap's DURATION (the wire's `last_lap_micros` display value), // and the last lap's COMPLETION time (`at`) — the running-order tie-break. The scorer ranks diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 6d4354a..0b5b00c 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -708,7 +708,12 @@ pub fn completed_heats(round: &RoundDef, events: &[Event]) -> Vec // an adjudication that moves the heat page moves the standings too (#226). The previous // pass-only `score_marshaled` discarded every adjudication, leaving the raw on-track // score here while the heat page showed the corrected one — the split-brain this closes. - let result = crate::app::score_heat_window(events, &heat, round.win_condition); + let result = crate::app::score_heat_window( + events, + &heat, + round.win_condition, + crate::app::min_lap_micros_of(Some(round)), + ); // The generator keys `next`/`ranking` on the heat ids it **emitted**; the log carries // the round-scoped id, so strip the scope back off before handing history to the // generator (and to every ranking consumer keyed on generator ids). @@ -2519,7 +2524,7 @@ mod tests { // The per-heat result the heat page shows, via the exact shared helper app.rs uses. let heat_result = - crate::app::score_heat_window(&log, &HeatId("h2h-1".into()), round.win_condition); + crate::app::score_heat_window(&log, &HeatId("h2h-1".into()), round.win_condition, None); let dq_pilot = heat_result .places .iter() @@ -2691,7 +2696,7 @@ mod tests { log.push(changed(heat, HeatTransition::Finished)); log.push(changed(heat, HeatTransition::Finalized)); - let result = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition); + let result = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); // Only run 2 scores: one lap each (holeshot + one), NOT run 1's ghost pile. let by_ref: std::collections::BTreeMap<&str, u32> = result .places @@ -2719,7 +2724,7 @@ mod tests { "B", Penalty::Disqualify { reason: None }, )); - let ruled = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition); + let ruled = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); assert!( ruled .places @@ -2759,7 +2764,7 @@ mod tests { // Heat 1's result carries the DQ... let heat1 = - crate::app::score_heat_window(&log, &HeatId("h2h-1".into()), round.win_condition); + crate::app::score_heat_window(&log, &HeatId("h2h-1".into()), round.win_condition, None); let dq: Vec<&str> = heat1 .places .iter() @@ -2769,7 +2774,7 @@ mod tests { assert_eq!(dq, vec!["A"], "the DQ lands in the heat it names"); // ...and heat 2 (the later, positionally-active heat) is untouched. let heat2 = - crate::app::score_heat_window(&log, &HeatId("h2h-2".into()), round.win_condition); + crate::app::score_heat_window(&log, &HeatId("h2h-2".into()), round.win_condition, None); assert!( heat2.places.iter().all(|p| !p.disqualified), "the DQ must not leak into the heat that happened to run last" diff --git a/plugins/gridfpv_mock/__init__.py b/plugins/gridfpv_mock/__init__.py index 4275550..a4f7ddb 100644 --- a/plugins/gridfpv_mock/__init__.py +++ b/plugins/gridfpv_mock/__init__.py @@ -102,10 +102,14 @@ def on_pass(data=None): val = int(round(baseline + (peak - baseline) * env)) n.history_values.append(val) n.history_times.append(t0 + i * (sample_ms / 1000.0)) - n.current_rssi = peak n.pass_peak_rssi = peak # Record the lap through RH's genuine pass pipeline (needs the race RACING). interface().intf_simulate_lap(node, 0) + # Land the live RSSI back at BASELINE, not the peak: a node parked at peak reads + # as "sitting on the gate" to RH's signal machinery at the NEXT race start, which + # fired an instant phantom crossing on every node — doubling the injected holeshot + # into a 4ms "lap 1" that shifted every real lap's number by one. + n.current_rssi = baseline ack("pass", node=node, peak=peak) except Exception as ex: # noqa: BLE001 nack("pass", ex) From 4ab25e4caf29713342c2996468b2393ea40f5d9f Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 21:56:55 +0000 Subject: [PATCH 2/3] wip: min-lap backend complete (fold + threading + freeze + bindings) Co-Authored-By: Claude Fable 5 --- bindings/NewRoundReq.ts | 6 +++++- bindings/RoundDef.ts | 10 ++++++++++ bindings/UpdateRoundReq.ts | 6 +++++- bindings/VoidReason.ts | 6 ++++++ bindings/VoidedPass.ts | 12 +++++++++++- crates/server/src/events.rs | 6 +++++- crates/server/src/round_engine.rs | 6 ++++++ 7 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 bindings/VoidReason.ts diff --git a/bindings/NewRoundReq.ts b/bindings/NewRoundReq.ts index 65beb8c..6a1f277 100644 --- a/bindings/NewRoundReq.ts +++ b/bindings/NewRoundReq.ts @@ -77,4 +77,8 @@ grace_window?: GraceWindow, * [`ProtestWindow::Off`] (manual finalize only); supply [`ProtestWindow::After`] to arm the * auto-official timer. */ -protest_window?: ProtestWindow, }; +protest_window?: ProtestWindow, +/** + * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + */ +min_lap_secs: number | null, }; diff --git a/bindings/RoundDef.ts b/bindings/RoundDef.ts index d92e99a..28d51f1 100644 --- a/bindings/RoundDef.ts +++ b/bindings/RoundDef.ts @@ -116,6 +116,16 @@ grace_window: GraceWindow, * Additive (`#[serde(default)]`) so a round persisted before this field reads back as `Off`. */ protest_window: ProtestWindow, +/** + * The **minimum lap time** floor, in seconds (D26) — GridFPV-native, because timers are + * dumb pass emitters and GridFPV owns lap semantics: a raw pass that would close a lap + * shorter than this (a gate reflection, a double-detection) is AUTO-SUPPRESSED by the + * corrected-passes fold — visible on the marshaling lap list as a struck removal-record + * row with a Restore override (marshal-created passes — inserts, re-times — are exempt: + * an explicit ruling always outranks the floor). `None`/`0` = off (rounds predating the + * field keep their scored results bit-identical). + */ +min_lap_secs?: number, /** * The **practice duration** for an open-practice round, in seconds (open-practice refinement). * When set, the runtime clock **auto-ends the practice** (`Running → Unofficial`) once the diff --git a/bindings/UpdateRoundReq.ts b/bindings/UpdateRoundReq.ts index dda3eef..aca0c2b 100644 --- a/bindings/UpdateRoundReq.ts +++ b/bindings/UpdateRoundReq.ts @@ -67,4 +67,8 @@ grace_window?: GraceWindow, * The new protest window (marshaling Slice 5). Optional — omit for the default * [`ProtestWindow::Off`] (manual finalize only). */ -protest_window?: ProtestWindow, }; +protest_window?: ProtestWindow, +/** + * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + */ +min_lap_secs: number | null, }; diff --git a/bindings/VoidReason.ts b/bindings/VoidReason.ts new file mode 100644 index 0000000..38f5eb3 --- /dev/null +++ b/bindings/VoidReason.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Why a pass sits on the removal record instead of the lap chain. + */ +export type VoidReason = "Marshal" | "UnderMinLap"; diff --git a/bindings/VoidedPass.ts b/bindings/VoidedPass.ts index 622351b..4255d19 100644 --- a/bindings/VoidedPass.ts +++ b/bindings/VoidedPass.ts @@ -1,6 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { LogRef } from "./LogRef"; import type { SourceTime } from "./SourceTime"; +import type { VoidReason } from "./VoidReason"; /** * One RD-voided gate pass, as the lap list records it (see [`CompetitorLaps::voided`]). @@ -18,5 +19,14 @@ at: SourceTime, pass_ref: LogRef, /** * The **standing void event's** offset — the target a RESTORE (void-the-void) addresses. + * For an AUTO-suppressed pass (see [`VoidReason::UnderMinLap`]) this is the pass's own + * offset: there is no void event, and the restore path is a marshal ruling on the pass + * itself (an [`Event::LapAdjusted`] re-asserting its raw instant — an explicit ruling + * always outranks the floor). */ -void_ref: LogRef, }; +void_ref: LogRef, +/** + * WHY the pass is off the lap chain — the console labels the row (and picks the restore + * command) by this. + */ +reason: VoidReason, }; diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 0223e40..f1315d3 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -3151,8 +3151,8 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, } - min_lap_secs: None, } #[test] @@ -3314,6 +3314,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }; // Race-day knobs (label / staging / etc.) and the `rounds` param stay editable. @@ -3372,6 +3373,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -3489,6 +3491,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ); assert!(matches!(self_ref, Err(RoundError::Invalid(_)))); @@ -3536,6 +3539,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ); assert!(matches!(self_winners, Err(RoundError::Invalid(_)))); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 0b5b00c..e30b0da 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -1903,6 +1903,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -2328,6 +2329,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -3121,6 +3123,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -3281,6 +3284,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -3692,6 +3696,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -3863,6 +3868,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } From a52000becc396c54d7fdaaf7a8552d2bfbeee4ee Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 22:07:21 +0000 Subject: [PATCH 3/3] feat(scoring): GridFPV-native minimum lap time (D26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timers are dumb emitters; GridFPV owns lap semantics. The trigger: a start-line double-detection gave every pilot a phantom 0.004s 'lap 1' — and RotorHazard HAD MinLapSec=10 set, but its default behavior records-and-highlights rather than discards, so the sub-min lap reached us with no invalid marker. Enforcement now lives in the system of record: - RoundDef.min_lap_secs (optional; the form seeds 5s for new rounds; 0/absent = off so pre-existing rounds keep bit-identical results; >600s rejected as a typo; FROZEN once the round has raced — it re-scores official heats, D24). - The corrected-passes fold auto-suppresses a RAW pass that would close an under-floor lap (keep-first per burst) — applied identically in the lap list, the live view, and every scoring path via corrected_passes_with_floor / lap_list_marshaled_with_floor / live_state_over_with_floor, so the score and the lap list can never disagree about an echo. The log keeps every raw pass: suppression is a view-fold rule, never data destruction. - The removal record grows a reason (VoidReason::Marshal | UnderMinLap): auto- removed crossings render 'under min lap, auto-removed' beside marshal voids, and Restore branches — a marshal void restores via void-the-void; a floor suppression restores via an AdjustLap re-asserting the raw instant (an explicit ruling always outranks the floor, so inserted/split/re-timed passes are exempt by construction; a whoop track's genuine 2s laps stay scoreable). - The pilot ws view (whole-log, cross-round) stays unfloored by design — rounds carry different floors; the per-heat views are the authoritative surfaces. - Rides along: the gridfpv_mock plugin lands node RSSI back at BASELINE after each injected pass — parked-at-peak nodes read as 'sitting on the gate' at the NEXT race start, which is what fired the phantom double in the first place. Docs: decisions.html D26 + marshaling.html note. Tests: 5 fold suppression cases, the scoring seam (a 4ms echo can no longer be a best lap), freeze + normalization + bounds, console removal-row label + Restore command, rounds-form round-trip. Co-Authored-By: Claude Fable 5 --- bindings/NewRoundReq.ts | 2 +- bindings/UpdateRoundReq.ts | 2 +- crates/projection/src/lib.rs | 20 +++-- crates/server/src/app.rs | 49 ++++++++++++ crates/server/src/events.rs | 75 +++++++++++++++++++ crates/server/src/round_engine.rs | 6 +- docs/decisions.html | 28 +++++++ docs/marshaling.html | 7 ++ .../rd-console/src/screens/EventRounds.svelte | 13 ++++ .../rd-console/src/screens/Marshaling.svelte | 31 ++++++-- .../apps/rd-console/tests/EventRounds.test.ts | 20 +++++ .../rd-console/tests/MarshalingScreen.test.ts | 28 ++++++- frontend/packages/types/src/generated.ts | 1 + 13 files changed, 262 insertions(+), 20 deletions(-) diff --git a/bindings/NewRoundReq.ts b/bindings/NewRoundReq.ts index 6a1f277..1bdddf9 100644 --- a/bindings/NewRoundReq.ts +++ b/bindings/NewRoundReq.ts @@ -81,4 +81,4 @@ protest_window?: ProtestWindow, /** * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. */ -min_lap_secs: number | null, }; +min_lap_secs?: number, }; diff --git a/bindings/UpdateRoundReq.ts b/bindings/UpdateRoundReq.ts index aca0c2b..2f6e0e7 100644 --- a/bindings/UpdateRoundReq.ts +++ b/bindings/UpdateRoundReq.ts @@ -71,4 +71,4 @@ protest_window?: ProtestWindow, /** * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. */ -min_lap_secs: number | null, }; +min_lap_secs?: number, }; diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 051ca85..b5d3d2e 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -339,7 +339,10 @@ where /// [`corrected_passes`] under a round's **minimum-lap floor** (D26) — the scoring-path /// sibling of [`lap_list_marshaled_with_floor`], so results and the lap list can never /// disagree about a suppressed pass. -pub fn corrected_passes_with_floor<'a, I>(events: I, min_lap_micros: Option) -> Vec<(u64, Pass)> +pub fn corrected_passes_with_floor<'a, I>( + events: I, + min_lap_micros: Option, +) -> Vec<(u64, Pass)> where I: IntoIterator, { @@ -637,8 +640,8 @@ where chain.sort_by_key(|(offset, p)| (p.at, *offset)); let mut last_kept: Option = None; for (offset, pass) in chain { - let too_close = last_kept - .is_some_and(|prev| pass.at.micros.saturating_sub(prev.micros) < floor); + let too_close = + last_kept.is_some_and(|prev| pass.at.micros.saturating_sub(prev.micros) < floor); if too_close && !exempt.contains(&offset) { voided.push((offset, offset, pass, VoidReason::UnderMinLap)); } else { @@ -1688,9 +1691,9 @@ mod marshaling_tests { // Under a 5s floor the echo drops to the removal record; the chain reads holeshot → // real laps, exactly as if the timer had never double-fired. let events = vec![ - pass("vd", "A", 651_000, Some(1)), // offset 0 — holeshot (kept: first) - pass("vd", "A", 655_000, Some(2)), // offset 1 — the 4ms echo (suppressed) - pass("vd", "A", 7_208_000, Some(3)), // offset 2 — real lap 1 + pass("vd", "A", 651_000, Some(1)), // offset 0 — holeshot (kept: first) + pass("vd", "A", 655_000, Some(2)), // offset 1 — the 4ms echo (suppressed) + 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)); @@ -1739,7 +1742,10 @@ mod marshaling_tests { .find(|c| c.competitor.competitor.0 == "A") .unwrap(); assert_eq!( - cl.laps.iter().map(|l| l.duration_micros).collect::>(), + cl.laps + .iter() + .map(|l| l.duration_micros) + .collect::>(), vec![2_000_000, 6_000_000], "the blessed pass closes its lap despite the floor" ); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 0d70c1a..a0be296 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -2138,6 +2138,55 @@ mod tests { ] } + /// D26: the min-lap floor reaches SCORING through `score_heat_window` — an echo pass + /// that closes an under-floor lap is suppressed from the scored chain, so the result and + /// the (floored) lap list agree: the 0.004s phantom can never be anyone's best lap. + #[test] + fn score_heat_window_applies_the_min_lap_floor() { + let mut events = recorded_heat(); // A: passes at 1.0s / 4.0s / 6.5s (laps 3.0s, 2.5s) + // A double-detection echo 4ms after A's second pass — inserted DURING the run + // (appending it after `Finalized` would hit the Final freeze instead, which this + // test's own control run would then be measuring). + let finished_at = events + .iter() + .position(|e| { + matches!( + e, + Event::HeatStateChanged { + transition: HeatTransition::Finished, + .. + } + ) + }) + .unwrap(); + events.insert(finished_at, pass("A", 4_004_000, 9)); + let heat = HeatId("q-1".into()); + + let unfloored = score_heat_window(&events, &heat, WinCondition::BestLap, None); + let a = unfloored + .places + .iter() + .find(|p| p.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!( + a.metric, + gridfpv_engine::scoring::Metric::BestLapMicros(Some(4_000)), + "without the floor the echo IS the (phantom) best lap" + ); + + let floored = score_heat_window(&events, &heat, WinCondition::BestLap, Some(1_000_000)); + let a = floored + .places + .iter() + .find(|p| p.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!( + a.metric, + gridfpv_engine::scoring::Metric::BestLapMicros(Some(2_500_000)), + "the floor suppresses the echo; the real 2.5s lap wins" + ); + } + /// The FINAL FREEZE: a pass landing AFTER a heat's run went official never joins its /// window — a delayed RotorHazard catch-up pass (tagged or untagged) used to silently /// change a Final result with no command, no ruling, and no audit entry. diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index f1315d3..e22adde 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -852,6 +852,7 @@ pub struct NewRoundReq { pub protest_window: Option, /// Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. #[serde(default)] + #[ts(optional)] pub min_lap_secs: Option, } @@ -910,6 +911,7 @@ pub struct UpdateRoundReq { pub protest_window: Option, /// Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. #[serde(default)] + #[ts(optional)] pub min_lap_secs: Option, } @@ -3155,6 +3157,79 @@ mod tests { } } + #[test] + fn min_lap_is_normalized_validated_and_frozen_once_raced() { + use gridfpv_events::{CompetitorRef, Event, HeatId, HeatTransition}; + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Floor Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + + // 0 normalizes to OFF (None) — omitted and zero mean the same on the wire. + let mut zero = round_req("Zeroed", vec![open.clone()]); + zero.min_lap_secs = Some(0); + let round = reg.add_round(&event.id, zero).unwrap(); + assert_eq!(round.min_lap_secs, None); + + // Out-of-range is rejected (a >10-minute floor eats every lap — a typo). + let mut typo = round_req("Typo", vec![open.clone()]); + typo.min_lap_secs = Some(601); + assert!(reg.add_round(&event.id, typo).is_err()); + + // A real floor sticks… + let mut real = round_req("Floored", vec![open.clone()]); + real.min_lap_secs = Some(5); + let round = reg.add_round(&event.id, real).unwrap(); + assert_eq!(round.min_lap_secs, Some(5)); + + // …and FREEZES once the round has raced (editing it re-scores official heats). + let state = reg.resolve(&event.id).unwrap(); + state + .append( + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into())], + class: Some(open.clone()), + round: Some(round.id.clone()), + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + let frozen_req = UpdateRoundReq { + label: round.label.clone(), + classes: round.classes.clone(), + format: round.format.clone(), + params: round.params.clone(), + win_condition: Some(round.win_condition), + seeding: round.seeding.clone(), + time_limit_secs: round.time_limit_secs, + channel_mode: Some(round.channel_mode), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: Some(10), // was 5 — a scoring change on a raced round + }; + let err = reg + .update_round(&event.id, &round.id, frozen_req) + .unwrap_err(); + assert!( + format!("{err:?}").contains("min lap time"), + "expected the min-lap freeze, got {err:?}" + ); + } + #[test] fn add_round_generates_an_id_and_appends() { let reg = EventRegistry::new(None).unwrap(); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index e30b0da..bdd6a6c 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -2698,7 +2698,8 @@ mod tests { log.push(changed(heat, HeatTransition::Finished)); log.push(changed(heat, HeatTransition::Finalized)); - let result = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); + let result = + crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); // Only run 2 scores: one lap each (holeshot + one), NOT run 1's ghost pile. let by_ref: std::collections::BTreeMap<&str, u32> = result .places @@ -2726,7 +2727,8 @@ mod tests { "B", Penalty::Disqualify { reason: None }, )); - let ruled = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); + let ruled = + crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); assert!( ruled .places diff --git a/docs/decisions.html b/docs/decisions.html index 7310476..4055462 100644 --- a/docs/decisions.html +++ b/docs/decisions.html @@ -757,6 +757,34 @@

D25 · The removal record: voids and re-detection share one truth; the whole correction vocabulary. +

D26 · Timers are dumb emitters: lap semantics — including the minimum-lap floor — live in GridFPV

+
+
Context
+
A start-line double-detection gave every pilot a phantom 0.004s "lap 1" (Audit + Shakedown). RotorHazard HAD MinLapSec=10 configured — but its default + behavior is highlight-don't-discard, so the sub-min lap was recorded and forwarded with + no invalid marker. Any timer, any config drift, same hole: relying on the timer to + enforce lap semantics means the results depend on someone else's settings screen.
+
Decision
+
Timers emit observations (passes, signal); GridFPV owns lap + semantics. Each round carries an optional min_lap_secs floor (the + form seeds 5s for new rounds; 0/absent = off, so pre-existing rounds keep bit-identical + results). The corrected-passes fold AUTO-SUPPRESSES a raw pass that would close an + under-floor lap — applied identically in the lap list, the live view, and every scoring + path, and surfaced on the marshaling removal record as "under min lap, auto-removed" + with a Restore override. Marshal-created passes (inserts, + split-synthetics) and re-timed passes are EXEMPT: an explicit ruling always outranks + the floor — and Restore is exactly that ruling (an AdjustLap re-asserting + the pass's raw instant). The floor freezes once the round has raced (D24: it re-scores + official heats). The log keeps every raw pass — suppression is a view-fold rule, never + data destruction.
+
Rationale
+
Defensible results require the enforcement point to be the system of record, not an + upstream device. Folding the floor once, beneath every projection, keeps the lap list + and the score incapable of disagreeing; recording everything keeps the evidence; the + marshal override keeps a whoop track's genuine 2-second laps scoreable.
+
+