clay-borg/crates/cb-game-runtime/src/replay.rs
tegwick b11fc91fd4 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.
2026-08-02 02:45:55 +02:00

238 lines
8.8 KiB
Rust

//! K10 replay bundles (GameKernel §2.4, MetricsAndScenarios §4).
//!
//! K10: *"A replay bundle (`.cbreplay`) contains manifest, command log,
//! initial snapshot, and failed expectations; the runner's `--replay`
//! re-executes it bit-identically."* Unimplemented until CB-WP-0006 T06 —
//! `games/` had no `replays/` directory, no `.cbreplay` reader or writer,
//! and `cb-sim` had no flag parsing at all, so `--replay` had nowhere to
//! go. INTENT names this design decision **8 of 10**.
//!
//! MetricsAndScenarios §4 states the point: *"A bug report without a
//! replay bundle is information; with one, it is work an agent can
//! start."*
//!
//! **Dev-only**, behind the `scenarios` feature, so it is charged to
//! AM-4b (9.4% headroom) and not to the shipped runtime.
//!
//! ## Why replay can fail, and must be able to
//!
//! ADR-0005 §6 required four controls, two of which the adversarial
//! reviewer supplied. The two that shape this module:
//!
//! * the comparison hash is **read out of the bundle**, written by the
//! process that produced it — never recomputed in the replaying process,
//! which would be `assert_eq!(h, h)` and is exactly the AM-7 defect;
//! * the recorded **seed must reproduce the recorded initial snapshot**,
//! so mutating the manifest seed makes replay fail. Without that check a
//! replay restored from the snapshot would ignore the seed entirely and
//! the control could not bite.
use std::path::{Path, PathBuf};
use cb_events::store::{parse, LogStore};
use cb_events::{state_hash_hex, FileLogStore};
use serde::{Deserialize, Serialize};
use crate::scenario::{CommandStep, ScenarioGame};
use crate::{ScenarioFile, Setup};
pub const MANIFEST: &str = "manifest.yaml";
pub const COMMANDS: &str = "commands.log";
pub const INITIAL: &str = "initial.snapshot";
pub const EXPECTED: &str = "expected.yaml";
/// `manifest.yaml` — everything needed to re-execute, and the hash the
/// replay must reproduce.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub scenario: String,
pub seed: u64,
pub setup: Setup,
pub schema_ver: u16,
/// Written by the producing process. The replay compares against
/// **this**, never against a value it computed itself.
pub end_state_hash: String,
/// Hash of the initial state, so a mutated seed is detectable.
pub initial_state_hash: String,
pub commit: String,
}
/// `expected.yaml` — the assertions that failed, expected vs actual.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Expected {
pub reason: String,
pub end_state: serde_json::Value,
}
#[derive(Debug)]
pub struct ReplayReport {
pub scenario: String,
pub commands: usize,
pub hash: String,
}
fn err(context: &str, e: impl std::fmt::Display) -> String {
format!("{context}: {e}")
}
/// Write a `.cbreplay` bundle. Called when a scenario fails.
pub fn write_bundle(
dir: &Path,
scenario: &ScenarioFile,
initial_state: &serde_json::Value,
initial_state_hash: &str,
end_state: &serde_json::Value,
end_state_hash: &str,
reason: &str,
) -> Result<PathBuf, String> {
let slug = scenario.scenario.replace('/', "-");
let bundle = dir.join(format!("{slug}.cbreplay"));
// A stale bundle from a previous run would replay the old failure.
let _ = std::fs::remove_dir_all(&bundle);
std::fs::create_dir_all(&bundle).map_err(|e| err("create bundle", e))?;
std::fs::write(
bundle.join(INITIAL),
serde_json::to_vec_pretty(initial_state).map_err(|e| err("encode initial", e))?,
)
.map_err(|e| err("write initial", e))?;
// The command stream goes through the K11 framing, so a truncated
// bundle is detected rather than replayed short.
let mut log = FileLogStore::open(bundle.join(COMMANDS)).map_err(|e| err("open log", e))?;
for step in &scenario.commands {
let bytes = serde_json::to_vec(step).map_err(|e| err("encode command", e))?;
log.append(&bytes).map_err(|e| err("append command", e))?;
}
let manifest = Manifest {
scenario: scenario.scenario.clone(),
seed: scenario.seed,
setup: scenario.setup.clone(),
schema_ver: 1,
end_state_hash: end_state_hash.to_string(),
initial_state_hash: initial_state_hash.to_string(),
commit: option_env!("CB_COMMIT").unwrap_or("unknown").to_string(),
};
std::fs::write(
bundle.join(MANIFEST),
serde_yaml::to_string(&manifest).map_err(|e| err("encode manifest", e))?,
)
.map_err(|e| err("write manifest", e))?;
std::fs::write(
bundle.join(EXPECTED),
serde_yaml::to_string(&Expected {
reason: reason.to_string(),
end_state: end_state.clone(),
})
.map_err(|e| err("encode expected", e))?,
)
.map_err(|e| err("write expected", e))?;
Ok(bundle)
}
/// Open a bundle for re-execution: the manifest, the restored initial
/// state, and the recorded steps in order.
///
/// 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,
{
let manifest: Manifest = serde_yaml::from_str(
&std::fs::read_to_string(bundle.join(MANIFEST)).map_err(|e| err("read manifest", e))?,
)
.map_err(|e| err("parse manifest", e))?;
// Control: the recorded seed and setup must reproduce the recorded
// initial snapshot. Without this the seed would be inert — replay
// restores from the snapshot — and a mutated-seed bundle would pass.
// Hash the TYPED aggregate, matching how the bundle was written.
let rebuilt = G::setup(&manifest.setup, manifest.seed)?;
let rebuilt_hash = state_hash_hex(&rebuilt);
if rebuilt_hash != manifest.initial_state_hash {
return Err(format!(
"bundle is inconsistent: seed {} and setup do not reproduce the \
recorded initial state ({} != {})",
manifest.seed, rebuilt_hash, manifest.initial_state_hash
));
}
let initial: serde_json::Value = serde_json::from_slice(
&std::fs::read(bundle.join(INITIAL)).map_err(|e| err("read initial", e))?,
)
.map_err(|e| err("parse 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 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);
}
}
applied += 1;
}
// Positive control: a bundle that replayed nothing must not be
// reported as a successful reproduction.
if applied == 0 {
return Err("bundle contains no commands; nothing was replayed".to_string());
}
let hash = state_hash_hex(&state);
if hash != manifest.end_state_hash {
return Err(format!(
"replay did not reproduce: {} != recorded {}",
hash, manifest.end_state_hash
));
}
Ok(ReplayReport {
scenario: manifest.scenario,
commands: applied,
hash,
})
}
/// Read a bundle's raw command log. Used by the negative controls.
pub fn raw_command_log(bundle: &Path) -> Result<Vec<u8>, String> {
std::fs::read(bundle.join(COMMANDS)).map_err(|e| err("read commands", e))
}
/// Overwrite a bundle's raw command log. Used by the negative controls.
pub fn set_raw_command_log(bundle: &Path, bytes: &[u8]) -> Result<(), String> {
std::fs::write(bundle.join(COMMANDS), bytes).map_err(|e| err("write commands", e))
}