From 6197677126aa5a976aba8f94766834f03332b7c7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 1 Aug 2026 14:29:45 +0200 Subject: [PATCH] =?UTF-8?q?CB-WP-0008-T01:=20bots=20=E2=80=94=20the=20kern?= =?UTF-8?q?el's=20first=20non-scenario=20consumer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Policy trait, a seeded random policy and a greedy one with a stated heuristic, a legal-command generator that filters candidates through validate, and a driver that runs a 3-player all-bot game to GameEnded. Same-seed runs are hash-identical (K8), and a different policy seed produces a different game — without that second assertion the first is satisfied by a bot that ignores its RNG. Every failure is loud, because the one a bot driver must not have is the silent one: no legal move, passing where an action is required, an out-of-range index (not clamped), a rejected command, and a stall guard. What the second consumer found, none of it fixed here: - GR-A13 admits SOLVE against an already-claimed Problem and resolution then does nothing — the action is silently wasted. The policy avoids it; the rule is left for a ruling. - The 3-player standard fixture cannot reach GR-E01's threshold of 7: three Problems valued 1,2,3 cap the total at 6. No scenario noticed because none plays to scoring. - K13's Project trait still has zero implementors. T02 is its first. Mutation-checked by hand. The first mutation was a no-op and survived; removing the Resolve call outright turned three tests red for the stated reason. Third instance of the weak-mutation class. Co-Authored-By: Claude Opus 5 --- games/ground/src/bot.rs | 849 +++++++++++++++++++++++++++ games/ground/src/lib.rs | 6 + history/260801-cb-wp-0008-log.md | 64 ++ workplans/CB-WP-0008-ship-stage-0.md | 9 +- 4 files changed, 926 insertions(+), 2 deletions(-) create mode 100644 games/ground/src/bot.rs create mode 100644 history/260801-cb-wp-0008-log.md diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs new file mode 100644 index 0000000..b11cf37 --- /dev/null +++ b/games/ground/src/bot.rs @@ -0,0 +1,849 @@ +//! Bots — the kernel's first non-scenario consumer (CB-WP-0008 T01). +//! +//! Everything here drives `GroundState` through the ordinary +//! `Aggregate::validate` → `fold` path. Nothing in this module reaches +//! into state to mutate it: a bot that did would prove nothing about the +//! kernel, which is the whole reason INTENT wants a *second* consumer. +//! +//! **Stated limit on [`legal_commands`].** It is complete only up to the +//! candidate shapes it enumerates. Every candidate it yields is legal — +//! each one is filtered through `validate` — but a command shape this +//! function forgets to construct is invisible, and nothing here detects +//! that. Under-generation is the failure mode; it would show up as a bot +//! that never uses a rule, not as an error. + +use crate::{ + Action, DarvoTarget, GroundChoice, GroundCommand, GroundMode, GroundState, Relation, RoundStep, + Selection, SupportResponse, +}; +use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed}; + +/// A seat's decision when offered the legal commands available to it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Choice { + /// Index into the offered slice. + Command(usize), + /// Decline to act. Legal only where the driver says it is. + Pass, +} + +/// How one seat decides. Implementors see the whole state — bots are not +/// the place to enforce hidden information; that is K13's projection, and +/// [`crate::bot`] deliberately does not pretend otherwise. +pub trait Policy { + fn name(&self) -> &'static str; + + /// Pick one of `legal`, or [`Choice::Pass`] when `may_pass`. + /// + /// `legal` is never empty when this is called: the driver treats an + /// empty legal set at an obligatory point as [`BotError::NoLegalMove`] + /// rather than asking a policy to invent a move. + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Choice; +} + +/// Every way a bot game can fail. All of them are **loud**: the failure a +/// bot driver must never have is the silent one, where a stalled game is +/// indistinguishable from a finished one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BotError { + /// A seat had to act and had nothing legal to do. + NoLegalMove { + seat: PlayerId, + round: u8, + step: RoundStep, + }, + /// A policy passed where passing is not allowed. + PassedWhenObligatory { + seat: PlayerId, + round: u8, + step: RoundStep, + }, + /// A policy returned an index outside the offered slice. Not clamped: + /// clamping would turn a broken policy into a quietly playing one. + IllegalChoice { + seat: PlayerId, + index: usize, + offered: usize, + }, + /// A command the driver generated was rejected. Since generation is + /// validate-filtered, this means state moved underneath it. + Rejected { + seat: Option, + command: String, + rejection: Rejection, + }, + /// The game did not progress. A bot that loops forever looks like a + /// bot that is working; this is the guard that makes it look broken. + Stalled { round: u8, detail: String }, + /// Fewer policies than seats. + NoPolicy { seat: PlayerId }, +} + +impl core::fmt::Display for BotError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + BotError::NoLegalMove { seat, round, step } => write!( + f, + "player {seat} has no legal command in round {round} {step:?}" + ), + BotError::PassedWhenObligatory { seat, round, step } => write!( + f, + "player {seat} passed in round {round} {step:?}, where an action is required" + ), + BotError::IllegalChoice { + seat, + index, + offered, + } => write!( + f, + "player {seat}'s policy chose index {index} of {offered} offered commands" + ), + BotError::Rejected { + seat, + command, + rejection, + } => write!(f, "{seat:?} issuing {command} was rejected: {rejection}"), + BotError::Stalled { round, detail } => { + write!(f, "game stalled in round {round}: {detail}") + } + BotError::NoPolicy { seat } => write!(f, "no policy for seat {seat}"), + } + } +} + +/// What a completed bot game produced. +#[derive(Debug, Clone)] +pub struct BotGame { + pub state: GroundState, + /// Every event, in order — the log a replay bundle would carry. + pub events: Vec, + /// Commands accepted, player and system alike. + pub commands: usize, + pub rounds: u8, +} + +// ------------------------------------------------------------ generation + +/// Every legal command `seat` may issue right now, in canonical order. +/// +/// Candidates are constructed then filtered through `validate`, so the +/// game rules stay in one place. See the module note for what this +/// cannot tell you. +pub fn legal_commands(state: &GroundState, seat: PlayerId) -> Vec { + let seats: Vec = state.players.keys().copied().collect(); + let problems: Vec = state.problems.keys().copied().collect(); + let mut candidates: Vec = Vec::new(); + + match state.step { + RoundStep::Select => { + for action in [ + Action::Investigate, + Action::Solve, + Action::Support, + Action::Attack, + Action::Ground, + ] { + match action { + Action::Investigate | Action::Solve => { + for problem in &problems { + candidates.push(GroundCommand::SelectAction { + action, + target: None, + problem: Some(*problem), + }); + } + } + Action::Support | Action::Attack => { + for other in &seats { + candidates.push(GroundCommand::SelectAction { + action, + target: Some(*other), + problem: None, + }); + } + } + Action::Ground => candidates.push(GroundCommand::SelectAction { + action, + target: None, + problem: None, + }), + } + } + candidates.push(GroundCommand::SpendFreedom); + } + RoundStep::Reveal => { + candidates.push(GroundCommand::ChooseGroundMode { + mode: GroundMode::Gr, + choice: None, + }); + for problem in &problems { + for choice in [ + GroundChoice::RestoreProblem { problem: *problem }, + GroundChoice::ProtectProblem { problem: *problem }, + ] { + candidates.push(GroundCommand::ChooseGroundMode { + mode: GroundMode::Ou, + choice: Some(choice), + }); + } + } + for other in &seats { + candidates.push(GroundCommand::ChooseGroundMode { + mode: GroundMode::Ou, + choice: Some(GroundChoice::CancelAttack { attacker: *other }), + }); + for choice in [ + GroundChoice::RemoveBlame { owner: *other }, + GroundChoice::BreakRelation { with: *other }, + ] { + candidates.push(GroundCommand::ChooseGroundMode { + mode: GroundMode::Nd, + choice: Some(choice), + }); + } + } + candidates.push(GroundCommand::ChooseGroundMode { + mode: GroundMode::Nd, + choice: Some(GroundChoice::RejectReverse), + }); + + for response in [ + SupportResponse::AcceptBond, + SupportResponse::DeclineBond, + SupportResponse::FlipToBond, + SupportResponse::BreakRivalry, + ] { + candidates.push(GroundCommand::RespondToSupport { response }); + } + + for problem in &problems { + candidates.push(GroundCommand::ChooseDarvoTarget { + target: DarvoTarget { + problem: Some(*problem), + player: None, + }, + }); + } + for other in &seats { + candidates.push(GroundCommand::ChooseDarvoTarget { + target: DarvoTarget { + problem: None, + player: Some(*other), + }, + }); + } + } + // GR-R06/R08: system-driven; a seat has nothing to issue. + RoundStep::Resolve | RoundStep::End => {} + } + + candidates + .into_iter() + .filter(|cmd| state.validate(Actor::Player(seat), cmd).is_ok()) + .collect() +} + +// --------------------------------------------------------------- policies + +/// Uniform over the legal commands, from a seeded kernel RNG so a bot +/// game stays deterministic and replayable (K8). +pub struct RandomPolicy { + rng: ChaChaRng, + /// Probability, in sixteenths, of taking an optional action rather + /// than passing. Fixed rather than tunable: a knob here would be a + /// parameter nobody has a second use for. + act_in_16: u32, +} + +impl RandomPolicy { + pub fn new(seed: u64) -> Self { + Self { + rng: ChaChaRng::from_seed(Seed(seed)), + act_in_16: 12, + } + } +} + +impl Policy for RandomPolicy { + fn name(&self) -> &'static str { + "random" + } + + fn choose( + &mut self, + _state: &GroundState, + _seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Choice { + if may_pass && self.rng.draw(16) >= self.act_in_16 { + return Choice::Pass; + } + Choice::Command(self.rng.draw(legal.len() as u32) as usize) + } +} + +/// A stated heuristic, in priority order: +/// +/// 1. **Get out from under the stress gate.** At Stress ≥ 4 only ATTACK +/// and GROUND are selectable (GR-R03), so GROUND—GR (−2 Stress) is +/// worth more than anything else on the board. +/// 2. **SOLVE** a face-up Problem — the only action that adds to the +/// shared total the outcome is scored against (GR-E01). +/// 3. **INVESTIGATE** — turns a hidden Problem face up, which is what +/// makes a later SOLVE possible. +/// 4. **SUPPORT** — Bonds are the cheapest relationship and feed GR-E04. +/// 5. **GROUND**, then **ATTACK** last: attacking raises another seat's +/// Stress, which under SHARED GROUND scoring costs the group. +/// +/// Deterministic by construction: ties break toward the earlier candidate +/// in canonical order, so two runs of a greedy game are identical without +/// needing a seed. +#[derive(Debug, Default, Clone, Copy)] +pub struct GreedyPolicy; + +impl GreedyPolicy { + fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 { + let gated = state + .players + .get(&seat) + .is_some_and(|p| p.stress >= 4 && !p.freedom_gate_lifted); + match cmd { + GroundCommand::SelectAction { + action, problem, .. + } => match action { + Action::Ground if gated => 100, + // GR-A13 admits SOLVE against an already-claimed Problem + // (it names only "face-up, non-Denied"), and resolution + // then does nothing — the action is silently wasted. The + // policy avoids it; the *rule* is left alone, and the gap + // is recorded rather than patched here. + Action::Solve + if problem + .and_then(|p| state.problems.get(&p)) + .is_some_and(|p| p.claimed_by.is_some()) => + { + 5 + } + Action::Solve => 90, + Action::Investigate => 80, + Action::Support => 70, + Action::Ground => 60, + Action::Attack => 10, + }, + // Only worth spending when the gate is actually biting. + GroundCommand::SpendFreedom => { + if gated { + 95 + } else { + 0 + } + } + GroundCommand::ChooseGroundMode { mode, choice } => match (mode, choice) { + // GR-A10: the only mode that lowers our own Stress. + (GroundMode::Gr, _) => 90, + (_, Some(GroundChoice::RestoreProblem { .. })) => 80, + (_, Some(GroundChoice::CancelAttack { .. })) => 75, + (_, Some(GroundChoice::ProtectProblem { .. })) => 70, + (_, Some(GroundChoice::RemoveBlame { .. })) => 65, + _ => 40, + }, + // GR-L02/A05: take the Bond wherever one is on offer. + GroundCommand::RespondToSupport { response } => match response { + SupportResponse::AcceptBond | SupportResponse::FlipToBond => 90, + SupportResponse::BreakRivalry => 50, + SupportResponse::DeclineBond => 10, + }, + GroundCommand::ChooseDarvoTarget { .. } => 50, + GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => -1, + } + } +} + +impl Policy for GreedyPolicy { + fn name(&self) -> &'static str { + "greedy" + } + + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + _may_pass: bool, + ) -> Choice { + let mut best = 0usize; + let mut best_rank = i32::MIN; + for (i, cmd) in legal.iter().enumerate() { + let rank = Self::rank(state, seat, cmd); + if rank > best_rank { + best_rank = rank; + best = i; + } + } + Choice::Command(best) + } +} + +// ----------------------------------------------------------------- driver + +/// Rounds the driver will run before declaring a stall. GR-R09 ends the +/// game after five; anything past that is a defect, not a long game. +const MAX_ROUNDS: u8 = 20; + +/// Attempts a single seat gets within one step. Bounded so a policy that +/// keeps choosing non-advancing commands fails instead of spinning. +const MAX_ATTEMPTS: usize = 8; + +/// Drive `state` to `GameEnded` with one policy per seat. +/// +/// Seats are matched to `policies` by index: `PlayerId(n)` gets +/// `policies[n]`. +pub fn play(mut state: GroundState, policies: &mut [Box]) -> Result { + let mut events = Vec::new(); + let mut commands = 0usize; + + let seats: Vec = state.players.keys().copied().collect(); + for seat in &seats { + if policies.get(seat.0 as usize).is_none() { + return Err(BotError::NoPolicy { seat: *seat }); + } + } + + let mut rounds = 0u8; + while state.outcome.is_none() { + rounds += 1; + if rounds > MAX_ROUNDS { + return Err(BotError::Stalled { + round: rounds, + detail: format!("no outcome after {MAX_ROUNDS} rounds"), + }); + } + let round = state.round; + + // GR-R02 — Select. Every seat must end this step with a selection. + for seat in &seats { + let mut attempts = 0; + while !state.selections.contains_key(seat) { + attempts += 1; + if attempts > MAX_ATTEMPTS { + return Err(BotError::Stalled { + round, + detail: format!("player {seat} never selected an action"), + }); + } + step_seat( + &mut state, + &mut events, + &mut commands, + policies, + *seat, + round, + false, + )?; + } + } + // Positive control: the driver must have done the work it claims. + if state.selections.len() != state.players.len() { + return Err(BotError::Stalled { + round, + detail: format!( + "Select ended with {} of {} selections", + state.selections.len(), + state.players.len() + ), + }); + } + + apply( + &mut state, + &mut events, + &mut commands, + Actor::System, + &GroundCommand::Reveal, + )?; + + // GR-R05 — after Reveal: modes are obligatory for a seat that + // revealed GROUND; Support responses and DARVO targets are not. + for seat in &seats { + let mut attempts = 0; + loop { + attempts += 1; + if attempts > MAX_ATTEMPTS { + return Err(BotError::Stalled { + round, + detail: format!("player {seat} kept acting after Reveal"), + }); + } + let obligatory = state + .selections + .get(seat) + .is_some_and(|s| s.action == Action::Ground) + && !state.ground_modes.contains_key(seat); + if !obligatory && legal_commands(&state, *seat).is_empty() { + break; + } + if !step_seat( + &mut state, + &mut events, + &mut commands, + policies, + *seat, + round, + !obligatory, + )? { + break; + } + } + } + + apply( + &mut state, + &mut events, + &mut commands, + Actor::System, + &GroundCommand::Resolve, + )?; + apply( + &mut state, + &mut events, + &mut commands, + Actor::System, + &GroundCommand::EndRound, + )?; + } + + Ok(BotGame { + state, + events, + commands, + rounds, + }) +} + +/// Offer one seat its legal commands and apply what the policy picks. +/// `Ok(false)` means the seat passed. +fn step_seat( + state: &mut GroundState, + events: &mut Vec, + commands: &mut usize, + policies: &mut [Box], + seat: PlayerId, + round: u8, + may_pass: bool, +) -> Result { + let legal = legal_commands(state, seat); + if legal.is_empty() { + if may_pass { + return Ok(false); + } + return Err(BotError::NoLegalMove { + seat, + round, + step: state.step, + }); + } + let policy = policies + .get_mut(seat.0 as usize) + .ok_or(BotError::NoPolicy { seat })?; + match policy.choose(state, seat, &legal, may_pass) { + Choice::Pass if may_pass => Ok(false), + Choice::Pass => Err(BotError::PassedWhenObligatory { + seat, + round, + step: state.step, + }), + Choice::Command(i) => { + let cmd = legal.get(i).ok_or(BotError::IllegalChoice { + seat, + index: i, + offered: legal.len(), + })?; + apply(state, events, commands, Actor::Player(seat), cmd)?; + Ok(true) + } + } +} + +fn apply( + state: &mut GroundState, + events: &mut Vec, + commands: &mut usize, + actor: Actor, + cmd: &GroundCommand, +) -> Result<(), BotError> { + let produced = state + .validate(actor, cmd) + .map_err(|rejection| BotError::Rejected { + seat: match actor { + Actor::Player(p) => Some(p), + Actor::System => None, + }, + command: format!("{cmd:?}"), + rejection, + })?; + for event in &produced { + state.fold(event); + } + events.extend(produced); + *commands += 1; + Ok(()) +} + +/// The selection a seat made this round, for callers that want to report +/// a game rather than only run one. +pub fn selection_of(state: &GroundState, seat: PlayerId) -> Option { + state.selections.get(&seat).copied() +} + +/// Relation between two seats, for the same reason. +pub fn relation_of(state: &GroundState, a: PlayerId, b: PlayerId) -> Option { + state.relations.get(&crate::Pair::new(a, b)).copied() +} + +#[cfg(all(test, feature = "scenarios"))] +mod tests { + use super::*; + use cb_events::state_hash_hex; + use cb_game_runtime::{ScenarioGame, Setup}; + + fn setup(players: u8, seed: u64) -> GroundState { + GroundState::setup( + &Setup { + players, + preset: format!("standard-{players}p"), + patch: Default::default(), + }, + seed, + ) + .expect("preset") + } + + fn policies(kind: &str, n: u8, seed: u64) -> Vec> { + (0..n) + .map(|i| -> Box { + match kind { + "greedy" => Box::new(GreedyPolicy), + _ => Box::new(RandomPolicy::new(seed + u64::from(i))), + } + }) + .collect() + } + + /// Acceptance, first half: a 3-player all-bot game reaches GR-R09's + /// end with every command legal (nothing was rejected — `play` + /// returns `Err` on the first rejection). + #[test] + fn three_player_all_bot_game_reaches_game_ended() { + for kind in ["random", "greedy"] { + let game = play(setup(3, 42), &mut policies(kind, 3, 42)) + .unwrap_or_else(|e| panic!("{kind} bots: {e}")); + let outcome = game.state.outcome.as_ref().expect("GR-R09 outcome"); + assert_eq!(game.state.round, 5, "{kind}: GR-R09 runs five rounds"); + assert!( + game.events + .iter() + .any(|e| matches!(e, crate::GroundEvent::GameEnded { .. })), + "{kind}: GameEnded must be in the log" + ); + // Positive control: a driver that did nothing would still see + // an outcome if the state arrived scored, so assert the work. + assert!( + game.commands > 3 * 5, + "{kind}: only {} commands for a five-round game", + game.commands + ); + assert!(outcome.threshold > 0); + } + } + + /// Acceptance, second half (K8): same seed, identical state hash. + #[test] + fn same_seed_bot_games_are_hash_identical() { + let a = play(setup(3, 7), &mut policies("random", 3, 7)).expect("run a"); + let b = play(setup(3, 7), &mut policies("random", 3, 7)).expect("run b"); + assert_eq!( + state_hash_hex(&a.state), + state_hash_hex(&b.state), + "same-seed bot games must be hash-identical" + ); + assert_eq!(a.events.len(), b.events.len()); + + // And the seed must matter, or the equality above is vacuous — + // two runs of a bot that ignores its RNG are also identical. + let c = play(setup(3, 7), &mut policies("random", 3, 999)).expect("run c"); + assert_ne!( + state_hash_hex(&a.state), + state_hash_hex(&c.state), + "different policy seeds must produce a different game" + ); + } + + /// The positive control the workplan asks for: no legal move must be + /// loud. A seat the game does not contain has no legal command, and + /// the driver must say so rather than skip it. + #[test] + fn a_seat_with_no_legal_move_fails_loudly() { + let mut state = setup(3, 1); + let ghost = PlayerId(9); + assert!(legal_commands(&state, ghost).is_empty()); + let mut ps = policies("greedy", 10, 0); + let err = step_seat( + &mut state, + &mut Vec::new(), + &mut 0, + &mut ps, + ghost, + 1, + false, + ) + .expect_err("a seat with nothing legal must fail"); + assert!(matches!(err, BotError::NoLegalMove { seat, .. } if seat == ghost)); + } + + /// A policy that passes where it may not is a defect, not a turn. + #[test] + fn passing_when_obligatory_fails_loudly() { + struct Passer; + impl Policy for Passer { + fn name(&self) -> &'static str { + "passer" + } + fn choose( + &mut self, + _: &GroundState, + _: PlayerId, + _: &[GroundCommand], + _: bool, + ) -> Choice { + Choice::Pass + } + } + let mut ps: Vec> = (0..3) + .map(|_| Box::new(Passer) as Box) + .collect(); + let err = play(setup(3, 3), &mut ps).expect_err("a passing bot must not finish a game"); + assert!( + matches!(err, BotError::PassedWhenObligatory { .. }), + "got {err}" + ); + } + + /// An out-of-range index is not clamped to a legal move. + #[test] + fn an_out_of_range_choice_fails_loudly() { + struct Wild; + impl Policy for Wild { + fn name(&self) -> &'static str { + "wild" + } + fn choose( + &mut self, + _: &GroundState, + _: PlayerId, + legal: &[GroundCommand], + _: bool, + ) -> Choice { + Choice::Command(legal.len()) + } + } + let mut ps: Vec> = + (0..3).map(|_| Box::new(Wild) as Box).collect(); + let err = play(setup(3, 3), &mut ps).expect_err("an out-of-range index must fail"); + assert!(matches!(err, BotError::IllegalChoice { .. }), "got {err}"); + } + + /// Every command a bot issues goes through `validate`. This asserts + /// the generator agrees with the kernel rather than trusting it. + #[test] + fn every_generated_command_validates() { + let state = setup(3, 11); + let mut total = 0; + for seat in state.players.keys().copied() { + let legal = legal_commands(&state, seat); + assert!(!legal.is_empty(), "seat {seat} has no opening move"); + for cmd in &legal { + assert!( + state.validate(Actor::Player(seat), cmd).is_ok(), + "generated {cmd:?} is not legal for {seat}" + ); + } + total += legal.len(); + } + assert!( + total > 10, + "only {total} legal opening commands across 3 seats" + ); + } + + /// The greedy heuristic must actually be a heuristic — a policy that + /// ranked everything equally would pick index 0 and still finish. + #[test] + fn greedy_prefers_solve_over_attack() { + let state = setup(3, 5); + let seat = PlayerId(0); + let legal = legal_commands(&state, seat); + let mut p = GreedyPolicy; + let Choice::Command(i) = p.choose(&state, seat, &legal, false) else { + panic!("greedy never passes"); + }; + match &legal[i] { + GroundCommand::SelectAction { action, .. } => assert!( + matches!(action, Action::Solve | Action::Investigate), + "greedy opened with {action:?}" + ), + other => panic!("greedy opened with {other:?}"), + } + } + /// HDN control: a bot game that "finishes" without touching the board + /// is the failure this project keeps finding. Assert the game moved. + #[test] + fn a_bot_game_does_substantive_work() { + for kind in ["random", "greedy"] { + let g = play(setup(3, 42), &mut policies(kind, 3, 42)).expect("game"); + let mut counts: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for e in &g.events { + let text = format!("{e:?}"); + let kind = text.split([' ', '{']).next().unwrap_or("?").to_string(); + *counts.entry(kind).or_default() += 1; + } + println!( + "{kind}: cmds={} events={} {counts:?}", + g.commands, + g.events.len() + ); + println!(" outcome {:?}", g.state.outcome); + + // Every seat selected in every round (GR-R02 × GR-R09). + assert_eq!( + counts.get("ActionSelected").copied().unwrap_or(0), + 3 * 5, + "{kind}: a five-round three-player game has fifteen selections" + ); + // And resolution actually did something with them. A driver + // that selected, revealed and ended without resolving would + // pass every assertion above this line. + let board_moved: usize = [ + "ProblemClaimed", + "ProblemRevealed", + "RelationFormed", + "StressSet", + "SolutionDrawn", + ] + .iter() + .filter_map(|k| counts.get(*k)) + .sum(); + assert!( + board_moved >= 4, + "{kind}: only {board_moved} board-changing events in a whole game" + ); + } + } +} diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 801184b..f1a1504 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -2,6 +2,12 @@ //! GameKernel K15–K16). Every rule realized here names its GR-id in a doc //! comment, giving a greppable rule→code→scenario chain. +/// Bots (CB-WP-0008 T01) — the kernel's first non-scenario consumer. +/// Deliberately **not** gated on `scenarios`: a bot needs only the +/// aggregate. That its tests do need `scenarios` is a real seam — setup +/// presets currently live behind that feature (see `bot.rs`). +pub mod bot; + #[cfg(feature = "scenarios")] use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup}; use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed}; diff --git a/history/260801-cb-wp-0008-log.md b/history/260801-cb-wp-0008-log.md new file mode 100644 index 0000000..6c65c3a --- /dev/null +++ b/history/260801-cb-wp-0008-log.md @@ -0,0 +1,64 @@ +# 2026-08-01 — CB-WP-0008 delivery notes + +Kept out of the workplan file so it stays loadable (`loop-lint`). + +## T01 — bots + +`games/ground/src/bot.rs`, 8 tests. `Policy` + `RandomPolicy` (seeded +`ChaChaRng`) + `GreedyPolicy` (stated heuristic, deterministic), a +`legal_commands` generator that filters candidates through `validate`, and +a driver that reaches `GameEnded`. + +Measured, 3-player, seed 42, both policies to GR-R09's fifth round: + +| policy | commands | events | claimed | total vs threshold | +|---|---|---|---|---| +| random | 36 | 81 | 1 | 2 / 7 | +| greedy | 30 | 51 | 2 | 3 / 7 | + +**The module is not gated on `scenarios`; its tests are.** A bot needs +only the aggregate, but the setup presets live behind that feature. That +is a real seam and it is left visible rather than papered over with a +feature the bot does not need. + +### What the second consumer found + +INTENT's second-use rule is why T01 was worth doing before the CLI. Three +findings, none of them fixed here, because fixing a rule from inside a bot +is exactly the invention-in-isolation the rule guards against. + +1. **GR-A13 admits SOLVE against an already-claimed Problem.** The rule + names "a face-up, non-Denied Problem"; `claimed_by` is not mentioned, + so `check_targeting` accepts it and resolution then does nothing. The + action is silently wasted. Greedy hit this immediately — it spent all + fifteen selections re-solving Problem 1 and scored 1. The **policy** + now avoids it; the **rule** is untouched and this is a U-item + candidate for GroundRules. + +2. **The 3-player standard fixture cannot succeed.** GR-S01 gives 3 + Problems at 3–4 players, the preset values them by priority (1, 2, 3 — + maximum 6), and GR-E01 sets the 3–4p threshold at **7**. + `group_success` is therefore unreachable at 3 players regardless of + play. No scenario noticed because no scenario plays to scoring with + maximal claiming. The fixture is documented as placeholder ("Problem + content is scenario data") — so this is evidence that the placeholder + is not neutral, not that GR-E01 is wrong. + +3. **K13's `Project` trait has zero implementors.** `grep` finds no + `impl Project` anywhere in the tree. T02 is its first consumer, and + the projection will be written against a trait that has never been + exercised. + +### Process + +The acceptance tests were mutation-checked by hand (no `mutation-check` +row — that denominator is the GameKernel acceptance table, and adding a +game-level row would move a metric by changing its question). + +**The first mutation was a no-op and SURVIVED**: it changed +`apply(...)?` to `let _ = apply(...)`, which still issues the command. +Removing the `Resolve` call outright turned three tests red for their +stated reason. Third recorded instance of the weak-mutation class — the +retrospective in `260801-instrument-the-table-retrospective.md` predicted +mutation strength would stay a standing maintenance cost, and this is +that cost arriving on the next pass. diff --git a/workplans/CB-WP-0008-ship-stage-0.md b/workplans/CB-WP-0008-ship-stage-0.md index a23cbe1..41ff563 100644 --- a/workplans/CB-WP-0008-ship-stage-0.md +++ b/workplans/CB-WP-0008-ship-stage-0.md @@ -2,7 +2,7 @@ id: CB-WP-0008 kind: product title: "Ship INTENT stage 0: a GROUND game you can actually play" -status: proposed +status: in_progress state_hub_workstream_id: "ee960213-ffc9-4654-a962-35017a231fe2" --- @@ -34,7 +34,7 @@ and the `LogStore` port are the right shapes. ```task id: CB-WP-0008-T01 -status: todo +status: done priority: high state_hub_task_id: "832d934d-a239-4392-a649-b8bb736ef07f" ``` @@ -55,6 +55,11 @@ because a stalled bot looks like a finished game. rounds) with every command legal, and two runs at the same seed produce identical state hashes (K8). +**Done 2026-08-01.** `games/ground/src/bot.rs`, 8 tests, both policies +finish. Three findings from the second consumer — a wasted-action gap in +GR-A13, an unwinnable 3-player fixture, and K13 with zero implementors — +are in `history/260801-cb-wp-0008-log.md`. + ## Task: `cb-play` — the CLI player ```task