CB-WP-0049 T04: a seat that attends to the modules in play
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:
tegwick 2026-08-09 01:25:22 +02:00
parent d713fbc1bc
commit 8ae68195b8
4 changed files with 379 additions and 3 deletions

View file

@ -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() {

View file

@ -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()
}

View file

@ -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"
---

View file

@ -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