diff --git a/bindings/Event.ts b/bindings/Event.ts index ee9e746..79839c8 100644 --- a/bindings/Event.ts +++ b/bindings/Event.ts @@ -28,7 +28,15 @@ import type { SourceTime } from "./SourceTime"; * `{ "VariantName": { ..fields } }`, which maps cleanly to a discriminated union in * the generated TypeScript (#4). */ -export type Event = { "AdapterConnected": { adapter: AdapterId, } } | { "AdapterDisconnected": { adapter: AdapterId, } } | { "SessionStarted": { adapter: AdapterId, session: SessionId, } } | { "SessionEnded": { adapter: AdapterId, session: SessionId, } } | { "CompetitorSeen": { adapter: AdapterId, competitor: CompetitorRef, } } | { "CompetitorRegistered": { adapter: AdapterId, competitor: CompetitorRef, pilot: PilotId, } } | { "Pass": Pass } | { "SignalChunk": SignalChunk } | { "SignalThresholds": SignalThresholds } | { "SignalHistory": SignalHistory } | { "HeatScheduled": { heat: HeatId, lineup: Array, +export type Event = { "AdapterConnected": { adapter: AdapterId, } } | { "AdapterDisconnected": { adapter: AdapterId, } } | { "SessionStarted": { adapter: AdapterId, session: SessionId, } } | { "SessionEnded": { adapter: AdapterId, session: SessionId, } } | { "CompetitorSeen": { adapter: AdapterId, competitor: CompetitorRef, } } | { "CompetitorRegistered": { adapter: AdapterId, competitor: CompetitorRef, pilot: PilotId, } } | { "Pass": Pass } | { "SignalChunk": SignalChunk } | { "SignalThresholds": SignalThresholds } | { "SignalHistory": SignalHistory } | { "RoundFieldDrawn": { +/** + * The round whose field this freezes. + */ +round: RoundId, +/** + * The resolved field, in seed order — the draw every later read replays. + */ +field: Array, } } | { "HeatScheduled": { heat: HeatId, lineup: Array, /** * The class this heat runs in, where the scheduler tagged it. */ diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 35a35b1..d54259c 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -596,6 +596,23 @@ pub enum Event { SignalHistory(SignalHistory), // --- race-engine events (#28) --- + /// A round's **seeded field is drawn** — the one-time freeze of a carry seeding's + /// resolution (issue #334; decision D18's "one grouping decision" extended to the field). + /// + /// A round seeded **from another round's outcome** (`FromRanking` / `FromRankingRange` / + /// `FromHeatWinners` / `Combine`) records its resolved field here at **first fill**, and + /// every later read (fills, ranking, standings, dependent seeding) uses the recorded + /// draw. Without this, the seeding re-resolved live on every read — so adjudicating the + /// *source* round after this round had already raced silently rewrote who this round's + /// field "was", vanishing raced results from its ranking. Roster-derived seedings + /// (`FromRoster` / `AllChannels`) never record a draw: they stay live so late entrants + /// keep working. + RoundFieldDrawn { + /// The round whose field this freezes. + round: RoundId, + /// The resolved field, in seed order — the draw every later read replays. + field: Vec, + }, /// A heat is created with its lineup and enters the `Scheduled` state — the /// `[*] → Scheduled` entry of the heat loop (race-engine.html §2). Carries the /// competitors in the heat and, additively, the class/round it belongs to and the diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 23a4c86..827fd3c 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -431,6 +431,7 @@ fn fill_round_once( heat, lineup, frequencies: static_freqs, + field_draw, }) => { let class = round_engine::round_class(meta, round); // Channel assignment differs by the round's channel mode (race redesign Slice 7a): @@ -467,6 +468,21 @@ fn fill_round_once( } }, }; + // FREEZE-AT-FILL (#334): a carry-seeded round's first fill records its resolved + // field BEFORE the heat, so every later read (fills, ranking, standings, dependent + // seeding) replays this draw instead of re-resolving a source whose adjudications + // may have since moved. + if let Some(field) = field_draw { + if let Err(err) = state.append( + Event::RoundFieldDrawn { + round: round.clone(), + field, + }, + None, + ) { + return FillStep::Failed(CommandAck::failed(err)); + } + } let event = Event::HeatScheduled { heat, lineup, diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index fa56e68..f591ed1 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -88,6 +88,11 @@ pub enum FillOutcome { /// from the timer's pool via [`assign_for_event`], the prior behaviour). The handler still /// enforces the node-count cap either way. frequencies: Option>, + /// The round's **field draw to record** (freeze-at-fill, #334): `Some` exactly when + /// this is a carry-seeded round's FIRST scheduled heat and no draw is recorded yet. + /// The handler appends the [`Event::RoundFieldDrawn`] *before* the `HeatScheduled`, + /// freezing the resolution every later read replays. + field_draw: Option>, }, /// The round is complete — the generator returned /// [`GeneratorStep::Complete`](gridfpv_engine::format::GeneratorStep::Complete). No @@ -351,9 +356,41 @@ fn round_field_at( events: &[Event], depth: usize, ) -> Result, FillError> { + // FREEZE-AT-FILL (#334): a carry seeding's field is drawn ONCE — at the round's first + // fill — and recorded in the log; every later read replays the recorded draw. Live + // re-resolution let an adjudication on the SOURCE round, landing after this round had + // already raced, silently rewrite who this round's field "was" (raced results vanished + // from its ranking). Before the first fill there is no draw and resolution stays live — + // a build-ahead round keeps tracking its source until it actually fills. + if seeding_freezes(&round.seeding) { + if let Some(field) = recorded_field(events, &round.id) { + return Ok(field); + } + } resolve_seeding(meta, &round.classes, &round.seeding, events, depth) } +/// Whether a [`SeedingRule`]'s resolution is **frozen at first fill** (issue #334). +/// +/// The carry seedings — those derived from another round's outcome — freeze: their meaning is +/// "the standings *as advanced*", a draw the RD saw and raced. Roster-derived seedings stay +/// live so a late entrant added to the class mid-round still joins the field/ranking. +fn seeding_freezes(seeding: &SeedingRule) -> bool { + !matches!( + seeding, + SeedingRule::FromRoster | SeedingRule::AllChannels { .. } + ) +} + +/// The round's **recorded field draw**, if one was frozen at fill ([`Event::RoundFieldDrawn`]). +/// Last one wins (a round refilled after a `Discard`-style reset re-records). +fn recorded_field(events: &[Event], round_id: &RoundId) -> Option> { + events.iter().rev().find_map(|event| match event { + Event::RoundFieldDrawn { round, field } if round == round_id => Some(field.clone()), + _ => None, + }) +} + /// Resolve a [`SeedingRule`] to a round's **field** as engine [`CompetitorRef`]s (race redesign /// Slice 3a; multi-main `FromRankingRange` / `Combine`). /// @@ -1268,6 +1305,12 @@ fn fill_round_per_heat( if field.is_empty() { return Err(FillError::EmptyField(round_id.0.clone())); } + // FREEZE-AT-FILL (#334): a carry seeding records its resolved draw alongside the round's + // FIRST scheduled heat (the handler appends `RoundFieldDrawn` before the `HeatScheduled`). + // From then on `round_field` replays the recorded draw — see `round_field_at`. + let field_draw = (seeding_freezes(&round.seeding) + && recorded_field(events, round_id).is_none()) + .then(|| field.clone()); let registry = FormatRegistry::standard(); let mut generator = registry @@ -1330,6 +1373,7 @@ fn fill_round_per_heat( // Per-heat: the handler assigns channels from the timer pool (first-fit), except // for open practice which carries empty frequencies (the lineup is channels). frequencies: open_practice_frequencies, + field_draw, }), // Every plan the generator wants this step is already scheduled (the RD // re-issued FillRound before scoring the outstanding heat): nothing new to @@ -1406,6 +1450,9 @@ fn fill_round_static( heat, lineup, frequencies: Some(assignment), + // Static rounds are validated FromRoster-only, and roster seedings never + // freeze (late entrants stay welcome) — no draw to record. + field_draw: None, }) } // Fixed-count: every planned channel-balanced heat is already scheduled → the round is @@ -2702,6 +2749,126 @@ mod tests { } } + #[test] + fn carry_seeding_freezes_at_first_fill_and_survives_source_adjudication() { + // FREEZE-AT-FILL (#334): once a FromRanking round fills, its field is a RECORDED draw — + // adjudicating the SOURCE round afterwards must not rewrite who the dependent round's + // field was (raced results would vanish from its ranking). A sibling round that has NOT + // yet filled keeps resolving live (build-ahead tracks its source until it draws). + let qual = qual_round("q1", "open"); + let mut fill_now = h2h_round("dep", "open", WinCondition::FirstToLaps { n: 1 }); + fill_now.seeding = SeedingRule::FromRanking { + source_rounds: vec![RoundId("q1".into())], + top_n: 2, + }; + fill_now.params.insert("group_size".into(), "2".into()); + let mut fill_later = h2h_round("dep2", "open", WinCondition::FirstToLaps { n: 1 }); + fill_later.seeding = SeedingRule::FromRanking { + source_rounds: vec![RoundId("q1".into())], + top_n: 2, + }; + fill_later.params.insert("group_size".into(), "2".into()); + let meta = meta_with( + vec![qual.clone(), fill_now.clone(), fill_later.clone()], + vec![member("open", &["A", "B", "C"])], + ); + + // Qualifier: A (1.0s) beats B (2.0s) beats C (3.0s) → top-2 carry = [A, B]. + let mut log = scored_heat( + "q-1", + "q1", + "open", + &[ + ("A", &[0, 1_000_000]), + ("B", &[0, 2_000_000]), + ("C", &[0, 3_000_000]), + ], + ); + + // Fill `dep` the way the real handler does: record the draw, then the heat. + let FillOutcome::Scheduled { + heat, + lineup, + field_draw, + .. + } = fill_round(&meta, &no_timers(), &fill_now.id, &log).unwrap() + else { + panic!("expected a scheduled heat"); + }; + let drawn = field_draw.expect("a carry seeding's first fill records its draw"); + assert_eq!(names_of(&drawn), vec!["A", "B"]); + log.push(Event::RoundFieldDrawn { + round: fill_now.id.clone(), + field: drawn, + }); + let names: Vec<&str> = lineup.iter().map(|c| c.0.as_str()).collect(); + log.push(scheduled(&heat.0, "dep", "open", &names)); + + // NOW the source adjudication lands: DQ A in the qualifier — its live ranking becomes + // [B, C] and a live top-2 carry would be [B, C]. + log.push(penalty_applied( + "q-1", + "A", + Penalty::Disqualify { reason: None }, + )); + + // The FILLED round's field is frozen at its recorded draw… + assert_eq!( + names_of(&round_field(&meta, &fill_now, &log).unwrap()), + vec!["A", "B"], + "the filled round keeps the field it actually raced" + ); + // …its next fill draws from the SAME frozen field (no second RoundFieldDrawn either)… + match fill_round(&meta, &no_timers(), &fill_now.id, &log).unwrap() { + FillOutcome::Scheduled { field_draw, .. } => { + assert!( + field_draw.is_none(), + "the draw is recorded once, not per heat" + ); + } + FillOutcome::Complete | FillOutcome::AlreadyScheduled => {} + } + // …while the NOT-yet-filled sibling resolves live and sees the adjudicated carry. + assert_eq!( + names_of(&round_field(&meta, &fill_later, &log).unwrap()), + vec!["B", "C"], + "an unfilled round keeps tracking its source until it draws" + ); + } + + #[test] + fn roster_seeding_stays_live_after_fill() { + // FromRoster never freezes (#334): a late entrant added to the class after the round + // filled still joins the round's field (and with it the ranking's no-value tail). + let round = h2h_round("r1", "open", WinCondition::FirstToLaps { n: 1 }); + let meta_before = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log: Vec = Vec::new(); + match fill_round(&meta_before, &no_timers(), &round.id, &log).unwrap() { + FillOutcome::Scheduled { + heat, + lineup, + field_draw, + .. + } => { + assert!(field_draw.is_none(), "a roster seeding records no draw"); + let names: Vec<&str> = lineup.iter().map(|c| c.0.as_str()).collect(); + log.push(scheduled(&heat.0, "r1", "open", &names)); + } + other => panic!("unexpected fill outcome {other:?}"), + } + // The late entrant lands in the membership; the round's field follows live. + let meta_after = meta_with(vec![round.clone()], vec![member("open", &["A", "B", "C"])]); + assert_eq!( + names_of(&round_field(&meta_after, &round, &log).unwrap()), + vec!["A", "B", "C"] + ); + } + + /// The bare names of a resolved field (freeze-at-fill test helper). + fn names_of(field: &[CompetitorRef]) -> Vec<&str> { + field.iter().map(|c| c.0.as_str()).collect() + } + /// A `RankEntry` for a competitor at a 1-based position (aggregation test helper). fn rank(competitor: &str, position: u32) -> RankEntry { RankEntry { @@ -2788,6 +2955,7 @@ mod tests { heat, lineup, frequencies, + .. } => { // Issue #54: the heat id is **round-scoped** (`-heat`), not the generator's // fixed `"open-practice"`, so two open-practice rounds get distinct heats. @@ -2969,6 +3137,7 @@ mod tests { heat, lineup, frequencies, + .. } => { let freqs = frequencies.expect("static round assigns channels itself"); // Each heat is ≤ node cap and channel-distinct. diff --git a/docs/decisions.html b/docs/decisions.html index cac6c81..2533834 100644 --- a/docs/decisions.html +++ b/docs/decisions.html @@ -219,6 +219,16 @@

D8 · The qualifying → bracket carry seeds from a ranking's top-N

The FromRanking UI cut was renamed "Top N advance" → "Take top" (PR #332), and the value is now bounded by the source rounds' real field — you cannot take more pilots than the source ranking actually contains.
+
Update (2026-07-03) — the carry freezes at first fill (#334)
+
A carry seeding (FromRanking / FromRankingRange / + FromHeatWinners / Combine) is resolved once, at the round's + first fill, recorded in the log (Event::RoundFieldDrawn), and every later + read — fills, ranking, standings, dependent seeding — replays the recorded draw. Live + re-resolution let an adjudication on the source round, landing after the dependent + round had raced, silently rewrite who its field "was" (raced results vanished from its + ranking). Before the first fill, resolution stays live (a build-ahead round tracks its source + until it draws), and roster seedings (FromRoster / AllChannels) never + freeze — a late entrant still joins mid-round. The user's call: freeze, not warn.

D9 · The native desktop app embeds the Director, ships portable-only, stores data next to the exe — implemented