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.
+
+
+
+ Min-lap floor (D26): a raw pass that
+ would close a lap under the round's minimum lap time is auto-removed by the same
+ fold that applies marshal voids — shown on the lap list as "under min lap, auto-removed"
+ with a Restore override (Restore is an explicit re-time ruling, which always outranks the
+ floor). Timers are dumb emitters; GridFPV owns lap semantics.
+
4. Architectural fit
diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte
index 2e30efe..b522fdf 100644
--- a/frontend/apps/rd-console/src/screens/EventRounds.svelte
+++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte
@@ -552,6 +552,10 @@
let startMinSeconds = $state(2); // randomized start hold: shortest, in seconds (→ min_delay_ms)
let startMaxSeconds = $state(5); // randomized start hold: longest, in seconds (→ max_delay_ms)
let graceSeconds = $state(30); // grace window after the win condition, in seconds
+ // Min lap time (D26): raw crossings that would close a shorter lap are auto-removed (a gate
+ // reflection / double-detection), marshal-restorable. 0 = off; NEW rounds seed the
+ // field-standard 5s so a double-fire never fabricates a 0.004s best lap out of the box.
+ let minLapSeconds = $state(5);
// ── Protest window (marshaling Slice 5) ──────────────────────────────────────
// The **auto-official timer**, in seconds. 0 (the default) = OFF: the result stays provisional
// (Unofficial) until the RD finalizes manually — today's behaviour. A positive value arms the
@@ -716,6 +720,7 @@
startMinSeconds = 2;
startMaxSeconds = 5;
graceSeconds = 30;
+ minLapSeconds = 5;
protestSeconds = 0; // off by default — manual finalize only
timeLimitMinutes = ''; // blank = no limit
}
@@ -796,6 +801,7 @@
const grace = round.grace_window;
graceSeconds =
grace && typeof grace !== 'string' ? Math.round(grace.Duration.micros / 1_000_000) : 30;
+ minLapSeconds = round.min_lap_secs ?? 0;
// Protest window (marshaling Slice 5): reflect an `After { micros }` back as seconds; `Off` (or a
// round that predates the field) reads back as 0 (the timer disabled — manual finalize only).
@@ -1035,6 +1041,7 @@
channel_mode: channelMode,
staging_timer_secs: buildStagingSecs(),
start_procedure: buildStartProcedure(),
+ min_lap_secs: Math.max(0, Math.round(Number(minLapSeconds) || 0)),
grace_window: buildGraceWindow(),
protest_window: buildProtestWindow()
};
@@ -1737,6 +1744,12 @@
aria-label="Grace window seconds"
/>
+
+
+ {
+ /** RESTORE a removed pass. A marshal-voided pass is undone by void-the-void (targeting the
+ * standing removal event); a floor-suppressed pass (UnderMinLap, D26) is BLESSED by an
+ * AdjustLap re-asserting its own raw instant — an explicit ruling outranks the floor, so
+ * the fold exempts it and the pass returns to the chain. */
+ function doRestorePass(v: {
+ void_ref: number;
+ pass_ref: number;
+ at: number;
+ reason: VoidReason;
+ }): Promise {
return submitCorrection(async () => {
- const ack = await session.send(voidDetectionCommand(v.void_ref));
+ const ack = await session.send(
+ v.reason === 'UnderMinLap'
+ ? adjustLapCommand(v.pass_ref, v.at)
+ : voidDetectionCommand(v.void_ref)
+ );
if (ack.ok) await afterCorrection();
});
}
@@ -1076,7 +1087,9 @@
∅removed pass at {formatMicros(v.at)}s — stays removed{v.reason === 'UnderMinLap'
+ ? `crossing at ${formatMicros(v.at)}s — under min lap, auto-removed`
+ : `removed pass at ${formatMicros(v.at)}s — stays removed`}
{#if canCorrect}