clay-borg/games/ground/examples/h2-panel.rs
tegwick 352940e741
Some checks failed
ci / check (push) Failing after 4s
cargo fmt after the H2 panel
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:11:21 +02:00

204 lines
6.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! **H2 measured against ground-game's own criteria** (CB-WP-0042 T05).
//!
//! Their §3 targets, not H1's reused from memory:
//!
//! 1. group success (greedy SHARED, 34p) well above H1's 0
//! 2. Stress **variance** — maxmin across seats — higher than baseline
//! and H1-greedy
//! 3. DARVO non-zero **for some policy that still sometimes wins**
//! 4. bond cards show an elevated SOLVE rate versus personal
//! 5. control: force every scope global and H1-like collapse returns
//!
//! **Criterion 4 is the one this instrument probably cannot answer**, and
//! that is said up front rather than discovered in the numbers: no policy
//! here models another seat, or knows what a scope is. If bond and
//! personal rates come out equal it is because nothing in the panel could
//! have made them differ — not because the incentive fails at a table.
use std::collections::BTreeMap;
use cb_game_runtime::{ScenarioGame, Setup};
use cb_kernel::PlayerId;
use games_ground::bot::{play, Choice, GreedyPolicy, Policy};
use games_ground::edition::StressScope;
use games_ground::{Action, GroundCommand, GroundState, ScoringMode, Variant};
const GAMES: u32 = 200;
/// Greedy with GROUND demoted below ATTACK — the unregulated seat from
/// CB-EV-0031, delegating so exactly one preference differs.
struct Reactive;
impl Policy for Reactive {
fn name(&self) -> &'static str {
"reactive"
}
fn choose(
&mut self,
state: &GroundState,
seat: PlayerId,
legal: &[GroundCommand],
_may_pass: bool,
) -> Choice {
let gated = state
.players
.get(&seat)
.is_some_and(|p| p.stress >= 4 && !p.freedom_gate_lifted);
let rank = |c: &GroundCommand| -> i32 {
match c {
GroundCommand::SelectAction {
action: Action::Ground,
..
} if gated => 5,
other => GreedyPolicy::rank(state, seat, other),
}
};
let mut best = 0;
for (i, c) in legal.iter().enumerate() {
if rank(c) > rank(&legal[best]) {
best = i;
}
}
Choice::Command(best)
}
}
#[derive(Default)]
struct Cell {
games: u32,
played: u32,
won: u32,
darvo: u32,
/// Summed maxmin final Stress, for criterion 2.
spread: u32,
/// Claimed / total, by scope, for criterion 4.
claimed: BTreeMap<&'static str, (u32, u32)>,
}
fn sweep(variant: Variant, players: u8, reactive: bool, force_global: bool) -> 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 {
continue;
};
let mut st = st.with_variant(variant);
st.mode = ScoringMode::SharedGround;
// Criterion 5's control: every scope forced global.
if force_global {
for p in st.problems.values_mut() {
if p.scope.is_some() {
p.scope = Some(StressScope::Global);
p.owner = None;
}
}
}
let mut ps: Vec<Box<dyn Policy>> = (0..players)
.map(|_| {
if reactive {
Box::new(Reactive) as Box<dyn Policy>
} else {
Box::new(GreedyPolicy) as Box<dyn Policy>
}
})
.collect();
let Ok(g) = play(st, &mut ps) else { 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;
}
for e in &g.events {
if matches!(e, games_ground::GroundEvent::DarvoTriggered { .. }) {
c.darvo += 1;
}
}
let stresses: Vec<u8> = g.state.players.values().map(|p| p.stress).collect();
let (lo, hi) = (
stresses.iter().copied().min().unwrap_or(0),
stresses.iter().copied().max().unwrap_or(0),
);
c.spread += u32::from(hi - lo);
for p in g.state.problems.values() {
let name = match p.scope {
Some(StressScope::Global) => "global",
Some(StressScope::Personal) => "personal",
Some(StressScope::Bond) => "bond",
None => continue,
};
let e = c.claimed.entry(name).or_default();
e.1 += 1;
if p.claimed_by.is_some() {
e.0 += 1;
}
}
}
assert_eq!(c.games, GAMES, "{players}p {variant:?}: short cell");
assert_eq!(
c.played, GAMES,
"{players}p {variant:?}: games did not finish"
);
c
}
fn main() {
println!("H2 — against ground-game's §3 criteria (CB-WP-0042 T05)\n");
println!("SHARED GROUND, {GAMES} games per cell. `spread` is the mean");
println!("maxmin final Stress across seats — their criterion 2.\n");
for (label, reactive) in [("greedy", false), ("reactive", true)] {
println!("{label}");
println!("seats variant won darvo spread");
for players in [2u8, 3, 4, 6] {
for (vname, v) in [
("baseline", Variant::Baseline),
("H1 ", Variant::H1ProblemStress),
("H2 ", Variant::H2ScopedProblemStress),
] {
let c = sweep(v, players, reactive, false);
println!(
" {players}p {vname} {:>4} {:>5} {:>5.2}",
c.won,
c.darvo,
f64::from(c.spread) / f64::from(GAMES)
);
}
}
println!();
}
println!("criterion 4 — claim rate by scope (H2, greedy)");
println!("seats global personal bond");
for players in [2u8, 3, 4, 6] {
let c = sweep(Variant::H2ScopedProblemStress, players, false, false);
let rate = |k: &str| match c.claimed.get(k) {
Some((a, b)) if *b > 0 => format!("{:>6.1}%", 100.0 * f64::from(*a) / f64::from(*b)),
_ => "".to_string(),
};
println!(
" {players}p {} {} {}",
rate("global"),
rate("personal"),
rate("bond")
);
}
println!("\ncriterion 5 — control: every scope forced global (H2, greedy)");
println!("seats won (scoped) won (all global)");
for players in [3u8, 4] {
let scoped = sweep(Variant::H2ScopedProblemStress, players, false, false);
let global = sweep(Variant::H2ScopedProblemStress, players, false, true);
println!(
" {players}p {:>4} {:>4}",
scoped.won, global.won
);
}
}