From ab8b46e1c6218958fd171885671f118adc48c52b Mon Sep 17 00:00:00 2001 From: Sergey Perfilev <18160720+perfilev-dev@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:26:29 +0500 Subject: [PATCH 1/5] Name the day at the desk, so a meeting is one rulebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A season skeleton is a rulebook a club keeps: format, seeding, ladder, field sizes, the fouls an official may call. The one thing in it that is not a rule is the particular Saturday in `[event]` — and `beam402 sheet` copied that through verbatim, so a meeting running practice on Friday, qualifying on Saturday and eliminations on Sunday needed three files. The skeleton's own comment said as much: change the id and the date for each event. Three copies of a rulebook drift. Somebody fixes a field size in one of them and the class runs two ways in one weekend, which is the failure this exists to stop rather than a tidiness argument. So the day is an argument: `--id`, `--name`, `--date`, `--ref`. Absent fields keep whatever the skeleton carries, so a club running one day at a time sees nothing new, and the override goes through the sheet's own checks rather than around them — a `--id` that cannot be a URL is refused at the desk, where **D34** says a thing that is wrong should be found. Rewritten line by line rather than through a TOML round trip, for the same reason the entries are appended instead of re-emitted: the skeleton is a file the club wrote and reads, and a parse-and-print hands it back with its comments gone. A key the skeleton never had — an `id` on a season that was never uploaded — is inserted rather than dropped. Co-Authored-By: Claude Opus 5 (1M context) --- events/season.toml | 10 +- software/crates/cli/src/main.rs | 32 +++++- software/crates/event/src/desk.rs | 173 ++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 4 deletions(-) diff --git a/events/season.toml b/events/season.toml index 4e36dbe..1b56c97 100644 --- a/events/season.toml +++ b/events/season.toml @@ -6,8 +6,14 @@ # # beam402 sheet entries.csv --event events/season.toml -o today.toml # -# Change the id and the date for each event, or generate them from whatever the -# club already uses to schedule its calendar. +# The [event] block below is the one thing here that is not a rule, so a meeting +# running over several days names each day on the command line rather than in a +# copy of this file — copies of a rulebook drift: +# +# beam402 sheet entries.csv --event events/season.toml \ +# --id club-2026-08-07 --name Practice --date 2026-08-07 -o friday.toml +# +# What is left here is the fallback for a club running one day at a time. [event] id = "club-day" diff --git a/software/crates/cli/src/main.rs b/software/crates/cli/src/main.rs index f3cfb1d..8336ac8 100644 --- a/software/crates/cli/src/main.rs +++ b/software/crates/cli/src/main.rs @@ -44,7 +44,7 @@ USAGE: beam402 ladder --format pro|sportsman beam402 serve [OPTIONS] [-o 0.0.0.0:8402] beam402 event [--log ] [--draw ] - beam402 sheet --event [-o sheet.toml] + beam402 sheet --event [--date ] [-o sheet.toml] beam402 push --log --to [--token ] beam402 host [-o 0.0.0.0:8403] @@ -53,6 +53,10 @@ OPTIONS: --event run a meeting: classes, ladders and pairs off an entry sheet --log the meeting's result log; required by --event --draw close qualifying and draw that class's ladder + --id this day's id, name, date and league key, overriding what + --name the season skeleton carries — a meeting that runs over + --date three days is three days off one rulebook rather than + --ref three hand-edited copies of it --to where to push a day (D33); also serve's live push target --token write authority for that event; BEAM402_TOKEN is preferred --format heads-up | bracket | index (default: heads-up) @@ -126,11 +130,22 @@ fn main() -> ExitCode { } } +/// Which day of a meeting the desk is importing, where the season skeleton says +/// something else. Empty is the common case: one rulebook, one Saturday. +#[derive(Default)] +struct DayArgs { + id: Option, + name: Option, + date: Option, + external: Option, +} + struct Args { command: Command, path: String, mapping: Option, event: Option, + day: DayArgs, log: Option, draw: Option, to: Option, @@ -195,7 +210,14 @@ fn desk(args: &Args) -> Result { "--event is required: the classes are a rulebook the club keeps, \ not a column of a spreadsheet somebody retypes every Saturday", )?; - let out = desk::import(&read(skeleton)?, &text).map_err(|e| format!("{}: {e}", args.path))?; + let day = desk::Day { + id: args.day.id.as_deref(), + name: args.day.name.as_deref(), + date: args.day.date.as_deref(), + external: args.day.external.as_deref(), + }; + let out = desk::import_as(&read(skeleton)?, &text, &day) + .map_err(|e| format!("{}: {e}", args.path))?; let sheet = Sheet::parse(&out).expect("import validates before it returns"); match &args.out { @@ -1043,6 +1065,7 @@ fn pairing_from(text: &str) -> Result { path: String::new(), mapping: None, event: None, + day: DayArgs::default(), log: None, draw: None, to: None, @@ -1118,6 +1141,7 @@ fn parse(argv: Vec) -> Result { path, mapping: None, event: None, + day: DayArgs::default(), log: None, draw: None, to: None, @@ -1138,6 +1162,10 @@ fn parse(argv: Vec) -> Result { match flag.as_str() { "--mapping" => args.mapping = Some(value()?), "--event" => args.event = Some(value()?), + "--id" => args.day.id = Some(value()?), + "--name" => args.day.name = Some(value()?), + "--date" => args.day.date = Some(value()?), + "--ref" => args.day.external = Some(value()?), "--log" => args.log = Some(value()?), "--draw" => args.draw = Some(value()?), "--to" => args.to = Some(value()?), diff --git a/software/crates/event/src/desk.rs b/software/crates/event/src/desk.rs index 983e85f..6880c93 100644 --- a/software/crates/event/src/desk.rs +++ b/software/crates/event/src/desk.rs @@ -141,12 +141,47 @@ fn rows(text: &str) -> Vec { out } +/// Which day this is, where the skeleton does not already say. +/// +/// A skeleton is a **rulebook kept across a season**, so the one thing in it that +/// is not a rule is the particular Saturday in `[event]`. A meeting that runs +/// over three days — practice, qualifying, eliminations — would otherwise need +/// three hand-edited copies of the rulebook, and copies of a rulebook drift: +/// somebody fixes a field size in one of them and the class runs two ways in one +/// weekend. +/// +/// Absent fields keep whatever the skeleton carries, so a club running one day at +/// a time never sees this. +#[derive(Clone, Copy, Default, Debug)] +pub struct Day<'a> { + pub id: Option<&'a str>, + pub name: Option<&'a str>, + pub date: Option<&'a str>, + /// The league's own key for this day, carried and never interpreted (**D35**). + pub external: Option<&'a str>, +} + +impl Day<'_> { + fn is_empty(&self) -> bool { + self.id.is_none() + && self.name.is_none() + && self.date.is_none() + && self.external.is_none() + } +} + /// Entries from a CSV, checked against the classes a skeleton declares. /// /// `skeleton` is a sheet with `[event]` and `[[class]]` and no entries — what a /// club maintains for a season. The output is that skeleton with the day's /// entries written into it. pub fn import(skeleton: &str, csv: &str) -> Result { + import_as(skeleton, csv, &Day::default()) +} + +/// The same, for one day of a meeting that runs over several. +pub fn import_as(skeleton: &str, csv: &str, day: &Day) -> Result { + let skeleton = &retitle(skeleton, day); let rows = rows(csv); let (header, body) = rows.split_first().ok_or(DeskError::Empty)?; if body.is_empty() { @@ -232,6 +267,83 @@ pub fn import(skeleton: &str, csv: &str) -> Result { Ok(text) } +/// Rewrite the `[event]` keys this day overrides, and nothing else. +/// +/// Line by line rather than through a TOML round trip, for the same reason +/// [`write`] appends instead of re-emitting: the skeleton is a file the club wrote +/// and reads, and a parse-and-print would hand it back with its comments gone. +/// +/// A key the skeleton does not have is **inserted** — a season that never carried +/// an `id` because it was never uploaded still gets one the day it is. +fn retitle(skeleton: &str, day: &Day) -> String { + if day.is_empty() { + return skeleton.to_string(); + } + let fields = [ + ("id", day.id), + ("name", day.name), + ("date", day.date), + ("ref", day.external), + ]; + + let mut out = String::with_capacity(skeleton.len() + 128); + let mut in_event = false; + let mut done = false; + let mut written: Vec<&str> = Vec::new(); + for line in skeleton.lines() { + let t = line.trim(); + // A table header ends the one before it, so `[event]` runs until the next + // `[` in column one — which is how every skeleton here is laid out. + if t.starts_with('[') { + if in_event { + // Anything overridden that the skeleton never had goes in before + // the section closes, while `[event]` is still the open table. + for (key, value) in fields { + if let Some(v) = value.filter(|_| !written.contains(&key)) { + let _ = writeln!(out, "{key} = {}", quote(v)); + } + } + done = true; + } + in_event = !done && t == "[event]"; + out.push_str(line); + out.push('\n'); + continue; + } + // A commented-out key is a note to a human, not a value to rewrite. + if in_event && !t.starts_with('#') { + if let Some((key, _)) = t.split_once('=') { + let key = key.trim(); + if let Some((_, value)) = fields.iter().find(|(k, _)| *k == key) { + written.push(key); + match value { + Some(v) => { + let _ = writeln!(out, "{key} = {}", quote(v)); + } + // Not overridden: keep the club's line verbatim. + None => { + out.push_str(line); + out.push('\n'); + } + } + continue; + } + } + } + out.push_str(line); + out.push('\n'); + } + // A skeleton whose `[event]` is its last table closes here instead. + if in_event { + for (key, value) in fields { + if let Some(v) = value.filter(|_| !written.contains(&key)) { + let _ = writeln!(out, "{key} = {}", quote(v)); + } + } + } + out +} + /// Render a skeleton plus entries as an entry sheet. /// /// The skeleton's own text is kept verbatim — comments and all — because it is a @@ -481,6 +593,67 @@ ladder = "pro" assert_eq!(sheet.entries[0].driver, "D. Kuznetsov"); } + /// A meeting that runs over three days is three days off **one** rulebook. + /// The alternative is three hand-edited copies of it, and copies of a rulebook + /// drift — somebody fixes a field size in one and the class runs two ways in + /// one weekend. + #[test] + fn one_rulebook_produces_the_days_of_a_meeting() { + let csv = "number,driver,class\n9,A,Super Gas\n"; + let day = Day { + id: Some("kubok-2026-08-07"), + name: Some("Кубок РК — тренировка"), + date: Some("2026-08-07"), + external: Some("RK-2026-E03-P"), + }; + let text = import_as(SKELETON, csv, &day).unwrap(); + let sheet = Sheet::parse(&text).unwrap(); + assert_eq!(sheet.event.id.as_deref(), Some("kubok-2026-08-07")); + assert_eq!(sheet.event.date, "2026-08-07"); + assert_eq!(sheet.event.name, "Кубок РК — тренировка"); + // The skeleton carries no `ref`, so an overridden one is inserted rather + // than dropped on the floor. + assert_eq!(sheet.event.external.as_deref(), Some("RK-2026-E03-P")); + + // And the club's file survives it: comments, classes, everything unnamed. + assert!(text.contains("# Kept across a season"), "{text}"); + assert!(text.contains("index_s = 9.90"), "{text}"); + assert_eq!(text.matches("[event]").count(), 1, "{text}"); + + // Overriding one thing leaves the others as the skeleton had them. + let only_date = Day { + date: Some("2026-08-09"), + ..Day::default() + }; + let sheet = Sheet::parse(&import_as(SKELETON, csv, &only_date).unwrap()).unwrap(); + assert_eq!(sheet.event.date, "2026-08-09"); + assert_eq!(sheet.event.name, "Club day"); + assert_eq!(sheet.event.id.as_deref(), Some("club-day")); + + // And overriding nothing is the same file as before this existed. + assert_eq!( + import(SKELETON, csv).unwrap(), + import_as(SKELETON, csv, &Day::default()).unwrap() + ); + } + + #[test] + fn a_days_id_is_checked_like_any_other() { + // The override goes through the sheet's own checks rather than around + // them: wrong at the desk is an inconvenience, wrong at upload time is a + // day nobody can publish. + let csv = "number,driver,class\n9,A,Super Gas\n"; + let day = Day { + id: Some("Тренировка 7 августа"), + ..Day::default() + }; + let err = import_as(SKELETON, csv, &day).unwrap_err(); + assert!( + matches!(err, DeskError::Rejected(ref w) if w.contains("cannot go in a URL")), + "{err}" + ); + } + #[test] fn an_event_id_that_cannot_be_a_url_is_caught_at_the_desk() { // The alternative is finding out at the end of the day, when the day is From eb08b3171f3a537c2dcadb41a2b618cd01c212d1 Mon Sep 17 00:00:00 2001 From: Sergey Perfilev <18160720+perfilev-dev@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:26:45 +0500 Subject: [PATCH 2/5] Wait for the numbers, not for the car to cross the line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running a practice day over the bus: the log took `Q Unlimited 17 - - -` for a pass that had just run 11.850. The staging machine calls a round complete when every car has been **seen** to cross the finish beam. That is the right statement about the strip and not yet a statement about the numbers — the ET is latched in the node and comes back on a later poll (**D25**), 1.3 s later on the reference venue. `record` gated on the phase alone, and the panel enabled the button for that whole beat. It is the worst shape a defect can have here. A pass with no time is a **legitimate** record — it is what a car that stops on the track gets, and it seeds that driver at the back — so nothing refused, nothing warned, and a driver leading their class was quietly put last. So the gate is the round being **whole**: every lane that raced has its ET, or there is no longer a reason to expect one. Two ways of the latter, and they matter. A car that never reached the finish beam has nothing coming, which the `Abandon` action already knows. And a node that stopped answering has nothing coming either — so past a three-second grace, `-` is the honest record and gets written deliberately rather than by beating the poll loop to a button. Refusing forever would strand the day. `next` takes the same gate, which is the other half of the same bug: clearing the round does not stop the records arriving, so the previous car's ET landed in the next pair's round and showed on the panel before that pair had staged. That was visible as a 11.85 sitting under a `ready` phase with no reaction time beside it. The state carries `settled` so the operator page stops offering a button that would do this, instead of relying on the person not being quick. Co-Authored-By: Claude Opus 5 (1M context) --- software/crates/cli/src/live.rs | 160 ++++++++++++++++++++++++++++++- software/crates/cli/src/pages.rs | 7 +- 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/software/crates/cli/src/live.rs b/software/crates/cli/src/live.rs index 477c047..f2ba3f6 100644 --- a/software/crates/cli/src/live.rs +++ b/software/crates/cli/src/live.rs @@ -35,7 +35,7 @@ use beam402_mapping::Mapping; use beam402_poller::{Phase as BusPhase, Poller}; use beam402_protocol::Lane; use beam402_race::staging::{Action, Config, Phase, Staging}; -use beam402_race::{decide, Outcome, Pairing, RunBuilder}; +use beam402_race::{decide, Outcome, Pairing, Round, RunBuilder}; use crate::meeting::Meeting; use crate::round::{self, STEP_MS}; @@ -210,9 +210,52 @@ pub struct Runtime<'m, B> { meeting: Option, armed: bool, cycles: u64, + /// How long the round has been [`Phase::Complete`], in loop time. + /// + /// The staging machine calls a round complete when every car has been *seen* + /// to cross the finish beam, which is the right statement about the strip and + /// not yet a statement about the numbers: the ETs are latched in the nodes and + /// arrive on a later poll (**D25**). This is how long they have had to. + complete_ms: u64, + /// A car never reached the finish beam, so this round's numbers are not late — + /// they do not exist. + abandoned: bool, note: String, } +/// How long a finished round is given to produce its numbers before "not yet" +/// becomes "never". +/// +/// Recording inside that window wrote a result with no time in it — a driver's +/// real pass logged as `-`, which seeds them at the back of a class they were +/// leading. Past it, a lane that was seen at the finish and still has nothing is a +/// record that is not coming, and refusing forever would strand the day. +/// +/// Wide enough for several poll cycles at 19,200 bps with a node down, short +/// enough that nobody standing at the panel thinks the button is broken. +const SETTLE_MS: u64 = 3_000; + +/// Whether a finished round is **whole**. +/// +/// Whole means every lane that raced has its ET, or there is no longer any reason +/// to expect one: the round was abandoned, or the records have had [`SETTLE_MS`] +/// to arrive and did not. That last case is a node that stopped answering, and the +/// honest record for it is the one with `-` in it — written deliberately, rather +/// than by beating the poll loop to a button. +/// +/// Only ever asked about a round the staging machine already calls complete. A +/// pairing sitting on the line has no times either, and that is not the same +/// question. +fn settled(round: &Round, pairing: &Pairing, abandoned: bool, complete_ms: u64) -> bool { + if abandoned || complete_ms >= SETTLE_MS { + return true; + } + pairing + .entries() + .iter() + .all(|e| round.lane(e.lane).is_some_and(|r| r.et_s.is_some())) +} + impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { pub fn new( bus: B, @@ -236,6 +279,8 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { meeting: None, armed: false, cycles: 0, + complete_ms: 0, + abandoned: false, note: String::new(), } } @@ -305,11 +350,22 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { } } Action::Abandon => { + // Nothing more is coming for a car that never reached the + // finish beam, so the round is as whole as it will get. + self.abandoned = true; self.note = "abandoned: a car never reached the finish beam".into() } } } + // How long the numbers have had to come back. Measured about this round + // rather than the day, so anything that is not a finished round clears it. + self.complete_ms = if self.staging.phase() == Phase::Complete { + self.complete_ms + STEP_MS + } else { + 0 + }; + for intent in intents { match intent { Intent::Arm => self.do_arm(), @@ -363,6 +419,7 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { self.staging.armed(handicap); self.poller.set_phase(BusPhase::Quiet); self.armed = true; + self.abandoned = false; self.note.clear(); } Err(e) => self.note = e, @@ -391,6 +448,16 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { self.note = "the round is not over".into(); return; } + // **Seen at the finish beam is not the same as measured.** The staging + // machine goes `Complete` on the beam edge; the ET is latched in the node + // and arrives on a later poll (**D25**). Recording in between wrote the + // pass down with no time in it — a real 11.85 logged as `-`, which seeds + // that driver at the back of a class they were leading, silently. So the + // gate is the round being whole rather than the car being over the line. + if !settled(&round, &pairing, self.abandoned, self.complete_ms) { + self.note = "the finish records have not come back yet".into(); + return; + } self.note = match meeting.record(&round, &pairing) { Ok(line) => format!("recorded: {line}"), Err(why) => why, @@ -553,9 +620,21 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { self.note = "this round has a result that has not been recorded".into(); return; } + // Nor by the button that brings up the next pair while the numbers are + // still on their way. Clearing the round here does not stop the records + // arriving — they are latched in the nodes and a poll is already asking + // (**D25**) — so what used to happen is that the previous car's ET landed + // in the next pair's round and showed on the panel before it had staged. + if self.staging.phase() == Phase::Complete + && !settled(&round, &self.pairing, self.abandoned, self.complete_ms) + { + self.note = "the finish records have not come back yet".into(); + return; + } self.builder.clear_round(); self.staging.reset(); + self.abandoned = false; self.poller.set_phase(BusPhase::Live); self.poller.release_tree(self.tree); // Deliberately **no** refetch. The nodes still hold the last round's @@ -644,12 +723,16 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> { }; format!( - "{{\"phase\":\"{phase}\",\"ready\":{},\"armed\":{},\"held\":{},\"holder\":{},\ + "{{\"phase\":\"{phase}\",\"ready\":{},\"armed\":{},\"settled\":{},\ +\"held\":{},\"holder\":{},\ \"cycles\":{},\"bus_ms\":{:.0},\"note\":\"{}\",\"winner\":\"{verdict}\",\ \"board\":{{\"w\":{},\"h\":{},\"bits\":\"{}\"}},\"event\":{event},\ \"lanes\":[{lanes}],\"nodes\":[{nodes}],\"slip\":\"{}\"}}", self.staging.is_ready(), self.armed, + // Whether the round is whole, so the panel can stop offering `record` + // in the window where it would write a pass down with no time in it. + settled(&round, &self.pairing, self.abandoned, self.complete_ms), holder.is_some(), match holder { Some(t) => t.to_string(), @@ -696,6 +779,79 @@ pub fn pace() { mod tests { use super::*; + /// The defect a practice day found. The staging machine calls a round complete + /// on the **finish beam**; the ET is latched in the node and comes back on a + /// later poll (**D25**) — 1.3 s later on the reference venue. The panel offered + /// `record` for that whole beat, and a press inside it wrote + /// `Q Unlimited 17 - - -`: a real 11.85 logged as no time at all, which seeds + /// that driver at the back of a class they were leading. Silently, because a + /// pass with no time is a legitimate record — it is what a car that stops on + /// the track gets. + #[test] + fn a_finished_round_is_not_whole_until_its_numbers_arrive() { + use beam402_race::{Entry, Format, LaneRun}; + + let one_lane = Pairing::new( + Format::HeadsUp, + vec![Entry { + lane: Lane::L1, + dial_s: None, + }], + ) + .unwrap(); + let timed = |et: f64| { + let mut r = Round::default(); + r.set_lane( + Lane::L1, + LaneRun { + reaction_s: Some(0.412), + et_s: Some(et), + ..LaneRun::default() + }, + ); + r + }; + // The window: over the line, nothing off the node yet. + let mut crossed = Round::default(); + crossed.set_lane( + Lane::L1, + LaneRun { + reaction_s: Some(0.412), + ..LaneRun::default() + }, + ); + assert!(!settled(&crossed, &one_lane, false, 0)); + assert!(!settled(&crossed, &one_lane, false, SETTLE_MS - STEP_MS)); + + // The number arrives and the round is whole. + assert!(settled(&timed(11.85), &one_lane, false, 0)); + + // Two ways it is whole without one. A car that never reached the finish + // beam has no number coming... + assert!(settled(&crossed, &one_lane, true, 0)); + // ...and neither has a node that stopped answering, which is the case that + // must not strand the day: past the grace, `-` is the honest record and it + // is written deliberately. + assert!(settled(&crossed, &one_lane, false, SETTLE_MS)); + + // Both lanes, because a pair is only as recordable as its slower node. + let two = Pairing::new( + Format::HeadsUp, + vec![ + Entry { + lane: Lane::L1, + dial_s: None, + }, + Entry { + lane: Lane::L2, + dial_s: None, + }, + ], + ) + .unwrap(); + assert!(!settled(&timed(11.85), &two, false, 0), "lane 2 has nothing"); + } + #[test] fn one_client_holds_control_and_the_others_are_told_so() { // D30's rule, and the failure it prevents: two people arming is worse diff --git a/software/crates/cli/src/pages.rs b/software/crates/cli/src/pages.rs index e5139ef..266b6d1 100644 --- a/software/crates/cli/src/pages.rs +++ b/software/crates/cli/src/pages.rs @@ -297,8 +297,13 @@ a{{color:var(--accent)}} $("next").disabled = !mine; // Recording is offered only when there is a round to record and an event to // record it into. Swapping stops being offered once the pair has been raced. + // + // `settled` and not just `complete`: the car is over the line a beat before its + // ET is off the node, and a press inside that beat wrote the pass down with no + // time in it. var ev = s.event; - $("record").disabled = !(mine && ev && ev.on && s.phase === "complete" && !ev.recorded); + $("record").disabled = !(mine && ev && ev.on && s.phase === "complete" + && s.settled && !ev.recorded); $("swap").disabled = !(mine && ev && ev.on && !s.armed && !ev.recorded); // Closing qualifying is offered only while a class is in it. How many passes // is a club's business, so nothing here decides the moment is right. From b42fe9450eb02b90753d6f3cb51141c38ef567a8 Mon Sep 17 00:00:00 2001 From: Sergey Perfilev <18160720+perfilev-dev@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:27:16 +0500 Subject: [PATCH 3/5] Give a day in qualifying a board, by the draw's own arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class that had not drawn its ladder printed one sentence — "qualifying, the ladder has not been drawn" — and the API said nothing at all about it. So a **practice day produced no output**, which is a strange thing for a feature the README describes as an entry sheet nobody ever draws: two passes recorded, and the only way to read them was the raw log. A qualifying session in progress was the same, all day, for everybody at the track. The board is derived and it is derived by [`Field::qualify`] — the function the draw itself calls. That is the whole design of it. A second implementation of "best pass" would eventually disagree with the ladder people are racing off, and it would disagree in the direction nobody checks: quietly, between passes, on the thing a driver is deciding their next run from. The cut is **shown, not applied**. Cars below the line are in the list with no seed, because they are still racing and a board that hid them would be hiding the only thing they are looking at. Where a class has a cut, its size comes from the real call rather than from a second copy of that one-line rule. A car with no time yet gets **no place**. The draw does place those — they qualify last, in entry order, because they entered and they paid — but entry order is not a qualifying position, and the first version of this told a driver who had not run that they were provisionally third. Likewise no cut line until somebody has a time, or the line is a statement about the alphabet. Scored by the class's own measure, so a bracket board shows how close to the dial and the ET both: on the clock alone the top qualifier looks like the slowest car there, which is exactly the sentence bracket racing exists to make possible. It stops answering once the ladder is drawn. From that moment the field is the answer and two orders for one class is one too many. Co-Authored-By: Claude Opus 5 (1M context) --- software/crates/cli/src/main.rs | 100 +++++++++++++- software/crates/event/src/lib.rs | 2 +- software/crates/event/src/progress.rs | 188 +++++++++++++++++++++++++- 3 files changed, 287 insertions(+), 3 deletions(-) diff --git a/software/crates/cli/src/main.rs b/software/crates/cli/src/main.rs index 8336ac8..4a3a0a8 100644 --- a/software/crates/cli/src/main.rs +++ b/software/crates/cli/src/main.rs @@ -275,6 +275,15 @@ fn push_day(args: &Args) -> Result { Ok(out) } +/// A number that may be absent, as JSON. Never a zero standing in for one nobody +/// measured — the same rule the time slip keeps. +fn num(v: Option) -> String { + match v { + Some(v) => format!("{v:.4}"), + None => "null".into(), + } +} + /// A string that may be absent, as JSON. fn opt_str(v: Option<&str>) -> String { match v { @@ -296,6 +305,40 @@ pub fn event_json(day: &beam402_event::Progress, skipped: usize) -> String { for name in day.class_names().map(str::to_string).collect::>() { let mut s = format!("{{\"name\":\"{}\"", esc(&name)); let _ = write!(s, ",\"entered\":{}", day.sheet().entries_in(&name).len()); + // Where a class stands while it is still qualifying — the same board the + // tower reads, so a league's front end can show a session in progress + // instead of an empty class until somebody draws. Absent once drawn: from + // that moment `field` is the answer and two orders would be one too many. + if day.field(&name).is_none() { + if let Some(n) = day.cut_at(&name) { + let _ = write!(s, ",\"cut\":{n}"); + } + let board: Vec = day + .standings(&name) + .into_iter() + .map(|st| { + let entry = day.sheet().entry(st.entry); + format!( + "{{\"seed\":{},\"number\":{},\"driver\":\"{}\",\"car\":\"{}\",\ +\"runs\":{},\"best\":{},\"off_dial\":{},\"ref\":{}}}", + st.seed.map_or("null".to_string(), |n| n.to_string()), + st.entry.0, + esc(entry.map(|e| e.driver.as_str()).unwrap_or_default()), + esc(entry.map(|e| e.car.as_str()).unwrap_or_default()), + st.runs, + num(st.best_et_s), + // Only where the class seeds by it, so a heads-up class + // does not carry a null nobody will ever read. + match day.sheet().class(&name).map(|c| c.seeding) { + Some(beam402_event::Seeding::ClosestToDial) => num(st.best), + _ => "null".into(), + }, + opt_str(entry.and_then(|e| e.external.as_deref())), + ) + }) + .collect(); + let _ = write!(s, ",\"qualifying\":[{}]", board.join(",")); + } if let Some(field) = day.field(&name) { let seeds: Vec = field .seeds() @@ -433,8 +476,63 @@ fn event(args: &Args) -> Result { } match day.field(&name) { + // Still qualifying, which used to print one sentence and nothing else + // — so a practice day had no output at all and a qualifying session + // could not be read off anything but the raw log. The board is where a + // class stands if it closed now, by the draw's own arithmetic. None => { - let _ = writeln!(out, " qualifying — the ladder has not been drawn\n"); + let cut = day.cut_at(&name); + let _ = writeln!( + out, + " qualifying — the ladder has not been drawn{}", + match cut { + Some(n) => format!(", top {n} would make the field"), + None => String::new(), + } + ); + let board = day.standings(&name); + if board.is_empty() { + let _ = writeln!(out); + continue; + } + let dialled = matches!( + day.sheet().class(&name).map(|c| c.seeding), + Some(beam402_event::Seeding::ClosestToDial) + ); + // No line until somebody has a time: with nothing run, the order + // above and below it is entry order and the cut would be a + // statement about the alphabet. + let cut = cut.filter(|_| board.iter().any(|s| s.best.is_some())); + for (i, s) in board.iter().enumerate() { + // Where the cut would fall, drawn once. A class everybody + // qualifies for has no line to draw. + if Some(i) == cut { + let _ = writeln!(out, " ---- cut ----"); + } + let _ = writeln!( + out, + " {:>3} {:<28}{:>9}{} {}", + s.seed.map_or("-".to_string(), |n| n.to_string()), + day.driver(s.entry), + match s.best_et_s { + Some(et) => format!("{et:.4}"), + // Never a zero and never a blank: this car has been + // down the track and has no time, or has not gone. + None => "—".into(), + }, + match (dialled, s.best) { + (true, Some(off)) => format!(" off dial {off:.4}"), + (true, None) => " ".into(), + _ => String::new(), + }, + match s.runs { + 0 => "no passes yet".into(), + 1 => "1 pass".to_string(), + n => format!("{n} passes"), + } + ); + } + let _ = writeln!(out); continue; } Some(field) => { diff --git a/software/crates/event/src/lib.rs b/software/crates/event/src/lib.rs index 5715668..1365cfc 100644 --- a/software/crates/event/src/lib.rs +++ b/software/crates/event/src/lib.rs @@ -27,7 +27,7 @@ pub mod sheet; pub mod sync; pub use ladder::{Pair, Style}; -pub use progress::{OnDeck, Progress, Refused}; +pub use progress::{OnDeck, Progress, Refused, Standing}; pub use sheet::{Record, Sheet}; pub use sync::{Appended, Cursor, Held, SyncError}; diff --git a/software/crates/event/src/progress.rs b/software/crates/event/src/progress.rs index 08e7013..cc5510e 100644 --- a/software/crates/event/src/progress.rs +++ b/software/crates/event/src/progress.rs @@ -16,7 +16,7 @@ use beam402_protocol::Lane; use beam402_race::{Entry as RaceEntry, Format, Pairing, PairingError}; use crate::sheet::{Record, Sheet}; -use crate::{Attempt, Class, Entry, EntryId, Field, Round, Seed}; +use crate::{Attempt, Class, Entry, EntryId, Field, Round, Seed, Seeding}; /// One pair, ready to be sent down the track. #[derive(Clone, Debug)] @@ -41,6 +41,33 @@ impl OnDeck { } } +/// One line of the qualifying board. +/// +/// A day in qualifying has a standing order all the same, and it is the thing +/// everybody at the track reads between passes: a driver deciding whether to take +/// another one, and an official deciding whether the class is ready to draw. +#[derive(Clone, PartialEq, Debug)] +pub struct Standing { + pub entry: EntryId, + /// The seed this car would be drawn at if qualifying closed now. + /// + /// `None` for a car the cut would leave out — which is the whole reason the + /// board exists while there are still passes left to take — and `None` for a + /// car with no time yet. The draw does place those: they qualify last, in entry + /// order, because they entered and they paid. But entry order is not a + /// qualifying position, and a board that printed one would be telling a driver + /// who has not run that they are provisionally third. + pub seed: Option, + /// Passes taken, scoring or not. A rulebook that gives three attempts does not + /// give a fourth to whoever broke on the first. + pub runs: usize, + /// The best pass by the class's **own** measure: seconds for `quickest-et`, + /// seconds off the dial for `closest-to-dial`. `None` until there is one. + pub best: Option, + /// The ET of that pass, which is the number a board shows either way. + pub best_et_s: Option, +} + /// Which end of a class's window a car fell out of. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum TooFast { @@ -374,6 +401,98 @@ impl Progress { Ok(r) } + /// The qualifying board: where a class stands if qualifying closed now. + /// + /// **Derived, and derived by the code the draw itself uses.** The order comes + /// from [`Field::qualify`] over the attempts so far, so the board a driver + /// reads between passes cannot disagree with the ladder they end up in — which + /// a second implementation of "best pass" would eventually manage to do. + /// + /// The cut is shown rather than applied: cars below the line are in the list, + /// with no seed. They are still racing, and a board that hid them would be + /// hiding the only thing they are looking at. + /// Empty once the ladder is drawn: from that moment [`Progress::field`] is the + /// answer, and two orders for one class is one too many. + pub fn standings(&self, class: &str) -> Vec { + let Ok(c) = self.class(class) else { + return Vec::new(); + }; + if self.is_drawn(class) { + return Vec::new(); + } + let attempts = self.attempts(class); + let out = self.scratched(class); + let entries: Vec = self + .sheet + .entries_in(class) + .into_iter() + .filter(|e| !out.contains(&e.id)) + .collect(); + + // Ordered with the cut off, then the cut taken from the real call. Two + // reads of one function rather than a second copy of the rulebook. + let uncut = Class { + field: Vec::new(), + ..c.clone() + }; + let order = Field::qualify(&uncut, &entries, attempts); + let cut = Field::qualify(&c, &entries, attempts).len(); + + order + .seeds() + .map(|(place, id)| { + let mut runs = 0; + let mut best: Option<(f64, Option)> = None; + for a in attempts.iter().filter(|a| a.entry == id) { + runs += 1; + // The same two rules the draw applies: a pass past the scoring + // limit and a voided one are taken and not counted. + if c.attempts.is_some_and(|max| runs > max) || a.void { + continue; + } + let score = match c.seeding { + Seeding::QuickestEt => a.et_s, + Seeding::ClosestToDial => match (a.et_s, a.dial_s) { + (Some(et), Some(dial)) => Some((et - dial).abs()), + _ => None, + }, + Seeding::EntryOrder | Seeding::Draw { .. } => None, + }; + if let Some(score) = score { + if best.is_none_or(|(b, _)| score < b) { + best = Some((score, a.et_s)); + } + } + } + Standing { + entry: id, + seed: (place <= cut && best.is_some()).then_some(place), + runs, + best: best.map(|(b, _)| b), + best_et_s: best.and_then(|(_, et)| et), + } + }) + .collect() + } + + /// How many the cut takes, where a class has one — the size a board draws its + /// line at. `None` is a class everybody qualifies for. + pub fn cut_at(&self, class: &str) -> Option { + let c = self.class(class).ok()?; + if c.field.is_empty() { + return None; + } + let out = self.scratched(class); + let entries: Vec = self + .sheet + .entries_in(class) + .into_iter() + .filter(|e| !out.contains(&e.id)) + .collect(); + let size = Field::qualify(&c, &entries, self.attempts(class)).len(); + (size < entries.len()).then_some(size) + } + /// Cars whose qualifying puts them outside their class's time window. /// /// **Reported, never acted on.** A class defined as `13.000–14.000` is a @@ -826,6 +945,73 @@ class = "Bracket" assert_eq!(p.did_not_qualify("Bracket"), vec![EntryId(1)]); } + /// A day in qualifying has a standing order all the same, and it used to be + /// invisible: the class printed one sentence and the API said nothing, so a + /// practice day produced no output at all and a session in progress could only + /// be read off the raw log. + #[test] + fn a_class_still_qualifying_has_a_board() { + let mut p = Progress::new(Sheet::parse(CUT).unwrap()); + + // Nobody has run. Three cars, no places: the draw would order them by + // entry number, and entry number is not a qualifying position. + let board = p.standings("Bracket"); + assert_eq!(board.len(), 3); + assert!(board + .iter() + .all(|s| s.seed.is_none() && s.runs == 0 && s.best_et_s.is_none())); + assert_eq!(p.cut_at("Bracket"), Some(2), "field = [2, 4] over three cars"); + + for (n, et) in [(1u32, 10.50), (2, 10.10)] { + p.qualified("Bracket", EntryId(n), Some(et), None, false) + .unwrap(); + } + let board = p.standings("Bracket"); + assert_eq!( + (board[0].entry, board[0].seed, board[0].best_et_s, board[0].runs), + (EntryId(2), Some(1), Some(10.10), 1) + ); + assert_eq!((board[1].entry, board[1].seed), (EntryId(1), Some(2))); + assert_eq!( + (board[2].entry, board[2].seed), + (EntryId(3), None), + "entered, has not run, and the cut takes two" + ); + + // The property the board exists for: it is the draw, asked early. A second + // implementation of "best pass" would eventually disagree with the ladder + // people are racing off. + p.draw("Bracket").unwrap(); + let drawn: Vec = p.field("Bracket").unwrap().seeds().map(|(_, e)| e).collect(); + assert_eq!(drawn, vec![EntryId(2), EntryId(1)]); + assert!( + p.standings("Bracket").is_empty(), + "and it stops answering once there is a field to read instead" + ); + } + + /// A bracket class is seeded on how close to the dial, so that is what its + /// board has to show — the ET alone would look like the wrong order. + #[test] + fn a_bracket_board_scores_the_dial_and_not_the_clock() { + let mut p = Progress::new(sheet()); + // #2 runs 7.60 on a 7.50 dial: quickest car on the property, 0.10 off. + // #1 runs 12.36 on 12.34: slowest car here, 0.02 off, and top qualifier. + for (n, et) in [(1u32, 12.36), (2, 7.60)] { + let dial = p.sheet().entry(EntryId(n)).unwrap().dial_s; + p.qualified("Bracket", EntryId(n), Some(et), dial, false) + .unwrap(); + } + let board = p.standings("Bracket"); + assert_eq!(board[0].entry, EntryId(1)); + assert_eq!(board[0].best.map(|b| (b * 1e4).round()), Some(200.0)); + assert_eq!(board[0].best_et_s, Some(12.36), "and the clock is still shown"); + assert_eq!(board[1].entry, EntryId(2)); + assert_eq!(board[1].best.map(|b| (b * 1e4).round()), Some(1000.0)); + // No cut in this class, so nothing is out. + assert_eq!(p.cut_at("Bracket"), None); + } + /// **D37.** A voided pass loses its time and not its attempt, and it does not /// touch the ladder — which is the property the whole format rule rests on. #[test] From 11a4e8f48158b087d1523607f75bdac69609a791 Mon Sep 17 00:00:00 2001 From: Sergey Perfilev <18160720+perfilev-dev@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:27:31 +0500 Subject: [PATCH 4/5] Publish every round a class ran, and everyone who is not in the field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a results page needs and the read contract could not carry. **Every round.** `Progress` replaced the current round with the next one as a class advanced, so a settled class was a final with nothing under it — no semi-final, no quarter-final, no way to see how a winner got there, which is most of what anybody opens a results page for. The rounds were never lost: they are `W` and `B` lines in the log, and discarding them here only meant every reader had to replay it again to get them back. So the state keeps them, `round` still points at the one a class is on, and the API gains `rounds` beside it — **D35** says a shape gains fields rather than changing what one means, and `round` is somebody's dependency. **Everyone else.** A facade holds a field and a count of entries; it can see that five entered and four qualified and it cannot name the fifth. That name is the difference between "seeded last" and "did not qualify", which the tower has printed since the cut existed, and withdrawn is a third sentence again — so `did_not_qualify` and `withdrawn` are separate keys rather than a flag on one. Checked by running a meeting end to end: four classes, sixteen pairs off the ladders, and the day read back with `quarter-final, semi-final, final` under the class that had byes in the first round. Co-Authored-By: Claude Opus 5 (1M context) --- software/crates/cli/src/main.rs | 50 ++++++++++++++++++++++++--- software/crates/event/src/progress.rs | 39 ++++++++++++++------- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/software/crates/cli/src/main.rs b/software/crates/cli/src/main.rs index 4a3a0a8..e36b473 100644 --- a/software/crates/cli/src/main.rs +++ b/software/crates/cli/src/main.rs @@ -362,7 +362,14 @@ pub fn event_json(day: &beam402_event::Progress, skipped: usize) -> String { } None => s.push_str(",\"champion\":null"), } - if let Some(round) = day.round(&name) { + // Every round, earliest first — and `round` kept beside them, pointing at + // the one the class is on. A day that published only the current round + // published a final with nothing under it: once a class was settled its + // semi-finals were gone, and how somebody reached that final is the thing a + // league's page exists to show. `round` stays because it is somebody's + // dependency and **D35** says a shape gains fields rather than changing what + // one means. + let one = |round: &beam402_event::Round| { let pairs: Vec = round .pairs .iter() @@ -378,14 +385,47 @@ pub fn event_json(day: &beam402_event::Progress, skipped: usize) -> String { ) }) .collect(); - let _ = write!( - s, - ",\"round\":{{\"number\":{},\"name\":\"{}\",\"pairs\":[{}]}}", + format!( + "{{\"number\":{},\"name\":\"{}\",\"pairs\":[{}]}}", round.number, esc(&beam402_event::round_name(round.pairs.len(), round.number)), pairs.join(",") - ); + ) + }; + let history: Vec = day.rounds(&name).iter().map(one).collect(); + if !history.is_empty() { + let _ = write!(s, ",\"rounds\":[{}]", history.join(",")); + } + if let Some(round) = day.round(&name) { + let _ = write!(s, ",\"round\":{}", one(round)); } + // Who entered and did not make it, which a facade cannot work out for + // itself: it has the field and the number entered, never the names the cut + // removed. Withdrawn is a different sentence from missing a cut, so it is a + // different key rather than a flag on the same one. + let withdrawn = day.scratched(&name); + let who = |ids: Vec| { + ids.into_iter() + .filter_map(|id| { + let e = day.sheet().entry(id)?; + Some(format!( + "{{\"number\":{},\"driver\":\"{}\",\"car\":\"{}\",\"ref\":{}}}", + e.number, + esc(&e.driver), + esc(&e.car), + opt_str(e.external.as_deref()), + )) + }) + .collect::>() + .join(",") + }; + let missed: Vec = day + .did_not_qualify(&name) + .into_iter() + .filter(|id| !withdrawn.contains(id)) + .collect(); + let _ = write!(s, ",\"did_not_qualify\":[{}]", who(missed)); + let _ = write!(s, ",\"withdrawn\":[{}]", who(withdrawn)); s.push('}'); classes.push(s); } diff --git a/software/crates/event/src/progress.rs b/software/crates/event/src/progress.rs index cc5510e..fb1e1aa 100644 --- a/software/crates/event/src/progress.rs +++ b/software/crates/event/src/progress.rs @@ -86,7 +86,14 @@ struct ClassState { /// ladder is fixed and a scratch is an annotation. scratched: std::collections::BTreeSet, field: Option, - round: Option, + /// Every round this class has run, in order — the one it is on is the last. + /// + /// Kept rather than replaced because a finished class **is** its rounds: a day + /// that published only the current one published a final with no semi-finals + /// under it, and how somebody got to that final is the thing a league's page + /// exists to show. They are in the log either way; discarding them here only + /// meant every reader had to replay it again to get them back. + rounds: Vec, /// The class is over and this seed won it. champion: Option, } @@ -243,11 +250,11 @@ impl Progress { }), Record::Drawn { order, .. } => { let field = Field::from_order(order.clone()); - state.round = Some(Round::open(&class, &field)); + state.rounds = vec![Round::open(&class, &field)]; state.field = Some(field); } Record::Won { position, seed, .. } => { - if let Some(round) = state.round.as_mut() { + if let Some(round) = state.rounds.last_mut() { let _ = round.win(*position, *seed); } Self::advance(state, &class); @@ -257,7 +264,7 @@ impl Progress { completed, .. } => { - if let Some(round) = state.round.as_mut() { + if let Some(round) = state.rounds.last_mut() { let _ = round.bye(*position, *completed); } Self::advance(state, &class); @@ -287,14 +294,14 @@ impl Progress { } fn advance(state: &mut ClassState, class: &Class) { - let Some(round) = state.round.as_ref() else { + let Some(round) = state.rounds.last() else { return; }; if !round.is_complete() { return; } match round.advance(class) { - Ok(Some(next)) => state.round = Some(next), + Ok(Some(next)) => state.rounds.push(next), // Nothing left to pair: whoever is standing has won the class. Ok(None) => { state.champion = round.survivors().first().copied(); @@ -618,8 +625,8 @@ impl Progress { .get(class) .ok_or_else(|| Refused::NoSuchClass(class.into()))?; let round = state - .round - .as_ref() + .rounds + .last() .ok_or_else(|| Refused::NotDrawnYet(class.into()))?; let pair = round .pairs @@ -655,8 +662,8 @@ impl Progress { .get(class) .ok_or_else(|| Refused::NoSuchClass(class.into()))?; let round = state - .round - .as_ref() + .rounds + .last() .ok_or_else(|| Refused::NotDrawnYet(class.into()))?; let r = Record::Bye { class: class.into(), @@ -681,7 +688,7 @@ impl Progress { pub fn round_number(&self, class: &str) -> Option { self.classes .get(class) - .and_then(|s| s.round.as_ref()) + .and_then(|s| s.rounds.last()) .map(|r| r.number) } @@ -689,8 +696,14 @@ impl Progress { self.classes.get(class).and_then(|s| s.field.as_ref()) } + /// The round a class is on, which is the last one it has opened. pub fn round(&self, class: &str) -> Option<&Round> { - self.classes.get(class).and_then(|s| s.round.as_ref()) + self.classes.get(class).and_then(|s| s.rounds.last()) + } + + /// Every round the class has run, earliest first. What a finished class *is*. + pub fn rounds(&self, class: &str) -> &[Round] { + self.classes.get(class).map_or(&[], |s| s.rounds.as_slice()) } /// The next pair that has not run, in any class — the operator's queue. @@ -706,7 +719,7 @@ impl Progress { if state.champion.is_some() { return None; } - let round = state.round.as_ref()?; + let round = state.rounds.last()?; let field = state.field.as_ref()?; let c = self.sheet.class(class)?; let pair = round.outstanding().first().copied().copied()?; From 3fa01da11a55523e56c9c4e46940cda45f8db9cb Mon Sep 17 00:00:00 2001 From: Sergey Perfilev <18160720+perfilev-dev@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:27:42 +0500 Subject: [PATCH 5/5] Say that race control is written, because it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CLAUDE.md` opened with "the repository currently contains no code" and "there is nothing to build, lint, or test — do not invent build commands", while `software/` holds a Rust workspace of thirteen crates and `cargo test` runs 325 of them. It is the first thing a contributor reads, and it was telling them not to look. The claim it was reaching for is still true and worth keeping, so it is kept and narrowed: **no hardware exists**, nothing has run against a beam, and no number any of this prints was measured. Tests passing is not a subsystem working. What changed is that the code is now findable, with the commands that exercise it, and `software/` is in the file table beside the documents. The ADR range was stale by one and the roadmap by five entries; both catch up. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 21 +++++++++++++-------- README.md | 14 ++++++++++++++ README.ru.md | 14 ++++++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e23980..1b0cc12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,12 +8,15 @@ Beam402 is an open source drag racing timing system: beam sensors, Christmas tree, ET / 60ft / trap speed measurement, and race control software, built from industrial off-the-shelf parts. -**The repository currently contains no code.** It is design documentation at -the pre-validation stage: architecture, a decision log, a software design, and a -prototype BOM. There is nothing to build, lint, or test — do not invent build -commands or claim that any subsystem works. +**No hardware exists.** Nothing here has run against a beam, a node or a tree, +and no number any of it prints was measured — every one came from a scenario +file that stated it. Do not claim a subsystem works because its tests pass. -The stack is decided but unwritten: **C on ESP-IDF** for node and tree firmware +Race control, however, **is written and does run**: `software/` is a Rust +workspace — `cargo test` in it, `cargo run -p beam402 -- ` for the +CLI, `cargo clippy --all-targets`. Firmware is still unwritten. + +The firmware stack is decided but unwritten: **C on ESP-IDF** for node and tree (`D22`, status *revisit* — chosen so the gating `T3` measurement carries one fewer unknown, **not** because Rust cannot do it: the `esp32s3` PAC exposes the capture and sync registers, and a Rust node becomes admissible the moment it @@ -21,17 +24,19 @@ reproduces the T3 number on the same rig), **Rust** for race control as a single binary that also serves the scoreboard (`D23`), **Python** for bench tooling only, and KiCad for hardware. `.gitignore` reserves space accordingly. -Until bench validation passes, **the design documents *are* the project** — so -edits to them are the substantive work, not paperwork around it. +Until bench validation passes, the design documents carry as much of the project +as the code does — so edits to them are substantive work, not paperwork around +it. | File | Role | |---|---| | `docs/architecture.md` | Full system design, §11 = ranked list of unverified assumptions, §12 = deployment stages | -| `docs/decisions.md` | ADRs `D01`–`D36`: context → decision → why → what would change it | +| `docs/decisions.md` | ADRs `D01`–`D37`: context → decision → why → what would change it | | `docs/bench-validation.md` | The current stage: rig construction, tests `T1`–`T5`, pass/fail criteria | | `docs/software.md` | Software architecture: program boundaries, poll strategy, build order, §8 = software-side open questions | | `docs/protocol.md` | Modbus register map and mapping file format — the contract between firmware and race control | | `hardware/BOM.md` | v0 prototype BOM (bench + parking-lot demo), organized by supplier basket | +| `software/` | The Rust workspace: protocol, mapping, simulator, poller, race logic, the event layer, the HTTP server and the `beam402` CLI | | `events/` | An entry sheet, a season skeleton and a registration CSV — the format a club fills in (**D34**) | | `deploy/` | Reference way to run a results receiver: reverse proxy for TLS, unit file, loopback binding (**D33**) | | `README.md` / `README.ru.md` | English canonical, Russian overview | diff --git a/README.md b/README.md index 2e757b1..1721c5a 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,20 @@ already a complete working system). competition has something to count and a mirror can say why somebody lost. An official calls the fouls the beams cannot see, voids a pass and takes a car out of a class from the panel, in their own rulebook's words +- [x] A day in qualifying has a **board**, derived by the draw's own arithmetic + so it cannot disagree with the ladder people end up in: where each car + stands, its passes, where the cut falls, and no provisional place for one + that has not run. A practice day used to produce no output at all +- [x] A finished class publishes **every round it ran**, not the last one, so a + results page can show how somebody reached a final — plus who missed the + cut and who withdrew, which a facade with a field and a count cannot work + out for itself +- [x] A meeting over several days off **one** rulebook — `beam402 sheet … --date + 2026-08-07 --id practice-day`, because hand-edited copies of a class list + drift and then the class runs two ways in one weekend +- [x] The record button waits for the **numbers**, not the finish beam: a car + is over the line about a second before its ET is off the node (**D25**), + and a press inside that beat wrote the pass down with no time in it - [ ] Tree-hosted deployment (**D31**): a tree, two nodes and a phone — arm and read every run with no computer at the track - [x] A reference receiver actually deployed, so the chain runs end to end — diff --git a/README.ru.md b/README.ru.md index ed2f7ef..3df7a58 100644 --- a/README.ru.md +++ b/README.ru.md @@ -130,6 +130,20 @@ Beam402 — ответ на это: открытая, воспроизводим круги, есть что считать, а зеркалу есть что сказать о причине поражения. Фолы, которых лучи не видят, аннулирование проезда и снятие из класса судья ставит с панели — словами своего регламента +- [x] У дня в квалификации есть **таблица**, и считается она той же + арифметикой, что и жеребьёвка, — поэтому не может разойтись с сеткой, в + которую машины попадут: место каждой, число попыток, где проходит отсечка. + У машины, которая ещё не проехала, места нет. Раньше тренировочный день не + давал вообще никакого вывода +- [x] Закончившийся класс публикует **все свои раунды**, а не последний, — чтобы + страница результатов показывала, как человек дошёл до финала; плюс кто не + прошёл отсечку и кто снялся, а это фасад по полю и счётчику не выведет +- [x] Этап на несколько дней с **одного** регламента — `beam402 sheet … --date + 2026-08-07 --id practice-day`: копии списка классов, поправленные руками, + расходятся, и тогда класс едет в один уикенд по двум правилам +- [x] Кнопка записи ждёт **цифры**, а не створ финиша: машина за створом + примерно на секунду раньше, чем её ET уходит с узла (**D25**), и нажатие + внутри этой секунды записывало проезд вообще без времени - [ ] Конфигурация с ёлкой-мастером (**D31**): ёлка, два узла и телефон — запуск и результат каждого заезда без компьютера на трассе - [x] Референсный получатель, реально задеплоенный, чтобы цепочка шла целиком —