diff --git a/benchmarks/synthetic-3p.yaml b/benchmarks/synthetic-3p.yaml new file mode 100644 index 0000000..3a7d9d5 --- /dev/null +++ b/benchmarks/synthetic-3p.yaml @@ -0,0 +1,46 @@ +# K18: the benchmark workload, expressed in the scenario format. +# +# Until CB-WP-0006 T07 the bench hardcoded this command sequence in Rust +# and never touched `ScenarioFile`, so K18 — "Criterion benches driving +# the same scenario format at scale" — was false, and +# MetricsAndScenarios §3's "benchmarks live in benchmarks/" pointed at a +# directory containing only `baselines/`. +# +# Mirrors the CB-RES-0001 harness shape: 3 players, commit/reveal round. +# The bench replays these commands per round at scale; `bench_shape` in +# the aggregate crate pins the resulting 7 commands / 13 events, so a +# change here that alters the shape breaks a test rather than silently +# rescaling AM-6 and AM-7. +# +# NOT an AM-3 subject. AM-3's baseline is a declarative *game object* +# (~36 LOC, boardgame.io) — moves, turn order, rules. This is a command +# list; the rules live in games/ground (1,575 lines). Marking this file +# as AM-3 would compare a script to a game definition, which is the +# category error AM-3 is currently blocked on. +scenario: ground/bench-synthetic-3p +description: One 3-player GROUND round, replayed at scale by the Criterion bench. +covers: [] +seed: 42 +setup: + players: 3 + preset: standard-3p +commands: + - actor: P1 + cmd: select_action + args: { action: ATTACK, target: P2 } + - actor: P3 + cmd: select_action + args: { action: SUPPORT, target: P2 } + - actor: P2 + cmd: select_action + args: { action: GROUND } + - actor: SYSTEM + cmd: reveal + - actor: P2 + cmd: choose_ground_mode + args: { mode: GR } + - actor: SYSTEM + cmd: resolve + - actor: SYSTEM + cmd: end_round +expect: {} diff --git a/crates/cb-game-runtime/src/lib.rs b/crates/cb-game-runtime/src/lib.rs index 54dc504..45b5f67 100644 --- a/crates/cb-game-runtime/src/lib.rs +++ b/crates/cb-game-runtime/src/lib.rs @@ -18,6 +18,17 @@ 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. +/// +/// **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. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommitWindow { /// Players who must submit, and their commitment once received. diff --git a/facts.toml b/facts.toml index ab9e8da..c0c384b 100644 --- a/facts.toml +++ b/facts.toml @@ -70,8 +70,8 @@ fmt = "{:,}" by = "tools/rule-coverage.py" [k_linked] -value = 16 -text = "16" +value = 18 +text = "18" fmt = "{:,}" by = "tools/rule-coverage.py" @@ -82,8 +82,8 @@ fmt = "{:,}" by = "tools/rule-coverage.py" [k_unlinked] -value = 'K14 K18' -text = "K14 K18" +value = '(none)' +text = "(none)" fmt = "{}" by = "tools/rule-coverage.py" diff --git a/games/ground/benches/synthetic.rs b/games/ground/benches/synthetic.rs index b20a903..b396a50 100644 --- a/games/ground/benches/synthetic.rs +++ b/games/ground/benches/synthetic.rs @@ -9,7 +9,7 @@ //! what matters is the shape of the curve, not only the peak number. use cb_events::state_hash_hex; -use cb_game_runtime::{ScenarioGame, Setup}; +use cb_game_runtime::{ScenarioFile, ScenarioGame, Setup}; use cb_kernel::{Actor, Aggregate, PlayerId}; use criterion::{criterion_group, criterion_main, BatchSize, Criterion, Throughput}; use games_ground::{Action, GroundCommand, GroundMode, GroundState}; @@ -39,66 +39,50 @@ fn apply(state: &mut GroundState, actor: Actor, command: &GroundCommand) -> usiz } } -/// One full round for three players: three Selects, Reveal, the GROUND -/// mode choice, Resolve, End. Returns the number of events applied. -fn play_round(state: &mut GroundState) -> usize { - let picks = [ - ( - PlayerId(0), - GroundCommand::SelectAction { - action: Action::Attack, - target: Some(PlayerId(1)), - problem: None, - }, - ), - ( - PlayerId(2), - GroundCommand::SelectAction { - action: Action::Support, - target: Some(PlayerId(1)), - problem: None, - }, - ), - ( - PlayerId(1), - GroundCommand::SelectAction { - action: Action::Ground, - target: None, - problem: None, - }, - ), - ]; +/// K18: the benchmark workload, **loaded from the scenario format**. +/// +/// Until CB-WP-0006 T07 this function hardcoded the command sequence in +/// Rust and never touched `ScenarioFile`, so K18 — "Criterion benches +/// driving the same scenario format at scale" — was false. Embedding the +/// file at compile time keeps the bench self-contained while making the +/// workload *data*: editing `benchmarks/synthetic-3p.yaml` changes what +/// AM-6 and AM-7 measure, and `bench_shape` in the aggregate crate breaks +/// if that changes the round's shape. +const WORKLOAD_YAML: &str = include_str!("../../../benchmarks/synthetic-3p.yaml"); - let mut applied = 0; - for (seat, command) in picks { - applied += apply(state, Actor::Player(seat), &command); - } - applied += apply(state, Actor::System, &GroundCommand::Reveal); - // GR-R05: the GROUND player picks a mode before resolution. - applied += apply( - state, - Actor::Player(PlayerId(1)), - &GroundCommand::ChooseGroundMode { - mode: GroundMode::Gr, - choice: None, - }, +fn workload() -> Vec<(Actor, GroundCommand)> { + let sc = ScenarioFile::from_yaml(WORKLOAD_YAML).expect("bench workload parses"); + // Positive control: an empty or mis-parsed workload would benchmark + // nothing while still reporting a rate. + assert!( + !sc.commands.is_empty(), + "K18: the bench workload has no commands" ); - applied += apply(state, Actor::System, &GroundCommand::Resolve); - applied += apply(state, Actor::System, &GroundCommand::EndRound); + sc.commands + .iter() + .map(|step| GroundState::parse_command(step).expect("bench command parses")) + .collect() +} + +/// One full round for three players, replayed from the scenario workload. +/// Returns the number of events applied. +fn play_round(state: &mut GroundState, round: &[(Actor, GroundCommand)]) -> usize { + let mut applied = 0; + for (actor, command) in round { + applied += apply(state, *actor, command); + } applied } -/// A game ends after Round 5 (GR-R09), so a long run starts a fresh game -/// rather than idling on a finished one. Setup cost is therefore part of -/// the measurement, at one setup per five rounds. fn run_rounds(rounds: usize) -> usize { let mut applied = 0; let mut state = setup(42); + let script = workload(); for round in 0..rounds { if state.outcome.is_some() { state = setup(42 + round as u64); } - let produced = play_round(&mut state); + let produced = play_round(&mut state, &script); // A workload whose commands get rejected still "runs", but it // measures nothing. An earlier version of this bench stalled on // the GR-R03 stress gate and reported throughput for rounds that @@ -163,7 +147,7 @@ fn record_round(state: &mut GroundState, log: &mut Vec `58/58 (100%)` read as "all rules". > > `make coverage` now reports a second, separate denominator: -> **16 of 18** K-rules are named across the source. -> Unlinked: **K14 K18**. -> (K10 was unlinked until CB-WP-0006 T06 implemented replay bundles.) +> **18 of 18** K-rules are named across the source. +> Unlinked: **(none)**. +> K10 was unlinked until T06 implemented replay bundles; K14 and K18 until +> T07 amended one and implemented the other. The link is complete — which +> is a statement about **names**, not about assertions, and the gate says +> so on every run. > > **Kernel rules are link-only, by design.** They are kernel invariants, > not game rules: there is no kernel aggregate, setup preset or command diff --git a/tools/__pycache__/rule-coverage.cpython-312.pyc b/tools/__pycache__/rule-coverage.cpython-312.pyc index ccfb67d..c975e36 100644 Binary files a/tools/__pycache__/rule-coverage.cpython-312.pyc and b/tools/__pycache__/rule-coverage.cpython-312.pyc differ diff --git a/tools/facts.py b/tools/facts.py index e89a4cb..7036fda 100644 --- a/tools/facts.py +++ b/tools/facts.py @@ -142,8 +142,10 @@ def measure(): facts["am_rows"] = (len(mrows), "{:,}", "tools/mutation-check.py") facts["am_unmutatable"] = (sum(1 for r in mrows if r.unmutatable), "{:,}", "tools/mutation-check.py") + # "(none)" rather than "" — an empty rendered value cannot be tagged in + # prose, and reads as a malformed fact rather than a true one. facts["k_unlinked"] = ( - " ".join(r for r in k_rules if r not in k_named), "{}", + " ".join(r for r in k_rules if r not in k_named) or "(none)", "{}", "tools/rule-coverage.py") return facts @@ -350,7 +352,10 @@ def self_test(): check_("every fact records the instrument that produced it", all(v.get("by") for v in keys.values())) check_("every fact records both a value and its rendered text", - all("value" in v and v.get("text") for v in keys.values())) + all("value" in v and v.get("text") is not None + for v in keys.values()), + "a legitimately empty value is still a fact — it must not be " + "reported as malformed") check_("registry is generated, not hand-written", open(REGISTRY).read().startswith("# GENERATED")) diff --git a/tools/rule-coverage.py b/tools/rule-coverage.py index f899413..d564974 100755 --- a/tools/rule-coverage.py +++ b/tools/rule-coverage.py @@ -186,6 +186,14 @@ def self_test(): # The gate date must actually change behaviour, in both directions. # A "binds later" that never binds is the AM-4 failure this replaces. + # What the gate must do depends on whether anything is unlinked, so + # compute that rather than assuming it. An earlier version hardcoded + # "unlinked rules exist today" and failed the moment T07 linked the + # last one — correctly, but for the wrong reason. + _k = parse_rules(open(os.path.join("specs", "GameKernel.md")).read(), + r"\*\*(K\d+)\*\*") + _named = code_ids_over(source_files(), r"\bK\d+\b") + _unlinked = [r for r in _k if r not in _named] before = kernel_arm(today=datetime.date(2026, 1, 1), quiet=True) after = kernel_arm(today=datetime.date(2027, 1, 1), quiet=True) @@ -208,9 +216,12 @@ def self_test(): f"{len(out.splitlines())} lines") check("kernel: quiet suppresses output, loud does not", out.strip() != "" and _silent_output() == "") - check("kernel: gate reports before the binding date, fails after", - before == 0 and after == 2, - f"before={before} after={after}; unlinked rules exist today") + check("kernel: gate never fails before the binding date", + before == 0, f"before={before}") + check("kernel: after the binding date the gate fails iff rules are unlinked", + after == (2 if _unlinked else 0), + f"after={after}, {len(_unlinked)} unlinked" + + (f" ({' '.join(_unlinked)})" if _unlinked else " — all linked")) print("rule-coverage self-test (positive control)") ok = True