clay-borg/crates/cb-game-runtime/src/scenario.rs
tegwick 1edadac9a2 CB-WP-0006 T06: K10 replay bundles, --replay, and AM-7 re-earned
INTENT design decision 8 of 10, unimplemented for six passes. cb-sim had
no flag parsing at all, so --replay had nowhere to go.

The bundle is manifest + commands.log + initial.snapshot + expected.yaml,
dev-only behind the scenarios feature and charged to AM-4b. The command
stream goes through the K11 framing built in T05, so a truncated bundle is
detected rather than replayed short — the two tasks compose rather than
duplicating.

The reviewer's D2 correction was real: this was not "a directory of four
files". Pass carried only the end state, RunOutcome::Failed was a
formatted String, and scenario.rs created an EventLog, appended to it and
never read it. All three had to change.

The first round trip failed to reproduce, and the cause is worth keeping:
state_hash_hex over a serde_json::Value is a different canonical form than
over the typed aggregate — Value's map is key-sorted, a struct serializes
in declaration order. The bundle was written with one basis and verified
with the other. A round trip written to recompute its own comparison value
would have PASSED this bug; it failed because the recorded hash came from
the producing process, which is control 2's entire purpose.

make replay-test implements ADR-0005 §6's four controls, 14/14: a
committed deliberately-failing fixture outside the corpus with covers: []
so it neither fails `make sim` nor inflates AM-1; a tampered recorded hash
must fail; a log short by one byte and a corrupted length prefix must be
rejected; and a mutated manifest seed must fail — which bites only because
replay re-derives the initial state from seed+setup and checks it against
the recorded snapshot, since restoring from the snapshot alone would leave
the seed inert. Plus a control on the controls: the bundle must still
replay after every mutation is reverted.

AM-7's hash-identical clause is re-earned. The probe records a hash per
per-game segment and replays each from its own genesis; folding from the
wrong seed now fails. That is the clause ADR-0005 §4 withdrew as
mutation-proven inert. The scaling >= 0.9x clause is still unenforced, so
AM-7 stays PARTIAL — reported, not rounded up.

Kernel coverage 15/18 -> 16/18. facts-check immediately caught the spec's
copy of that number going stale, on a number that moved the same hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:05:37 +02:00

441 lines
15 KiB
Rust

//! Scenario file format and runner (MetricsAndScenarios §2, GameKernel
//! K17). Games opt in by implementing [`ScenarioGame`]; [`run`] executes a
//! scenario twice with the same seed and fails on hash divergence (K8).
use cb_events::{state_hash_hex, Envelope, EventLog};
use cb_kernel::{Actor, Aggregate, EventSeq, GameId, PlayerId};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
/// One scenario file (`scenarios/<game>/<slug>.yaml`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioFile {
pub scenario: String,
#[serde(default)]
pub description: String,
/// Numbered rule IDs this scenario covers (feeds M-D1-COV / AM-1).
pub covers: Vec<String>,
/// Marks scenarios encoding a PROVISIONAL U-item default
/// (GroundRules §Underdetermined): a ground-game ruling flips the
/// scenario, not the kernel.
#[serde(default)]
pub provisional: bool,
/// Who must rule on the U-item this scenario encodes. Required when
/// `provisional` is true: a provisional default with no owner can
/// shape the kernel indefinitely while looking handled
/// (CB-WP-0003 T08).
#[serde(default)]
pub provisional_owner: String,
/// ISO date the provisional default was raised, so its age is
/// reportable by `make coverage`.
#[serde(default)]
pub provisional_raised: String,
pub seed: u64,
pub setup: Setup,
pub commands: Vec<CommandStep>,
pub expect: Expect,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Setup {
pub players: u8,
/// Named setup preset from the game spec (e.g. "standard-3p").
pub preset: String,
/// Explicit state overrides applied after the preset (dot-paths).
#[serde(default)]
pub patch: BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandStep {
pub actor: String,
pub cmd: String,
#[serde(default)]
pub args: BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Expect {
/// Ordered subsequence of event kinds that must occur.
#[serde(default)]
pub events: Vec<BTreeMap<String, serde_yaml::Value>>,
/// Partial end-state assertions: dot-path → expected value.
#[serde(default)]
pub state: BTreeMap<String, serde_yaml::Value>,
/// Indices into `commands` that must be rejected.
#[serde(default)]
pub rejects: Vec<usize>,
/// Optional full-state golden hash.
#[serde(default)]
pub state_hash: Option<String>,
}
impl ScenarioFile {
pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
serde_yaml::from_str(yaml)
}
}
/// Result of executing one scenario (twice, per the K8 double-run rule).
#[derive(Debug)]
pub enum RunOutcome {
Passed {
covers: Vec<String>,
},
Failed {
reason: String,
/// The failing run's states, so a K10 bundle can be written.
/// `None` when execution failed before producing a pass.
evidence: Option<Box<FailureEvidence>>,
},
}
/// What a `.cbreplay` bundle needs from a failed run (K10).
#[derive(Debug)]
pub struct FailureEvidence {
pub initial: serde_json::Value,
pub initial_hash: String,
pub end_state: serde_json::Value,
pub end_state_hash: String,
}
/// A game aggregate the scenario runner can drive (GameKernel K17). The
/// game owns setup presets and the command vocabulary; the runner owns
/// execution, assertions, and the determinism check.
pub trait ScenarioGame: Aggregate + Serialize + serde::de::DeserializeOwned + Sized {
/// Build the initial state for a named preset (GR-S rules for GROUND).
fn setup(setup: &Setup, seed: u64) -> Result<Self, String>;
/// Resolve one scenario step into an actor and a typed command.
fn parse_command(step: &CommandStep) -> Result<(Actor, Self::Command), String>;
/// Current round, for the event envelope (GameKernel K4).
fn round(&self) -> u8;
}
/// `P1` → `PlayerId(0)`, `SYSTEM` → [`Actor::System`]. Seat numbering is
/// 1-based in scenario files and 0-based in state, matching the
/// `players.0.*` assertion paths.
pub fn parse_actor(actor: &str) -> Result<Actor, String> {
if actor == "SYSTEM" {
return Ok(Actor::System);
}
actor
.strip_prefix('P')
.and_then(|n| n.parse::<u8>().ok())
.filter(|n| *n >= 1)
.map(|n| Actor::Player(PlayerId(n - 1)))
.ok_or_else(|| format!("unparseable actor {actor:?} (expected P<n> or SYSTEM)"))
}
/// One execution pass: returns the end state as JSON, its hash, the
/// emitted events, and the indices of rejected commands.
pub(crate) struct Pass {
/// The state *before* any command ran — `Pass` previously carried only
/// the end state, so a K10 bundle had no `initial.snapshot` to write.
pub(crate) initial: serde_json::Value,
/// Hash of the initial state, taken over the **typed** aggregate.
/// Hashing `serde_json::Value` instead gives a different canonical
/// form — `Value`'s map is key-sorted, a struct serializes in
/// declaration order — so a bundle written one way and verified the
/// other never reproduces. That defect cost the first K10 round trip.
pub(crate) initial_hash: String,
pub(crate) state: serde_json::Value,
pub(crate) hash: String,
events: Vec<serde_json::Value>,
rejected: BTreeSet<usize>,
}
fn execute<G>(scenario: &ScenarioFile) -> Result<Pass, String>
where
G: ScenarioGame,
G::Event: Serialize,
{
let mut state = G::setup(&scenario.setup, scenario.seed)?;
// The runner owns `setup.patch` so no game reimplements it: overrides
// are applied to the canonical form and read back.
if !scenario.setup.patch.is_empty() {
state = apply_patch(&state, &scenario.setup.patch)?;
}
let initial = serde_json::to_value(&state).map_err(|e| format!("initial encode: {e}"))?;
let initial_hash = state_hash_hex(&state);
let mut log: EventLog<G::Event> = EventLog::new();
let mut events = Vec::new();
let mut rejected = BTreeSet::new();
let mut seq = 0u64;
for (index, step) in scenario.commands.iter().enumerate() {
let (actor, command) = G::parse_command(step)?;
match state.validate(actor, &command) {
Err(_) => {
rejected.insert(index);
}
Ok(produced) => {
for event in produced {
events.push(
serde_json::to_value(&event).map_err(|e| format!("event encode: {e}"))?,
);
state.fold(&event);
// K4/K11: everything applied is logged in envelope order.
log.append(Envelope {
seq: EventSeq(seq),
game_id: GameId(0),
round: state.round(),
schema_ver: 1,
payload: event,
})
.map_err(|e| format!("event log: {e}"))?;
seq += 1;
}
}
}
}
Ok(Pass {
initial,
initial_hash,
state: serde_json::to_value(&state).map_err(|e| format!("state encode: {e}"))?,
hash: state_hash_hex(&state),
events,
rejected,
})
}
/// Execute a scenario against game `G`: run twice with the same seed,
/// compare hashes (K8), then check the `expect` block.
pub fn run<G>(scenario: &ScenarioFile) -> RunOutcome
where
G: ScenarioGame,
G::Event: Serialize,
{
let first = match execute::<G>(scenario) {
Ok(pass) => pass,
Err(reason) => {
return RunOutcome::Failed {
reason,
evidence: None,
}
}
};
let second = match execute::<G>(scenario) {
Ok(pass) => pass,
Err(reason) => {
return RunOutcome::Failed {
reason,
evidence: None,
}
}
};
if first.hash != second.hash {
let reason = format!(
"K8 divergence: run 1 hash {} != run 2 hash {}",
first.hash, second.hash
);
return RunOutcome::Failed {
evidence: Some(Box::new(evidence_of(&first))),
reason,
};
}
if let Err(reason) = check(scenario, &first) {
return RunOutcome::Failed {
evidence: Some(Box::new(evidence_of(&first))),
reason,
};
}
RunOutcome::Passed {
covers: scenario.covers.clone(),
}
}
fn evidence_of(pass: &Pass) -> FailureEvidence {
FailureEvidence {
initial: pass.initial.clone(),
initial_hash: pass.initial_hash.clone(),
end_state: pass.state.clone(),
end_state_hash: pass.hash.clone(),
}
}
fn check(scenario: &ScenarioFile, pass: &Pass) -> Result<(), String> {
let expected_rejects: BTreeSet<usize> = scenario.expect.rejects.iter().copied().collect();
if pass.rejected != expected_rejects {
return Err(format!(
"rejects mismatch: expected {expected_rejects:?}, got {:?}",
pass.rejected
));
}
for (path, want) in &scenario.expect.state {
let want = to_json(want)?;
let got = lookup(&pass.state, path)
.ok_or_else(|| format!("state path {path:?} not found in end state"))?;
if got != &want {
return Err(format!("state {path:?}: expected {want}, got {got}"));
}
}
// `expect.events` is an ordered subsequence: each entry must match a
// later event than the previous one, by field subset.
let mut cursor = 0usize;
for want in &scenario.expect.events {
let want = to_json(want)?;
let found = pass.events[cursor..]
.iter()
.position(|event| is_subset(&want, event));
match found {
Some(offset) => cursor += offset + 1,
None => return Err(format!("expected event {want} not found in order")),
}
}
if let Some(want) = &scenario.expect.state_hash {
if want != &pass.hash {
return Err(format!(
"state_hash mismatch: expected {want}, got {}",
pass.hash
));
}
}
Ok(())
}
/// Apply `setup.patch` dot-path overrides to a freshly built state. Every
/// path must already exist — a typo is an error, never a silent no-op.
fn apply_patch<G: ScenarioGame>(
state: &G,
patch: &BTreeMap<String, serde_yaml::Value>,
) -> Result<G, String> {
let mut json = serde_json::to_value(state).map_err(|e| format!("state encode: {e}"))?;
for (path, value) in patch {
let value = to_json(value)?;
let (parent_path, key) = path.rsplit_once('.').unwrap_or(("", path.as_str()));
// The parent must exist — a typo mid-path is an error, never a
// silent no-op. The final key may be new, so scenarios can seed
// open-ended maps (relations, focus) that start empty.
let parent = if parent_path.is_empty() {
&mut json
} else {
lookup_mut(&mut json, parent_path).ok_or_else(|| {
format!("patch path {path:?}: {parent_path:?} does not exist in the initial state")
})?
};
match parent {
serde_json::Value::Object(map) => {
map.insert(key.to_string(), value);
}
serde_json::Value::Array(items) => {
let index = key
.parse::<usize>()
.map_err(|_| format!("patch path {path:?}: {key:?} is not an array index"))?;
let slot = items
.get_mut(index)
.ok_or_else(|| format!("patch path {path:?}: index {index} out of range"))?;
*slot = value;
}
_ => return Err(format!("patch path {path:?}: parent is not a container")),
}
}
serde_json::from_value(json).map_err(|e| format!("patch produced invalid state: {e}"))
}
fn lookup_mut<'v>(
root: &'v mut serde_json::Value,
path: &str,
) -> Option<&'v mut serde_json::Value> {
path.split('.').try_fold(root, |node, segment| match node {
serde_json::Value::Object(map) => map.get_mut(segment),
serde_json::Value::Array(items) => items.get_mut(segment.parse::<usize>().ok()?),
_ => None,
})
}
fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, String> {
serde_json::to_value(value).map_err(|e| format!("expectation encode: {e}"))
}
/// Dot-path lookup: object keys and array indices, so `players.0.stress`
/// reads the same whether the container is a map or a sequence.
fn lookup<'v>(root: &'v serde_json::Value, path: &str) -> Option<&'v serde_json::Value> {
path.split('.').try_fold(root, |node, segment| match node {
serde_json::Value::Object(map) => map.get(segment),
serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?),
_ => None,
})
}
/// Field-subset match: every key in `want` must be present and equal in
/// `got`, so scenarios can assert on the fields they care about.
fn is_subset(want: &serde_json::Value, got: &serde_json::Value) -> bool {
match (want, got) {
(serde_json::Value::Object(w), serde_json::Value::Object(g)) => w
.iter()
.all(|(key, value)| g.get(key).is_some_and(|found| is_subset(value, found))),
_ => want == got,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
scenario: ground/smoke-setup
description: Parses the documented scenario shape end to end.
covers: [GR-S02, GR-S03]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"players.0.stress": 4
commands:
- actor: P1
cmd: select_action
args: { action: ATTACK, target: P2 }
expect:
state:
"round": 1
rejects: []
"#;
#[test]
fn scenario_format_roundtrips() {
let parsed = ScenarioFile::from_yaml(SAMPLE).unwrap();
assert_eq!(parsed.scenario, "ground/smoke-setup");
assert_eq!(parsed.covers, vec!["GR-S02", "GR-S03"]);
assert_eq!(parsed.setup.players, 3);
assert_eq!(parsed.commands.len(), 1);
}
#[test]
fn actors_parse_1_based_into_0_based_seats() {
assert_eq!(parse_actor("P1").unwrap(), Actor::Player(PlayerId(0)));
assert_eq!(parse_actor("P3").unwrap(), Actor::Player(PlayerId(2)));
assert_eq!(parse_actor("SYSTEM").unwrap(), Actor::System);
assert!(parse_actor("P0").is_err());
assert!(parse_actor("bogus").is_err());
}
#[test]
fn dot_paths_index_objects_and_arrays() {
let state = serde_json::json!({ "players": { "0": { "stress": 2 } }, "deck": [7, 9] });
assert_eq!(lookup(&state, "players.0.stress").unwrap(), &2);
assert_eq!(lookup(&state, "deck.1").unwrap(), &9);
assert!(lookup(&state, "players.9.stress").is_none());
}
#[test]
fn unknown_fields_are_rejected() {
let bad = SAMPLE.replace("seed: 42", "seed: 42\ntypo_field: 1");
assert!(ScenarioFile::from_yaml(&bad).is_err());
}
}