Compare commits

...

9 commits

Author SHA1 Message Date
63dcffd9f1 T09: InnerLoop v1.0 — corrected from the first full pass
Some checks failed
ci / check (push) Has been cancelled
The loop has now survived CB-WP-0001 end to end and is revised from what
that pass actually taught, recorded in
history/260731-inner-loop-retrospective.md.

Held up: the adversarial review (one blocking and three significant
findings on its single use, resolved with new measurements rather than
argument), the parity-cap rule, the provisional U-item mechanism, and
the ADR gate.

The gap: both serious errors in the pass were measurement errors, and
the loop caught neither. Review reads prose; these were claims about
numbers. In both cases a harness ran successfully while doing no work —
a JS harness timing rejected no-ops, and a Rust benchmark computing
throughput for rounds that never completed.

v1.0 adds:
- Measurement validity: a harness must assert it performed the work it
  reports. A number from a run that cannot prove it did the work is void.
- Metric feasibility: every metric names its instrument and is checked
  reachable against the contracts in its own spec.
- No silently-ignored input; decisions get commands, not defaults;
  scaffolds are exercised or marked; coverage gates that count tags say
  so.
- Evidence must state what a comparison does not support.
- The chaos roll is recorded even when it changes nothing, so a
  mechanism that never fires is visible rather than assumed.

CB-WP-0001 is complete: 9/9 tasks done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:11:19 +02:00
eb1378e667 T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.

Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.

Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.

AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.

A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.

The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
85d93a9e3c T08 iter 6: game end and the three scoring modes; AM-1 at 100%
- GR-R09: after Round 5's End the game ends and scoring runs instead of
  the round advancing.
- GR-E01: claimed Problems sum their printed values against the
  player-count threshold (2p 5, 3-4p 7, 5-6p 9 in dataset 0.1).
- GR-E02 SHARED GROUND: shared score, Mastery reduced per Blame token
  and per Denied Problem.
- GR-E03 COMMON PROBLEM: personal score is claimed value less Blame,
  tiebroken by lower Stress then more Bonds.
- GR-E04 BONDED COALITIONS: connected components over Bonds only, so
  Rivalries do not connect and an unbonded player is a coalition of
  one; tiebroken by lower combined Stress then fewer Blame.

Ties yield every tied candidate rather than an arbitrary pick, which is
what "shared victory" in GR-E03/E04 asks for.

AM-1 rule coverage is now 58/58 (100%), 21 scenarios, 17 tests.

Caveat recorded rather than papered over: GR-E02's "successes" is not
defined in dataset 0.1. It is implemented as the count of claimed
Problems and both scoring scenarios are marked provisional, so a
ground-game ruling flips a scenario rather than the kernel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:33:27 +02:00
0e92535f11 T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:

- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
  then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
  down and Deny it. Under the U3 default no legal target is a no-op and
  the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
  place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
  Protection to the owner, unless the holder's GROUND-ND rejects it.
  Under the U5 default the owner takes -2 either way and the sequence
  ends.
- GR-D06 early end: a Support through a Bond that predates this round's
  Support step cancels the stage and ends the sequence, and the placed
  Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.

The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.

Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.

18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
290ad06c31 T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:

- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
  from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
  the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
  decline a Bond where no relation exists, flip or break an existing
  Rivalry. A Bond can no longer form without consent.

Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.

GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.

14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
27016af216 Add the AM-1 rule-coverage gate
make coverage compares the GR-rule IDs defined in specs/GroundRules.md
against the covers: lists in scenarios/ground/*.yaml. It exits 1 if a
scenario claims a rule the spec does not define, so coverage cannot be
inflated by an invented ID, and exits 2 while rules remain uncovered.

Current reading: 34/58 (58%). AM-1 requires 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:38 +02:00
27b7fe4329 T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):

- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
  Reveal. Resolution refuses to start while any revealed GROUND lacks a
  mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
  draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
  later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
  face-up non-Denied one. Previously any existing Problem was accepted.

Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.

Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.

Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.

11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
b58a9139aa T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):

- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
  Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
  not implemented yet; their Actions resolve as no-ops and no scenario
  claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
  formation, flip and break per GR-L01/L03/L04 and Protection
  cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
  Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
  spike that is reduced before End does not trigger DARVO.

Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.

Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.

setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.

8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
a09d76f370 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
33 changed files with 4039 additions and 93 deletions

View file

@ -0,0 +1,21 @@
---
active: true
iteration: 3
session_id: 8cbd5701-a096-45a4-a419-9b7b1c9419bc
max_iterations: 20
completion_promise: "HEUREKA"
workplan_id: CB-WP-0001
workplan_file: workplans/CB-WP-0001-inner-loop.md
started_at: "2026-07-31T00:08:23Z"
---
Read the workplan at `workplans/CB-WP-0001-inner-loop.md`.
If every task has `status: done` AND frontmatter `status: done`:
run `rm -f .claude/ralph-loop.local.md` first (deactivates the loop so the stop hook exits cleanly),
then output <promise>HEUREKA</promise>.
Otherwise implement the next `todo` task as described in the workplan.
Set task `in_progress` when starting, `done` when complete.
When all tasks are done set frontmatter `status: done`.

View file

@ -15,6 +15,9 @@ test:
$(CARGO) test --workspace
## run all GROUND scenarios through cb-sim
coverage:
python3 tools/rule-coverage.py
sim:
$(CARGO) run -q -p cb-sim -- scenarios/ground/*.yaml

View file

@ -3,7 +3,7 @@
pub mod scenario;
pub use scenario::{RunOutcome, ScenarioFile};
pub use scenario::{parse_actor, run, CommandStep, RunOutcome, ScenarioFile, ScenarioGame, Setup};
use cb_kernel::PlayerId;
use serde::{Deserialize, Serialize};

View file

@ -1,9 +1,11 @@
//! Scenario file format and runner scaffold (MetricsAndScenarios §2,
//! GameKernel K17). The format parses fully; execution against a game
//! aggregate is wired up in T08 — until then `run` reports Unimplemented.
//! 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;
use std::collections::{BTreeMap, BTreeSet};
/// One scenario file (`scenarios/<game>/<slug>.yaml`).
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -71,21 +73,250 @@ impl ScenarioFile {
/// Result of executing one scenario (twice, per the K8 double-run rule).
#[derive(Debug)]
pub enum RunOutcome {
Passed {
covers: Vec<String>,
},
Failed {
reason: String,
},
/// Scaffold state: parsing works, execution lands in T08.
Unimplemented,
Passed { covers: Vec<String> },
Failed { reason: String },
}
/// Runner scaffold. T08 replaces the body with: build aggregate from
/// setup, apply commands via validate/fold, check expectations, run twice
/// and compare hashes, emit a replay bundle on failure.
pub fn run(_scenario: &ScenarioFile) -> RunOutcome {
RunOutcome::Unimplemented
/// 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.
struct Pass {
state: serde_json::Value,
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 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 {
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 },
};
let second = match execute::<G>(scenario) {
Ok(pass) => pass,
Err(reason) => return RunOutcome::Failed { reason },
};
if first.hash != second.hash {
return RunOutcome::Failed {
reason: format!(
"K8 divergence: run 1 hash {} != run 2 hash {}",
first.hash, second.hash
),
};
}
if let Err(reason) = check(scenario, &first) {
return RunOutcome::Failed { reason };
}
RunOutcome::Passed {
covers: scenario.covers.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)]
@ -120,7 +351,23 @@ expect:
assert_eq!(parsed.covers, vec!["GR-S02", "GR-S03"]);
assert_eq!(parsed.setup.players, 3);
assert_eq!(parsed.commands.len(), 1);
assert!(matches!(run(&parsed), RunOutcome::Unimplemented));
}
#[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]

View file

@ -0,0 +1,193 @@
# CB-EV-0001 — GROUND game kernel: acceptance evidence
Status: **T08 complete, with one acceptance metric not met (AM-4).**
Recorded: 2026-07-31
Workplan: CB-WP-0001, task T08
Spec: `specs/GameKernel.md` §4 (AM-1..AM-12)
Baseline: `research/CB-RES-0001-game-kernel.md`, measurements in
`research/CB-RES-0001-harness/boardgame-io/results-260731.json`
Machine: WSL2, Linux 6.18.33.2-microsoft-standard-WSL2, rustc 1.97.1,
`--release`, Criterion 1s warm-up / 3s measurement.
## 1. Scoreboard
| Metric | Target | Measured | Verdict |
|---|---|---|---|
| AM-1 rule coverage | 100% of GR-rules | 58/58 (100%) | **met** |
| AM-4 dependency weight | ≤20 crates | 33 | **NOT MET** |
| AM-6 throughput | ≥100,000 events/s | 1,651,400 events/s | **met, 16.5×** |
| AM-7 scaling | ≥0.9× at 20× workload | 1.08× | **met** |
| AM-7 replay | 100k events ≤5s | 4.13 ms | **met, 1,210×** |
| AM-8 determinism | zero divergence, 10 replays | 1 distinct hash / 10 runs | **met** |
| AM-8 lint | fmt + clippy clean | clean, `-D warnings` | **met** |
| AM-10 foreign types | zero `HashMap`/`HashSet` | 0 | **met** |
| AM-11 impl pairs | null + reference per port | 1 of 1 (`KernelRng`) | **met, narrow** |
| AM-12 cost log | present | §5 | **met** |
AM-2, AM-3, AM-5 and AM-9 are not reported: see §6.
## 2. Throughput and scaling (AM-6, AM-7)
Workload: 3-player GROUND rounds, 7 commands and 13 events per round
(12 on a game's fifth round, where GR-R09 ends the game). Pinned by
`bench_shape` in `games/ground/src/lib.rs`, so a change to the workload
breaks the test rather than silently rescaling the metric.
| Rounds | Throughput (events/s) | vs 5k |
|---|---|---|
| 5,000 | 1,524,200 | 1.00× |
| 10,000 | 1,638,200 | 1.07× |
| 20,000 | 1,577,000 | 1.03× |
| 40,000 | 1,626,000 | 1.07× |
| 100,000 | 1,651,400 | 1.08× |
Replay — folding one growing event log back into state:
| Events | Time | Rate |
|---|---|---|
| 10,010 | 465 µs | 21.5M events/s |
| 100,007 | 4.13 ms | 24.2M events/s |
### The comparison against boardgame.io, stated carefully
boardgame.io measured **1,930 moves/s at 5,000 moves**, falling to
**870 moves/s at 20,000**, and **did not finish 100,000 moves in 300 s**.
Our figure in the same unit is ~129,000 rounds/s × 7 = **~903,000
commands/s**, and 100,000 rounds complete in 776 ms.
That is roughly a 400500× ratio, and it is **not a like-for-like
measurement**. Four differences matter, all favouring us:
1. **Different language and process model.** Rust in-process against
Node.js with immutable state, patch generation and undo history.
2. **Different feature set.** boardgame.io's per-move cost includes
producing client patches and maintaining undo state; the run with
`--disable-undo` still degraded (0.66× at 40k). We do neither.
3. **Different workload shape.** Our 5-round games (GR-R09) bound state
size by construction. The boardgame.io harness ran one match with
unbounded history, which is exactly the axis it degraded on.
4. **No network or storage layer** on our side.
Point 3 is the important one and it is why the flat curve in the table
above is *weak evidence on its own* — a game that resets every five
rounds cannot exhibit history-growth degradation. The replay benchmark
is the honest test of that axis, because there the log grows without
bound, and it stays linear (21.5M → 24.2M events/s from 10k to 100k).
**Claim we are willing to defend:** the kernel meets AM-6 and AM-7 with
large margin, and does not degrade as event-log length grows.
**Claim we are not making:** that Clay-Borg is ~450× "faster than
boardgame.io" as a like-for-like engine comparison. Per the
InnerLoop parity-cap rule, a cross-runtime ratio this coarse is not a
verdict, it is a direction.
### A measurement error found and corrected
The first run of this benchmark reported **9.3M events/s with a
perfectly flat curve** — a number that would have been reported as a
93× beat of AM-6. It was wrong. The workload had P2 selecting SUPPORT
every round while being attacked; after three rounds P2 sat at Stress 4,
GR-R03 rejected the SUPPORT, the round never completed, and the loop
spun on rejected commands. Throughput was computed as
`rounds × 13 events` while most rounds produced 2.
Found by a probe test asserting that a round produces events at all.
The benchmark now asserts the per-round event count on every round and
panics rather than measuring a stalled loop. The corrected figure is
**5.6× lower** than the bogus one.
## 3. Determinism (AM-8)
- Every scenario runs twice per invocation with the same seed and fails
on state-hash divergence (K8). 21/21 pass.
- Ten consecutive full runs of all 21 scenarios produced **one distinct
output hash**, i.e. zero divergence.
- `cargo fmt --check` and `cargo clippy --workspace --all-targets
-D warnings` are clean.
- `clippy.toml` denies `HashMap`/`HashSet` workspace-wide (K6); the
aggregate holds only ordered collections, so iteration order cannot
vary between runs.
## 4. AM-4 — not met, and why it is reported rather than fixed
**33 transitive crates against a ≤20 target.** The baseline it was set
against is boardgame.io's 120 npm packages, so we are 3.6× lighter, but
the metric as written is missed and is recorded as missed.
Attribution:
| Group | Crates | Count |
|---|---|---|
| `sha2` (K7 state hashing) | sha2, digest, block-buffer, crypto-common, generic-array, typenum, cpufeatures, cfg-if | 8 |
| serde derive chain | serde_derive, proc-macro2, quote, syn, unicode-ident | 5 |
| serde runtime + json | serde, serde_core, serde_json, itoa, ryu, memchr, zmij | 7 |
| `serde_yaml` (scenario files only) | serde_yaml, unsafe-libyaml, indexmap, hashbrown, equivalent | 5 |
| `rand_chacha` (K5 seeded RNG) | rand_chacha, rand_core, ppv-lite86, zerocopy | 4 |
| Clay-Borg crates | cb-kernel, cb-events, cb-game-runtime, games-ground | 4 |
The honest options, in order of preference:
1. **Make `serde_yaml` optional** behind a `scenarios` feature. YAML is
a test-and-tooling concern; a shipped game runtime does not need it.
Removes 5 crates from the default build for no loss of capability.
This is the one to do first, and it improves D4 optionality as well
as D2.
2. **Revisit the target.** ≤20 was set before the K5/K7 contracts named
ChaCha and SHA-256. Those two contracts cost 12 crates between them
and are load-bearing for determinism. A target that a spec's own
contracts make unreachable is a bad target.
What we are **not** doing: hand-rolling SHA-256 or ChaCha to win a
dependency count. That trades an auditable, well-tested primitive for a
number on a scoreboard.
This is a T09 input: either the metric moves for a stated reason, or
option 1 lands and the remainder is justified.
## 5. Cost log (AM-12)
Per `specs/MetricsAndScenarios.md` §1a. Model: Claude Fable 5, at
`benchmarks/baselines/model-prices.toml` rates ($10/$50 per MTok).
| Task | Model | Iterations | Notes |
|---|---|---|---|
| T08 | claude-fable-5 | 6 code iterations + benchmarks | Token counts not captured per iteration; see limitation below |
**Limitation, stated rather than fabricated:** exact per-task token
counts were not instrumented during T08, so the USD figure the metric
asks for cannot be computed honestly from this run. Recording an
estimate here would defeat the purpose of the metric. T09 should either
wire real token accounting into the loop or drop M-D2-CST as
unmeasurable in this setup.
## 6. Metrics not reported
- **AM-2, AM-3, AM-5** — specification-quality metrics that need a
second capability to compare against; a single data point is not a
measurement.
- **AM-9 (≤64MB)** — not instrumented. The aggregate is a few KB and
the largest log measured here is 100k events, so the budget is very
unlikely to bind, but "unlikely" is not "measured" and it is left
unclaimed.
- **AM-11** — the `KernelRng` null/reference pair exists and is
exercised. It is the only port with a pair so far, so the metric is
met narrowly and will mean more once storage has one.
## 7. Rules implemented under a provisional default
Ten U-items in `specs/GroundRules.md` carry PROVISIONAL defaults. Those
realized here are U2 (clamp on every application), U3 (DENY with no
legal target is a no-op that still advances), U4 (deck reshuffle), U5
(REVERSE owner relief applies whether or not the Reverse was rejected)
and U8 (GROUND—OU cancellation precedes Protection).
One further ambiguity was found during T08 and is **not** in the U-list:
**GR-E02's "successes"** is undefined in dataset 0.1. It is implemented
as the count of claimed Problems. Both scoring scenarios are marked
`provisional: true`.
All provisional behaviour lives behind named functions and is covered by
scenarios tagged `provisional: true`, so a ground-game ruling flips a
scenario rather than the kernel (K16). **Action for ground-game:** rule
on the ten U-items and on GR-E02's "successes".

View file

@ -1,48 +1,219 @@
//! Criterion skeleton for AM-6/AM-7 (GameKernel §4), wired to the
//! CB-RES-0001 baseline shape (3-player commit/reveal synthetic workload).
//! T08 replaces the placeholder body with the real aggregate loop; the
//! bench IDs and workload sizes are fixed here so results stay comparable
//! to benchmarks/baselines/ recordings.
//! AM-6/AM-7 benchmarks (GameKernel §4): the real GROUND aggregate under
//! the CB-RES-0001 synthetic workload (3-player commit/reveal rounds).
//!
//! The baseline is boardgame.io, recorded in
//! research/CB-RES-0001-harness/boardgame-io/results-260731.json. That
//! harness measured moves/second *while history grew*, and its headline
//! finding was that throughput halved as history doubled. AM-7 exists
//! because of that finding, so the same workload sizes are run here:
//! what matters is the shape of the curve, not only the peak number.
use cb_events::state_hash;
use cb_game_runtime::CommitWindow;
use cb_kernel::PlayerId;
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
use cb_events::state_hash_hex;
use cb_game_runtime::{ScenarioGame, Setup};
use cb_kernel::{Actor, Aggregate, PlayerId};
use criterion::{criterion_group, criterion_main, BatchSize, Criterion, Throughput};
use games_ground::{Action, GroundCommand, GroundMode, GroundState};
use std::collections::BTreeMap;
/// Placeholder workload: open/submit/reveal one 3-player commit window and
/// hash a small value. Exists so the bench harness, IDs, and baseline
/// wiring compile and run before the aggregate exists.
fn commit_reveal_round() -> usize {
let mut w: CommitWindow<u8> = CommitWindow::open([PlayerId(0), PlayerId(1), PlayerId(2)]);
for p in 0..3u8 {
w.submit(PlayerId(p), p).unwrap();
}
let revealed = w.reveal().unwrap();
state_hash(&revealed.len()).len()
fn setup(seed: u64) -> GroundState {
GroundState::setup(
&Setup {
players: 3,
preset: "standard-3p".to_string(),
patch: BTreeMap::new(),
},
seed,
)
.expect("standard-3p setup")
}
fn apply(state: &mut GroundState, actor: Actor, command: &GroundCommand) -> usize {
match state.validate(actor, command) {
Ok(events) => {
for event in &events {
state.fold(event);
}
events.len()
}
Err(_) => 0,
}
}
/// 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,
},
),
];
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,
},
);
applied += apply(state, Actor::System, &GroundCommand::Resolve);
applied += apply(state, Actor::System, &GroundCommand::EndRound);
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);
for round in 0..rounds {
if state.outcome.is_some() {
state = setup(42 + round as u64);
}
let produced = play_round(&mut state);
// 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
// never happened, so refuse to measure that.
assert!(
produced == EVENTS_PER_ROUND || produced == FINAL_ROUND_EVENTS,
"round {round} produced {produced} events, expected {EVENTS_PER_ROUND} \
(or {FINAL_ROUND_EVENTS} on a game's last round)"
);
applied += produced;
}
applied
}
/// Pinned by the `bench_shape` test in the aggregate crate.
const EVENTS_PER_ROUND: usize = 13;
/// GR-R09: a game's fifth round emits GameEnded instead of RoundEnded
/// plus StepAdvanced, so it is one event shorter.
const FINAL_ROUND_EVENTS: usize = 12;
/// Mean events per round over a 5-round game, scaled by 5 to stay in
/// integers: (4 x 13 + 12) = 64.
const EVENTS_PER_5_ROUNDS: usize = 64;
fn bench_synthetic(c: &mut Criterion) {
// Events per round is fixed by the workload, so throughput can be
// reported in events/second — the AM-6 unit.
assert_eq!(play_round(&mut setup(1)), EVENTS_PER_ROUND);
let mut group = c.benchmark_group("synthetic-ground-3p");
// AM-6 headline workload sizes; AM-7 compares 5k vs 100k throughput.
for &rounds in &[5_000usize, 100_000] {
group.bench_function(format!("commit-reveal-{rounds}"), |b| {
b.iter_batched(
|| rounds,
|n| {
let mut acc = 0usize;
for _ in 0..n / 1000 {
// Scaffold runs 1/1000th scale until T08 wires the
// real aggregate; the group/ID layout is what T07
// delivers.
acc += commit_reveal_round();
}
acc
},
BatchSize::SmallInput,
)
// AM-6 headline throughput and AM-7 scaling, at the sizes the
// boardgame.io harness used so the curves line up.
for &rounds in &[5_000usize, 10_000, 20_000, 40_000, 100_000] {
group.throughput(Throughput::Elements(
(rounds * EVENTS_PER_5_ROUNDS / 5) as u64,
));
group.bench_function(format!("rounds-{rounds}"), |b| {
b.iter_batched(|| rounds, run_rounds, BatchSize::SmallInput)
});
}
group.finish();
// AM-7 replay, and the honest analogue of the boardgame.io finding:
// a *single* growing event log folded back into state. The round
// benches above restart the game every 5 rounds (GR-R09), so their
// flat curve is partly by construction — this one is not, because
// the log here grows without bound.
let mut replay = c.benchmark_group("replay-ground-3p");
for &events in &[10_000usize, 100_000] {
replay.throughput(Throughput::Elements(events as u64));
replay.bench_function(format!("fold-{events}-events"), |b| {
// Build one log of `events` events, then measure folding it.
let mut source = setup(42);
let mut log = Vec::with_capacity(events);
while log.len() < events {
if source.outcome.is_some() {
source = setup(43);
}
let picks = [
(
PlayerId(0),
GroundCommand::SelectAction {
action: Action::Attack,
target: Some(PlayerId(1)),
problem: None,
},
),
(
PlayerId(1),
GroundCommand::SelectAction {
action: Action::Support,
target: Some(PlayerId(2)),
problem: None,
},
),
];
for (seat, cmd) in picks {
if let Ok(produced) = source.validate(Actor::Player(seat), &cmd) {
for e in &produced {
source.fold(e);
log.push(e.clone());
}
}
}
if let Ok(produced) = source.validate(Actor::System, &GroundCommand::Reveal) {
for e in &produced {
source.fold(e);
log.push(e.clone());
}
}
if let Ok(produced) = source.validate(Actor::System, &GroundCommand::EndRound) {
for e in &produced {
source.fold(e);
log.push(e.clone());
}
}
}
b.iter(|| {
let mut state = setup(42);
for event in &log {
state.fold(event);
}
state_hash_hex(&state)
})
});
}
replay.finish();
// AM-7: hashing the full aggregate, the per-round determinism cost.
let mut hashing = c.benchmark_group("state-hash-ground-3p");
hashing.throughput(Throughput::Elements(1));
hashing.bench_function("hash-one-state", |b| {
let state = setup(42);
b.iter(|| state_hash_hex(&state))
});
hashing.finish();
}
criterion_group!(benches, bench_synthetic);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
# Inner-loop retrospective — after CB-WP-0001 (first full pass)
Date: 2026-07-31
Subject: `specs/InnerLoop.md` v0.2 → v1.0
Evidence: `evidence/CB-EV-0001-game-kernel.md`
The loop has now survived one full pass, from SOTA survey through an
adversarial review, an ADR, two specs, and six code iterations that
ended in a committed acceptance run. This is what it got right, what it
missed, and what changes in v1.0.
## What earned its cost
**The adversarial review (Step 2).** On its single use it produced one
blocking finding and three significant ones. The blocking one mattered:
the survey claimed boardgame.io's degradation was "architectural, not a
tuning artifact", and the challenge pointed at the `disableUndo` flag
that the claim had not accounted for. The resolution was a rerun with
undo disabled, not a rhetorical defence — degradation persisted (0.66×
at 40k, still DNF at 100k), so the overclaim was retracted and two
mechanisms were attributed separately. A survey that had gone straight
to an ADR would have carried a false claim into the decision.
**The parity-cap rule.** It stopped the boardgame.io comparison from
being written up as a verdict. The final evidence file says the ~450×
command-rate ratio is a direction, not a verdict, and lists the four
reasons it is not like-for-like. That paragraph exists because the rule
required it.
**Deferring rules that need a decision.** Four rules (GR-L02, GR-A05,
GR-A11, GR-A12) were left unimplemented across two iterations rather
than given invented defaults, then implemented as explicit commands.
No scenario claimed coverage of them in the interim. This is the single
most useful habit the pass produced and v1.0 promotes it to a rule.
**The provisional mechanism.** Ten U-items with `provisional: true`
scenarios meant an underdetermined rule cost a scenario tag, not a
kernel decision. One further ambiguity (GR-E02's "successes") was found
during implementation and captured the same way.
## What the loop missed
**Both serious errors in this pass were measurement errors, and the
loop caught neither.** Review caught claims; nothing caught numbers.
1. The boardgame.io harness v1 reported "8.4s for 100k moves". It was
measuring rejected no-ops — every move was refused. Caught by
noticing the error text in output, not by any gate.
2. The Rust benchmark reported **9.3M events/s on a flat curve** and
would have been published as a 93× beat of AM-6. The workload had a
player selecting SUPPORT while parked at Stress 4, so GR-R03 rejected
it, rounds never completed, and throughput was computed for rounds
that never happened. Caught only because a probe test asserted a
round produces events at all. The corrected number is 5.6× lower.
Both share a shape: **the harness ran successfully while doing no
work**, and success was inferred from "it completed" rather than from
"it did the thing". This is the dominant failure mode of an agent-driven
loop, because an agent will happily report a fast number.
**Scaffold code with unexercised paths.** T07 produced a compiling,
"green" workspace that contained two latent defects: relations keyed by
a tuple, which JSON cannot serialize, so `state_hash` would have
panicked on any state holding a relation; and `setup.patch`, which was
parsed and silently discarded, so every scenario using it would have
tested the wrong initial state. Both were invisible until real scenarios
exercised them. Green gates on a scaffold mean very little.
**Metrics were specified without checking they were reachable or
instrumented.** AM-4 (≤20 crates) is unreachable given the spec's own
K5 and K7 contracts, which require ChaCha and SHA-256 and cost 12 crates
between them — the metric was written before those contracts existed and
never revisited. AM-12 (cost in USD) was specified in detail, with a
price sheet and a formula, and then never instrumented, so it could not
be computed at all. A metric with no named instrument is a wish.
**The chaos roll never fired.** Across the pass the d10 never selected
a tier different from the structural one, so the mechanism is untested
in practice. It is retained, but v1.0 requires the roll be recorded even
when it changes nothing, so its absence is visible rather than assumed.
**The coverage gate counts tags, not behaviour.** `make coverage`
reports 58/58 by comparing rule IDs in the spec against `covers:` lists.
It catches invented IDs and outright gaps, which is real, but a scenario
can name a rule it does not exercise. 100% on that gate is not proof
AM-1 is met, and v1.0 says so where the number is reported.
## Changes in v1.0
1. **Measurement validity (new, Step 5).** Every benchmark and harness
must assert it performed the work it reports — a positive control.
A number from a run that cannot prove it did the work is void.
2. **Metric feasibility (Step 4).** Each acceptance metric names its
instrument and is checked reachable against the contracts in the same
spec. Re-checked whenever a contract is added.
3. **No silently-ignored input (new).** A parsed-but-unused field is a
defect. Inputs are honoured or rejected, never dropped.
4. **Decisions get commands, not defaults (new).** A rule requiring a
participant's choice is implemented as a command; if it is not
implemented yet, nothing claims coverage of it.
5. **Scaffolds are exercised or marked (new).** Scaffold code paths not
reached by a test are marked as unexercised; a scaffold's green gates
are not evidence.
6. **Evidence states what it does not support (Step 5).** Every
cross-runtime comparison names its disanalogies explicitly.
7. **The chaos roll is always recorded**, including when it does not
change the tier.
## What did not need changing
The five steps, the four-dimension rubric, the tier system's structural
triggers, the ADR gate ("no implementation code before the ADR is
committed"), the survey template, and the agentic-efficiency
requirements all held up. The gate in particular was never
uncomfortable — by the time the ADR was written the decision was easy,
which is what a good gate feels like.

View file

@ -0,0 +1,43 @@
scenario: ground/gr-a01-investigate
description: >
INVESTIGATE reveals the chosen hidden Problem and draws one Solution
(GR-A01). Selecting an already face-up Problem is rejected (GR-A13).
covers: [GR-A01, GR-S01]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
commands:
- actor: P1
cmd: select_action
args: { action: INVESTIGATE, problem: 2 }
# GR-A13: Problem 1 is the Surface Problem, already face up.
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 1 }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: ProblemRevealed
problem: 2
- kind: SolutionDrawn
player: 0
state:
"problems.2.face_up": true
"problems.3.face_up": true
# GR-S02 dealt two; INVESTIGATE drew a third. The suit is the
# golden value for seed 42 under the GR-S04 shuffle.
"players.0.hand.2.suit": Change
rejects: [1]

View file

@ -0,0 +1,43 @@
scenario: ground/gr-a02-solve
description: >
SOLVE discards a Solution of the Problem's suit and claims it
(GR-A02). A second solver of the same Problem the same round spends
nothing, because an earlier resolver in Lead order already claimed it.
covers: [GR-A02, GR-R07, GR-O04, GR-P04]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.hand": [{ suit: Clarify }]
"players.1.hand": [{ suit: Clarify }]
commands:
- actor: P1
cmd: select_action
args: { action: SOLVE, problem: 1 }
- actor: P2
cmd: select_action
args: { action: SOLVE, problem: 1 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: SolutionDiscarded
player: 0
- kind: ProblemClaimed
problem: 1
by: 0
state:
# Problem 1 is the Clarify Surface Problem (GR-S01).
"problems.1.claimed_by": 0
"players.0.hand": []
# GR-A02: P2 resolved second, so its Solution is not spent.
"players.1.hand": [{ suit: Clarify }]
rejects: []

View file

@ -0,0 +1,48 @@
scenario: ground/gr-a04-bond-support
description: >
Support through an existing Bond: 2 Stress and the target's Freedom
token is readied (GR-A04, GR-F04). A Support with no relation forms no
Bond, because GR-L02 requires the target's consent.
covers: [GR-A04, GR-F04, GR-L02, GR-L05]
provisional: true
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"relations.0-1": Bond
"players.1.stress": 3
"players.1.freedom_ready": false
commands:
- actor: P1
cmd: select_action
args: { action: SUPPORT, target: P2 }
# No relation with P3: Stress drops but no Bond forms without consent.
- actor: P2
cmd: select_action
args: { action: SUPPORT, target: P3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: StressSet
player: 1
stress: 1
- kind: FreedomReadied
player: 1
- kind: StressSet
player: 2
stress: 1
state:
"players.1.stress": 1
"players.1.freedom_ready": true
"players.2.stress": 1
# No Bond formed with P3: the relation map is unchanged.
"relations": { "0-1": Bond }
rejects: []

View file

@ -0,0 +1,57 @@
scenario: ground/gr-a05-support-response
description: >
A Support target answers after Reveal: through a Rivalry they flip it
to a Bond or break it (GR-A05); with no relation a Bond forms only if
they accept (GR-L02). A player not receiving Support cannot answer.
covers: [GR-A05, GR-L02, GR-A03]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"relations.0-1": Rivalry
commands:
- actor: P1
cmd: select_action
args: { action: SUPPORT, target: P2 }
- actor: P2
cmd: select_action
args: { action: SUPPORT, target: P3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
# GR-A05: P2 is supported through a Rivalry, so it may flip.
- actor: P2
cmd: respond_to_support
args: { response: flip_to_bond }
# GR-L02: P3 has no relation with its supporter, so it accepts.
- actor: P3
cmd: respond_to_support
args: { response: accept_bond }
# P1 received no Support this round.
- actor: P1
cmd: respond_to_support
args: { response: accept_bond }
# GR-A05: a flip is not available where no relation exists.
- actor: P3
cmd: respond_to_support
args: { response: flip_to_bond }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: SupportAnswered
player: 1
response: FlipToBond
- kind: RelationFormed
relation: Bond
state:
"relations.0-1": Bond
"relations.1-2": Bond
"players.1.stress": 1
"players.2.stress": 1
rejects: [6, 7]

View file

@ -0,0 +1,38 @@
scenario: ground/gr-a07-bond-flip
description: >
Attack through an existing Bond: +2 Stress and the Bond flips to a
Rivalry (GR-A07, GR-L04).
covers: [GR-A07, GR-L04, GR-O05]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"relations.0-1": Bond
commands:
- actor: P1
cmd: select_action
args: { action: ATTACK, target: P2 }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: StressSet
player: 1
stress: 4
- kind: RelationFormed
relation: Rivalry
state:
"players.1.stress": 4
"relations.0-1": Rivalry
rejects: []

View file

@ -0,0 +1,42 @@
scenario: ground/gr-a08-rivalry-break
description: >
Attack through an existing Rivalry: +2 Stress and the relation breaks
(GR-A08, GR-L04). Protection absorbs a separate Attack (GR-A09).
covers: [GR-A08, GR-A09, GR-T01]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"relations.0-1": Rivalry
"players.2.protection": 1
commands:
- actor: P1
cmd: select_action
args: { action: ATTACK, target: P2 }
# P3 is protected, so this Attack is cancelled and costs the token.
- actor: P2
cmd: select_action
args: { action: ATTACK, target: P3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: StressSet
player: 1
stress: 4
- kind: RelationBroken
- kind: AttackCancelled
target: 2
state:
"players.1.stress": 4
"players.2.stress": 2
"players.2.protection": 0
rejects: []

View file

@ -0,0 +1,55 @@
scenario: ground/gr-a10-ground-gr
description: >
GROUND—GR (Ground & Restate): the mode is chosen after Reveal
(GR-R05), and on resolution the player takes 2 Stress and readies
their Freedom token (GR-A10, GR-F04). Resolution refuses to start
while any revealed GROUND still lacks a mode.
covers: [GR-R05, GR-A10]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.stress": 5
"players.0.freedom_ready": false
commands:
- actor: P1
cmd: select_action
args: { action: GROUND }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
# GR-R05: no mode chosen yet, so resolution is refused.
- actor: SYSTEM
cmd: resolve
# GR-R05: only a player who revealed GROUND may choose a mode.
- actor: P2
cmd: choose_ground_mode
args: { mode: GR }
- actor: P1
cmd: choose_ground_mode
args: { mode: GR }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: GroundModeChosen
player: 0
mode: Gr
- kind: StressSet
player: 0
stress: 3
- kind: FreedomReadied
player: 0
state:
"players.0.stress": 3
"players.0.freedom_ready": true
"step": Resolve
rejects: [4, 5]

View file

@ -0,0 +1,43 @@
scenario: ground/gr-a11-ground-ou
description: >
GROUND—OU restores a Denied Problem (GR-A11), and a Denied Problem is
not otherwise revealable (GR-P02). A mode's sub-choice must belong to
that mode (GR-A10).
covers: [GR-A11, GR-P02, GR-P01]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"problems.2.denied": true
commands:
- actor: P1
cmd: select_action
args: { action: GROUND }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
# GR-A10: GROUND—GR takes no sub-choice.
- actor: P1
cmd: choose_ground_mode
args: { mode: GR, choice: restore_problem, problem: 2 }
- actor: P1
cmd: choose_ground_mode
args: { mode: OU, choice: restore_problem, problem: 2 }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: ProblemRestored
problem: 2
state:
"problems.2.denied": false
"problems.2.face_up": true
rejects: [4]

View file

@ -0,0 +1,47 @@
scenario: ground/gr-a12-ground-nd
description: >
GROUND—ND removes a Blame token from its player (GR-A12, GR-T02). The
choice must name a token that is actually there, and a relation break
must name a relation that exists.
covers: [GR-A12, GR-T02, GR-T03]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.blame_from": [1]
commands:
- actor: P1
cmd: select_action
args: { action: GROUND }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
# GR-T02: P3 has no Blame token in front of P1.
- actor: P1
cmd: choose_ground_mode
args: { mode: ND, choice: remove_blame, seat: 2 }
# GR-A12: no relation with P2 exists to break.
- actor: P1
cmd: choose_ground_mode
args: { mode: ND, choice: break_relation, seat: 1 }
- actor: P1
cmd: choose_ground_mode
args: { mode: ND, choice: remove_blame, seat: 1 }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: BlameRemoved
player: 0
owner: 1
state:
"players.0.blame_from": []
rejects: [4, 5]

View file

@ -0,0 +1,42 @@
scenario: ground/gr-d01-darvo-trigger
description: >
DARVO trigger: a player who ends the round at Stress 5 with the marker
OFF is set to DENY (GR-D01, GR-R08). Stress clamps at 5 on every
application (GR-F01 under the U2 default).
covers: [GR-D01, GR-R08, GR-F01]
provisional: true
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.1.stress": 4
commands:
- actor: P1
cmd: select_action
args: { action: ATTACK, target: P2 }
- actor: P2
cmd: select_action
args: { action: ATTACK, target: P3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
- actor: SYSTEM
cmd: end_round
expect:
events:
- kind: StressSet
player: 1
stress: 5
- kind: DarvoTriggered
player: 1
state:
"players.1.stress": 5
"players.1.darvo": Deny
"round": 2
rejects: []

View file

@ -0,0 +1,48 @@
scenario: ground/gr-d03-darvo-deny
description: >
The DENY stage turns one face-up, unsolved, unprotected Problem face
down and Denies it (GR-D03), then the sequence advances to ATTACK
(GR-D02). A protected Problem is not a legal DENY target (GR-A11).
covers: [GR-D02, GR-D03, GR-P01]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.darvo": Deny
"problems.2.face_up": true
commands:
- actor: P1
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
# GR-D02: only a player with a live sequence names a stage target.
- actor: P2
cmd: choose_darvo_target
args: { problem: 1 }
- actor: P1
cmd: choose_darvo_target
args: { problem: 1 }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: ProblemDenied
problem: 1
- kind: DarvoAdvanced
player: 0
stage: Attack
state:
"problems.1.denied": true
"problems.1.face_up": false
"players.0.darvo": Attack
rejects: [4]

View file

@ -0,0 +1,53 @@
scenario: ground/gr-d04-darvo-attack
description: >
The ATTACK stage makes one extra Attack under the normal relation
rules and places the Focus token beside the target (GR-D04), then
advances to REVERSE.
covers: [GR-D04, GR-T03]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.darvo": Attack
commands:
- actor: P1
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
# GR-D04: the extra Attack targets another player.
- actor: P1
cmd: choose_darvo_target
args: { target: P1 }
- actor: P1
cmd: choose_darvo_target
args: { target: P2 }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: StressSet
player: 1
stress: 3
- kind: FocusPlaced
owner: 0
target: 1
- kind: DarvoAdvanced
player: 0
stage: Reverse
state:
"players.1.stress": 3
"focus.0": 1
"players.0.darvo": Reverse
# GR-L03: the extra Attack forms a Rivalry like any other.
"relations.0-1": Rivalry
rejects: [4]

View file

@ -0,0 +1,54 @@
scenario: ground/gr-d05-darvo-reverse
description: >
The REVERSE stage flips Focus to Blame in front of the Focus holder,
gives them +1 Stress and the owner one Protection token, then the
owner takes 2 Stress and the sequence ends (GR-D05, GR-D07).
covers: [GR-D05, GR-D07, GR-T01]
provisional: true
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.darvo": Reverse
"players.0.stress": 3
"focus.0": 1
commands:
- actor: P1
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: FocusFlippedToBlame
owner: 0
target: 1
- kind: StressSet
player: 1
stress: 3
- kind: ProtectionGained
player: 0
- kind: StressSet
player: 0
stress: 1
- kind: DarvoEnded
player: 0
state:
"players.1.blame_from": [0]
"players.1.stress": 3
"players.0.protection": 1
"players.0.stress": 1
# GR-D07: the marker is OFF, so a new sequence can trigger later.
"players.0.darvo": Off
"focus": {}
rejects: []

View file

@ -0,0 +1,43 @@
scenario: ground/gr-d06-darvo-early-end
description: >
A Support through a Bond that existed before this round cancels the
current stage and ends the sequence; the placed Focus token is removed
(GR-D06, GR-A04, GR-L05).
covers: [GR-D06, GR-L05]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.darvo": Attack
"focus.0": 2
"relations.0-1": Bond
commands:
- actor: P2
cmd: select_action
args: { action: SUPPORT, target: P1 }
- actor: P1
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: P1
cmd: choose_darvo_target
args: { target: P3 }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: DarvoEnded
player: 0
state:
"players.0.darvo": Off
# GR-D06: the stage never fired, so P3 took no extra Attack.
"players.2.stress": 2
"focus": {}
rejects: []

View file

@ -0,0 +1,50 @@
scenario: ground/gr-e02-shared-ground
description: >
SHARED GROUND scoring after Round 5 (GR-R09, GR-E01, GR-E02): one
shared total against the player-count threshold, with a Mastery
rating reduced by each Blame token and each Denied Problem.
covers: [GR-R09, GR-E01, GR-E02, GR-P03]
provisional: true
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"round": 5
"mode": SharedGround
# 3p threshold is 7; claimed values 1+3 fall short.
"problems.1.claimed_by": 0
"problems.3.claimed_by": 1
"problems.2.denied": true
"players.2.blame_from": [0]
commands:
- actor: P1
cmd: select_action
args: { action: INVESTIGATE, problem: 2 }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 2 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 2 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
- actor: SYSTEM
cmd: end_round
expect:
events:
- kind: GameEnded
state:
"outcome.total": 4
"outcome.threshold": 7
"outcome.group_success": false
# 2 claimed Problems, 1 Blame, 1 Denied.
"outcome.mastery": 0
"outcome.winners": []
# GR-R09: the game ended rather than advancing to Round 6.
"round": 5
"step": End
rejects: []

View file

@ -0,0 +1,54 @@
scenario: ground/gr-e04-coalitions
description: >
BONDED COALITIONS scoring (GR-E04): Bond networks score together,
Rivalries do not connect, and an unbonded player is a coalition of
one. Personal scores subtract Blame (GR-E03, GR-T02).
covers: [GR-E03, GR-E04, GR-O03]
provisional: true
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"round": 5
"mode": BondedCoalitions
# Values 1+2+3 = 6 claimed; 3p threshold is 7, so no group success.
"problems.1.claimed_by": 0
"problems.2.claimed_by": 1
"problems.3.claimed_by": 2
"relations.0-1": Bond
"relations.1-2": Rivalry
"players.0.blame_from": [2]
commands:
- actor: P1
cmd: select_action
args: { action: SUPPORT, target: P2 }
- actor: P2
cmd: select_action
args: { action: SUPPORT, target: P1 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 2 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
- actor: SYSTEM
cmd: end_round
expect:
events:
- kind: GameEnded
state:
"outcome.total": 6
"outcome.group_success": false
# P1 claimed value 1 less one Blame; P2 value 2; P3 value 3.
"outcome.personal.0": 0
"outcome.personal.1": 2
"outcome.personal.2": 3
# GR-E04: P1+P2 are Bonded; the Rivalry leaves P3 solo.
"outcome.coalitions.0.members": [0, 1]
"outcome.coalitions.0.score": 2
"outcome.coalitions.1.members": [2]
"outcome.coalitions.1.score": 3
rejects: []

View file

@ -0,0 +1,29 @@
scenario: ground/gr-f02-no-gate
description: >
Below the stress gate every Action is selectable (GR-F02): at Stress 3
a player may choose SOLVE, which GR-R03 would refuse at Stress 4.
covers: [GR-F02]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.stress": 3
"players.1.stress": 4
"players.0.hand": [{ suit: Clarify }]
"players.1.hand": [{ suit: Clarify }]
commands:
# GR-F02: Stress 3 is below the gate, so SOLVE is available.
- actor: P1
cmd: select_action
args: { action: SOLVE, problem: 1 }
# GR-R03: the same Action at Stress 4 is refused.
- actor: P2
cmd: select_action
args: { action: SOLVE, problem: 1 }
expect:
state:
"selections.0.action": Solve
rejects: [1]

View file

@ -0,0 +1,35 @@
scenario: ground/gr-r02-select
description: >
Select step: each player commits exactly one face-down Action, and a
second commit from the same player is rejected as a duplicate.
covers: [GR-R02, GR-A13]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
commands:
- actor: P1
cmd: select_action
args: { action: ATTACK, target: P2 }
- actor: P2
cmd: select_action
args: { action: SUPPORT, target: P3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 2 }
# GR-R02: one selection per player per round.
- actor: P1
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
expect:
events:
- kind: ActionSelected
player: 0
- kind: ActionSelected
player: 1
state:
"selections.0.action": Attack
"selections.1.action": Support
"selections.2.problem": 2
rejects: [3]

View file

@ -0,0 +1,34 @@
scenario: ground/gr-r03-stress-gate
description: >
Stress gate: at Stress 4 only ATTACK or GROUND may be selected, until
the READY Freedom token is spent, which admits any one Action.
covers: [GR-R03, GR-F03]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"players.0.stress": 4
commands:
# Gated: SUPPORT is not admitted at Stress 4.
- actor: P1
cmd: select_action
args: { action: SUPPORT, target: P2 }
- actor: P1
cmd: spend_freedom
# The same Action is now legal, and the token reads SPENT.
- actor: P1
cmd: select_action
args: { action: SUPPORT, target: P2 }
expect:
events:
- kind: FreedomSpent
player: 0
- kind: ActionSelected
player: 0
state:
"players.0.stress": 4
"players.0.freedom_ready": false
"selections.0.action": Support
rejects: [0]

View file

@ -0,0 +1,50 @@
scenario: ground/gr-r06-round-resolve
description: >
A full round: Select, Reveal, Resolve in GR-R06 step order, End. An
unrelated Attack raises Stress and forms a Rivalry; a Support with no
relation lowers Stress without forming a Bond.
covers: [GR-R01, GR-R04, GR-R06, GR-R07, GR-R08, GR-A03, GR-A06, GR-L01, GR-L03, GR-F01]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
commands:
- actor: P1
cmd: select_action
args: { action: ATTACK, target: P2 }
- actor: P2
cmd: select_action
args: { action: SUPPORT, target: P3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
- actor: SYSTEM
cmd: end_round
expect:
events:
- kind: Revealed
# GR-R06: Support (step 2) resolves before Attack (step 5).
- kind: StressSet
player: 2
stress: 1
- kind: StressSet
player: 1
stress: 3
- kind: RelationFormed
relation: Rivalry
- kind: RoundEnded
round: 2
state:
"round": 2
"lead": 1
"players.1.stress": 3
"players.2.stress": 1
"step": Select
rejects: []

View file

@ -2,7 +2,7 @@ scenario: ground/smoke-setup
description: >
Smoke scenario for the T07 scaffold: exercises the documented file format
end to end. Assertions are the GR-S setup facts; execution lands in T08.
covers: [GR-S02, GR-S03, GR-O01]
covers: [GR-S02, GR-S03, GR-S04, GR-O01, GR-O02]
provisional: false
seed: 42
setup:
@ -14,4 +14,10 @@ expect:
"round": 1
"players.0.stress": 2
"players.0.freedom_ready": true
"players.0.darvo": Off
"players.0.protection": 0
"players.0.blame_from": []
# GR-S04: 24 core Solutions less the 2 dealt to each of 3 players.
"solution_deck.17.suit": Change
"solution_discard": []
rejects: []

View file

@ -1,9 +1,11 @@
# The Inner Loop — Assimilate and Surpass
Status: **v0.2 draft** — becomes v1.0 only after surviving its first full
pass (CB-WP-0001-T09 retrospective). v0.2 adds loop tiers with the chaos
roll, adversarial survey review, and the runnable-baseline option
(maintainer decision, 2026-07-31).
Status: **v1.0** — survived its first full pass (CB-WP-0001, the GROUND
game kernel) and was corrected from it on 2026-07-31. Changes from v0.2:
measurement validity (the positive control), metric feasibility and
instrument naming, four implementation rules the pass earned, and the
requirement that evidence state what it does not support. Rationale and
the failures behind each: `history/260731-inner-loop-retrospective.md`.
Normative process for building every Clay-Borg capability. Referenced by
all workplans. The loop's own optimization target is **agentic efficiency**:
@ -42,7 +44,10 @@ are never skipped for code-producing work.
(`shuf -i 1-10 -n 1`). On a **10**, the tier is instead picked uniformly at
random (`shuf -e S M L -n 1`), overriding the structural derivation — up or
down. Both rolls are recorded in the tier declaration
(`tier: M (structural L, chaos 10→M)`). Purpose: an occasional random
(`tier: M (structural L, chaos 10→M)`). **Record the roll every time,
including when it changes nothing** (`tier: L (structural L, chaos 4)`),
so a mechanism that never fires is visible rather than assumed. Purpose:
an occasional random
reweighting keeps the classification honest — arguing everything into S
stops paying off when audits can compare argued tiers against the random
sample — and occasionally forces a deep look at something "obviously
@ -115,6 +120,18 @@ the conventions in [MetricsAndScenarios.md](MetricsAndScenarios.md),
including the rule that metric selection itself passes through a mini
research step (metric provenance).
**Every metric names its instrument, and is checked reachable.** A row
in the acceptance table carries the command that produces its number.
A metric with no named instrument is a wish, not a metric. A metric must
also be checked against the contracts in the *same spec*: if a contract
makes a target unreachable, one of the two is wrong and the conflict is
resolved when it is noticed, not at the acceptance run. Re-check the
table whenever a contract is added.
*(v1.0, from CB-WP-0001: AM-4's ≤20-crate target was made unreachable by
the K5 and K7 contracts written after it, and AM-12's cost metric was
fully specified and never instrumented, so it could not be computed.)*
### Step 5 — Code loop
Implement iteratively. Each iteration:
@ -129,6 +146,40 @@ comparison numbers are committed as an evidence file
(`evidence/CB-EV-NNNN-<slug>.md`). A failed scenario must yield a replay
artifact an agent can re-execute locally.
#### Measurement validity — the positive control
**Every benchmark and harness must assert that it performed the work it
reports.** Completing without error is not evidence of having done
anything: a loop whose commands are all rejected runs fast and reports a
throughput for work that never happened.
Concretely, a measurement harness must, on every run:
- assert the unit of work produced its expected effect (events applied,
rows written, moves accepted) — not merely that the call returned;
- fail loudly rather than report a number when that assertion fails;
- state the divisor used to convert raw timings into the metric's unit,
pinned by a test so a workload change cannot silently rescale it.
**A number from a run that cannot prove it did the work is void** and
must not reach an evidence file.
*(v1.0, from CB-WP-0001: both serious errors in the first pass were of
exactly this shape. A JS harness reported 8.4s for 100k moves while
every move was being rejected, and a Rust benchmark reported 9.3M
events/s — a 93× beat — while most rounds never completed because a
stress gate rejected one player's action. The corrected figure was 5.6×
lower. Adversarial review caught neither; both were claims about
numbers, and review reads prose.)*
#### Evidence states what it does not support
An evidence file that compares across runtimes, languages, or feature
sets **names the disanalogies explicitly**, in the same section as the
number. The reader must not have to infer that a ratio is not
like-for-like. This is the parity-cap rule applied to the write-up:
state the claim you will defend, and the claim you are not making.
---
## The four-dimension rubric
@ -171,6 +222,33 @@ Which candidate leads per dimension; what none of them do well
---
## Implementation rules the first pass earned
These are cheap, and each exists because its absence cost something in
CB-WP-0001. See `history/260731-inner-loop-retrospective.md`.
1. **No silently-ignored input.** A field that is parsed and then unused
is a defect, not a stub. Inputs are honoured or rejected with an
error — never dropped. *(A scenario `setup.patch` was parsed and
discarded; every scenario using it would have tested the wrong
initial state while passing.)*
2. **Decisions get commands, not defaults.** A rule that requires a
participant's choice is implemented as a command carrying that
choice. Until it is, **nothing claims coverage of it** — no tag, no
scenario, no acceptance row. Inventing a default to make a rule
"done" is the failure this prevents.
3. **Scaffolds are exercised or marked.** A scaffold's green gates are
not evidence. Any scaffold path no test reaches is marked as
unexercised. *(A compiling, fully-green scaffold shipped a state-hash
that would panic on any state holding a relation.)*
4. **Coverage gates that count tags say so.** A gate comparing rule IDs
against `covers:` lists proves no rule is unclaimed and no claimed
rule is invented. It does **not** prove a scenario exercises what it
names. Wherever such a number is reported, that limit is reported
with it.
---
## Agentic-efficiency requirements
The loop exists to be driven by agents. Therefore:
@ -202,6 +280,12 @@ A capability has completed the loop when all of the following are committed:
- [ ] specs/<Capability>.md with acceptance-metrics table
- [ ] passing scenarios covering every numbered spec rule
- [ ] evidence/CB-EV-NNNN with final comparison vs baseline, no `unmeasured`
- [ ] every reported number produced by a harness with a positive
control; any metric that could not be instrumented is recorded as
uncomputable rather than estimated
- [ ] every unmet metric reported as unmet, with attribution and the
options for resolving it — a missed target is an output of the
loop, not a reason to move the target quietly
- [ ] retrospective note (may be one paragraph appended to the evidence
file): what the loop itself should change
```

View file

@ -1,9 +1,10 @@
//! cb-sim — scenario runner binary (GameKernel K17; precursor of `cb sim`).
//! T07 scope: parse and validate scenario files, report coverage tags.
//! T08 wires execution. Exit codes: 0 all passed, 1 failures, 2 not yet
//! executable (parse-only), 64 usage error.
//! Parses scenario files, dispatches each to its game by the `<game>/`
//! prefix of its `scenario` field, and executes it. Exit codes: 0 all
//! passed, 1 failures, 2 unknown game, 64 usage error.
use cb_game_runtime::{scenario, RunOutcome, ScenarioFile};
use games_ground::GroundState;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
@ -12,8 +13,10 @@ fn main() {
std::process::exit(64);
}
let mut unimplemented = false;
let mut unknown_game = false;
let mut failed = false;
let mut passed = 0usize;
let mut covered: Vec<String> = Vec::new();
for path in &args {
let text = match std::fs::read_to_string(path) {
@ -24,36 +27,50 @@ fn main() {
continue;
}
};
match ScenarioFile::from_yaml(&text) {
let sc = match ScenarioFile::from_yaml(&text) {
Err(e) => {
eprintln!("{path}: parse error: {e}");
failed = true;
continue;
}
Ok(sc) => sc,
};
let outcome = match sc.scenario.split('/').next() {
Some("ground") => scenario::run::<GroundState>(&sc),
_ => {
println!("SKIP {} — no game registered for this prefix", sc.scenario);
unknown_game = true;
continue;
}
};
match outcome {
RunOutcome::Passed { covers } => {
println!(
"PASS {} covers={}{}",
sc.scenario,
covers.join(","),
if sc.provisional { " provisional" } else { "" }
);
covered.extend(covers);
passed += 1;
}
RunOutcome::Failed { reason } => {
println!("FAIL {}{reason}", sc.scenario);
failed = true;
}
Ok(sc) => match scenario::run(&sc) {
RunOutcome::Passed { covers } => {
println!("PASS {} covers={}", sc.scenario, covers.join(","));
}
RunOutcome::Failed { reason } => {
println!("FAIL {}{reason}", sc.scenario);
failed = true;
}
RunOutcome::Unimplemented => {
println!(
"PARSED {} covers={}{} (runner not yet implemented — T08)",
sc.scenario,
sc.covers.join(","),
if sc.provisional { " provisional" } else { "" }
);
unimplemented = true;
}
},
}
}
covered.sort();
covered.dedup();
println!("{passed} passed, {} rules covered", covered.len());
if failed {
std::process::exit(1);
}
if unimplemented {
if unknown_game {
std::process::exit(2);
}
}

34
tools/rule-coverage.py Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""AM-1 / M-D1-COV: every numbered GR-rule needs >=1 scenario.
Compares the rule IDs declared in specs/GroundRules.md against the
`covers:` lists in scenarios/ground/*.yaml. Exits non-zero when a
scenario claims a rule the spec does not define, so coverage can never
be inflated by a typo'd or invented rule ID.
"""
import glob
import re
import sys
spec = open("specs/GroundRules.md").read()
rules = sorted(set(re.findall(r"\*\*(GR-[A-Z]+\d+)", spec)))
covered = set()
for path in sorted(glob.glob("scenarios/ground/*.yaml")):
match = re.search(r"covers: \[(.*?)\]", open(path).read(), re.S)
if match:
covered |= {c.strip() for c in match.group(1).split(",") if c.strip()}
known = set(rules)
hit = sorted(known & covered)
missing = [r for r in rules if r not in covered]
invented = sorted(covered - known)
pct = 100 * len(hit) // len(rules) if rules else 0
print(f"AM-1 rule coverage: {len(hit)}/{len(rules)} ({pct}%)")
if missing:
print(" uncovered:", " ".join(missing))
if invented:
print(" ERROR — claimed but not defined in the spec:", " ".join(invented))
sys.exit(1)
sys.exit(0 if not missing else 2)

View file

@ -1,7 +1,7 @@
---
id: CB-WP-0001
title: "Establish the assimilate-and-surpass inner loop via the GROUND game kernel"
status: active
status: done
state_hub_workstream_id: "a1b434dc-b1c6-46b5-bbd9-80a4e6b7620f"
---
@ -161,7 +161,7 @@ the baselines. An empty-but-compiling, measurable loop bed.
```task
id: CB-WP-0001-T08
status: todo
status: done
priority: medium
state_hub_task_id: "3a42ff70-f3c6-4e3e-b022-f701729e71ff"
```
@ -172,11 +172,18 @@ scenarios pass headless, replay is deterministic, and every acceptance
metric meets or beats its recorded baseline — with the comparison numbers
committed as evidence.
**Outcome:** evidence/CB-EV-0001-game-kernel.md. 21 scenarios pass, AM-1
rule coverage 58/58, AM-6/AM-7/AM-8/AM-10 met with margin. **AM-4 is not
met** (33 crates vs ≤20) and is carried into T09 as a decision: make
serde_yaml optional, or move a target that the spec's own K5/K7
contracts make unreachable. AM-12 could not be computed honestly because
per-task token counts were never instrumented — also a T09 input.
## Task: Retrospective — harden the loop from what the example taught
```task
id: CB-WP-0001-T09
status: todo
status: done
priority: low
state_hub_task_id: "e99b107e-087e-49e1-96b5-67805deb242f"
```
@ -186,3 +193,11 @@ heavy or too thin, what the survey template missed, what metrics turned out
to matter. Output is InnerLoop v1.0 — the process the next capability
workplan starts from. The loop is only "established" once it has survived
its first full pass and been corrected.
**Outcome:** specs/InnerLoop.md v1.0 and
history/260731-inner-loop-retrospective.md. The adversarial review, the
parity cap, the provisional mechanism and the ADR gate held. The gap the
pass exposed: both serious errors were *measurement* errors and review
caught neither, because review reads prose and these were claims about
numbers. v1.0 adds the positive-control rule, metric feasibility and
instrument naming, and four implementation rules.