CB-RES-0008 with a runnable baseline (games/ground/examples/difficulty-baseline.rs), and the measurement produced a finding before any solver exists. A GREEDY BOT WINS 200 OF 200 GAMES AT FIVE AND SIX SEATS. Median margin +3, 11.8-12.0 points available against a threshold of 9. The curve is 66% / 82.5% / 95% / 100% / 100% across 2/3/4/5/6 seats. Row-level, as GROUND-WP-0004 T02 requires: available points 6 / 9 / 12 against thresholds 5 / 7 / 9, so the ratio RISES with seat count (1.20, 1.29, 1.33) while the table also gains actions per round to clear it with. Three multipliers pointing the same direction. It also explains the maintainer's report without needing a solver at all: "I felt it was too easy but then we lost" is two true statements about different seat counts. Cost measured and it rules out the obvious approach. Branching is small (mean 4.7-9.1) but legal_commands costs 112-161 us per call because it filters candidates through full validate. Exhaustive search is out at every seat count; 10^4-10^5 nodes is 1.4-14 seconds, which is the budget the ADR must design inside. Prior art names the trap: determinized search (PIMC) suffers strategy fusion (Frank, Basin & Matsubara 1998) -- the search picks different actions in states a real player cannot distinguish, so the witness may require knowing what was on top of the deck. Such a line still replays green, so the checkability benchmark does not catch it. Honesty and checkability are different properties; stated explicitly so T03 cannot conflate them. The survey states its own most likely killer up front (§6): a view-only search cannot fold events, so making the information boundary structural rather than a promise may not be affordable. Better found here than in T05. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
5.6 KiB
Rust
166 lines
5.6 KiB
Rust
//! CB-RES-0008's runnable baseline (CB-WP-0025 T01).
|
|
//!
|
|
//! Two numbers the survey needs and cannot cite from anyone else, because
|
|
//! they are about **our** game on **our** machine:
|
|
//!
|
|
//! 1. **What the bots actually achieve** — win rate by seat count over a
|
|
//! seed range, which is the difficulty denominator `ground-game`'s
|
|
//! GROUND-WP-0005 is blocked on.
|
|
//! 2. **What a search would cost** — the branching factor of
|
|
//! `legal_commands` and the price of enumerating it, which decides
|
|
//! whether the honest version of "could we have won" is affordable.
|
|
//!
|
|
//! **This measures, it does not conclude.** Whether a bot win rate *is* a
|
|
//! difficulty is exactly what the survey and the review have to argue
|
|
//! about; this only makes the number exist.
|
|
//!
|
|
//! ```text
|
|
//! cargo run --release -p games-ground --example difficulty-baseline
|
|
//! ```
|
|
|
|
use cb_game_runtime::{ScenarioGame, Setup};
|
|
use cb_kernel::Aggregate;
|
|
use games_ground::bot::{legal_commands, play, GreedyPolicy, Policy, RandomPolicy};
|
|
use games_ground::GroundState;
|
|
|
|
const SEEDS: u64 = 200;
|
|
|
|
fn setup(players: u8, seed: u64) -> Option<GroundState> {
|
|
GroundState::setup(
|
|
&Setup {
|
|
players,
|
|
preset: format!("standard-{players}p"),
|
|
patch: Default::default(),
|
|
},
|
|
seed,
|
|
)
|
|
.ok()
|
|
}
|
|
|
|
fn policies(kind: &str, players: u8, seed: u64) -> Vec<Box<dyn Policy>> {
|
|
(0..players)
|
|
.map(|i| -> Box<dyn Policy> {
|
|
match kind {
|
|
"random" => Box::new(RandomPolicy::new(seed ^ u64::from(i))),
|
|
_ => Box::new(GreedyPolicy),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Win rate, and the margin — because "we lost" and "we lost by one point"
|
|
/// are different games, and a rate alone hides which one this is.
|
|
fn win_rate(kind: &str, players: u8) {
|
|
let (mut wins, mut played, mut total_pts, mut total_thr) = (0u32, 0u32, 0u64, 0u64);
|
|
let mut margins: Vec<i64> = Vec::new();
|
|
for seed in 0..SEEDS {
|
|
let Some(state) = setup(players, seed) else {
|
|
continue;
|
|
};
|
|
let mut ps = policies(kind, players, seed);
|
|
let Ok(game) = play(state, &mut ps) else {
|
|
continue;
|
|
};
|
|
let Some(o) = &game.state.outcome else {
|
|
continue;
|
|
};
|
|
played += 1;
|
|
if o.group_success {
|
|
wins += 1;
|
|
}
|
|
total_pts += u64::from(o.total);
|
|
total_thr += u64::from(o.threshold);
|
|
margins.push(i64::from(o.total) - i64::from(o.threshold));
|
|
}
|
|
if played == 0 {
|
|
println!(" {players}p {kind:>6} no games completed");
|
|
return;
|
|
}
|
|
margins.sort_unstable();
|
|
let median = margins[margins.len() / 2];
|
|
// Wilson-free: report the count, not a confidence interval we have not
|
|
// argued for. The spec (T04) decides what interval is claimed.
|
|
println!(
|
|
" {players}p {kind:>6} {wins:>3}/{played:<3} won = {rate:>5.1}% \
|
|
mean total {mt:>4.1} of {th:>4.1} median margin {median:+}",
|
|
rate = 100.0 * f64::from(wins) / f64::from(played),
|
|
mt = total_pts as f64 / f64::from(played),
|
|
th = total_thr as f64 / f64::from(played),
|
|
);
|
|
}
|
|
|
|
/// What one node of a search costs, and how wide it is.
|
|
///
|
|
/// Measured on real mid-game states rather than on a fresh deal: at deal
|
|
/// time most of the interesting branches do not exist yet, and a
|
|
/// branching factor taken there would flatter any search proposal.
|
|
fn search_cost(players: u8) {
|
|
let mut widths: Vec<usize> = Vec::new();
|
|
let mut nodes = 0u64;
|
|
let start = std::time::Instant::now();
|
|
|
|
for seed in 0..40u64 {
|
|
let Some(state) = setup(players, seed) else {
|
|
continue;
|
|
};
|
|
// Walk a real game and sample the branching at every decision.
|
|
let mut ps = policies("greedy", players, seed);
|
|
let Ok(game) = play(state, &mut ps) else {
|
|
continue;
|
|
};
|
|
// Re-run the recorded commands, enumerating legality at each step.
|
|
let Some(mut replay) = setup(players, seed) else {
|
|
continue;
|
|
};
|
|
for (actor, cmd) in &game.steps {
|
|
if let cb_kernel::Actor::Player(seat) = actor {
|
|
let legal = legal_commands(&replay, *seat);
|
|
widths.push(legal.len());
|
|
nodes += 1;
|
|
}
|
|
if let Ok(events) = replay.validate(*actor, cmd) {
|
|
for e in &events {
|
|
replay.fold(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let elapsed = start.elapsed();
|
|
if widths.is_empty() {
|
|
println!(" {players}p no decisions sampled");
|
|
return;
|
|
}
|
|
widths.sort_unstable();
|
|
let sum: usize = widths.iter().sum();
|
|
println!(
|
|
" {players}p {n} decisions branching mean {mean:.1} median {med} max {max} \
|
|
{per:.0} us/node",
|
|
n = widths.len(),
|
|
mean = sum as f64 / widths.len() as f64,
|
|
med = widths[widths.len() / 2],
|
|
max = widths[widths.len() - 1],
|
|
per = elapsed.as_micros() as f64 / nodes as f64,
|
|
);
|
|
}
|
|
|
|
fn main() {
|
|
println!("CB-RES-0008 baseline — measured, not concluded\n");
|
|
println!("bot win rate over {SEEDS} seeds (GR-E01 group success):");
|
|
for players in [2u8, 3, 4, 5, 6] {
|
|
win_rate("greedy", players);
|
|
}
|
|
for players in [2u8, 3, 4] {
|
|
win_rate("random", players);
|
|
}
|
|
|
|
println!("\nsearch cost — legal_commands at every real decision point:");
|
|
for players in [2u8, 3, 4] {
|
|
search_cost(players);
|
|
}
|
|
|
|
println!(
|
|
"\nNOTE: a win rate is this POLICY's win rate over THIS seed range.\n\
|
|
Whether that is 'the difficulty' is T02's argument, not this tool's claim."
|
|
);
|
|
}
|