CB-WP-0049 T02/T03: a seat that plays its objective, and F27 splits in two
Some checks failed
ci / check (push) Has been cancelled

objective() reads GroundState::score (now public) rather than restating
what winning is; a copy in the bot would disagree with the kernel the
first time ground-game rules on F28.

Working out WHERE the modes can differ was most of the task and it
bounds the result: SOLVE always claims for the actor, so own-score and
group-score want the same SOLVE nearly everywhere. That is a fact about
GROUND's action set, not a shortcoming of the bot. Two real divergences,
both readable off the table: SUPPORT regulates someone else (worth less
against a rival, worth MORE under coalitions where a Bond merges them
into my side), and SOLVE's value is the card's value, which greedy
ignores entirely.

THE RESULT — F27 splits in two:
  group success  UNCHANGED in 34 of 36 cells
  who wins       MOVES: BONDED COALITIONS at 4p goes 2.04 -> 2.98,
                 2.12 -> 3.29, 2.05 -> 3.01 winning seats per game

So "the competitive modes are scoring lenses over cooperative play" was
too strong and is withdrawn. The sharper claim: GROUND's scoring modes
change WHO WINS, not WHETHER THE GROUP SUCCEEDS. And the effect is
seat-band dependent -- 2p none, 4p largest, 6p none under coalitions;
two relation slots capping network growth is a candidate explanation and
is untested.

The panel now prints BOTH policies side by side. That was a correction
mid-task: the first version printed only the new one and I compared it
against a figure remembered from CB-WP-0047 -- a comparison against a
board nobody re-ran.

Control that makes the numbers mean anything: under SHARED GROUND the
two policies agree at all but <=2 decision points across 12 boards, so a
moving column is mode-awareness and not simply a different bot.

Also: two T01 tests keyed on `status: proposed`, which ground-game
renamed to `ready-for-implement` mid-session. They now find the module
by asking resolve() -- the structural property is ours and does not move
when another repo edits its vocabulary.

Also: `make vendor` replaces three hand re-vendors with a tool that
regenerates digests by walking editions/, and reports one-sided files
rather than resolving them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 23:31:11 +02:00
parent 82b9e7df31
commit 3045eb03f8
16 changed files with 859 additions and 94 deletions

View file

@ -14,7 +14,7 @@
use crate::{
Action, DarvoTarget, GroundChoice, GroundCommand, GroundMode, GroundState, Relation, RoundStep,
Selection, SupportResponse,
ScoringMode, Selection, SupportResponse,
};
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
@ -1100,6 +1100,155 @@ mod tests {
// than removed, and carries why the numbers changed.
}
/// What a seat is trying to maximise (CB-WP-0049 T02).
///
/// **Read off `GroundState::score`, never restated.** The game already
/// computes all three answers to build its `Outcome`; a copy in the bot
/// would be a second definition of winning, and the two would disagree
/// the first time ground-game rules on F28.
///
/// | mode | objective |
/// |---|---|
/// | SHARED GROUND | the group total |
/// | COMMON PROBLEM | own claimed value own Blame |
/// | BONDED COALITIONS | own coalition's summed personal score |
///
/// It reads only `claimed_by`, `value` of **claimed** Problems and Blame
/// tokens — all of which are on the table — so it is blind by
/// construction (ADR-0023) rather than by inspection.
pub fn objective(state: &GroundState, seat: PlayerId) -> i32 {
let o = state.score();
match state.mode {
ScoringMode::SharedGround => o.total as i32,
ScoringMode::CommonProblem => o.personal.get(&seat).copied().unwrap_or(0),
ScoringMode::BondedCoalitions => o
.coalitions
.iter()
.find(|c| c.members.contains(&seat))
.map(|c| c.score)
// A seat in no coalition is a one-seat coalition (GR-E04), so
// falling back to its own score is the rule and not a guess.
.unwrap_or_else(|| o.personal.get(&seat).copied().unwrap_or(0)),
}
}
/// A seat that plays **its own** objective (CB-WP-0049 T03).
///
/// ## Where the modes actually differ, and where they cannot
///
/// Working this out was most of the task. **SOLVE always claims for the
/// actor**, so a seat maximising its own score and a seat maximising the
/// group's want the same SOLVE in almost every position — which is a
/// fact about GROUND's action set, not a shortcoming of the bot, and it
/// bounds how far apart any two policies can get.
///
/// Two places the objective really does diverge, both readable off the
/// table:
///
/// - **SUPPORT regulates someone else.** Under SHARED GROUND that is
/// worth what it is worth to the group. Under COMMON PROBLEM the
/// beneficiary is a rival, so it is worth less. Under BONDED
/// COALITIONS a Bond *merges that seat into my coalition*, and my score
/// becomes the coalition's sum — so it is worth more, and worth most
/// with a seat not already in my network.
/// - **SOLVE's value is the Problem's value.** `GreedyPolicy::rank` gives
/// every legal SOLVE 90 regardless of what the card is worth. Under a
/// competitive objective the difference between a 2 and a 3 is the
/// whole margin.
///
/// **It delegates.** Every arm this does not name comes from
/// `GreedyPolicy::rank`. CB-WP-0039 re-typed an abridged copy of greedy
/// and called it "one preference changed"; it differed in five places and
/// burned the Freedom token in round one of every game. Delegating makes
/// "these arms and no others" structurally true instead of a claim in a
/// comment.
///
/// **Blind by construction** (ADR-0023): it reads Problem values only for
/// face-up Problems, and relations, which are on the table.
pub struct ObjectivePolicy;
impl ObjectivePolicy {
/// The seats whose personal score counts toward `seat`'s objective.
fn my_side(state: &GroundState, seat: PlayerId) -> Vec<PlayerId> {
match state.mode {
// Everyone's claims are my claims.
ScoringMode::SharedGround => state.players.keys().copied().collect(),
ScoringMode::CommonProblem => vec![seat],
ScoringMode::BondedCoalitions => state
.score()
.coalitions
.into_iter()
.find(|c| c.members.contains(&seat))
.map(|c| c.members)
.unwrap_or_else(|| vec![seat]),
}
}
pub fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 {
let base = GreedyPolicy::rank(state, seat, cmd);
match cmd {
// SOLVE, weighted by what the card is worth. Face-up only:
// `legal_commands` offers SOLVE on face-up Problems, and the
// lookup returns nothing for a card this seat cannot read.
GroundCommand::SelectAction {
action: Action::Solve,
problem: Some(n),
..
} => {
let worth = state
.problems
.get(n)
.filter(|p| p.face_up && p.claimed_by.is_none())
.map(|p| i32::from(p.value))
.unwrap_or(0);
base + worth
}
// SUPPORT, weighted by whether the target is on my side.
GroundCommand::SelectAction {
action: Action::Support,
target: Some(other),
..
} => {
let side = Self::my_side(state, seat);
if side.contains(other) {
base + 5
} else {
match state.mode {
// A Bond would bring them onto my side, and my
// score is my side's sum.
ScoringMode::BondedCoalitions => base + 10,
// Regulating a rival is work I do for them.
ScoringMode::CommonProblem => base - 30,
ScoringMode::SharedGround => base,
}
}
}
_ => base,
}
}
}
impl Policy for ObjectivePolicy {
fn name(&self) -> &'static str {
"objective"
}
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
@ -1272,6 +1421,135 @@ mod blindness_tests {
assert!(err.contains("peeker"), "{err}");
}
/// The new policy is blind too — the point of T01 being first.
#[test]
fn the_objective_policy_is_blind() {
for seed in [1u64, 7, 42] {
for players in [2u8, 4, 6] {
for mode in [
ScoringMode::SharedGround,
ScoringMode::CommonProblem,
ScoringMode::BondedCoalitions,
] {
let mut st = deal(players, seed);
st.mode = mode;
for seat in st.players.keys().copied() {
is_blind(|| ObjectivePolicy, &st, seat)
.unwrap_or_else(|e| panic!("{players}p {mode:?} seed {seed}: {e}"));
}
}
}
}
}
/// **The objective is the game's own scoring** (CB-WP-0049 T02).
#[test]
fn the_objective_agrees_with_the_outcome() {
let mut st = deal(4, 5);
let seats: Vec<PlayerId> = st.players.keys().copied().collect();
// Claim two Problems for two different seats, so group and
// personal cannot coincide by accident.
let keys: Vec<u32> = st.problems.keys().copied().collect();
st.problems.get_mut(&keys[0]).unwrap().claimed_by = Some(seats[0]);
st.problems.get_mut(&keys[1]).unwrap().claimed_by = Some(seats[1]);
let o = st.score();
st.mode = ScoringMode::SharedGround;
assert_eq!(objective(&st, seats[0]), o.total as i32);
// Everyone shares one number.
assert_eq!(objective(&st, seats[0]), objective(&st, seats[2]));
st.mode = ScoringMode::CommonProblem;
assert_eq!(objective(&st, seats[0]), o.personal[&seats[0]]);
// **And a seat's objective is NOT the group's** — the claim that
// makes a competitive mode competitive, asserted on a board
// rather than argued.
assert_ne!(
objective(&st, seats[0]),
st.score().total as i32,
"under COMMON PROBLEM a seat's objective coincided with the group's"
);
assert_ne!(
objective(&st, seats[0]),
objective(&st, seats[2]),
"a claiming seat and an empty-handed seat had the same objective"
);
st.mode = ScoringMode::BondedCoalitions;
let mine = st
.score()
.coalitions
.into_iter()
.find(|c| c.members.contains(&seats[0]))
.expect("every seat is in a coalition");
assert_eq!(objective(&st, seats[0]), mine.score);
}
/// **Under SHARED GROUND the objective policy IS greedy** — the
/// control that separates "attends to the objective" from "plays
/// differently".
///
/// Without it, any change in the panel could be the new policy simply
/// being a different bot. The two must agree wherever the objective
/// is the group's, and diverge only where it is not.
#[test]
fn under_shared_ground_the_objective_policy_and_greedy_want_the_same_thing() {
let mut differed = 0;
for seed in [1u64, 7, 42, 99] {
for players in [2u8, 4, 6] {
let mut st = deal(players, seed);
st.mode = ScoringMode::SharedGround;
for seat in st.players.keys().copied() {
let legal = legal_commands(&st, seat);
if legal.is_empty() {
continue;
}
let a = GreedyPolicy.choose(&st, seat, &legal, false);
let b = ObjectivePolicy.choose(&st, seat, &legal, false);
if a != b {
differed += 1;
}
}
}
}
// SOLVE is weighted by card value in every mode, so a tie greedy
// broke by order can break the other way here. That is a
// refinement of greedy's own objective, not a different one — so
// the assertion is that divergence is RARE, with the number
// stated rather than a vague "mostly".
assert!(
differed <= 2,
"under SHARED GROUND the two policies differed at {differed} decision points; they are supposed to share an objective"
);
}
/// **Under COMMON PROBLEM they do NOT** — and the reason is Support.
#[test]
fn a_competitive_seat_values_supporting_a_rival_less() {
let mut st = deal(4, 5);
let seat = PlayerId(0);
let other = PlayerId(1);
let support = GroundCommand::SelectAction {
action: Action::Support,
target: Some(other),
problem: None,
};
st.mode = ScoringMode::SharedGround;
let shared = ObjectivePolicy::rank(&st, seat, &support);
st.mode = ScoringMode::CommonProblem;
let selfish = ObjectivePolicy::rank(&st, seat, &support);
st.mode = ScoringMode::BondedCoalitions;
let coalition = ObjectivePolicy::rank(&st, seat, &support);
assert!(
selfish < shared,
"a seat scoring only its own claims valued regulating a rival the same as a cooperative seat did ({selfish} vs {shared})"
);
assert!(
coalition > shared,
"under BONDED COALITIONS a Bond brings that seat's score into mine, so Support is worth MORE, not the same ({coalition} vs {shared})"
);
}
/// And the shipped policies are blind.
#[test]
fn every_shipped_policy_is_blind_to_what_its_seat_cannot_see() {

View file

@ -283,6 +283,29 @@ fn no_kernel_path(cat: &crate::catalog::Catalog, id: &str) -> String {
mod tests {
use super::*;
/// A module the catalog has and this kernel has **no path for**.
///
/// **Found by asking `resolve`, not by matching a status string.**
/// The first version searched for `status: proposed` — and
/// ground-game renamed those to `ready-for-implement` the same day,
/// so two tests failed over a word neither of them was about. The
/// structural property is ours to determine and does not move when
/// another repo edits its vocabulary; the same lesson as matching a
/// rules passage by heading rather than by row number (CB-WP-0046).
///
/// `None` when the kernel implements everything — a real state, and
/// the day it arrives these tests should skip rather than fail.
fn a_module_we_cannot_run() -> Option<(String, String)> {
let cat = crate::catalog::catalog().ok()?;
cat.modules.iter().find_map(|m| {
let mut c = Configuration::default();
c.modules.insert(m.aspect.clone(), m.module_id.clone());
c.resolve()
.is_err()
.then(|| (m.aspect.clone(), m.module_id.clone()))
})
}
/// **A proposed module is refused BY NAME, distinguishably from a
/// typo** (ADR-0022 D1).
///
@ -292,23 +315,16 @@ mod tests {
/// statement about the edition, and the wrong-subject family again.
#[test]
fn a_proposed_module_and_a_typo_are_different_errors() {
let cat = crate::catalog::catalog().expect("catalog.yaml");
// A module the catalog has, with a rules_delta, that no kernel
// path implements. Found in the catalog, not hardcoded: if
// ground-game implements it upstream this test looks elsewhere
// rather than going stale.
let proposed = cat
.modules
.iter()
.find(|m| m.status == "proposed")
.expect("the catalog has at least one proposed module");
let Some((aspect, module)) = a_module_we_cannot_run() else {
// The kernel implements every module the catalog has. Nothing
// to distinguish, and nothing broken.
return;
};
let mut c = Configuration::default();
c.modules
.insert(proposed.aspect.clone(), proposed.module_id.clone());
c.modules.insert(aspect.clone(), module.clone());
let refused = c.resolve().expect_err("a proposed module must be refused");
assert!(
refused.contains(&proposed.module_id) && refused.contains("no kernel path"),
refused.contains(&module) && refused.contains("no kernel path"),
"a proposed module was not refused by name: {refused}"
);
assert!(
@ -318,10 +334,7 @@ mod tests {
// And a real typo says the other thing.
let mut typo = Configuration::default();
typo.modules.insert(
proposed.aspect.clone(),
format!("{}_zzz", proposed.module_id),
);
typo.modules.insert(aspect, format!("{module}_zzz"));
let unknown = typo.resolve().expect_err("a typo must be refused");
assert!(
unknown.contains("is not a module the catalog has"),
@ -358,21 +371,12 @@ mod tests {
/// failure (ADR-0022 D0).
#[test]
fn a_module_with_no_kernel_path_still_names_a_configuration() {
let cat = crate::catalog::catalog().expect("catalog.yaml");
let proposed = cat
.modules
.iter()
.find(|m| m.status == "proposed")
.expect("a proposed module");
let c = Configuration::from_modules(
"ground-darvo-r0",
std::slice::from_ref(&proposed.module_id),
)
.expect("selecting a catalog module must NAME a configuration");
assert_eq!(
c.module_on(&proposed.aspect),
Some(proposed.module_id.as_str())
);
let Some((aspect, module)) = a_module_we_cannot_run() else {
return;
};
let c = Configuration::from_modules("ground-darvo-r0", std::slice::from_ref(&module))
.expect("selecting a catalog module must NAME a configuration");
assert_eq!(c.module_on(&aspect), Some(module.as_str()));
// It round-trips, so a recording can carry it.
let s = serde_yaml::to_string(&c).unwrap();
let back: Configuration = serde_yaml::from_str(&s).unwrap();

View file

@ -1834,7 +1834,15 @@ impl GroundState {
}
/// GR-E01..E04: final scoring for the configured mode.
fn score(&self) -> Outcome {
/// **Public since CB-WP-0049 T02.** A policy needs to know what its
/// seat is trying to maximise, and the game already computes all
/// three answers here. A second copy in the bot would be a second
/// definition of winning — and the two would disagree the first time
/// ground-game rules on F28's mastery reading.
///
/// It is a pure function of the state, so it answers mid-game too:
/// "what would this position score if it stopped now".
pub fn score(&self) -> Outcome {
// GR-E01/P03: a claimed Problem counts its printed value.
let total: u32 = self
.problems