//! CB-WP-0025 T04 — what one search node actually costs. //! //! **ADR-0013 D7 exists because two measurements disagreed by 5×.** The //! survey published 112–161 µs/node from a timer that bracketed two //! `setup`s and a whole greedy game (C1). The author's re-measurement said //! 3.0–4.1 µs with `Instant::now()` around each call; the adversarial //! reviewer's isolation said 15.6–20.4 µs. Both agreed the published //! figure was wrong by 1–2 orders and neither established which //! replacement was right. //! //! So the spec quotes **this** and nothing else. `criterion` handles the //! things hand-rolled timing gets wrong here: per-call clock overhead //! against a ~microsecond subject, warm-up, and run-to-run variance — //! which is what let the survey's figure move 161 → 112 between two runs //! of the same unmodified binary. //! //! Two subjects, because a search node is not one call: //! //! * `legal_commands` — enumerating a seat's options; //! * `validate + fold` — taking one branch, which any search does per //! child and which the survey never separated out. use cb_game_runtime::{ScenarioGame, Setup}; use cb_kernel::{Actor, Aggregate, PlayerId}; use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; use games_ground::bot::{legal_commands, play, GreedyPolicy, Policy}; use games_ground::GroundState; use std::collections::BTreeMap; fn setup(players: u8, seed: u64) -> GroundState { GroundState::setup( &Setup { players, preset: format!("standard-{players}p"), patch: BTreeMap::new(), }, seed, ) .expect("preset") } /// A **mid-game state at a real decision point** for `seat`. /// /// Not a fresh deal: at deal time most branches do not exist yet, and a /// node cost taken there would flatter any search proposal. /// /// **And not a fixed step count either.** The first version stopped at /// step 20 for every seat count, which put 2p and 4p in a state where /// seat 0 had *no* legal commands at all — so the benchmark reported /// ~120 ns (the cost of returning an empty `Vec`) and silently skipped /// `validate_fold` because there was nothing to validate. A fixture that /// measures the empty case and calls it a node cost is the same defect /// class this whole pass exists to correct, one layer down. /// /// So: advance until the seat genuinely has a choice, and assert it. fn midgame(players: u8, seed: u64, seat: PlayerId) -> GroundState { let mut ps: Vec> = (0..players) .map(|_| Box::new(GreedyPolicy) as Box) .collect(); let game = play(setup(players, seed), &mut ps).expect("a complete game"); let mut state = setup(players, seed); let mut best: Option = None; for (i, (actor, cmd)) in game.steps.iter().enumerate() { // Past the opening, take the first state where the seat has a real // branch. `> 1` rather than `> 0`: a forced move is not a node. if i >= 8 && legal_commands(&state, seat).len() > 1 { best = Some(state.clone()); break; } if let Ok(events) = state.validate(*actor, cmd) { for e in &events { state.fold(e); } } } let state = best.expect("a mid-game state where the seat has a choice"); assert!( legal_commands(&state, seat).len() > 1, "benchmark fixture has no branch to measure — it would time the empty case" ); state } fn bench(c: &mut Criterion) { for players in [2u8, 3, 4] { let seat = PlayerId(0); let state = midgame(players, 7, seat); let width = legal_commands(&state, seat).len(); println!(" fixture {players}p: {width} legal commands at the measured node"); c.bench_function(&format!("legal_commands/{players}p"), |b| { b.iter(|| std::hint::black_box(legal_commands(&state, seat))) }); // A search must COPY the state per branch (or undo, which we do // not have). `iter_batched` excludes setup from the timing, so // without this the budget would rest on an unmeasured span — // which is the exact mistake C1 caught in the survey. c.bench_function(&format!("clone/{players}p"), |b| { b.iter(|| std::hint::black_box(state.clone())) }); // One branch taken: what a search pays per CHILD, on top of // enumeration. The survey folded this into "us/node" without // separating it, and a search's real cost is enumeration once plus // this per child. let legal = legal_commands(&state, seat); if let Some(cmd) = legal.first() { c.bench_function(&format!("validate_fold/{players}p"), |b| { b.iter_batched( || state.clone(), |mut s| { if let Ok(events) = s.validate(Actor::Player(seat), cmd) { for e in &events { s.fold(e); } } std::hint::black_box(s) }, BatchSize::SmallInput, ) }); } } } criterion_group!(benches, bench); criterion_main!(benches);