CB-WP-0011-T02: cb-play --inspect walks a recorded game

Renders the table after every step of a .cbreplay bundle or a scenario
YAML, from any seat's projection or a spectator's. This is the first
thing in the project that answers 'what did the table look like when it
went wrong?' without adding a dbg! and re-running.

INTERFACE CHANGE (flagged per InnerLoop chaos limits -- this is a
tier-S pass that touched a runtime crate): cb-game-runtime gains
replay::open, extracted out of replay::replay. Dev-only, behind the
scenarios feature, no type changed. The point of the extraction is that
the inspector and the replay gate share one bundle reader, controls
included, so the inspector cannot show a state a replay never reached.

Three M-D1-MUT controls, each red for its stated reason. The
load-bearing one asserts one rendered table per step: without it, a
walk that rendered nothing would still report a matching hash.
This commit is contained in:
tegwick 2026-08-02 02:45:55 +02:00
parent d2ca1046c0
commit b11fc91fd4
4 changed files with 478 additions and 22 deletions

View file

@ -133,12 +133,18 @@ pub fn write_bundle(
Ok(bundle)
}
/// Re-execute a bundle and require it to reproduce the recorded hash.
/// Open a bundle for re-execution: the manifest, the restored initial
/// state, and the recorded steps in order.
///
/// Returns `Err` when the bundle is corrupt, incomplete, internally
/// inconsistent, or does not reproduce — all of which are the point. A
/// round-trip that cannot fail proves nothing.
pub fn replay<G>(bundle: &Path) -> Result<ReplayReport, String>
/// Extracted from [`replay`] by CB-WP-0011 T02 so the inspector walks a
/// bundle through **the same reader the replay gate uses**. A second
/// reader would let the inspector show states a replay never reached —
/// and it would be a duplicated fact, checked by nothing, in the one
/// place where being wrong is silent.
///
/// Every control [`replay`] performed before touching the command log is
/// performed here, including the one that makes the seed load-bearing.
pub fn open<G>(bundle: &Path) -> Result<(Manifest, G, Vec<CommandStep>), String>
where
G: ScenarioGame,
{
@ -165,17 +171,33 @@ where
&std::fs::read(bundle.join(INITIAL)).map_err(|e| err("read initial", e))?,
)
.map_err(|e| err("parse initial", e))?;
let mut state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?;
let state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?;
// K11 framing: a truncated or corrupt command log is rejected here.
let raw = std::fs::read(bundle.join(COMMANDS)).map_err(|e| err("read commands", e))?;
let records = parse(&raw).map_err(|e| err("command log", e))?;
let mut steps = Vec::with_capacity(records.len());
for record in &records {
steps.push(serde_json::from_slice(record).map_err(|e| err("decode step", e))?);
}
Ok((manifest, state, steps))
}
/// Re-execute a bundle and require it to reproduce the recorded hash.
///
/// Returns `Err` when the bundle is corrupt, incomplete, internally
/// inconsistent, or does not reproduce — all of which are the point. A
/// round-trip that cannot fail proves nothing.
pub fn replay<G>(bundle: &Path) -> Result<ReplayReport, String>
where
G: ScenarioGame,
{
let (manifest, mut state, steps) = open::<G>(bundle)?;
let mut applied = 0usize;
for record in &records {
let step: CommandStep =
serde_json::from_slice(record).map_err(|e| err("decode step", e))?;
let (actor, command) = G::parse_command(&step)?;
for step in &steps {
let (actor, command) = G::parse_command(step)?;
if let Ok(produced) = state.validate(actor, &command) {
for event in produced {
state.fold(&event);

View file

@ -25,8 +25,10 @@
//! needs a rendering port, which needs an ADR, which this pass does not
//! have (see the workplan's tier declaration).
use cb_kernel::PlayerId;
use cb_game_runtime::{Project, ScenarioGame};
use cb_kernel::{Aggregate, PlayerId};
use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView};
use games_ground::GroundState;
pub fn suit_name(s: games_ground::Suit) -> &'static str {
match s {
@ -255,6 +257,170 @@ pub fn render(view: &GroundView) -> String {
out
}
// ------------------------------------------------------------------ walk
/// Which seat's projection a walk renders.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Eyes {
Seat(PlayerId),
Spectator,
}
impl Eyes {
pub fn parse(raw: &str) -> Result<Self, String> {
if raw.eq_ignore_ascii_case("spectator") {
return Ok(Eyes::Spectator);
}
raw.parse::<u8>()
.map(|n| Eyes::Seat(PlayerId(n)))
.map_err(|_| format!("--as expects a 0-based seat or 'spectator', got {raw:?}"))
}
fn label(self) -> String {
match self {
Eyes::Seat(p) => format!("{} (their hand only)", seat_name(p)),
Eyes::Spectator => "a spectator (no hands)".into(),
}
}
fn viewer(self) -> cb_game_runtime::Viewer {
match self {
Eyes::Seat(p) => cb_game_runtime::Viewer::Player(p),
Eyes::Spectator => cb_game_runtime::Viewer::Spectator,
}
}
}
/// What a completed walk reports.
#[derive(Debug)]
pub struct Walk {
pub source: String,
pub steps: usize,
/// Steps the aggregate refused. A recorded game may legitimately
/// contain them — a scenario can assert a rejection — so they are
/// counted and shown, not treated as an error.
pub rejected: usize,
pub end_state_hash: String,
/// `Some` only for a bundle, which records the hash its own producer
/// computed. A scenario file may or may not pin one.
pub expected_hash: Option<String>,
}
/// Replay a recorded game and render the table after every step.
///
/// This is the answer to *"what did the table look like when it went
/// wrong?"* that previously required adding a `dbg!` and re-running.
///
/// The bundle path goes through `replay::open`, the same reader
/// `make replay-test` uses, so the states shown here are the states a
/// replay reaches — structurally, not by assertion. The hash is then
/// checked anyway, because a structural argument that is never executed
/// is the class of claim this project keeps finding to be wrong.
pub fn walk<W: std::io::Write>(
source: &std::path::Path,
eyes: Eyes,
out: &mut W,
) -> Result<Walk, String> {
let (mut state, steps, expected_hash) = load(source)?;
let name = source.display().to_string();
let _ = writeln!(
out,
"inspecting {name} as {} — {} step(s)",
eyes.label(),
steps.len()
);
let _ = write!(out, "{}", render(&state.project(eyes.viewer())));
let mut rejected = 0usize;
for (i, step) in steps.iter().enumerate() {
let (actor, command) = GroundState::parse_command(step)?;
match state.validate(actor, &command) {
Ok(produced) => {
for event in produced {
state.fold(&event);
}
let _ = writeln!(out, "\n[{}] {} {}", i + 1, step.actor, describe_step(step));
}
Err(rejection) => {
rejected += 1;
let _ = writeln!(
out,
"\n[{}] {} {} — REJECTED: {rejection:?}",
i + 1,
step.actor,
describe_step(step)
);
}
}
let _ = write!(out, "{}", render(&state.project(eyes.viewer())));
}
let end_state_hash = cb_events::state_hash_hex(&state);
if let Some(expected) = &expected_hash {
if &end_state_hash != expected {
return Err(format!(
"the walk did not reproduce the recorded end state: {end_state_hash} != {expected}"
));
}
}
let _ = writeln!(
out,
"\nend-state hash {end_state_hash}{}",
match &expected_hash {
Some(_) => " (matches the recording)",
None => " (the source pins no hash)",
}
);
Ok(Walk {
source: name,
steps: steps.len(),
rejected,
end_state_hash,
expected_hash,
})
}
/// A `.cbreplay` bundle or a scenario YAML. Both already reconstruct a
/// command sequence; neither needed a new format for this.
fn load(
source: &std::path::Path,
) -> Result<
(
GroundState,
Vec<cb_game_runtime::CommandStep>,
Option<String>,
),
String,
> {
if source.is_dir() {
let (manifest, state, steps) = cb_game_runtime::replay::open::<GroundState>(source)?;
return Ok((state, steps, Some(manifest.end_state_hash)));
}
let yaml =
std::fs::read_to_string(source).map_err(|e| format!("read {}: {e}", source.display()))?;
let file = cb_game_runtime::ScenarioFile::from_yaml(&yaml)
.map_err(|e| format!("parse {}: {e}", source.display()))?;
let state = GroundState::setup(&file.setup, file.seed)?;
Ok((state, file.commands, file.expect.state_hash))
}
fn describe_step(step: &cb_game_runtime::CommandStep) -> String {
let mut out = step.cmd.clone();
for (key, value) in &step.args {
let rendered = match value {
serde_yaml::Value::String(s) => s.clone(),
other => serde_yaml::to_string(other)
.unwrap_or_default()
.trim()
.to_string(),
};
out.push_str(&format!(" {key}={rendered}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@ -577,4 +743,136 @@ mod tests {
assert!(!out.contains("(you)"), "{out}");
assert!(!out.contains("hand ["), "{out}");
}
// ------------------------------------------------------- the walk
/// A recorded game, produced the way a user produces one: play it
/// with bots and ask for a bundle. Fixtures written by hand would
/// test the reader against the writer's assumptions rather than
/// against what the writer actually writes.
fn recorded(tag: &str) -> (std::path::PathBuf, std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("cb-inspect-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("tmp");
let config = crate::table::Config {
seed: 42,
players: 3,
human_seats: vec![],
bot: "greedy".into(),
replay_dir: Some(dir.clone()),
record: Some(dir.join("session.yaml")),
};
let mut sink: Vec<u8> = Vec::new();
let summary =
crate::table::play(&config, "".as_bytes(), &mut sink).expect("the bot game runs");
(
summary.bundle.expect("bundle"),
summary.recorded.expect("scenario"),
summary.end_state_hash,
)
}
/// The acceptance: an inspector that shows a state the replay never
/// reached is worse than no inspector. Both source kinds are walked,
/// because "it works for bundles" was the shape of the last three
/// half-checked claims in this repo.
#[test]
fn a_walk_reproduces_the_recorded_end_state() {
let (bundle, scenario, hash) = recorded("walk");
let mut out: Vec<u8> = Vec::new();
let report = super::walk(&bundle, Eyes::Spectator, &mut out).expect("bundle walks");
assert_eq!(report.end_state_hash, hash);
assert_eq!(report.expected_hash.as_deref(), Some(hash.as_str()));
assert!(
report.steps > 5,
"a 3-player game is more than {} steps",
report.steps
);
// The render must have run once per step plus once for the
// initial state — otherwise the walk "succeeded" having shown
// nothing, which is the harness-does-nothing shape.
let text = String::from_utf8(out).expect("utf8");
assert_eq!(
text.matches(" problems:").count(),
report.steps + 1,
"one table per step plus the opening one\n{text}"
);
let mut out: Vec<u8> = Vec::new();
let from_yaml = super::walk(&scenario, Eyes::Spectator, &mut out).expect("scenario walks");
assert_eq!(from_yaml.end_state_hash, hash);
let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir"));
}
/// The control for the assertion above. A bundle whose recorded hash
/// has been altered must make the walk fail, or the hash comparison
/// is decoration.
#[test]
fn a_walk_that_does_not_reproduce_fails() {
let (bundle, _, hash) = recorded("tamper");
let manifest = bundle.join("manifest.yaml");
let text = std::fs::read_to_string(&manifest).expect("read manifest");
std::fs::write(
&manifest,
text.replace(
&hash,
"0000000000000000000000000000000000000000000000000000000000000000",
),
)
.expect("write manifest");
let mut out: Vec<u8> = Vec::new();
let err =
super::walk(&bundle, Eyes::Spectator, &mut out).expect_err("tampered bundle must fail");
assert!(err.contains("did not reproduce"), "wrong reason: {err}");
let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir"));
}
/// `--as` is a projection, not a filter applied afterwards. Seat 2
/// (P3) sees its own hand and counts for the rest.
///
/// Seated deliberately at the *last* seat: the equivalent stage-0
/// test was vacuous twice (CB-EV-0007) because it inspected a
/// position where the hidden thing had not yet been written.
#[test]
fn a_seat_walk_shows_that_seat_and_no_other() {
let (bundle, _, _) = recorded("eyes");
let mut out: Vec<u8> = Vec::new();
super::walk(&bundle, Eyes::Seat(PlayerId(2)), &mut out).expect("walks");
let seated = String::from_utf8(out).expect("utf8");
let mut out: Vec<u8> = Vec::new();
super::walk(&bundle, Eyes::Spectator, &mut out).expect("walks");
let spectator = String::from_utf8(out).expect("utf8");
// Exactly one seat shows cards, and it is P3.
let tables = seated.matches(" problems:").count();
assert_eq!(
seated.matches("hand [").count(),
tables,
"P3 shows a hand in every table and nobody else does\n{seated}"
);
for line in seated.lines().filter(|l| l.contains("hand [")) {
assert!(line.contains("P3 (you)"), "a hand leaked: {line}");
}
// A spectator sees none at all — the same walk, one argument
// apart, so a render that ignored `Eyes` would fail here.
assert!(!spectator.contains("hand ["), "{spectator}");
assert!(!spectator.contains("(you)"), "{spectator}");
assert_ne!(seated, spectator);
let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir"));
}
#[test]
fn eyes_parse_and_bad_ones_are_refused() {
assert_eq!(Eyes::parse("2").unwrap(), Eyes::Seat(PlayerId(2)));
assert_eq!(Eyes::parse("SPECTATOR").unwrap(), Eyes::Spectator);
assert!(Eyes::parse("P3").is_err());
assert!(Eyes::parse("").is_err());
}
}

View file

@ -16,8 +16,10 @@ use table::Config;
const USAGE: &str = "\
usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
[--replay DIR] [--all-bots]
[--replay DIR] [--all-bots] [--record FILE]
cb-play --inspect PATH [--as SEAT|spectator]
play:
--seed N game seed (default 1); the same seed replays identically
--players N 2..6 seats (default 3)
--seat N a seat you play, 0-based, repeatable (default 0)
@ -25,10 +27,34 @@ usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
--bot KIND policy for every other seat: greedy (default) or random
--replay DIR write a .cbreplay bundle of the finished game to DIR
--record FILE write the finished game as a scenario YAML
inspect a recorded game a .cbreplay bundle directory or a scenario YAML:
--inspect PATH render the table after every recorded step
--as WHO whose projection to render: a 0-based seat, or
spectator (default). A seat sees only its own hand.
The two modes take disjoint flags: inspecting reads a recording, it does
not deal one, so a seed or a seat count would be silently ignored.
";
fn parse_args(argv: &[String]) -> Result<Config, String> {
/// The two things this binary does. Separate rather than one `Config`
/// with optional halves: `--inspect --seed 9` is not a request anyone
/// can mean, and a flag that is accepted and ignored is worse than one
/// that is refused.
enum Mode {
Play(Config),
Inspect {
source: std::path::PathBuf,
eyes: inspect::Eyes,
},
}
fn parse_args(argv: &[String]) -> Result<Mode, String> {
let mut config = Config::default();
let mut source: Option<std::path::PathBuf> = None;
let mut eyes = inspect::Eyes::Spectator;
let mut play_flags: Vec<String> = Vec::new();
let mut inspect_flags: Vec<String> = Vec::new();
let mut seats: Vec<u8> = Vec::new();
let mut all_bots = false;
let mut i = 0;
@ -41,18 +67,21 @@ fn parse_args(argv: &[String]) -> Result<Config, String> {
let flag = argv[i].as_str();
match flag {
"--seed" => {
play_flags.push(flag.into());
config.seed = value(i, argv, flag)?
.parse()
.map_err(|e| format!("--seed: {e}"))?;
i += 2;
}
"--players" => {
play_flags.push(flag.into());
config.players = value(i, argv, flag)?
.parse()
.map_err(|e| format!("--players: {e}"))?;
i += 2;
}
"--seat" => {
play_flags.push(flag.into());
seats.push(
value(i, argv, flag)?
.parse()
@ -61,25 +90,54 @@ fn parse_args(argv: &[String]) -> Result<Config, String> {
i += 2;
}
"--bot" => {
play_flags.push(flag.into());
config.bot = value(i, argv, flag)?;
i += 2;
}
"--record" => {
play_flags.push(flag.into());
config.record = Some(value(i, argv, flag)?.into());
i += 2;
}
"--replay" => {
play_flags.push(flag.into());
config.replay_dir = Some(value(i, argv, flag)?.into());
i += 2;
}
"--all-bots" => {
all_bots = true;
play_flags.push(flag.into());
i += 1;
}
"--inspect" => {
source = Some(value(i, argv, flag)?.into());
inspect_flags.push(flag.into());
i += 2;
}
"--as" => {
eyes = inspect::Eyes::parse(&value(i, argv, flag)?)?;
inspect_flags.push(flag.into());
i += 2;
}
"-h" | "--help" => return Err(USAGE.into()),
other => return Err(format!("unknown flag {other:?}\n\n{USAGE}")),
}
}
if let Some(source) = source {
if !play_flags.is_empty() {
return Err(format!(
"--inspect reads a recording; it cannot also {}\n\n{USAGE}",
play_flags.join(", ")
));
}
return Ok(Mode::Inspect { source, eyes });
}
if !inspect_flags.is_empty() {
return Err(format!(
"{} only means something with --inspect\n\n{USAGE}",
inspect_flags.join(", ")
));
}
if !(2..=6).contains(&config.players) {
return Err(format!("GR-O01: {} players is outside 26", config.players));
}
@ -96,12 +154,12 @@ fn parse_args(argv: &[String]) -> Result<Config, String> {
config.players
));
}
Ok(config)
Ok(Mode::Play(config))
}
fn main() {
let argv: Vec<String> = std::env::args().skip(1).collect();
let config = match parse_args(&argv) {
let mode = match parse_args(&argv) {
Ok(c) => c,
Err(message) => {
eprintln!("{message}");
@ -109,6 +167,33 @@ fn main() {
}
};
let config = match mode {
Mode::Play(c) => c,
Mode::Inspect { source, eyes } => {
let mut out = std::io::stdout().lock();
match inspect::walk(&source, eyes, &mut out) {
Ok(walk) => {
println!(
" {}: {} step(s), {} rejected, hash {}{}",
walk.source,
walk.steps,
walk.rejected,
walk.end_state_hash,
match walk.expected_hash {
Some(_) => " — reproduces the recording",
None => " — the source pins no hash to check",
}
);
return;
}
Err(message) => {
eprintln!("cb-play: {message}");
std::process::exit(1);
}
}
}
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
match table::play(&config, stdin.lock(), stdout.lock()) {
@ -265,21 +350,43 @@ mod tests {
assert_eq!(summary.rounds, 5);
}
/// `parse_args` in play mode. Panics on an inspect-mode result, so a
/// flag that quietly switched modes could not pass for a play flag.
fn play_args(list: &[&str]) -> Result<Config, String> {
match parse_args(&args(list))? {
Mode::Play(c) => Ok(c),
Mode::Inspect { .. } => panic!("{list:?} parsed as inspect, not play"),
}
}
#[test]
fn flags_parse_and_bad_ones_are_refused() {
let c = parse_args(&args(&["--seed", "9", "--players", "4", "--seat", "2"])).unwrap();
let c = play_args(&["--seed", "9", "--players", "4", "--seat", "2"]).unwrap();
assert_eq!((c.seed, c.players, c.human_seats.clone()), (9, 4, vec![2]));
assert_eq!(parse_args(&args(&[])).unwrap().human_seats, vec![0]);
assert!(parse_args(&args(&["--all-bots"]))
.unwrap()
.human_seats
.is_empty());
assert_eq!(play_args(&[]).unwrap().human_seats, vec![0]);
assert!(play_args(&["--all-bots"]).unwrap().human_seats.is_empty());
// GR-O01's range, refused at the door rather than at setup.
assert!(parse_args(&args(&["--players", "7"])).is_err());
assert!(parse_args(&args(&["--players", "1"])).is_err());
// A seat nobody occupies would silently never be prompted.
assert!(parse_args(&args(&["--players", "3", "--seat", "3"])).is_err());
assert!(parse_args(&args(&["--seed"])).is_err());
// The two modes take disjoint flags in both directions. A flag
// accepted and ignored is how a user believes they inspected
// seed 9 when they inspected whatever the recording holds.
assert!(parse_args(&args(&["--inspect", "x", "--seed", "9"])).is_err());
assert!(parse_args(&args(&["--as", "2"])).is_err());
assert!(matches!(
parse_args(&args(&["--inspect", "x", "--as", "2"])).unwrap(),
Mode::Inspect { eyes, .. } if eyes == inspect::Eyes::Seat(cb_kernel::PlayerId(2))
));
assert!(matches!(
parse_args(&args(&["--inspect", "x"])).unwrap(),
Mode::Inspect {
eyes: inspect::Eyes::Spectator,
..
}
));
assert!(parse_args(&args(&["--nope"])).is_err());
}

View file

@ -133,7 +133,7 @@ while writing the gate, before it had ever been committed.
```task
id: CB-WP-0011-T02
status: todo
status: done
priority: high
state_hub_task_id: "08f8a9c7-3dfe-4f72-b510-df9efa457370"
```
@ -160,6 +160,35 @@ must not show anyone else's. The stage-0 test that checked this was
vacuous twice before it held (CB-EV-0007); seat the assertion where the
hidden thing is actually hidden.
**Done 2026-08-02.** `cb-play --inspect PATH [--as SEAT|spectator]`
walks a `.cbreplay` bundle or a scenario YAML and renders the table after
every step.
The design decision worth recording: `replay::replay` was split, and its
bundle reader extracted as `replay::open`, so **the inspector walks
through the same reader the replay gate uses** — including the control
that makes the recorded seed load-bearing. A second reader would let the
inspector show states a replay never reached, and it would be a
duplicated fact in the one place where being wrong is silent. The hash is
then asserted anyway, because a structural argument that is never
executed is the class of claim this project keeps finding to be wrong.
The two modes take **disjoint** flags in both directions
(`--inspect --seed 9` and a bare `--as 2` are both refused, exit 64): a
flag accepted and ignored is how a user comes to believe they inspected
seed 9 when they inspected whatever the recording holds.
Three controls, each red for its stated reason:
| control | result |
|---|---|
| the hash comparison removed | `a_walk_that_does_not_reproduce_fails` fails — the tampered bundle walks clean |
| `Eyes::Seat` resolved to `Viewer::Spectator` | `P3 shows a hand in every table and nobody else does` |
| the per-step render dropped | `one table per step plus the opening one` |
The third is the one that matters: without it a walk that rendered
nothing at all would still have reported a matching hash and passed.
## Task: evidence, and what the chaos roll cost
```task