diff --git a/games/ground/examples/module-panel.rs b/games/ground/examples/module-panel.rs index c9f67f0..19ef6af 100644 --- a/games/ground/examples/module-panel.rs +++ b/games/ground/examples/module-panel.rs @@ -19,7 +19,7 @@ use cb_game_runtime::{ScenarioGame, Setup}; use cb_kernel::PlayerId; -use games_ground::bot::{play, GreedyPolicy, ModuleAwarePolicy, Policy}; +use games_ground::bot::{play, GateAttackPolicy, GreedyPolicy, ModuleAwarePolicy, Policy}; use games_ground::config::Configuration; use games_ground::{GroundState, ScoringMode}; @@ -94,6 +94,10 @@ fn fired(m: &str, g: &games_ground::bot::BotGame) -> bool { enum Seat { Greedy, ModuleAware, + /// F31's falsifier: a seat that attacks at the stress gate, so + /// `attack_relief.self_soothe_ge4` gets an opportunity at all. **A + /// probe, not a recommendation** — its win rate is not advice. + GateAttack, } fn sweep(cfg: &Configuration, players: u8, who: Seat) -> Cell { @@ -120,6 +124,7 @@ fn sweep(cfg: &Configuration, players: u8, who: Seat) -> Cell { .map(|_| match who { Seat::Greedy => Box::new(GreedyPolicy) as Box, Seat::ModuleAware => Box::new(ModuleAwarePolicy) as Box, + Seat::GateAttack => Box::new(GateAttackPolicy) as Box, }) .collect(); let g = match play(st, &mut ps) { @@ -227,6 +232,11 @@ fn main() { 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!("`atk!` is GateAttackPolicy, which attacks at the stress gate so"); + println!("attack_relief gets an opportunity at all (F31). It is a PROBE:"); + println!("its win rate is not a recommendation. The peak/darvo/atk columns"); + println!("are its games, because they are the only ones where ATTACK"); + println!("happens.\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"); @@ -235,8 +245,8 @@ fn main() { 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" + " {:<18} {:<38} {:>5} {:>5} {:>5} {:>6} {:>6} {:>6}", + "aspect", "module / profile", "grdy", "mod", "atk!", "peak", "darvo", "atk" ); for (aspect, name, cfg) in points() { let cfg = match cfg { @@ -257,6 +267,7 @@ fn main() { } let g = sweep(&cfg, players, Seat::Greedy); let m = sweep(&cfg, players, Seat::ModuleAware); + let a = sweep(&cfg, players, Seat::GateAttack); // **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 @@ -269,7 +280,10 @@ fn main() { // 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 + // **The probe's row decides "never fired", not the others.** + // A module is unreachable only if the seat built to reach it + // could not either. + let never: Vec<&String> = a .unfired .iter() .filter(|(_, n)| **n == GAMES) @@ -288,8 +302,8 @@ fn main() { ) }; println!( - " {aspect:<18} {name:<38} {:>5} {:>5} {:>6} {:>6} {:>6}{note}", - g.won, m.won, m.peak_stress, m.darvo, m.attacks + " {aspect:<18} {name:<38} {:>5} {:>5} {:>5} {:>6} {:>6} {:>6}{note}", + g.won, m.won, a.won, a.peak_stress, a.darvo, a.attacks ); } println!(); diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index 4ddf83b..f3eca5b 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -1356,6 +1356,71 @@ impl Policy for ModuleAwarePolicy { } } +/// A seat that **attacks at the stress gate** — F31's falsifier. +/// +/// ## This is a probe, not a better bot +/// +/// `attack_relief.self_soothe_ge4` acts only on an uncancelled ATTACK by +/// a seat at Stress >= 4. `module-panel` found **zero ATTACKs in every +/// cell**, so the module has never had an opportunity and no claim about +/// it is supported in either direction — including the one embedded in +/// H1's rejection, whose other half is this module. +/// +/// The obstacle is a ranking, not the game: `GreedyPolicy` ranks +/// `Ground` at 100 when the gate bites against `Attack`'s 10, so at +/// exactly the position where self-soothe would pay, GROUND wins. +/// `ModuleAwarePolicy` adds 55 and still loses. +/// +/// **So this exists to create the precondition, and nothing else.** It is +/// not offered as good play and its win rate is not a recommendation. +/// The question it answers is whether the module *does* anything once +/// reachable — measured by holding this policy fixed and varying only +/// the module, which is the only comparison that isolates it. +/// +/// **It delegates**, overriding exactly one arm. CB-WP-0039 re-typed an +/// abridged greedy and called it "one preference changed"; it differed +/// in five, and every number measuring it was measuring the difference. +pub struct GateAttackPolicy; + +impl GateAttackPolicy { + pub fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 { + let gated = state + .players + .get(&seat) + .is_some_and(|p| p.stress >= 4 && !p.freedom_gate_lifted); + match cmd { + // ONE ARM OVERRIDDEN. At the gate, ATTACK outranks GROUND's + // 100 — which is the whole intervention. + GroundCommand::SelectAction { + action: Action::Attack, + .. + } if gated => 110, + other => ModuleAwarePolicy::rank(state, seat, other), + } + } +} + +impl Policy for GateAttackPolicy { + fn name(&self) -> &'static str { + "gate-attack" + } + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + _may_pass: bool, + ) -> Choice { + let mut best = 0; + for (i, c) in legal.iter().enumerate() { + if Self::rank(state, seat, c) > Self::rank(state, seat, &legal[best]) { + best = i; + } + } + Choice::Command(best) + } +} + /// **A policy is bound by what its seat can see** ([ADR-0023]). /// /// `Policy::choose` takes the whole `GroundState`, which carries every @@ -1545,6 +1610,8 @@ mod blindness_tests { .unwrap_or_else(|e| panic!("{players}p {mode:?} seed {seed}: {e}")); is_blind(|| ModuleAwarePolicy, &st, seat) .unwrap_or_else(|e| panic!("{players}p {mode:?} seed {seed}: {e}")); + is_blind(|| GateAttackPolicy, &st, seat) + .unwrap_or_else(|e| panic!("{players}p {mode:?} seed {seed}: {e}")); } } } @@ -1884,6 +1951,51 @@ mod blindness_tests { ); } + /// **The probe actually attacks** — F31's falsifier must create the + /// precondition, or it falsifies nothing. + /// + /// Asserted on real games rather than on the ranking, because a rank + /// of 110 proves an intention and the panel needs an occurrence. + #[test] + fn the_gate_attack_probe_creates_the_precondition_it_exists_for() { + let mut attacks = 0; + let mut gated_attacks = 0; + for seed in 0..40u64 { + let st = deal(4, seed).with_config(crate::config::Configuration::select("h1").unwrap()); + let mut ps: Vec> = (0..4) + .map(|_| Box::new(GateAttackPolicy) as Box) + .collect(); + let Ok(g) = play(st, &mut ps) else { continue }; + for (_, c) in &g.steps { + if let GroundCommand::SelectAction { + action: Action::Attack, + .. + } = c + { + attacks += 1; + } + } + // The module needs an ATTACK **at Stress >= 4**, which is a + // stronger condition than "an ATTACK happened". + if g.events + .iter() + .any(|e| matches!(e, crate::GroundEvent::StressSet { stress, .. } if *stress >= 4)) + && attacks > 0 + { + gated_attacks += 1; + } + } + assert!( + attacks > 0, + "the probe never attacked, so it cannot falsify F31" + ); + assert!( + gated_attacks > 0, + "the probe attacked but never with a seat at the gate, which is \ + the only position attack_relief.self_soothe_ge4 acts on" + ); + } + /// And the shipped policies are blind. #[test] fn every_shipped_policy_is_blind_to_what_its_seat_cannot_see() { diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md index fa54efd..8b19008 100644 --- a/specs/FindingRegister.md +++ b/specs/FindingRegister.md @@ -56,7 +56,7 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by | F26 | inert | ruled | `crates/cb-render-html/src/lib.rs::a_scoped_table_says_what_a_scope_does` | counterexample | 2026-08-08 | ground-game | | F27 | unplayed | reported | `games/ground/examples/scenario-panel.rs` | counterexample | 2026-08-08 | clay-borg | | F29 | inconsistent | applied | `games_ground::edition::card_text_tests::which_scenarios_are_mechanically_distinct` | counterexample | 2026-08-08 | ground-game | -| F31 | unplayed | raised | `games/ground/examples/module-panel.rs` | counterexample | 2026-08-08 | clay-borg | +| F31 | unplayed | reported | `games/ground/examples/module-panel.rs` | counterexample | 2026-08-08 | clay-borg | | F30 | degenerate | ruled | `games/ground/examples/scenario-panel.rs` | counterexample | 2026-08-08 | ground-game | | F28 | underdetermined | applied | `games_ground::tests::the_mastery_rating_and_the_shared_score_count_different_things` | counterexample | 2026-08-08 | ground-game | @@ -95,6 +95,49 @@ are the whole register and `make design` reads them from here. **no claim about this module is supported in either direction**, and CB-EV-0030's H1 verdict rests on its flat-pressure half alone. + **FALSIFIED, 2026-08-08, and the answer has two parts.** + `GateAttackPolicy` — a probe that ranks ATTACK above GROUND at the + stress gate, delegating everything else — was built for exactly this. + + **(a) The module is unreachable in the printed game, not merely + unchosen.** With `attack_relief.self_soothe_ge4` selected *alone* the + probe still never attacks and the row still reads `never fired`: + **peak Stress never exceeds 2**, so the gate never bites and no ATTACK + is ever gated. The baseline has no inbound Stress (F17's observation + from the other side), so this module needs a `problem_stress` module + before it can act at all. **A module can be unreachable because of an + aspect it does not name.** + + **(b) Once reachable it is real.** Holding the probe fixed and varying + only the module — the only comparison that isolates it: + + | seats | `scoped` | `scoped_plus_attack_soothe` | group wins | DARVO arms | + |---|---|---|---|---| + | 3p | 12 | **21** | +9 | 299 → 221 | + | 4p | 19 | **28** | +9 | 294 → 213 | + | 6p | 50 | **57** | +7 | 334 → 164 | + + Under flat pressure the same shape appears in DARVO alone: `h1` against + `problem_stress.flat_any_open` arms 200 against 300 / 400 / 600 at + 3/4/6p, with group wins 0 in both. + + **Stated as the conditional it is:** *given seats that attack at the + gate*, self-soothe raises group success by ~7–9 per 100 and cuts DARVO + arming by a quarter to a half. The probe is deliberately poor play — it + wins 12/100 where greedy wins 60/100 under the same module — so this is + **not** a recommendation to attack, and the row is not evidence against + F17. + + **What this changes about H1.** CB-EV-0030 rejected H1 with policies + that never attacked, so H1-B was inert for every game behind that + verdict. The rejection stands on flat pressure alone — group wins are 0 + with or without self-soothe — but *"H1-B does nothing"* was never + established and is now known to be false. + + **Sensitivity:** vary only the ATTACK rank. At `Attack = 10` (greedy) + and at `+55` (module-aware) the module never fires; at `110` it fires + in every game. Nothing about the module changed — only whether any seat + ever gave it a turn. - **F30 — SCN_04 is materially harder at 2 players.** 52/100 group success against 67 and 73, with **every other parameter held by the edition itself**: same deal shape, same 6 available points, same diff --git a/workplans/CB-WP-0050-every-configuration-faceted-by-aspect.md b/workplans/CB-WP-0050-every-configuration-faceted-by-aspect.md index c16f510..b8867fe 100644 --- a/workplans/CB-WP-0050-every-configuration-faceted-by-aspect.md +++ b/workplans/CB-WP-0050-every-configuration-faceted-by-aspect.md @@ -84,16 +84,60 @@ The numbers in such a row are real for the modules that did fire. Saying which one did not is what stops the row being read as a verdict on all of them. +## Task: F31's falsifier + +```task +id: CB-WP-0050-T02 +status: done +priority: high +``` + +`GateAttackPolicy` ranks ATTACK above GROUND at the stress gate and +delegates everything else. **A probe, not a better bot** — its win rate +is not a recommendation, and the panel's banner says so, because a +column that looks like a policy comparison will be read as one. + +**Controls:** +- **it actually attacks, and at the gate** — asserted on real games, not + on the ranking: a rank of 110 proves an intention where the panel needs + an occurrence; +- **blind** (ADR-0023); +- **the probe's row decides "never fired"**, not the others — a module is + unreachable only if the seat built to reach it could not reach it + either. + +**Done 2026-08-08.** F31 answered, in two parts. + +**(a) The module is unreachable in the printed game.** Selected *alone* +it still never fires: peak Stress never exceeds 2, so the gate never +bites and no ATTACK is ever gated. **A module can be unreachable because +of an aspect it does not name** — `attack_relief` needs a +`problem_stress` module before it can act at all, and nothing in its own +declaration says so. + +**(b) Once reachable it is real.** Holding the probe fixed and varying +only the module: group wins +9 / +9 / +7 at 3/4/6p, and DARVO arming down +by a quarter to a half. + +Stated as the conditional it is: *given seats that attack at the gate*. +The probe wins 12/100 where greedy wins 60/100 under the same module, so +this is not advice to attack and not evidence against F17. + +**And it changes what we told ground-game about H1.** CB-EV-0030 rejected +H1 with policies that never attacked, so H1-B was inert for every game +behind that verdict. The rejection stands on flat pressure alone — wins +are 0 either way — but *"H1-B does nothing"* was never established and is +now known to be false. + ## Not done here - **The panel measures SHARED GROUND only.** Mode × module is a three-dimensional sweep and nothing yet says the modules behave the same under the competitive modes — where, per CB-EV-0033, *who wins* moves even when group success does not. -- **F31 has no falsifier running.** A seat that attacks at the gate would - settle whether `attack_relief.self_soothe_ge4` is real or merely - unreachable; until one exists, no claim about that module is supported - in either direction. +- **ground-game has not been told about F31.** It bears on a verdict we + already reported to them (H1's rejection), and a correction we owe is + worth more than one we volunteer. - **The scope term is inert at round-one positions** (CB-WP-0049 T04), so `problem_stress.scoped`'s measured effect is driven by later rounds. The panel does not report per-round effect, and that is where the