diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index 4506016..4ddf83b 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -1249,6 +1249,113 @@ impl Policy for ObjectivePolicy { } } +/// A seat that attends to the modules in play (CB-WP-0049 T04). +/// +/// ## One policy, not one per module +/// +/// The obvious shape is `H2AwarePolicy` — and that is the blob schema 2 +/// exists to retire, rebuilt one layer up. This reads the **resolved +/// `Rules`**, so a configuration carrying two modules gets both terms, +/// and a module the kernel gains later is one arm here rather than a new +/// policy for every combination it appears in. +/// +/// ## It delegates twice +/// +/// `ObjectivePolicy::rank` (which delegates to `GreedyPolicy::rank`) is +/// the base, so this differs from an objective-aware seat in **exactly** +/// the arms named below and nowhere else. That is what makes +/// `a_module_aware_seat_is_ordinary_when_no_module_is_live` a real +/// control rather than a hope. +/// +/// ## Both terms read the table +/// +/// `stress_scope` and the owner marker are **public regardless of the +/// card's face** — the H2 rules say to place the owner marker on the +/// card, and at a table everyone can see it. So this is blind +/// (ADR-0023) without needing to reason about hidden Problems. +pub struct ModuleAwarePolicy; + +impl ModuleAwarePolicy { + /// Does this Problem's End-of-Round Stress fall on `seat`? + fn presses(state: &GroundState, seat: PlayerId, problem: u32) -> bool { + use crate::edition::StressScope; + let Some(p) = state.problems.get(&problem) else { + return false; + }; + match p.scope { + None | Some(StressScope::Global) => true, + Some(StressScope::Personal) => p.owner == Some(seat), + Some(StressScope::Bond) => p + .owner + .is_some_and(|o| state.bond_network(o).contains(&seat)), + } + } + + pub fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 { + let base = ObjectivePolicy::rank(state, seat, cmd); + let rules = state.rules(); + match cmd { + // **Clear the Problem that is pressing ME.** Under + // `problem_stress.scoped` an unclaimed Problem ticks only the + // seats in its scope, so two equally valuable cards are not + // equally urgent — which is the whole point of the module and + // the thing no previous bot could see. + GroundCommand::SelectAction { + action: Action::Solve, + problem: Some(n), + .. + } if rules.problem_stress == crate::config::ProblemStress::Scoped => { + if Self::presses(state, seat, *n) { + base + 8 + } else { + base + } + } + // **ATTACK is a Stress tool at the gate.** Under + // `attack_relief.self_soothe_ge4` an uncancelled ATTACK by a + // seat at Stress >= 4 sheds 1. Greedy ranks ATTACK at 10 in + // every position because for the printed rules it does + // nothing for the attacker. + GroundCommand::SelectAction { + action: Action::Attack, + .. + } if rules.attack_relief == crate::config::AttackRelief::SelfSootheGe4 => { + let gated = state + .players + .get(&seat) + .is_some_and(|p| p.stress >= 4 && !p.freedom_gate_lifted); + if gated { + base + 55 + } else { + base + } + } + _ => base, + } + } +} + +impl Policy for ModuleAwarePolicy { + fn name(&self) -> &'static str { + "module-aware" + } + 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 @@ -1436,6 +1543,8 @@ mod blindness_tests { for seat in st.players.keys().copied() { is_blind(|| ObjectivePolicy, &st, seat) .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}")); } } } @@ -1550,6 +1659,231 @@ mod blindness_tests { ); } + /// Two face-up, unclaimed, equally valuable Problems that `seat` can + /// solve either of — the position where a preference between SOLVEs + /// can actually change a choice. + /// + /// **Built, because it does not occur at a fresh deal**: only the + /// Surface Problem is face up in round one, so there is one SOLVE + /// candidate and no ranking term can move the argmax. A control that + /// sweeps fresh deals cannot see a module term fire at all. + fn two_solvable_problems(config: &str) -> (GroundState, PlayerId, u32, u32) { + use crate::edition::StressScope; + let mut st = deal(4, 5).with_config(crate::config::Configuration::select(config).unwrap()); + let seat = PlayerId(0); + let keys: Vec = st.problems.keys().copied().take(2).collect(); + for (i, k) in keys.iter().enumerate() { + let p = st.problems.get_mut(k).unwrap(); + p.face_up = true; + p.denied = false; + p.claimed_by = None; + p.value = 2; + p.suit = crate::Suit::Repair; + p.scope = Some(StressScope::Personal); + p.owner = Some(if i == 0 { PlayerId(1) } else { seat }); + } + st.players.get_mut(&seat).unwrap().hand = vec![ + crate::SolutionCard { + suit: crate::Suit::Repair, + }, + crate::SolutionCard { + suit: crate::Suit::Repair, + }, + ]; + (st, seat, keys[1], keys[0]) + } + + /// **A module-aware seat is ordinary when no module is live** + /// (CB-WP-0049 T04). + /// + /// The control that separates "attends to the module" from "is + /// simply a different bot". Compared against `ObjectivePolicy`, not + /// greedy, so it isolates module-awareness from objective-awareness: + /// the two differ in exactly the arms `ModuleAwarePolicy` names, and + /// under the baseline none of them fire. + #[test] + fn a_module_aware_seat_is_ordinary_when_no_module_is_live() { + for seed in [1u64, 7, 42, 99] { + for players in [2u8, 4, 6] { + for mode in [ + ScoringMode::SharedGround, + ScoringMode::CommonProblem, + ScoringMode::BondedCoalitions, + ] { + let mut st = deal(players, seed); + st.mode = mode; + assert!( + st.config.active_modules().is_empty(), + "the fixture must be the baseline for this control to mean anything" + ); + for seat in st.players.keys().copied() { + let legal = legal_commands(&st, seat); + if legal.is_empty() { + continue; + } + assert_eq!( + ObjectivePolicy.choose(&st, seat, &legal, false), + ModuleAwarePolicy.choose(&st, seat, &legal, false), + "{players}p {mode:?} seed {seed} seat {seat:?}: the two \ + diverged with no module live" + ); + } + } + } + } + + // **The sweep above cannot fail, and mutation said so.** Forcing + // the scope term to fire regardless of configuration left it + // green: at a fresh deal one SOLVE is legal, so no ranking term + // can move the argmax. A control that cannot distinguish the + // property from its negation is not a control (ADR-0006 D3). + // + // This is the same position the divergence test uses, played + // under the BASELINE. There the two policies must agree, and a + // module term that fires anyway shows up immediately. + let (st, seat, mine, theirs) = two_solvable_problems("ground-darvo-r0"); + assert!( + st.config.active_modules().is_empty(), + "the control must run with no module live" + ); + let solve = |n: u32| GroundCommand::SelectAction { + action: Action::Solve, + target: None, + problem: Some(n), + }; + for n in [mine, theirs] { + assert_eq!( + ObjectivePolicy::rank(&st, seat, &solve(n)), + ModuleAwarePolicy::rank(&st, seat, &solve(n)), + "with no module live the module-aware seat ranked SOLVE on \ + Problem {n} differently" + ); + } + } + + /// **And it is NOT ordinary when one is** — or the control above + /// passes by the policy doing nothing at all. + /// + /// **The position is built, not hoped for.** The first version swept + /// fresh deals and found zero divergence, correctly: at a fresh deal + /// only the Surface Problem is face up, so there is never a *choice* + /// between two SOLVEs for scope to decide, and no ranking term can + /// matter where there is one candidate. That is a real limit on when + /// the module reaches a decision, and it is recorded rather than + /// tuned away — the term is **inert at round-one positions**. + #[test] + fn a_module_aware_seat_prefers_the_problem_that_presses_it() { + let (st, seat, mine, theirs) = two_solvable_problems("h2"); + let solve = |n: u32| GroundCommand::SelectAction { + action: Action::Solve, + target: None, + problem: Some(n), + }; + // The objective-aware seat is indifferent: same value, same suit. + assert_eq!( + ObjectivePolicy::rank(&st, seat, &solve(mine)), + ObjectivePolicy::rank(&st, seat, &solve(theirs)), + "the two Problems must be identical to a seat that cannot see scope, \ + or this test is measuring something else" + ); + // The module-aware seat is not. + assert!( + ModuleAwarePolicy::rank(&st, seat, &solve(mine)) + > ModuleAwarePolicy::rank(&st, seat, &solve(theirs)), + "the seat did not prefer the Problem whose Stress falls on it" + ); + } + + /// **ATTACK becomes a Stress tool under `attack_relief`.** + #[test] + fn a_module_aware_seat_attacks_to_shed_stress_only_under_that_module() { + let attack = GroundCommand::SelectAction { + action: Action::Attack, + target: Some(PlayerId(1)), + problem: None, + }; + let at_gate = |cfg: &str| { + let mut st = deal(4, 5).with_config(crate::config::Configuration::select(cfg).unwrap()); + st.players.get_mut(&PlayerId(0)).unwrap().stress = 5; + st + }; + let baseline = at_gate("ground-darvo-r0"); + let soothe = at_gate("h1"); + assert_eq!( + ModuleAwarePolicy::rank(&baseline, PlayerId(0), &attack), + ObjectivePolicy::rank(&baseline, PlayerId(0), &attack), + "ATTACK gained a bonus with no attack_relief module live" + ); + assert!( + ModuleAwarePolicy::rank(&soothe, PlayerId(0), &attack) + > ObjectivePolicy::rank(&soothe, PlayerId(0), &attack), + "under attack_relief.self_soothe_ge4 a gated seat did not value \ + ATTACK as the Stress tool the module makes it" + ); + // Below the gate it sheds nothing, so it is worth no more. + let mut calm = at_gate("h1"); + calm.players.get_mut(&PlayerId(0)).unwrap().stress = 1; + assert_eq!( + ModuleAwarePolicy::rank(&calm, PlayerId(0), &attack), + ObjectivePolicy::rank(&calm, PlayerId(0), &attack), + "a calm seat valued ATTACK as relief it would not receive" + ); + } + + /// The scope term must name the seats the RULE names. + #[test] + fn the_scope_term_follows_the_editions_scope_rule() { + use crate::edition::StressScope; + let mut st = deal(4, 5).with_config(crate::config::Configuration::select("h2").unwrap()); + let seats: Vec = st.players.keys().copied().collect(); + let key = *st.problems.keys().next().unwrap(); + + // personal: the owner and nobody else. + { + let p = st.problems.get_mut(&key).unwrap(); + p.scope = Some(StressScope::Personal); + p.owner = Some(seats[1]); + } + assert!(ModuleAwarePolicy::presses(&st, seats[1], key)); + assert!(!ModuleAwarePolicy::presses(&st, seats[0], key)); + + // global: everyone, owner or not. + st.problems.get_mut(&key).unwrap().scope = Some(StressScope::Global); + for s in &seats { + assert!( + ModuleAwarePolicy::presses(&st, *s, key), + "global missed {s:?}" + ); + } + + // bond: the owner's network. With no Bonds that is the owner + // alone — the rule's own fallback, not a rendering convenience. + { + let p = st.problems.get_mut(&key).unwrap(); + p.scope = Some(StressScope::Bond); + p.owner = Some(seats[1]); + } + st.relations.clear(); + assert!(ModuleAwarePolicy::presses(&st, seats[1], key)); + assert!(!ModuleAwarePolicy::presses(&st, seats[2], key)); + st.relations + .insert(crate::Pair::new(seats[1], seats[2]), crate::Relation::Bond); + assert!( + ModuleAwarePolicy::presses(&st, seats[2], key), + "a Bond partner is in the network and shares the tick" + ); + // Rivalry does NOT expand the network (Rules_Text.csv row 20). + st.relations.clear(); + st.relations.insert( + crate::Pair::new(seats[1], seats[3]), + crate::Relation::Rivalry, + ); + assert!( + !ModuleAwarePolicy::presses(&st, seats[3], key), + "a Rivalry expanded bond scope; the edition says it does not" + ); + } + /// And the shipped policies are blind. #[test] fn every_shipped_policy_is_blind_to_what_its_seat_cannot_see() { diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 9acd597..eb8642c 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -403,7 +403,7 @@ impl GroundState { /// where that is refused loudly; by the time a rule is being applied /// the game is already in progress, and a mid-game panic would turn /// a selection error into a crash at an unrelated moment. - fn rules(&self) -> crate::config::Rules { + pub fn rules(&self) -> crate::config::Rules { self.config.resolve().unwrap_or_default() } diff --git a/workplans/CB-WP-0048-a-configuration-not-a-variant.md b/workplans/CB-WP-0048-a-configuration-not-a-variant.md index 7094d2a..2e7d41a 100644 --- a/workplans/CB-WP-0048-a-configuration-not-a-variant.md +++ b/workplans/CB-WP-0048-a-configuration-not-a-variant.md @@ -2,7 +2,7 @@ id: CB-WP-0048 kind: product title: "A configuration, not a variant" -status: active +status: done state_hub_workstream_id: "44ceb8d8-e6e2-46d6-a694-fd275804b771" --- diff --git a/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md b/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md index 5d672a4..ee2fe37 100644 --- a/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md +++ b/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md @@ -214,7 +214,7 @@ mode-aware. ```task id: CB-WP-0049-T04 -status: todo +status: done priority: normal state_hub_task_id: "1b26c7e9-e049-49d0-aecd-63830c7c8a37" ``` @@ -236,6 +236,48 @@ achievable cheaply, the honest move is one policy that reads the resolved control that separates "attends to the module" from "plays differently"; - **blind** (T01). +**Done 2026-08-08.** `ModuleAwarePolicy` reads the resolved `Rules` — +**one policy, not one per module.** `H2AwarePolicy` would have been the +blob schema 2 exists to retire, rebuilt a layer up; reading `Rules` means +a configuration carrying two modules gets both terms and a module the +kernel gains later is one arm here rather than a new policy for every +combination it appears in. + +It delegates to `ObjectivePolicy::rank`, which delegates to +`GreedyPolicy::rank`, so it differs in exactly two arms: + +| module | term | +|---|---| +| `problem_stress.scoped` | prefer the Problem whose Stress falls **on me** — two equally valuable cards are not equally urgent, which is the module's whole point | +| `attack_relief.self_soothe_ge4` | at the gate, ATTACK is a Stress tool; greedy ranks it 10 in every position because under the printed rules it does nothing for the attacker | + +Both read the table: `stress_scope` and the owner marker are public +regardless of the card's face, because the H2 rules say to place the +marker **on the card** and at a table everyone can see it. So it is blind +(ADR-0023) by construction, and the T01 sweep now covers it. + +### The control I claimed was real was vacuous, and mutation said so + +*"Under the baseline the two must be identical"* swept fresh deals across +four seeds × three seat bands × three modes — and **forcing the scope +term to fire regardless of configuration left it green.** + +At a fresh deal only the Surface Problem is face up, so exactly one SOLVE +is legal and **no ranking term can move the argmax**. The control could +not distinguish the property from its negation, which is the definition +of decoration (ADR-0006 D3). + +Both tests now use a **built** position — two face-up, unclaimed, equally +valuable Problems the seat can solve either of, differing only in scope — +played under H2 for the divergence and under the baseline for the +control. The mutation goes red there. + +**This is also a finding about the module, not only about the test.** The +scope term is **inert at round-one positions**: the module cannot reach a +decision until more than one Problem is solvable, so anything measuring +H2 with round-one-heavy play is measuring a mechanism that has not +started. Recorded rather than tuned away. + ## Two things this pass fixed that were not the task **A test keyed on a word another repo owns.** T01's controls found their