CB-WP-0049 T04: a seat that attends to the modules in play
Some checks are pending
ci / check (push) Waiting to run
Some checks are pending
ci / check (push) Waiting to run
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 two-module configuration gets both terms and a future module is one arm here rather than a new policy per combination. Two terms, both reading the table: prefer the Problem whose Stress falls on me (problem_stress.scoped), and treat ATTACK as a Stress tool at the gate (attack_relief.self_soothe_ge4). stress_scope and the owner marker are public regardless of the card's face -- the H2 rules place the marker ON the card -- so it is blind by construction. THE CONTROL I CLAIMED WAS REAL WAS VACUOUS, AND MUTATION SAID SO. "Under the baseline the two must be identical" swept fresh deals across 36 combinations, 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. Both tests now use a BUILT position -- two face-up, unclaimed, equally valuable Problems differing only in scope -- under H2 for divergence and under the baseline for the control. The mutation goes red there. That is also a finding about the module: the scope term is INERT at round-one positions, so anything measuring H2 with round-one-heavy play is measuring a mechanism that has not started. Recorded, not tuned away. Also: CB-WP-0048 was `active` with every task closed; loop-lint's lifecycle rule caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d713fbc1bc
commit
8ae68195b8
4 changed files with 379 additions and 3 deletions
|
|
@ -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<u32> = 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<PlayerId> = 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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue