CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
//! 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,
|
CB-WP-0049 T02/T03: a seat that plays its objective, and F27 splits in two
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>
2026-08-08 23:31:11 +02:00
|
|
|
|
ScoringMode, Selection, SupportResponse,
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
};
|
|
|
|
|
|
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<PlayerId>,
|
|
|
|
|
|
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<crate::GroundEvent>,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
/// Every accepted command, in order, as issued. `record::to_step`
|
|
|
|
|
|
/// turns these into a scenario a replay bundle can carry.
|
|
|
|
|
|
pub steps: Vec<(Actor, GroundCommand)>,
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
/// 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<GroundCommand> {
|
|
|
|
|
|
let seats: Vec<PlayerId> = state.players.keys().copied().collect();
|
|
|
|
|
|
let problems: Vec<u32> = state.problems.keys().copied().collect();
|
|
|
|
|
|
let mut candidates: Vec<GroundCommand> = Vec::new();
|
|
|
|
|
|
|
|
|
|
|
|
match state.step {
|
|
|
|
|
|
RoundStep::Select => {
|
|
|
|
|
|
for action in [
|
|
|
|
|
|
Action::Investigate,
|
|
|
|
|
|
Action::Solve,
|
|
|
|
|
|
Action::Support,
|
|
|
|
|
|
Action::Attack,
|
|
|
|
|
|
Action::Ground,
|
|
|
|
|
|
] {
|
|
|
|
|
|
match action {
|
CB-WP-0023: SOLVE is legal only where it can do something
Implements ground-game's ruling of 2026-08-03. make all exits 0, 26
scenarios, rule coverage 59/59, and no scenario encoded the bug.
The rule ended up somewhere other than where I put it, and a gate moved
it. It went into legal_commands first; the AM-1 coverage gate then
demanded a scenario for the new GR-P05, and scenarios drive validate, not
the offer layer. A rule enforced only by the offer is enforced only for
clients that ask what is legal -- the browser would be filtered and a
scenario file would walk straight past it. Once GR-P05 moved into
validate, every condition in legal_commands was dead code, and the
layering test said so in those words.
And the reported case was not the one I reported. CB-WP-0018 and the
message to ground-game described SOLVE offered on a FACE-DOWN Problem.
Measured: validate already rejected face-down, so it never was offered.
Problem 1 is the Surface Problem, face-up from the deal -- the
maintainer's three inert SOLVEs were the HAND case, holding no Clarify
for a Clarify Problem. The ruling covers both so nothing is invalidated,
but the record was wrong.
Four conditions asserted separately, because one 'SOLVE is filtered' test
would pass with three of four implemented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:18:40 +02:00
|
|
|
|
// Offered on every Problem; `validate` decides
|
|
|
|
|
|
// which are legal, and this function filters every
|
|
|
|
|
|
// candidate through it below.
|
|
|
|
|
|
//
|
|
|
|
|
|
// CB-WP-0023 first put GR-P05's conditions here, in
|
|
|
|
|
|
// the OFFER layer. The AM-1 coverage gate then asked
|
|
|
|
|
|
// for a scenario covering GR-P05 — and scenarios drive
|
|
|
|
|
|
// `validate`, not this. That is what revealed the rule
|
|
|
|
|
|
// belonged in `validate`: a rule enforced only by the
|
|
|
|
|
|
// offer is enforced only for clients that ask what is
|
|
|
|
|
|
// legal. Once it moved, everything here was dead code.
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
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 {
|
CB-REV-0001: the adversarial review, and it was not approvable
Thirteen challenges, five FATAL, all five conceded. Nothing had reached
ground-game, which is the only reason this is a correction and not a
retraction.
The worst: `Reactive` was not "greedy with one preference changed". It
differed in five, including SpendFreedom — ranked 95 unconditionally
against greedy's `95 if gated else 0` — so the seat burned its Freedom
token in round one of every game. A second change to the exact mechanism
under study, and every number in CB-EV-0031 was measuring it. The pass
claimed ADR-0018's one-varying-parameter discipline in its own workplan
while violating it. GreedyPolicy::rank is now public and the policy
delegates, overriding one match arm, so the control is structurally true.
Withdrawn entirely: "H1-B suppresses DARVO in the attacker". Disabling
H1-B under the corrected policy changes the arm count by exactly zero.
The pass hedged the wrong variable — it disclaimed "the number 2" and
defended "the direction", and the direction is what failed. The
supporting inference was invalid anyway: final Stress cannot show who
armed, because DarvoEnded resets the stage and REVERSE gives its owner -2.
Corrected: criterion 1 was failed on the greedy column while the pass's
own printed table showed 31-1000 arms in the other columns — the
selective-column move, in the file that names it. "Peak Stress was 1" was
a maximum over StressSet payloads, not held state (true: 2); the baseline
game count was 1,600 not 3,200; and "a reckless policy plays identically
to a careful one" is refuted by this repo's own rank-95 policy.
Inert controls replaced, each verified red against the reviewer's own
mutation: the baseline hash test compared two identically-constructed
states (serde(skip) on variant left 57/57 green); the `unchanged:` test
checked 3 of 7 entries and passed with SOLVE made illegal; H1-A's ordering
and H1-B's OU-cancel path had no test at all.
edition-check now covers catalog.yaml and rules_delta.yaml, whose digests
CB-WP-0038 claimed and never recorded — the review found it and reported
it unverified rather than absent, which was the right call.
Still open: H1-B on the DARVO extra-Attack path is untested, regulation.rs
still skips setup failures silently, and round-5 arms are counted though
they can never act.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:02:35 +02:00
|
|
|
|
/// The ranking, **public so a variant policy can override exactly one
|
|
|
|
|
|
/// arm and inherit the rest** (CB-WP-0039, after review).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// It was private, so `regulation.rs` re-typed an abridged copy and
|
|
|
|
|
|
/// called it "greedy with one preference changed". It differed in
|
|
|
|
|
|
/// five places — including `SpendFreedom`, which the copy ranked 95
|
|
|
|
|
|
/// unconditionally where this ranks it 0 unless the gate is biting,
|
|
|
|
|
|
/// so the "reactive" seat burned its Freedom token in round one of
|
|
|
|
|
|
/// every game. **A second change to the exact mechanism the pass was
|
|
|
|
|
|
/// studying**, and every number in CB-EV-0031 was measuring it.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Making this callable removes the possibility rather than testing
|
|
|
|
|
|
/// for it: a caller that delegates cannot drift.
|
|
|
|
|
|
pub fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 {
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
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]`.
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
pub fn play<'a>(
|
2026-08-03 02:15:30 +02:00
|
|
|
|
state: GroundState,
|
|
|
|
|
|
policies: &mut [Box<dyn Policy + 'a>],
|
|
|
|
|
|
) -> Result<BotGame, BotError> {
|
|
|
|
|
|
play_journaled(state, policies, None)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The same, appending every applied command and its events to `journal`
|
|
|
|
|
|
/// as it goes, for a caller rendering the game while it runs.
|
|
|
|
|
|
pub fn play_journaled<'a>(
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
mut state: GroundState,
|
|
|
|
|
|
policies: &mut [Box<dyn Policy + 'a>],
|
2026-08-03 02:15:30 +02:00
|
|
|
|
journal: Option<Journal>,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
) -> Result<BotGame, BotError> {
|
2026-08-03 02:15:30 +02:00
|
|
|
|
let mut log = Log {
|
|
|
|
|
|
journal,
|
|
|
|
|
|
..Log::default()
|
|
|
|
|
|
};
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
|
|
|
|
|
|
let seats: Vec<PlayerId> = 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"),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
step_seat(&mut state, &mut log, policies, *seat, round, false)?;
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
apply(&mut state, &mut log, Actor::System, &GroundCommand::Reveal)?;
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
if !step_seat(&mut state, &mut log, policies, *seat, round, !obligatory)? {
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
apply(&mut state, &mut log, Actor::System, &GroundCommand::Resolve)?;
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
apply(
|
|
|
|
|
|
&mut state,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
&mut log,
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
Actor::System,
|
|
|
|
|
|
&GroundCommand::EndRound,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(BotGame {
|
|
|
|
|
|
state,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
commands: log.steps.len(),
|
|
|
|
|
|
events: log.events,
|
|
|
|
|
|
steps: log.steps,
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
rounds,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 02:15:30 +02:00
|
|
|
|
/// One command and everything it produced.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **The empty case is the load-bearing one** (CB-WP-0018 T02). GR-A02's
|
|
|
|
|
|
/// resolver silently `continue`s when a SOLVE cannot be fulfilled, so a
|
|
|
|
|
|
/// player can select it three rounds running and change nothing. A journal
|
|
|
|
|
|
/// built only from events would show nothing for those and reproduce the
|
|
|
|
|
|
/// silence; keeping the command with an empty `events` is what lets a
|
|
|
|
|
|
/// reader say *"this happened and did nothing"*.
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct Applied {
|
|
|
|
|
|
pub actor: Actor,
|
|
|
|
|
|
pub command: GroundCommand,
|
|
|
|
|
|
pub events: Vec<crate::GroundEvent>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A live account of a game in progress, shared with whoever is watching.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `BotGame.events` is the same information but only after `play` returns,
|
|
|
|
|
|
/// which is no use to a page rendered mid-game.
|
|
|
|
|
|
pub type Journal = std::rc::Rc<std::cell::RefCell<Vec<Applied>>>;
|
|
|
|
|
|
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
/// What the driver accumulates while a game runs.
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
struct Log {
|
|
|
|
|
|
events: Vec<crate::GroundEvent>,
|
|
|
|
|
|
steps: Vec<(Actor, GroundCommand)>,
|
2026-08-03 02:15:30 +02:00
|
|
|
|
journal: Option<Journal>,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
/// Offer one seat its legal commands and apply what the policy picks.
|
|
|
|
|
|
/// `Ok(false)` means the seat passed.
|
|
|
|
|
|
fn step_seat(
|
|
|
|
|
|
state: &mut GroundState,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
log: &mut Log,
|
|
|
|
|
|
policies: &mut [Box<dyn Policy + '_>],
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
seat: PlayerId,
|
|
|
|
|
|
round: u8,
|
|
|
|
|
|
may_pass: bool,
|
|
|
|
|
|
) -> Result<bool, BotError> {
|
|
|
|
|
|
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(),
|
|
|
|
|
|
})?;
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
apply(state, log, Actor::Player(seat), cmd)?;
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
Ok(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn apply(
|
|
|
|
|
|
state: &mut GroundState,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
log: &mut Log,
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-08-03 02:15:30 +02:00
|
|
|
|
if let Some(j) = &log.journal {
|
|
|
|
|
|
j.borrow_mut().push(Applied {
|
|
|
|
|
|
actor,
|
|
|
|
|
|
command: cmd.clone(),
|
|
|
|
|
|
events: produced.clone(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
log.events.extend(produced);
|
|
|
|
|
|
log.steps.push((actor, cmd.clone()));
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
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<Selection> {
|
|
|
|
|
|
state.selections.get(&seat).copied()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Relation between two seats, for the same reason.
|
|
|
|
|
|
pub fn relation_of(state: &GroundState, a: PlayerId, b: PlayerId) -> Option<Relation> {
|
|
|
|
|
|
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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0023: SOLVE is legal only where it can do something
Implements ground-game's ruling of 2026-08-03. make all exits 0, 26
scenarios, rule coverage 59/59, and no scenario encoded the bug.
The rule ended up somewhere other than where I put it, and a gate moved
it. It went into legal_commands first; the AM-1 coverage gate then
demanded a scenario for the new GR-P05, and scenarios drive validate, not
the offer layer. A rule enforced only by the offer is enforced only for
clients that ask what is legal -- the browser would be filtered and a
scenario file would walk straight past it. Once GR-P05 moved into
validate, every condition in legal_commands was dead code, and the
layering test said so in those words.
And the reported case was not the one I reported. CB-WP-0018 and the
message to ground-game described SOLVE offered on a FACE-DOWN Problem.
Measured: validate already rejected face-down, so it never was offered.
Problem 1 is the Surface Problem, face-up from the deal -- the
maintainer's three inert SOLVEs were the HAND case, holding no Clarify
for a Clarify Problem. The ruling covers both so nothing is invalidated,
but the record was wrong.
Four conditions asserted separately, because one 'SOLVE is filtered' test
would pass with three of four implemented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:18:40 +02:00
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
|
// CB-WP-0023: SOLVE's legality, ruled by ground-game 2026-08-03.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Four conditions, each asserted on its own, because a single
|
|
|
|
|
|
// "SOLVE is filtered" test would pass with three of the four
|
|
|
|
|
|
// implemented and nobody would know which.
|
|
|
|
|
|
|
|
|
|
|
|
/// Problems that SOLVE is offered on, for `seat`.
|
|
|
|
|
|
fn solvable(state: &GroundState, seat: PlayerId) -> Vec<u32> {
|
|
|
|
|
|
legal_commands(state, seat)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.filter_map(|c| match c {
|
|
|
|
|
|
GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Solve,
|
|
|
|
|
|
problem: Some(n),
|
|
|
|
|
|
..
|
|
|
|
|
|
} => Some(n),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A 3p game where seat 0 can solve exactly problem 1.
|
|
|
|
|
|
fn solvable_fixture() -> (GroundState, u32) {
|
|
|
|
|
|
let mut st = setup(3, 42);
|
|
|
|
|
|
let (n, suit) = st
|
|
|
|
|
|
.problems
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|(_, p)| p.face_up)
|
|
|
|
|
|
.map(|(n, p)| (*n, p.suit))
|
|
|
|
|
|
.expect("GR-S01 deals one face-up Surface Problem");
|
|
|
|
|
|
// Give seat 0 the matching Solution, so the ONLY thing under test
|
|
|
|
|
|
// is the condition each case perturbs.
|
|
|
|
|
|
st.players.get_mut(&PlayerId(0)).expect("seat 0").hand = vec![crate::SolutionCard { suit }];
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
solvable(&st, PlayerId(0)),
|
|
|
|
|
|
vec![n],
|
|
|
|
|
|
"fixture is not solvable"
|
|
|
|
|
|
);
|
|
|
|
|
|
(st, n)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// **Which layer enforces GR-P05**, pinned so it cannot drift.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// All four conditions live in `validate`, and `legal_commands` gets
|
|
|
|
|
|
/// them for free by filtering candidates through it. This test is what
|
|
|
|
|
|
/// keeps that true: if `validate` ever stops rejecting one, SOLVE
|
|
|
|
|
|
/// becomes offerable again and the offer layer would have to grow a
|
|
|
|
|
|
/// filter back.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn validate_enforces_all_four_solve_conditions() {
|
|
|
|
|
|
let (base, n) = solvable_fixture();
|
|
|
|
|
|
let sel = |p: u32| GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Solve,
|
|
|
|
|
|
target: None,
|
|
|
|
|
|
problem: Some(p),
|
|
|
|
|
|
};
|
|
|
|
|
|
let ok =
|
|
|
|
|
|
|st: &GroundState, p: u32| st.validate(Actor::Player(PlayerId(0)), &sel(p)).is_ok();
|
|
|
|
|
|
|
|
|
|
|
|
let face_down = *base
|
|
|
|
|
|
.problems
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|(_, p)| !p.face_up)
|
|
|
|
|
|
.map(|(k, _)| k)
|
|
|
|
|
|
.expect("a face-down problem");
|
|
|
|
|
|
println!(
|
|
|
|
|
|
" validate accepts SOLVE on face-down: {}",
|
|
|
|
|
|
ok(&base, face_down)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let mut denied = base.clone();
|
|
|
|
|
|
denied.problems.get_mut(&n).unwrap().denied = true;
|
|
|
|
|
|
println!(" validate accepts SOLVE on denied: {}", ok(&denied, n));
|
|
|
|
|
|
|
|
|
|
|
|
let mut claimed = base.clone();
|
|
|
|
|
|
claimed.problems.get_mut(&n).unwrap().claimed_by = Some(PlayerId(1));
|
|
|
|
|
|
println!(
|
|
|
|
|
|
" validate accepts SOLVE on claimed: {}",
|
|
|
|
|
|
ok(&claimed, n)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let mut nohand = base.clone();
|
|
|
|
|
|
nohand.players.get_mut(&PlayerId(0)).unwrap().hand = vec![];
|
|
|
|
|
|
println!(" validate accepts SOLVE with no hand: {}", ok(&nohand, n));
|
|
|
|
|
|
|
|
|
|
|
|
// validate's: adding these to `legal_commands` would be dead code.
|
|
|
|
|
|
for (what, accepted) in [
|
|
|
|
|
|
("face-down", ok(&base, face_down)),
|
|
|
|
|
|
("Denied", ok(&denied, n)),
|
|
|
|
|
|
("claimed", ok(&claimed, n)),
|
|
|
|
|
|
("handless", ok(&nohand, n)),
|
|
|
|
|
|
] {
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!accepted,
|
|
|
|
|
|
"validate accepts SOLVE on a {what} Problem — GR-P05 is not \
|
|
|
|
|
|
enforced where rules live, so only clients that ask what is \
|
|
|
|
|
|
legal would be constrained"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Positive control: the fixture must still be solvable, or this
|
|
|
|
|
|
// test would pass by rejecting everything.
|
|
|
|
|
|
assert!(ok(&base, n), "the solvable fixture stopped being solvable");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// **The maintainer's reported case.** SOLVE was offered on a
|
|
|
|
|
|
/// face-down Problem and did nothing; they played it three rounds
|
|
|
|
|
|
/// running with no explanation. ground-game: *"not offered — illegal
|
|
|
|
|
|
/// target. Browser no-ops were a filter bug, not a bluff mechanic."*
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn solve_is_not_offered_on_a_face_down_problem() {
|
|
|
|
|
|
let (st, open) = solvable_fixture();
|
|
|
|
|
|
let face_down: Vec<u32> = st
|
|
|
|
|
|
.problems
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|(_, p)| !p.face_up)
|
|
|
|
|
|
.map(|(n, _)| *n)
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(!face_down.is_empty(), "GR-S01 deals face-down Problems");
|
|
|
|
|
|
let offered = solvable(&st, PlayerId(0));
|
|
|
|
|
|
for n in face_down {
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!offered.contains(&n),
|
|
|
|
|
|
"SOLVE offered on face-down problem {n}; offered = {offered:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(offered.contains(&open), "the face-up one is still offered");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn solve_is_not_offered_without_a_matching_solution_in_hand() {
|
|
|
|
|
|
let (mut st, n) = solvable_fixture();
|
|
|
|
|
|
let wrong = [
|
|
|
|
|
|
crate::Suit::Clarify,
|
|
|
|
|
|
crate::Suit::Repair,
|
|
|
|
|
|
crate::Suit::Boundary,
|
|
|
|
|
|
crate::Suit::Change,
|
|
|
|
|
|
]
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.find(|s| *s != st.problems[&n].suit)
|
|
|
|
|
|
.expect("another suit exists");
|
|
|
|
|
|
st.players.get_mut(&PlayerId(0)).expect("seat 0").hand =
|
|
|
|
|
|
vec![crate::SolutionCard { suit: wrong }];
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!solvable(&st, PlayerId(0)).contains(&n),
|
|
|
|
|
|
"SOLVE offered with no matching Solution in hand"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn solve_is_not_offered_on_a_denied_problem() {
|
|
|
|
|
|
let (mut st, n) = solvable_fixture();
|
|
|
|
|
|
st.problems.get_mut(&n).expect("problem").denied = true;
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!solvable(&st, PlayerId(0)).contains(&n),
|
|
|
|
|
|
"SOLVE offered on a Denied Problem"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The ruling's (c): a Problem claimed in a PRIOR round is not
|
|
|
|
|
|
/// offered. At Select time every `claimed_by` is prior-round, because
|
|
|
|
|
|
/// claims land at Resolve — so the same-round race needs no code.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn solve_is_not_offered_on_an_already_claimed_problem() {
|
|
|
|
|
|
let (mut st, n) = solvable_fixture();
|
|
|
|
|
|
st.problems.get_mut(&n).expect("problem").claimed_by = Some(PlayerId(1));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!solvable(&st, PlayerId(0)).contains(&n),
|
|
|
|
|
|
"SOLVE offered on an already-claimed Problem"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
fn policies(kind: &str, n: u8, seed: u64) -> Vec<Box<dyn Policy>> {
|
|
|
|
|
|
(0..n)
|
|
|
|
|
|
.map(|i| -> Box<dyn Policy> {
|
|
|
|
|
|
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);
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
let err = step_seat(&mut state, &mut Log::default(), &mut ps, ghost, 1, false)
|
|
|
|
|
|
.expect_err("a seat with nothing legal must fail");
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
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<Box<dyn Policy>> = (0..3)
|
|
|
|
|
|
.map(|_| Box::new(Passer) as Box<dyn Policy>)
|
|
|
|
|
|
.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<Box<dyn Policy>> =
|
|
|
|
|
|
(0..3).map(|_| Box::new(Wild) as Box<dyn Policy>).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<String, usize> =
|
|
|
|
|
|
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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
CB-WP-0008-T03: prove the 2-6 player range
GR-O01 states 2-6 players; every scenario in the corpus was 3-player.
Now all five counts play to GameEnded under both policies and reproduce
at the same seed, with scenarios at both boundaries and the CLI
transcript run at 2p, 3p and 6p.
Nothing broke — the rules are seat-count-generic. What the boundaries
exposed is arithmetic: with the standard preset's placeholder Problem
values (value = priority), the best total any game can reach is 3 at 2p,
6 at 3-4p, 10 at 5-6p, against GR-E01 thresholds of 5, 7 and 9. Group
success is unreachable below five seats regardless of play, and no
scenario noticed because none had played to scoring with everything
claimed.
GR-S01 calls the fixture a stand-in for scenario Problem data, so this
is evidence the stand-in is not neutral, not that GR-E01 is wrong. It is
pinned by a passing scenario, an arithmetic test, and a provisional
marker owned by ground-game so it ages in `make coverage`. The test
states its own delete-by: it is expected to fail when Problem values
become real data, and that failure is the signal to delete it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:20:04 +02:00
|
|
|
|
/// GR-O01 says 2–6 players. Every scenario in the corpus was
|
|
|
|
|
|
/// 3-player until CB-WP-0008 T03, so the range was stated and tested
|
|
|
|
|
|
/// at one point. This plays all five counts with both policies.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn every_seat_count_in_gr_o01_plays_to_the_end() {
|
|
|
|
|
|
for players in 2..=6u8 {
|
|
|
|
|
|
for kind in ["random", "greedy"] {
|
|
|
|
|
|
let game = play(setup(players, 42), &mut policies(kind, players, 42))
|
|
|
|
|
|
.unwrap_or_else(|e| panic!("{players}p {kind}: {e}"));
|
|
|
|
|
|
let outcome = game
|
|
|
|
|
|
.state
|
|
|
|
|
|
.outcome
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.unwrap_or_else(|| panic!("{players}p {kind}: no outcome"));
|
|
|
|
|
|
assert_eq!(game.state.round, 5, "{players}p {kind}: GR-R09");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
outcome.personal.len(),
|
|
|
|
|
|
usize::from(players),
|
|
|
|
|
|
"{players}p {kind}: every seat must be scored"
|
|
|
|
|
|
);
|
|
|
|
|
|
// K8 at each boundary, not only at three seats.
|
|
|
|
|
|
let again =
|
|
|
|
|
|
play(setup(players, 42), &mut policies(kind, players, 42)).expect("second run");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
cb_events::state_hash_hex(&game.state),
|
|
|
|
|
|
cb_events::state_hash_hex(&again.state),
|
|
|
|
|
|
"{players}p {kind}: same seed must reproduce"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
// `the_standard_preset_cannot_reach_the_threshold_below_five_seats`
|
|
|
|
|
|
// lived here and was deleted 2026-08-04, on its own instruction: it
|
|
|
|
|
|
// said "the failure is the signal to delete it, not to re-tune it".
|
|
|
|
|
|
// ground-game ruled GR-S01's deal and the game became winnable.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The record did not go with it. `gd0001_group_success_is_reachable_at
|
|
|
|
|
|
// _every_seat_count` in lib.rs is the same arithmetic, inverted rather
|
|
|
|
|
|
// than removed, and carries why the numbers changed.
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
}
|
ADR-0023 + CB-WP-0049 T01: a policy is bound by what its seat can see
Policy::choose takes the whole GroundState -- every face-down Problem's
suit and value, every seat's hand -- which is exactly what project()
exists to withhold. No shipped policy reads it, but the first thing a
competitive policy must do is VALUE a Problem, and value is the hidden
field. The trap goes live on the first line of the F27 work.
Established behaviourally rather than by narrowing the trait: vary only
what the seat cannot see, and the choice must not move. That binds every
policy including ones written later and outside this crate, without
their cooperation. The mirror of ADR-0013 D1 -- same kernel, two
searches, opposite permissions, discriminated by WHEN the question is
asked; a policy plays from inside an information set, so retrospective
permission would be strategy fusion.
Running the control found two defects IN THE CONTROL:
1. The rearrangements rotated hidden values 2<->3 together, leaving max
invariant -- so the deliberate peeker, which ranks by the largest
hidden value, was not caught. A control whose variation is invariant
under the statistic a violator reads is not a control.
2. It accused `random` of peeking, because it reused one policy instance
and compared a first call against a fourth. It takes a constructor
now, so every variant is judged from identical policy state.
Both are a difference in output read as evidence about hidden state --
the wrong-subject family, found twice inside a control written to detect
wrong subjects.
Three mutations, three red. The control is proven against a deliberate
violator before being trusted about compliant policies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:45:55 +02:00
|
|
|
|
|
CB-WP-0049 T02/T03: a seat that plays its objective, and F27 splits in two
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>
2026-08-08 23:31:11 +02:00
|
|
|
|
/// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
ADR-0023 + CB-WP-0049 T01: a policy is bound by what its seat can see
Policy::choose takes the whole GroundState -- every face-down Problem's
suit and value, every seat's hand -- which is exactly what project()
exists to withhold. No shipped policy reads it, but the first thing a
competitive policy must do is VALUE a Problem, and value is the hidden
field. The trap goes live on the first line of the F27 work.
Established behaviourally rather than by narrowing the trait: vary only
what the seat cannot see, and the choice must not move. That binds every
policy including ones written later and outside this crate, without
their cooperation. The mirror of ADR-0013 D1 -- same kernel, two
searches, opposite permissions, discriminated by WHEN the question is
asked; a policy plays from inside an information set, so retrospective
permission would be strategy fusion.
Running the control found two defects IN THE CONTROL:
1. The rearrangements rotated hidden values 2<->3 together, leaving max
invariant -- so the deliberate peeker, which ranks by the largest
hidden value, was not caught. A control whose variation is invariant
under the statistic a violator reads is not a control.
2. It accused `random` of peeking, because it reused one policy instance
and compared a first call against a fourth. It takes a constructor
now, so every variant is judged from identical policy state.
Both are a difference in output read as evidence about hidden state --
the wrong-subject family, found twice inside a control written to detect
wrong subjects.
Three mutations, three red. The control is proven against a deliberate
violator before being trusted about compliant policies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:45:55 +02:00
|
|
|
|
/// **A policy is bound by what its seat can see** ([ADR-0023]).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `Policy::choose` takes the whole `GroundState`, which carries every
|
|
|
|
|
|
/// face-down Problem's suit and value and every seat's hand — exactly
|
|
|
|
|
|
/// what `project` exists to withhold. Nothing in the type system stops a
|
|
|
|
|
|
/// policy reading it, so this establishes the property behaviourally:
|
|
|
|
|
|
/// **vary only what the seat cannot see, and the choice must not move.**
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This binds every policy, including ones written later and ones written
|
|
|
|
|
|
/// outside this crate, without their cooperation.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// [ADR-0023]: ../../../decisions/ADR-0023-a-policy-is-bound-by-what-its-seat-can-see.md
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
pub mod blindness {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
/// Every rearrangement of what `seat` cannot see, as states that are
|
|
|
|
|
|
/// **identical in that seat's projection**.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Concretely: permute the suits and values of every face-down
|
|
|
|
|
|
/// Problem, and permute the other seats' hands. A seat that plays the
|
|
|
|
|
|
/// game can tell none of these apart.
|
|
|
|
|
|
pub fn hidden_rearrangements(state: &GroundState, seat: PlayerId) -> Vec<GroundState> {
|
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
|
// **The multiset must move, not only the arrangement.** The first
|
|
|
|
|
|
// version rotated every hidden value 2<->3 together, which leaves
|
|
|
|
|
|
// `max` and (with equal counts) `sum` invariant — so a policy
|
|
|
|
|
|
// ranking by the largest hidden value was NOT caught. A control
|
|
|
|
|
|
// whose variation is invariant under the statistic a violator
|
|
|
|
|
|
// reads is not a control. These force all-2, all-3 and an
|
|
|
|
|
|
// alternating split, so any symmetric function of the hidden
|
|
|
|
|
|
// values moves across the family.
|
|
|
|
|
|
for shift in 0..=3u8 {
|
|
|
|
|
|
let mut alt = state.clone();
|
|
|
|
|
|
for (i, p) in alt.problems.values_mut().enumerate() {
|
|
|
|
|
|
if p.face_up {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
// A different card under the same back.
|
|
|
|
|
|
p.value = match shift {
|
|
|
|
|
|
0 => 2,
|
|
|
|
|
|
1 => 3,
|
|
|
|
|
|
2 => 2 + (i as u8 % 2),
|
|
|
|
|
|
_ => 3 - (i as u8 % 2),
|
|
|
|
|
|
};
|
|
|
|
|
|
p.suit = rotate_suit(p.suit, shift + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (other, ps) in alt.players.iter_mut() {
|
|
|
|
|
|
if *other == seat {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
for c in ps.hand.iter_mut() {
|
|
|
|
|
|
c.suit = rotate_suit(c.suit, shift);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
out.push(alt);
|
|
|
|
|
|
}
|
|
|
|
|
|
out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn rotate_suit(s: crate::Suit, by: u8) -> crate::Suit {
|
|
|
|
|
|
use crate::Suit::*;
|
|
|
|
|
|
let order = [Clarify, Repair, Boundary, Change];
|
|
|
|
|
|
let i = order.iter().position(|x| *x == s).unwrap_or(0);
|
|
|
|
|
|
order[(i + by as usize) % order.len()]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Does `policy` choose the same thing across every rearrangement?
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns the disagreement if there is one, so the caller can report
|
|
|
|
|
|
/// *what* moved rather than only that something did.
|
|
|
|
|
|
/// **Takes a constructor, not a policy.** `choose` may advance
|
|
|
|
|
|
/// internal state — `RandomPolicy` draws from its own stream — so
|
|
|
|
|
|
/// reusing one instance compares a first call against a fourth and
|
|
|
|
|
|
/// reports every stateful policy as a peeker. The first version did
|
|
|
|
|
|
/// exactly that and accused `random`. Each variant is judged from an
|
|
|
|
|
|
/// identical starting policy, which is the only way the difference
|
|
|
|
|
|
/// between two runs is the state and nothing else.
|
|
|
|
|
|
pub fn is_blind<P: Policy>(
|
|
|
|
|
|
mut make: impl FnMut() -> P,
|
|
|
|
|
|
state: &GroundState,
|
|
|
|
|
|
seat: PlayerId,
|
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
|
let legal = legal_commands(state, seat);
|
|
|
|
|
|
if legal.is_empty() {
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
|
|
|
|
|
let base = make().choose(state, seat, &legal, false);
|
|
|
|
|
|
for alt in hidden_rearrangements(state, seat) {
|
|
|
|
|
|
// **The same legal set, deliberately.** Rearranging hidden
|
|
|
|
|
|
// cards can change which moves are legal, and a policy that
|
|
|
|
|
|
// picks a different INDEX into a different list has not
|
|
|
|
|
|
// necessarily seen anything. Holding the list fixed varies
|
|
|
|
|
|
// only the state, which is the variable under test.
|
|
|
|
|
|
let mut p = make();
|
|
|
|
|
|
let got = p.choose(&alt, seat, &legal, false);
|
|
|
|
|
|
if got != base {
|
|
|
|
|
|
return Err(format!(
|
|
|
|
|
|
"{} chose {:?} and then {:?} over the same legal moves, \
|
|
|
|
|
|
with only hidden cards rearranged",
|
|
|
|
|
|
p.name(),
|
|
|
|
|
|
base,
|
|
|
|
|
|
got
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod blindness_tests {
|
|
|
|
|
|
use super::blindness::*;
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use cb_game_runtime::{ScenarioGame, Setup};
|
|
|
|
|
|
|
|
|
|
|
|
fn deal(players: u8, seed: u64) -> GroundState {
|
|
|
|
|
|
GroundState::setup(
|
|
|
|
|
|
&Setup {
|
|
|
|
|
|
players,
|
|
|
|
|
|
preset: format!("standard-{players}p"),
|
|
|
|
|
|
patch: Default::default(),
|
|
|
|
|
|
},
|
|
|
|
|
|
seed,
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("preset")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// **A policy that peeks IS CAUGHT** (ADR-0023 D3).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The control is proven against a deliberate violator before it is
|
|
|
|
|
|
/// trusted about compliant ones. A peek control that nothing can fail
|
|
|
|
|
|
/// is decoration (ADR-0006 D3) — and this is the shape of policy
|
|
|
|
|
|
/// CB-WP-0049 is about to write, ranking a claim by a Problem's
|
|
|
|
|
|
/// value, which is the field the projection hides.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_peek_control_catches_a_peeking_policy() {
|
|
|
|
|
|
struct Peeker;
|
|
|
|
|
|
impl Policy for Peeker {
|
|
|
|
|
|
fn name(&self) -> &'static str {
|
|
|
|
|
|
"peeker"
|
|
|
|
|
|
}
|
|
|
|
|
|
fn choose(
|
|
|
|
|
|
&mut self,
|
|
|
|
|
|
state: &GroundState,
|
|
|
|
|
|
_seat: PlayerId,
|
|
|
|
|
|
legal: &[GroundCommand],
|
|
|
|
|
|
_may_pass: bool,
|
|
|
|
|
|
) -> Choice {
|
|
|
|
|
|
// Rank by the value of the highest FACE-DOWN Problem —
|
|
|
|
|
|
// information the seat does not have.
|
|
|
|
|
|
let hidden: u8 = state
|
|
|
|
|
|
.problems
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.filter(|p| !p.face_up)
|
|
|
|
|
|
.map(|p| p.value)
|
|
|
|
|
|
.max()
|
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
Choice::Command((hidden as usize) % legal.len())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let st = deal(3, 7);
|
|
|
|
|
|
let seat = PlayerId(0);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
st.problems.values().any(|p| !p.face_up),
|
|
|
|
|
|
"the fixture must actually hide something"
|
|
|
|
|
|
);
|
|
|
|
|
|
let err = is_blind(|| Peeker, &st, seat)
|
|
|
|
|
|
.expect_err("a policy reading face-down values must be caught");
|
|
|
|
|
|
assert!(err.contains("peeker"), "{err}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0049 T02/T03: a seat that plays its objective, and F27 splits in two
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>
2026-08-08 23:31:11 +02:00
|
|
|
|
/// 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})"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
ADR-0023 + CB-WP-0049 T01: a policy is bound by what its seat can see
Policy::choose takes the whole GroundState -- every face-down Problem's
suit and value, every seat's hand -- which is exactly what project()
exists to withhold. No shipped policy reads it, but the first thing a
competitive policy must do is VALUE a Problem, and value is the hidden
field. The trap goes live on the first line of the F27 work.
Established behaviourally rather than by narrowing the trait: vary only
what the seat cannot see, and the choice must not move. That binds every
policy including ones written later and outside this crate, without
their cooperation. The mirror of ADR-0013 D1 -- same kernel, two
searches, opposite permissions, discriminated by WHEN the question is
asked; a policy plays from inside an information set, so retrospective
permission would be strategy fusion.
Running the control found two defects IN THE CONTROL:
1. The rearrangements rotated hidden values 2<->3 together, leaving max
invariant -- so the deliberate peeker, which ranks by the largest
hidden value, was not caught. A control whose variation is invariant
under the statistic a violator reads is not a control.
2. It accused `random` of peeking, because it reused one policy instance
and compared a first call against a fourth. It takes a constructor
now, so every variant is judged from identical policy state.
Both are a difference in output read as evidence about hidden state --
the wrong-subject family, found twice inside a control written to detect
wrong subjects.
Three mutations, three red. The control is proven against a deliberate
violator before being trusted about compliant policies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:45:55 +02:00
|
|
|
|
/// And the shipped policies are blind.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn every_shipped_policy_is_blind_to_what_its_seat_cannot_see() {
|
|
|
|
|
|
for seed in [1u64, 7, 42] {
|
|
|
|
|
|
for players in [2u8, 4, 6] {
|
|
|
|
|
|
let st = deal(players, seed);
|
|
|
|
|
|
for seat in st.players.keys().copied() {
|
|
|
|
|
|
is_blind(|| GreedyPolicy, &st, seat)
|
|
|
|
|
|
.unwrap_or_else(|e| panic!("{players}p seed {seed}: {e}"));
|
|
|
|
|
|
// Random is seeded from its own stream, so it is
|
|
|
|
|
|
// blind for a different reason — included because
|
|
|
|
|
|
// "every shipped policy" must mean every one.
|
|
|
|
|
|
is_blind(|| RandomPolicy::new(seed), &st, seat)
|
|
|
|
|
|
.unwrap_or_else(|e| panic!("{players}p seed {seed}: {e}"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The rearrangements must actually rearrange.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// A control that varies nothing passes for every policy, including
|
|
|
|
|
|
/// the peeker — so the fixture's own sensitivity is asserted rather
|
|
|
|
|
|
/// than assumed.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_rearrangements_change_the_hidden_cards() {
|
|
|
|
|
|
let st = deal(4, 3);
|
|
|
|
|
|
let alts = hidden_rearrangements(&st, PlayerId(0));
|
|
|
|
|
|
assert!(!alts.is_empty());
|
|
|
|
|
|
let hidden = |s: &GroundState| -> Vec<(u8, String)> {
|
|
|
|
|
|
s.problems
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.filter(|p| !p.face_up)
|
|
|
|
|
|
.map(|p| (p.value, format!("{:?}", p.suit)))
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
alts.iter().any(|a| hidden(a) != hidden(&st)),
|
|
|
|
|
|
"no rearrangement changed a hidden card"
|
|
|
|
|
|
);
|
|
|
|
|
|
// And they leave the VISIBLE game alone, or the test would be
|
|
|
|
|
|
// varying two things at once.
|
|
|
|
|
|
let seen = |s: &GroundState| -> Vec<(u8, String)> {
|
|
|
|
|
|
s.problems
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.filter(|p| p.face_up)
|
|
|
|
|
|
.map(|p| (p.value, format!("{:?}", p.suit)))
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
};
|
|
|
|
|
|
for a in &alts {
|
|
|
|
|
|
assert_eq!(seen(a), seen(&st), "a rearrangement moved a face-up card");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|