2026-07-31 01:57:13 +02:00
|
|
|
//! cb-game-runtime — round/phase machinery, commit windows, projections,
|
|
|
|
|
//! and the scenario runner (GameKernel §2.5, §3). Game-agnostic.
|
|
|
|
|
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
#[cfg(feature = "scenarios")]
|
2026-07-31 01:57:13 +02:00
|
|
|
pub mod scenario;
|
|
|
|
|
|
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
|
|
|
/// K10 replay bundles. Dev-only: a shipped game runtime writes no bundles.
|
|
|
|
|
#[cfg(feature = "scenarios")]
|
|
|
|
|
pub mod replay;
|
|
|
|
|
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
pub use scenario::{parse_actor, run, CommandStep, RunOutcome, ScenarioFile, ScenarioGame, Setup};
|
2026-07-31 01:57:13 +02:00
|
|
|
|
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
/// A simultaneous commit window (GameKernel K12): the runtime opens it
|
|
|
|
|
/// naming who must submit; submissions are commitment events hidden from
|
|
|
|
|
/// projections until reveal.
|
CB-WP-0006 T07: implement K18, amend K14
Two rules, two different answers, which is the point of a task phrased
"implement, or amend and say why".
K18 is implemented. "Criterion benches driving the same scenario format at
scale" was false — the bench hardcoded its commands and never touched
ScenarioFile, while MetricsAndScenarios §3 pointed at a benchmarks/
directory containing only baselines/. benchmarks/synthetic-3p.yaml now
holds the workload and both the bench and bench_shape read it: the
workload is data, not code.
A second defect surfaced while fixing the first. After the bench switched
to the file, bench_shape still hardcoded the same sequence, so the
workload existed twice — deleting end_round from the YAML broke bench-test
while bench_shape kept passing. Duplicated-fact drift in executable form.
Both now read the same include_str! and deleting a command breaks both.
Explicitly not claimed: this does not unblock AM-3. AM-3's baseline is a
declarative game object — moves, turn order, rules. synthetic-3p.yaml is a
command list; the rules live in games/ground. Marking it as AM-3's subject
would compare a script to a game definition, which is the category error
AM-3 is blocked on. The file says so in its own header, where the next
person will be tempted.
K14 is amended. CommitWindow had zero non-test users and GROUND enforces
the same contract inline. Wiring GROUND through it was rejected: it would
change the serialized shape of `selections`, which four scenario files
assert by dot-path and every state hash depends on, for the sole benefit
of making a sentence literally true.
The deciding argument is INTENT's, not convenience: abstractions are
extracted from working games rather than invented in isolation, and no
concept becomes canonical until it survives a second concrete use.
CommitWindow was invented before any game needed it and has survived none.
Imposing it on GROUND would manufacture the first use rather than discover
it. So K14 states what is actually guaranteed, CommitWindow is marked
provisional in the source, and it carries a delete-by date of 2026-12-31.
Kernel spec->code link 16/18 -> 18/18, stated with the caveat the gate
prints every run: that is about names, not assertions.
Two self-tests broke and both broke correctly. rule-coverage's gate test
hardcoded "unlinked rules exist today" and failed when the last one was
linked; it now computes that and asserts the gate fails iff rules are
unlinked. facts' text check rejected k_unlinked once it became
legitimately empty; empty now renders as "(none)" and the check
distinguishes absent from empty.
M-D1-MUT: 8 of 14, unchanged — K14 and K18 are kernel rules, not
acceptance rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:47:16 +02:00
|
|
|
///
|
|
|
|
|
/// **PROVISIONAL — zero non-test users as of 2026-08-01 (K14, GameKernel
|
|
|
|
|
/// §2.5a).** GROUND implements the same contract inline in its own
|
|
|
|
|
/// aggregate. This type is the *extracted* form, kept because it
|
|
|
|
|
/// documents the seam stage 3 and stage 4 will need — but INTENT says a
|
|
|
|
|
/// concept becomes canonical only *"after surviving a second concrete
|
|
|
|
|
/// use"*, and this has survived none.
|
|
|
|
|
///
|
|
|
|
|
/// **Delete it if no second game uses it by 2026-12-31.** A primitive
|
|
|
|
|
/// with one hypothetical user and a test that exercises only itself is
|
|
|
|
|
/// the AM-11 shape, and this project has paid for that shape twice.
|
2026-08-02 02:19:23 +02:00
|
|
|
///
|
|
|
|
|
/// **Second-use log** (CB-WP-0010 T03). Two passes have now had the
|
|
|
|
|
/// opportunity and declined:
|
|
|
|
|
///
|
|
|
|
|
/// * **CB-WP-0008** — bots and `cb-play` drive GROUND's inline
|
|
|
|
|
/// commit/reveal. A second *consumer* of the aggregate did not become
|
|
|
|
|
/// a consumer of this type (CB-EV-0007 §2).
|
|
|
|
|
/// * **CB-WP-0010** — the consolidation pass reviewed it and changed
|
|
|
|
|
/// nothing.
|
|
|
|
|
///
|
|
|
|
|
/// **The date is not moved and the type is not deleted early.** The
|
|
|
|
|
/// stated condition is a second *game*, and no second game has been
|
|
|
|
|
/// attempted, so the test has not run — only the opportunity has passed.
|
|
|
|
|
/// Deleting on a test that was never run, or extending the date because
|
|
|
|
|
/// it is inconvenient, are the same error in opposite directions, and
|
|
|
|
|
/// InnerLoop §Step 4 forbids the second by name.
|
2026-07-31 01:57:13 +02:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct CommitWindow<C> {
|
|
|
|
|
/// Players who must submit, and their commitment once received.
|
|
|
|
|
pending: BTreeMap<PlayerId, Option<C>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<C> CommitWindow<C> {
|
|
|
|
|
pub fn open(players: impl IntoIterator<Item = PlayerId>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
pending: players.into_iter().map(|p| (p, None)).collect(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Record a commitment. Errors on players outside the window or on a
|
|
|
|
|
/// duplicate submission (GameKernel K12).
|
|
|
|
|
pub fn submit(&mut self, player: PlayerId, commitment: C) -> Result<(), CommitError> {
|
|
|
|
|
match self.pending.get_mut(&player) {
|
|
|
|
|
None => Err(CommitError::NotInWindow(player)),
|
|
|
|
|
Some(slot @ None) => {
|
|
|
|
|
*slot = Some(commitment);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
Some(Some(_)) => Err(CommitError::AlreadyCommitted(player)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn is_complete(&self) -> bool {
|
|
|
|
|
self.pending.values().all(Option::is_some)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Consume the window at reveal, yielding commitments in PlayerId
|
|
|
|
|
/// order (deterministic iteration, GameKernel K6).
|
|
|
|
|
pub fn reveal(self) -> Result<Vec<(PlayerId, C)>, CommitError> {
|
|
|
|
|
if !self.is_complete() {
|
|
|
|
|
return Err(CommitError::Incomplete);
|
|
|
|
|
}
|
|
|
|
|
Ok(self
|
|
|
|
|
.pending
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(p, c)| (p, c.expect("checked complete")))
|
|
|
|
|
.collect())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
|
|
|
pub enum CommitError {
|
|
|
|
|
NotInWindow(PlayerId),
|
|
|
|
|
AlreadyCommitted(PlayerId),
|
|
|
|
|
Incomplete,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl core::fmt::Display for CommitError {
|
|
|
|
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
CommitError::NotInWindow(p) => write!(f, "player {p} is not in this window"),
|
|
|
|
|
CommitError::AlreadyCommitted(p) => write!(f, "player {p} already committed"),
|
|
|
|
|
CommitError::Incomplete => write!(f, "window is not complete"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Per-player projection (GameKernel K13): a total function from state to
|
|
|
|
|
/// what one seat may see. Implemented by game packages; the runtime only
|
|
|
|
|
/// fixes the shape so projections can never feed back into validation.
|
|
|
|
|
pub trait Project {
|
|
|
|
|
type View: Serialize;
|
|
|
|
|
fn project(&self, viewer: Viewer) -> Self::View;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum Viewer {
|
|
|
|
|
Player(PlayerId),
|
|
|
|
|
Spectator,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn commit_window_gates_and_reveals_in_order() {
|
|
|
|
|
let mut w: CommitWindow<u8> = CommitWindow::open([PlayerId(0), PlayerId(1)]);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
w.submit(PlayerId(2), 9),
|
|
|
|
|
Err(CommitError::NotInWindow(PlayerId(2)))
|
|
|
|
|
);
|
|
|
|
|
w.submit(PlayerId(1), 7).unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
w.submit(PlayerId(1), 8),
|
|
|
|
|
Err(CommitError::AlreadyCommitted(PlayerId(1)))
|
|
|
|
|
);
|
|
|
|
|
assert!(!w.is_complete());
|
|
|
|
|
w.submit(PlayerId(0), 5).unwrap();
|
|
|
|
|
assert!(w.is_complete());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
w.reveal().unwrap(),
|
|
|
|
|
vec![(PlayerId(0), 5), (PlayerId(1), 7)]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|