clay-borg/games/ground/examples/module-panel.rs
tegwick 3f2ba2ec5d
Some checks failed
ci / check (push) Failing after 4s
CB-WP-0050: every configuration, faceted by aspect — and F31
module-panel sweeps points in aspect space the way scenario-panel sweeps
boards: one module at a time with every other aspect held, then the
profiles where an interaction is the hypothesis.

F31: attack_relief.self_soothe_ge4 HAS NEVER FIRED. Zero ATTACKs in
every cell, every seat band, both bots. The module acts only on an
uncancelled ATTACK by a seat at Stress >= 4, and greedy ranks Ground 100
at the gate against Attack's 10 -- so at exactly the position where
self-soothe would pay, GROUND wins. ModuleAwarePolicy adds 55 and that
is still not enough. `unplayed`, not `inert`: the kernel implements it
correctly and nothing has ever given it an opportunity.

This reaches backwards: CB-EV-0030's H1 verdict rests on its
flat-pressure half alone, because H1's other half never had an
opportunity in those games either.

Also: problem_stress.scoped is the only module with a measured effect
(3p 85->60 greedy, 86->63 module-aware, peak Stress 2->4);
flat_any_open drives group success to 0 at every band, reproducing the
H1 rejection from the module side; and scoped_plus_attack_soothe is
EXACTLY scoped alone -- necessarily, given F31 -- so the catalog's first
intentional multi-aspect combination cannot currently be evaluated as a
combination.

THE PANEL'S OWN DEFECT, first run: it printed 85/86 for a module that
never fired -- real-looking numbers inviting "measured, no effect" when
no seat ever created the precondition. Its docstring already said it
would not do that; the claim was written before the behaviour was.
First fix marked whole rows unmeasured, which threw away h1's real
flat-pressure result; the shipped fix names the specific module and
keeps the row's numbers, which are real for the modules that did fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:45:53 +02:00

297 lines
12 KiB
Rust

//! 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<String, u32>,
}
/// 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<String> {
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<Box<dyn Policy>> = (0..players)
.map(|_| match who {
Seat::Greedy => Box::new(GreedyPolicy) as Box<dyn Policy>,
Seat::ModuleAware => Box::new(ModuleAwarePolicy) as Box<dyn Policy>,
})
.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<PlayerId, u8> =
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<Configuration, String>)> {
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::<Vec<_>>()
.join(", ")
)
};
println!(
" {aspect:<18} {name:<38} {:>5} {:>5} {:>6} {:>6} {:>6}{note}",
g.won, m.won, m.peak_stress, m.darvo, m.attacks
);
}
println!();
}
}