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
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:
parent
82b9e7df31
commit
3045eb03f8
16 changed files with 859 additions and 94 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue