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