Compare commits

..

No commits in common. "8ae68195b86c12ceb83d678c26eaf6c861ea5b33" and "650f9b3a0bb9ece1785067d204e517411a4e9eb3" have entirely different histories.

5 changed files with 6 additions and 382 deletions

View file

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

View file

@ -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<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.
pub fn rules(&self) -> crate::config::Rules {
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: done
status: active
state_hub_workstream_id: "44ceb8d8-e6e2-46d6-a694-fd275804b771"
---

View file

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