Compare commits
2 commits
650f9b3a0b
...
8ae68195b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ae68195b8 | |||
| d713fbc1bc |
5 changed files with 382 additions and 6 deletions
|
|
@ -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 | 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-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-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 |
|
||||
|
|
|
|||
|
|
@ -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