//! Every configuration the catalog offers, faceted by aspect //! (CB-WP-0050, CB-RES-0010 §5.4). //! //! `scenario-panel` sweeps boards. This sweeps **points in aspect //! space**: each aspect held at its default while one module varies, then //! the profiles where the hypothesis is an interaction. //! //! ## What it will not do //! //! **It does not omit what it cannot run.** A module with no kernel path //! is printed as `no kernel path` rather than left out — a panel whose //! absences are invisible is how `end_condition` and `problem_deal` would //! quietly stop being questions. CB-RES-0010 §5.6. //! //! **It does not report a module as failed when nothing could have //! tested it.** Both policies are named per row, and the honest reading //! of a flat row under `greedy` is that greedy cannot see the module — //! F27's shape, and the reason `ModuleAwarePolicy` exists. use cb_game_runtime::{ScenarioGame, Setup}; use cb_kernel::PlayerId; use games_ground::bot::{play, GreedyPolicy, ModuleAwarePolicy, Policy}; use games_ground::config::Configuration; use games_ground::{GroundState, ScoringMode}; /// Games per cell. Named once so the banner and the assertion cannot /// disagree (CB-REV-0002 #1). const GAMES: u32 = 100; #[derive(Default)] struct Cell { games: u32, played: u32, won: u32, /// Peak Stress **held**, not assigned — a table that sat at 2 all /// game reported 1 when this was a max over event payloads /// (CB-REV-0003 #5). peak_stress: u8, darvo: u32, attacks: u32, setup_fails: u32, /// Games in which **every active module actually acted**. /// /// A module that never fires has not been measured, and printing a /// group-success number for it is worse than printing nothing: 85 vs /// 86 reads as "measured, no effect" when the truth is that no seat /// ever created the precondition. `attack_relief.self_soothe_ge4` /// showed exactly this on the panel's first run — zero ATTACKs in /// every cell, because GROUND outranks ATTACK at the stress gate, so /// the module could not fire and its row looked like a null result. unfired: std::collections::BTreeMap, } /// Did every non-default module in `cfg` actually act in this game? /// /// Matched per module rather than inferred, for the same reason `Rules` /// is exhaustive: a module added later must be a compile error here, not /// a silent "fired" that turns an untested row into a measured one. fn unfired_modules(cfg: &Configuration, g: &games_ground::bot::BotGame) -> Vec { cfg.active_modules() .into_iter() .filter(|m| !fired(m, g)) .collect() } /// Did this one module act in this game? /// /// **Per module, not inferred.** Same reason `Rules` is exhaustive: a /// module added later must be a compile error here rather than a silent /// "fired" that turns an untested row into a measured one. fn fired(m: &str, g: &games_ground::bot::BotGame) -> bool { match m { // Its Stress tick happens at Round End; the ledger moves. "problem_stress.flat_any_open" | "problem_stress.scoped" => g .events .iter() .any(|e| matches!(e, games_ground::GroundEvent::StressSet { .. })), // It only acts on an uncancelled ATTACK by a seat at Stress >= 4. "attack_relief.self_soothe_ge4" => g.steps.iter().any(|(_, c)| { matches!( c, games_ground::GroundCommand::SelectAction { action: games_ground::Action::Attack, .. } ) }), // Anything else has no kernel path, so the row never gets here. _ => false, } } #[derive(Clone, Copy, PartialEq)] enum Seat { Greedy, ModuleAware, } fn sweep(cfg: &Configuration, players: u8, who: Seat) -> Cell { let mut c = Cell::default(); for seed in 0..GAMES as u64 { let Ok(st) = GroundState::setup( &Setup { players, preset: format!("standard-{players}p"), patch: Default::default(), }, seed, ) else { c.setup_fails += 1; continue; }; // `with_config`, never a field write: problem_stress.scoped // assigns owners at setup and a bare write leaves the module // inert (ADR-0022 D3). let mut st = st.with_config(cfg.clone()); st.mode = ScoringMode::SharedGround; let start = st.players.values().map(|p| p.stress).max().unwrap_or(0); let mut ps: Vec> = (0..players) .map(|_| match who { Seat::Greedy => Box::new(GreedyPolicy) as Box, Seat::ModuleAware => Box::new(ModuleAwarePolicy) as Box, }) .collect(); let g = match play(st, &mut ps) { Ok(g) => g, Err(e) => { eprintln!(" !! {players}p seed {seed}: {e:?}"); continue; } }; c.games += 1; if g.state.outcome.is_some() && g.rounds == 5 { c.played += 1; } if g.state.outcome.as_ref().is_some_and(|o| o.group_success) { c.won += 1; } let mut held: std::collections::BTreeMap = g.state.players.keys().map(|s| (*s, start)).collect(); c.peak_stress = c.peak_stress.max(start); for e in &g.events { match e { games_ground::GroundEvent::StressSet { player, stress } => { held.insert(*player, *stress); c.peak_stress = c.peak_stress.max(held.values().copied().max().unwrap_or(0)); } games_ground::GroundEvent::DarvoTriggered { .. } => c.darvo += 1, _ => {} } } for m in unfired_modules(cfg, &g) { *c.unfired.entry(m).or_insert(0) += 1; } for (_, cmd) in &g.steps { if let games_ground::GroundCommand::SelectAction { action: games_ground::Action::Attack, .. } = cmd { c.attacks += 1; } } } assert_eq!( c.games, GAMES, "{players}p: only {} of {GAMES} games ran ({} setups refused)", c.games, c.setup_fails ); assert_eq!( c.played, GAMES, "{players}p: {} of {} games reached an outcome over five rounds", c.played, c.games ); c } /// Every point worth measuring, in the order a reader should read them. fn points() -> Vec<(String, String, Result)> { let cat = games_ground::catalog::catalog().expect("catalog.yaml"); let mut out = vec![( "—".to_string(), "baseline (every aspect at its default)".to_string(), Configuration::select("ground-darvo-r0"), )]; // ONE MODULE AT A TIME, grouped by aspect: "hold all aspects fixed, // vary one" is what makes a row a measurement of that module rather // than of a bundle (CB-RES-0010 §5.4). for aspect in &cat.aspects { for m in cat.modules.iter().filter(|m| m.aspect == aspect.id) { if m.is_default || m.module_id == aspect.default_module { continue; } out.push(( aspect.id.clone(), m.module_id.clone(), Configuration::from_modules( &cat.default_baseline, std::slice::from_ref(&m.module_id), ), )); } } // Then the profiles, which are where an INTERACTION is the // hypothesis. A single-module profile is skipped: it is the row above. for p in &cat.profiles { if p.modules.len() < 2 { continue; } out.push(( "(interaction)".to_string(), p.profile_id.clone(), Configuration::from_profile(&p.profile_id), )); } out } fn main() { println!("CB-WP-0050 — every configuration, faceted by aspect\n"); println!("SHARED GROUND; {GAMES} games per cell; seeds 0..{GAMES}."); println!("One module at a time first, holding every other aspect at its"); println!("default; then the profiles, where an interaction is the point.\n"); println!("`grdy` is GreedyPolicy, which reads no module at all. `mod` is"); println!("ModuleAwarePolicy. A row flat under `grdy` and moving under"); println!("`mod` is a module that needs a seat able to see it — not a"); println!("module that does nothing (F27's distinction).\n"); println!("`never fired: UNMEASURED` means no seat created that module's"); println!("precondition in any game, so its contribution to the row is not"); println!("a null result — it is an absence of evidence. The other numbers"); println!("in such a row are still real for the modules that DID fire.\n"); for players in [3u8, 4, 6] { println!("{players} players"); println!( " {:<18} {:<38} {:>5} {:>5} {:>6} {:>6} {:>6}", "aspect", "module / profile", "grdy", "mod", "peak", "darvo", "atk" ); for (aspect, name, cfg) in points() { let cfg = match cfg { Ok(c) => c, Err(e) => { println!(" {aspect:<18} {name:<38} — — {e}"); continue; } }; // **Named, never omitted.** A module the kernel cannot run is // a question that stays open, and a blank row is how it would // stop being one. if let Err(e) = cfg.resolve() { let why = e.split(" (catalog status").next().unwrap_or(&e).to_string(); let why = why.replace(&format!("{name} is a known module with "), ""); println!(" {aspect:<18} {name:<38} {:>5} {:>5} {why}", "—", "—"); continue; } let g = sweep(&cfg, players, Seat::Greedy); let m = sweep(&cfg, players, Seat::ModuleAware); // **A module that never acted is UNMEASURED, not null.** // Printing win counts for it invites the reading that it does // nothing, when what happened is that no seat ever created // the precondition. CB-RES-0010 §5.4's last bullet, one level // deeper than F27: not "the bot cannot see the module" but // "the bot never gives the module anything to see". // **Name the module that never acted, not the whole row.** // Reporting a two-module profile as wholly unmeasured throws // away the half that did fire: h1's flat pressure reaches // every game and drives group success to 0, which is a real // result about that module even though its ATTACK half never // gets an opportunity. let never: Vec<&String> = m .unfired .iter() .filter(|(_, n)| **n == GAMES) .map(|(k, _)| k) .collect(); let note = if never.is_empty() { String::new() } else { format!( " \u{2190} {} never fired: UNMEASURED", never .iter() .map(|s| s.as_str()) .collect::>() .join(", ") ) }; println!( " {aspect:<18} {name:<38} {:>5} {:>5} {:>6} {:>6} {:>6}{note}", g.won, m.won, m.peak_stress, m.darvo, m.attacks ); } println!(); } }