Some checks failed
ci / check (push) Has been cancelled
T06. games/ground/examples/difficulty.rs, make difficulty, wired into make self-tests, and a report file in ground-game under GROUND-WP-0005 with a hub message pointing at it. THE REPORT OPENS WITH THE RETRACTION, because what this task was written to send was withdrawn by T02 and GROUND-WP-0005 is blocked on exactly that number. They are told, in the first section, that we nearly sent them "the game is too easy at 5-6 seats" and why it was wrong. seats winnable greedy random first-legal spread 2p 60% 60.0% 5.0% 76.7% 71.7 3p 93% 88.3% 6.7% 25.0% 81.7 4p 100% 93.3% 6.7% 30.0% 86.7 5p 100% 100.0% 3.3% 0.0% 100.0 6p 100% 100.0% 3.3% 0.0% 100.0 SPREAD justifies the whole redesign: 71.7 to 100.0 points between three trivial policies. The table now shows why no single rate is a difficulty rather than asserting it. And the 5-6 rows point the OPPOSITE way from the withdrawn claim -- first-legal 0% against greedy 100% is the widest spread in the table, which suggests play matters MORE there, not less. Neither reading is established and the report says so. The confound is stated in the tool's own output, not only in prose: `winnable` is conditioned on greedy's play up to the final round, because searching from round 1 is unaffordable. Presenting it as a property of the deal would repeat this pass's error in a subtler form -- which is exactly how a corrected project reintroduces a defect. NO THRESHOLD CHANGES ARE PROPOSED. The instrument can fail (spec §5): a witness must replay to a win, an unwinnable position must report searched-out rather than a budget cut, a one-node budget must not claim exhaustion, and the policy panel must actually disagree. difficulty-baseline.rs marked superseded, kept as the survey's dated snapshot. Registered as F16, inconsistent / withdrawn. T07. evidence/CB-EV-0024. Five of nine defects came only from the review; four from execution, and all four of those were in work written after it. The wrong-denominator family now has five instances and still no control -- facts-check catches copies that disagree, nothing catches a number computed correctly against the wrong base. Tier L was an over-declaration (no port, structurally M) and paid for itself anyway, because the review is L-only. Chaos window 2 will close with zero overrides, making its retirement condition untestable. Named as open rather than implied done: the witness is NOT wired to the ending page. The search works; the browser cannot ask it yet. make all: exit 0. loop-lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
10 KiB
Rust
304 lines
10 KiB
Rust
//! CB-WP-0025 T06 — the difficulty table, and the instrument that can fail.
|
|
//!
|
|
//! Implements `specs/RetrospectiveAnalysis.md` §4. Replaces
|
|
//! `difficulty-baseline.rs`, which CB-WP-0022's admissibility rule and
|
|
//! this pass's own review both found inadmissible: it had no assertions,
|
|
//! no `--self-test` and no `make` target, so nothing could turn it red.
|
|
//!
|
|
//! ## What it will not print
|
|
//!
|
|
//! **A single policy's win rate as a difficulty** (§4.1). Measured on
|
|
//! identical deals, `GreedyPolicy` wins 100% at five and six seats where
|
|
//! `FirstLegal` wins 0%. The panel is plural for that reason, and the
|
|
//! spread is reported rather than hidden.
|
|
//!
|
|
//! ```text
|
|
//! cargo run --release -p games-ground --example difficulty [--self-test]
|
|
//! ```
|
|
|
|
use cb_game_runtime::{ScenarioGame, Setup};
|
|
use cb_kernel::{Aggregate, PlayerId};
|
|
use games_ground::bot::{play, Choice, GreedyPolicy, Policy, RandomPolicy};
|
|
use games_ground::search::{winnable_within, Verdict};
|
|
use games_ground::{GroundCommand, GroundState};
|
|
|
|
/// Seeds per cell. Small by default: the winnable fraction needs an
|
|
/// exhaustive search per deal, which is the expensive half (§3.1).
|
|
const SEEDS: u64 = 60;
|
|
/// §3's measured limit: `K=1` is exhaustible, `K=2` is not.
|
|
const K: u8 = 1;
|
|
const BUDGET: usize = 2_000_000;
|
|
|
|
/// A policy with no heuristic at all. **It is in the panel because it is
|
|
/// what falsified the survey's headline** — it beats greedy at two seats
|
|
/// and scores zero at six.
|
|
struct FirstLegal;
|
|
impl Policy for FirstLegal {
|
|
fn name(&self) -> &'static str {
|
|
"first-legal"
|
|
}
|
|
fn choose(
|
|
&mut self,
|
|
_s: &GroundState,
|
|
_seat: PlayerId,
|
|
_legal: &[GroundCommand],
|
|
_may_pass: bool,
|
|
) -> Choice {
|
|
Choice::Command(0)
|
|
}
|
|
}
|
|
|
|
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))),
|
|
"first-legal" => Box::new(FirstLegal),
|
|
_ => Box::new(GreedyPolicy),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Games a named policy actually wins.
|
|
fn policy_wins(kind: &str, players: u8) -> (u32, u32) {
|
|
let (mut wins, mut played) = (0, 0);
|
|
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;
|
|
};
|
|
if let Some(o) = &game.state.outcome {
|
|
played += 1;
|
|
if o.group_success {
|
|
wins += 1;
|
|
}
|
|
}
|
|
}
|
|
(wins, played)
|
|
}
|
|
|
|
/// Rewind a played game to the start of its last `k` rounds.
|
|
fn last_rounds(players: u8, seed: u64, k: usize) -> Option<GroundState> {
|
|
let mut ps = policies("greedy", players, seed);
|
|
let game = play(setup(players, seed)?, &mut ps).ok()?;
|
|
let total = game
|
|
.steps
|
|
.iter()
|
|
.filter(|(_, c)| matches!(c, GroundCommand::EndRound))
|
|
.count();
|
|
let mut st = setup(players, seed)?;
|
|
let mut ends = 0usize;
|
|
for (a, c) in &game.steps {
|
|
if let Ok(ev) = st.validate(*a, c) {
|
|
for e in &ev {
|
|
st.fold(e);
|
|
}
|
|
}
|
|
if matches!(c, GroundCommand::EndRound) {
|
|
ends += 1;
|
|
if ends >= total.saturating_sub(k) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Some(st)
|
|
}
|
|
|
|
/// In what fraction of deals does a winning line exist in the last `K`
|
|
/// rounds?
|
|
///
|
|
/// **Less policy-dependent than a win rate, but NOT policy-free, and
|
|
/// saying otherwise would repeat this pass's own error in a subtler
|
|
/// form.** The position searched is the one `GreedyPolicy` left at the
|
|
/// start of the last round, so the figure is *"winnable from where greedy
|
|
/// got to"*. A genuinely policy-free measure would search from round 1,
|
|
/// which §3.1 measured as unaffordable.
|
|
///
|
|
/// What it does buy: the last round's outcome no longer depends on which
|
|
/// agent plays it, so the measure is insensitive to exactly the variation
|
|
/// that made the bot rate meaningless (§4.1).
|
|
///
|
|
/// Deals where the search was cut by its budget are **not counted either
|
|
/// way** — they are reported separately, because folding "we stopped
|
|
/// looking" into "not winnable" is exactly the collapse §2.3 forbids.
|
|
fn winnable_fraction(players: u8) -> (u32, u32, u32) {
|
|
let (mut yes, mut decided, mut undecided) = (0, 0, 0);
|
|
for seed in 0..SEEDS {
|
|
let Some(state) = last_rounds(players, seed, K as usize) else {
|
|
continue;
|
|
};
|
|
match winnable_within(&state, K, BUDGET) {
|
|
Verdict::Winnable { .. } => {
|
|
yes += 1;
|
|
decided += 1;
|
|
}
|
|
Verdict::NoneFound {
|
|
exhausted: true, ..
|
|
} => decided += 1,
|
|
Verdict::NoneFound {
|
|
exhausted: false, ..
|
|
} => undecided += 1,
|
|
}
|
|
}
|
|
(yes, decided, undecided)
|
|
}
|
|
|
|
fn report() {
|
|
println!("difficulty — specs/RetrospectiveAnalysis.md §4\n");
|
|
println!(
|
|
" winnable-from-round-{} over {SEEDS} seeds, budget {BUDGET} nodes",
|
|
6 - K
|
|
);
|
|
println!(" policy win rates over the same {SEEDS} seeds\n");
|
|
println!(" seats winnable greedy random first-legal spread undecided");
|
|
|
|
for players in [2u8, 3, 4, 5, 6] {
|
|
let (yes, decided, undecided) = winnable_fraction(players);
|
|
let pct = |(w, n): (u32, u32)| {
|
|
if n == 0 {
|
|
-1.0
|
|
} else {
|
|
100.0 * f64::from(w) / f64::from(n)
|
|
}
|
|
};
|
|
let g = pct(policy_wins("greedy", players));
|
|
let r = pct(policy_wins("random", players));
|
|
let f = pct(policy_wins("first-legal", players));
|
|
let spread = [g, r, f].iter().cloned().fold(f64::MIN, f64::max)
|
|
- [g, r, f].iter().cloned().fold(f64::MAX, f64::min);
|
|
let wf = if decided == 0 {
|
|
"n/a".to_string()
|
|
} else {
|
|
format!("{:.0}%", 100.0 * f64::from(yes) / f64::from(decided))
|
|
};
|
|
println!(
|
|
" {players}p {wf:>6} {g:>5.1}% {r:>5.1}% {f:>5.1}% \
|
|
{spread:>5.1} {undecided:>3}"
|
|
);
|
|
}
|
|
|
|
println!(
|
|
"\n WINNABLE is conditioned on GREEDY's play up to the last round —\n \
|
|
it is 'winnable from where greedy got to', not a property of the\n \
|
|
deal alone. Searching from round 1 is unaffordable (spec §3.1).\n\n \
|
|
It is also a LOWER BOUND: a K={K} search cannot see a line that\n \
|
|
needed an earlier round. `undecided` are deals whose search hit the\n \
|
|
node budget — they are excluded from the fraction, not counted as\n \
|
|
unwinnable.\n\n \
|
|
SPREAD is the range across three policies. Where it is large, no\n \
|
|
single policy's rate says anything about the game (§4.1)."
|
|
);
|
|
}
|
|
|
|
fn self_test() -> i32 {
|
|
let mut ok = true;
|
|
let mut check = |name: &str, cond: bool, detail: String| {
|
|
ok &= cond;
|
|
println!(
|
|
" [{}] {name}{}",
|
|
if cond { "ok " } else { "FAIL" },
|
|
if detail.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" — {detail}")
|
|
}
|
|
);
|
|
};
|
|
|
|
// The control the old harness never had: a search that finds a line
|
|
// must produce one that REPLAYS. §2.1 is a gate, not a metric.
|
|
let state = last_rounds(3, 7, K as usize).expect("a 3p game");
|
|
match winnable_within(&state, K, BUDGET) {
|
|
Verdict::Winnable { line, .. } => {
|
|
let mut replay = state.clone();
|
|
let mut good = true;
|
|
for m in &line {
|
|
match replay.validate(m.actor, &m.command) {
|
|
Ok(ev) => {
|
|
for e in &ev {
|
|
replay.fold(e);
|
|
}
|
|
}
|
|
Err(_) => good = false,
|
|
}
|
|
}
|
|
let won = replay.outcome.as_ref().is_some_and(|o| o.group_success);
|
|
check(
|
|
"a witness replays to a win",
|
|
good && won,
|
|
format!("{} moves", line.len()),
|
|
);
|
|
}
|
|
Verdict::NoneFound { .. } => check(
|
|
"a witness replays to a win",
|
|
false,
|
|
"3p seed 7 found no line — the fixture moved".into(),
|
|
),
|
|
}
|
|
|
|
// The negative: a search must be able to return nothing, exhaustively.
|
|
let lost = last_rounds(2, 7, 1).expect("a 2p game");
|
|
match winnable_within(&lost, 1, BUDGET) {
|
|
Verdict::NoneFound { exhausted, nodes } => check(
|
|
"an unwinnable position is reported as searched-out",
|
|
exhausted && nodes > 100,
|
|
format!("{nodes} nodes"),
|
|
),
|
|
Verdict::Winnable { .. } => check(
|
|
"an unwinnable position is reported as searched-out",
|
|
false,
|
|
"found a win in a game 2p seed 7 lost".into(),
|
|
),
|
|
}
|
|
|
|
// A budget of one must NOT claim exhaustion — the distinction §2.3
|
|
// rests on.
|
|
match winnable_within(&state, K, 1) {
|
|
Verdict::NoneFound { exhausted, .. } => check(
|
|
"a budget cut is not reported as exhaustion",
|
|
!exhausted,
|
|
String::new(),
|
|
),
|
|
Verdict::Winnable { .. } => check(
|
|
"a budget cut is not reported as exhaustion",
|
|
false,
|
|
String::new(),
|
|
),
|
|
}
|
|
|
|
// §4.1's reason, asserted rather than asserted-about: the panel must
|
|
// actually disagree, or reporting three policies is ceremony.
|
|
let g = policy_wins("greedy", 6);
|
|
let f = policy_wins("first-legal", 6);
|
|
check(
|
|
"the policy panel disagrees, so no single rate is a difficulty",
|
|
g.0 != f.0,
|
|
format!("greedy {}/{}, first-legal {}/{}", g.0, g.1, f.0, f.1),
|
|
);
|
|
|
|
println!("difficulty self-test (positive control)");
|
|
i32::from(!ok)
|
|
}
|
|
|
|
fn main() {
|
|
if std::env::args().any(|a| a == "--self-test") {
|
|
std::process::exit(self_test());
|
|
}
|
|
report();
|
|
}
|