diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 4a55107..b325c29 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -253,9 +253,9 @@ | task | CB-WP-0047-T01 | done | — | workplans/CB-WP-0047-all-four-boards-and-all-three-modes.md | | task | CB-WP-0048-T00 | done | — | workplans/CB-WP-0048-a-configuration-not-a-variant.md | | task | CB-WP-0048-T01 | done | — | workplans/CB-WP-0048-a-configuration-not-a-variant.md | -| task | CB-WP-0048-T02 | done | — | workplans/CB-WP-0048-a-configuration-not-a-variant.md | -| task | CB-WP-0048-T03 | done | — | workplans/CB-WP-0048-a-configuration-not-a-variant.md | -| task | CB-WP-0048-T04 | done | — | workplans/CB-WP-0048-a-configuration-not-a-variant.md | +| task | CB-WP-0048-T02 | todo | — | workplans/CB-WP-0048-a-configuration-not-a-variant.md | +| task | CB-WP-0048-T03 | todo | — | workplans/CB-WP-0048-a-configuration-not-a-variant.md | +| task | CB-WP-0048-T04 | todo | — | workplans/CB-WP-0048-a-configuration-not-a-variant.md | | task | CB-WP-0049-T01 | done | — | workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md | | task | CB-WP-0049-T02 | done | — | workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md | | task | CB-WP-0049-T03 | done | — | workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md | diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index 4ddf83b..4506016 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -1249,113 +1249,6 @@ 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 @@ -1543,8 +1436,6 @@ 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}")); } } } @@ -1659,231 +1550,6 @@ 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 eb8642c..9acd597 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. - pub fn rules(&self) -> crate::config::Rules { + 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 2e7d41a..7094d2a 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: done +status: active 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 ee2fe37..5d672a4 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: done +status: todo priority: normal state_hub_task_id: "1b26c7e9-e049-49d0-aecd-63830c7c8a37" ``` @@ -236,48 +236,6 @@ 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