Compare commits
9 commits
be52250850
...
63dcffd9f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 63dcffd9f1 | |||
| eb1378e667 | |||
| 85d93a9e3c | |||
| 0e92535f11 | |||
| 290ad06c31 | |||
| 27016af216 | |||
| 27b7fe4329 | |||
| b58a9139aa | |||
| a09d76f370 |
33 changed files with 4039 additions and 93 deletions
21
.claude/ralph-loop.local.md
Normal file
21
.claude/ralph-loop.local.md
Normal 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`.
|
||||
|
||||
3
Makefile
3
Makefile
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
193
evidence/CB-EV-0001-game-kernel.md
Normal file
193
evidence/CB-EV-0001-game-kernel.md
Normal 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 400–500× 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".
|
||||
|
|
@ -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
116
history/260731-inner-loop-retrospective.md
Normal file
116
history/260731-inner-loop-retrospective.md
Normal 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.
|
||||
43
scenarios/ground/gr-a01-investigate.yaml
Normal file
43
scenarios/ground/gr-a01-investigate.yaml
Normal 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]
|
||||
43
scenarios/ground/gr-a02-solve.yaml
Normal file
43
scenarios/ground/gr-a02-solve.yaml
Normal 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: []
|
||||
48
scenarios/ground/gr-a04-bond-support.yaml
Normal file
48
scenarios/ground/gr-a04-bond-support.yaml
Normal 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: []
|
||||
57
scenarios/ground/gr-a05-support-response.yaml
Normal file
57
scenarios/ground/gr-a05-support-response.yaml
Normal 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]
|
||||
38
scenarios/ground/gr-a07-bond-flip.yaml
Normal file
38
scenarios/ground/gr-a07-bond-flip.yaml
Normal 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: []
|
||||
42
scenarios/ground/gr-a08-rivalry-break.yaml
Normal file
42
scenarios/ground/gr-a08-rivalry-break.yaml
Normal 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: []
|
||||
55
scenarios/ground/gr-a10-ground-gr.yaml
Normal file
55
scenarios/ground/gr-a10-ground-gr.yaml
Normal 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]
|
||||
43
scenarios/ground/gr-a11-ground-ou.yaml
Normal file
43
scenarios/ground/gr-a11-ground-ou.yaml
Normal 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]
|
||||
47
scenarios/ground/gr-a12-ground-nd.yaml
Normal file
47
scenarios/ground/gr-a12-ground-nd.yaml
Normal 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]
|
||||
42
scenarios/ground/gr-d01-darvo-trigger.yaml
Normal file
42
scenarios/ground/gr-d01-darvo-trigger.yaml
Normal 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: []
|
||||
48
scenarios/ground/gr-d03-darvo-deny.yaml
Normal file
48
scenarios/ground/gr-d03-darvo-deny.yaml
Normal 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]
|
||||
53
scenarios/ground/gr-d04-darvo-attack.yaml
Normal file
53
scenarios/ground/gr-d04-darvo-attack.yaml
Normal 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]
|
||||
54
scenarios/ground/gr-d05-darvo-reverse.yaml
Normal file
54
scenarios/ground/gr-d05-darvo-reverse.yaml
Normal 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: []
|
||||
43
scenarios/ground/gr-d06-darvo-early-end.yaml
Normal file
43
scenarios/ground/gr-d06-darvo-early-end.yaml
Normal 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: []
|
||||
50
scenarios/ground/gr-e02-shared-ground.yaml
Normal file
50
scenarios/ground/gr-e02-shared-ground.yaml
Normal 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: []
|
||||
54
scenarios/ground/gr-e04-coalitions.yaml
Normal file
54
scenarios/ground/gr-e04-coalitions.yaml
Normal 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: []
|
||||
29
scenarios/ground/gr-f02-no-gate.yaml
Normal file
29
scenarios/ground/gr-f02-no-gate.yaml
Normal 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]
|
||||
35
scenarios/ground/gr-r02-select.yaml
Normal file
35
scenarios/ground/gr-r02-select.yaml
Normal 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]
|
||||
34
scenarios/ground/gr-r03-stress-gate.yaml
Normal file
34
scenarios/ground/gr-r03-stress-gate.yaml
Normal 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]
|
||||
50
scenarios/ground/gr-r06-round-resolve.yaml
Normal file
50
scenarios/ground/gr-r06-round-resolve.yaml
Normal 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: []
|
||||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
34
tools/rule-coverage.py
Executable 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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue