diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md deleted file mode 100644 index bf5f62a..0000000 --- a/.claude/ralph-loop.local.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -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 HEUREKA. - -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`. - diff --git a/Makefile b/Makefile index 2975860..1cf6521 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,6 @@ 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 diff --git a/crates/cb-game-runtime/src/lib.rs b/crates/cb-game-runtime/src/lib.rs index 4b7d9df..6484c9c 100644 --- a/crates/cb-game-runtime/src/lib.rs +++ b/crates/cb-game-runtime/src/lib.rs @@ -3,7 +3,7 @@ pub mod scenario; -pub use scenario::{parse_actor, run, CommandStep, RunOutcome, ScenarioFile, ScenarioGame, Setup}; +pub use scenario::{RunOutcome, ScenarioFile}; use cb_kernel::PlayerId; use serde::{Deserialize, Serialize}; diff --git a/crates/cb-game-runtime/src/scenario.rs b/crates/cb-game-runtime/src/scenario.rs index 1a6aa08..5d45cf5 100644 --- a/crates/cb-game-runtime/src/scenario.rs +++ b/crates/cb-game-runtime/src/scenario.rs @@ -1,11 +1,9 @@ -//! 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). +//! 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. -use cb_events::{state_hash_hex, Envelope, EventLog}; -use cb_kernel::{Actor, Aggregate, EventSeq, GameId, PlayerId}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; /// One scenario file (`scenarios//.yaml`). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -73,250 +71,21 @@ impl ScenarioFile { /// Result of executing one scenario (twice, per the K8 double-run rule). #[derive(Debug)] pub enum RunOutcome { - Passed { covers: Vec }, - Failed { reason: String }, + Passed { + covers: Vec, + }, + Failed { + reason: String, + }, + /// Scaffold state: parsing works, execution lands in T08. + 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; - - /// 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 { - if actor == "SYSTEM" { - return Ok(Actor::System); - } - actor - .strip_prefix('P') - .and_then(|n| n.parse::().ok()) - .filter(|n| *n >= 1) - .map(|n| Actor::Player(PlayerId(n - 1))) - .ok_or_else(|| format!("unparseable actor {actor:?} (expected P 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, - rejected: BTreeSet, -} - -fn execute(scenario: &ScenarioFile) -> Result -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 = 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(scenario: &ScenarioFile) -> RunOutcome -where - G: ScenarioGame, - G::Event: Serialize, -{ - let first = match execute::(scenario) { - Ok(pass) => pass, - Err(reason) => return RunOutcome::Failed { reason }, - }; - let second = match execute::(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 = 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( - state: &G, - patch: &BTreeMap, -) -> Result { - 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::() - .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::().ok()?), - _ => None, - }) -} - -fn to_json(value: &T) -> Result { - 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::().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, - } +/// 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 } #[cfg(test)] @@ -351,23 +120,7 @@ expect: assert_eq!(parsed.covers, vec!["GR-S02", "GR-S03"]); assert_eq!(parsed.setup.players, 3); assert_eq!(parsed.commands.len(), 1); - } - - #[test] - fn actors_parse_1_based_into_0_based_seats() { - assert_eq!(parse_actor("P1").unwrap(), Actor::Player(PlayerId(0))); - assert_eq!(parse_actor("P3").unwrap(), Actor::Player(PlayerId(2))); - assert_eq!(parse_actor("SYSTEM").unwrap(), Actor::System); - assert!(parse_actor("P0").is_err()); - assert!(parse_actor("bogus").is_err()); - } - - #[test] - fn dot_paths_index_objects_and_arrays() { - let state = serde_json::json!({ "players": { "0": { "stress": 2 } }, "deck": [7, 9] }); - assert_eq!(lookup(&state, "players.0.stress").unwrap(), &2); - assert_eq!(lookup(&state, "deck.1").unwrap(), &9); - assert!(lookup(&state, "players.9.stress").is_none()); + assert!(matches!(run(&parsed), RunOutcome::Unimplemented)); } #[test] diff --git a/evidence/CB-EV-0001-game-kernel.md b/evidence/CB-EV-0001-game-kernel.md deleted file mode 100644 index f69af30..0000000 --- a/evidence/CB-EV-0001-game-kernel.md +++ /dev/null @@ -1,193 +0,0 @@ -# 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". diff --git a/games/ground/benches/synthetic.rs b/games/ground/benches/synthetic.rs index 8a6d596..b1700b6 100644 --- a/games/ground/benches/synthetic.rs +++ b/games/ground/benches/synthetic.rs @@ -1,219 +1,48 @@ -//! 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. +//! 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. -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; +use cb_events::state_hash; +use cb_game_runtime::CommitWindow; +use cb_kernel::PlayerId; +use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; -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, +/// 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 = 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() } -/// 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 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) + // 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, + ) }); } 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); diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index c1d5061..00d211e 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -1,9 +1,8 @@ //! games-ground — the GROUND rules aggregate (specs/GroundRules.md, -//! GameKernel K15–K16). Every rule realized here names its GR-id in a doc -//! comment, giving a greppable rule→code→scenario chain. +//! GameKernel K15–K16). T07 scaffolds the state shell; validate/fold per +//! GR-rule land in T08 with rule IDs cross-referenced in doc comments. -use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup}; -use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed}; +use cb_kernel::PlayerId; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -12,12 +11,8 @@ use std::collections::BTreeMap; pub struct PlayerState { /// GR-F01: clamped 0–5. pub stress: u8, - /// GR-F03: the Freedom token is READY until spent. + /// GR-F03. pub freedom_ready: bool, - /// GR-R03: set when Freedom is spent this round, lifting the stress - /// gate for this Select step only. Cleared at round End. - #[serde(default)] - pub freedom_gate_lifted: bool, /// GR-D01/D02: OFF or the pending/active stage. pub darvo: DarvoStage, pub hand: Vec, @@ -66,54 +61,6 @@ pub enum Relation { Rivalry, } -/// GR-O05: an unordered player pair, canonically ordered low→high. -/// -/// Serialized as `"a-b"` rather than as a tuple: canonical form is JSON -/// (GameKernel K7), and JSON object keys must be strings — a tuple key -/// makes `state_hash` fail on any state that holds a relation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct Pair(pub PlayerId, pub PlayerId); - -impl Pair { - pub fn new(a: PlayerId, b: PlayerId) -> Self { - if a <= b { - Pair(a, b) - } else { - Pair(b, a) - } - } - - pub fn contains(&self, player: PlayerId) -> bool { - self.0 == player || self.1 == player - } -} - -impl core::fmt::Display for Pair { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "{}-{}", self.0 .0, self.1 .0) - } -} - -impl Serialize for Pair { - fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(self) - } -} - -impl<'de> Deserialize<'de> for Pair { - fn deserialize>(deserializer: D) -> Result { - let raw = String::deserialize(deserializer)?; - let (a, b) = raw - .split_once('-') - .ok_or_else(|| serde::de::Error::custom(format!("bad relation key {raw:?}")))?; - let parse = |s: &str| { - s.parse::() - .map_err(|e| serde::de::Error::custom(format!("bad seat in {raw:?}: {e}"))) - }; - Ok(Pair::new(PlayerId(parse(a)?), PlayerId(parse(b)?))) - } -} - /// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered /// collections only (GameKernel K6). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -122,1788 +69,12 @@ pub struct GroundState { pub lead: PlayerId, pub players: BTreeMap, /// Keyed by (low, high) player pair. - pub relations: BTreeMap, + pub relations: BTreeMap<(PlayerId, PlayerId), Relation>, pub problems: BTreeMap, pub solution_deck: Vec, pub solution_discard: Vec, /// Focus placements: sequence owner → target (GR-T03). pub focus: BTreeMap, - /// GR-R01: which of the four steps the round is in. - pub step: RoundStep, - /// GR-R02: face-down selections, hidden until Reveal. - pub selections: BTreeMap, - /// GR-R05: GROUND modes chosen after Reveal, before Resolve. - pub ground_modes: BTreeMap, - /// GR-A11/A12: the sub-choice accompanying an OU or ND mode. - pub ground_choices: BTreeMap, - /// GR-L02/A05: a Support target's response, keyed by target. - pub support_responses: BTreeMap, - /// GR-D03/D04: the mandatory target a DARVO stage needs this round. - pub darvo_targets: BTreeMap, - /// GR-E02..E04: which scoring mode this game uses. - pub mode: ScoringMode, - /// GR-R09: set once the game has ended and scoring has run. - pub outcome: Option, - /// GR-S04/U4: retained so a deck reshuffle stays a pure function of - /// state, keeping `validate` deterministic without holding RNG state. - pub seed: u64, -} - -/// GR-E02..E04: the three scoring modes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ScoringMode { - /// GR-E02, co-op: one shared score against the threshold. - SharedGround, - /// GR-E03, semi-co-op: personal scores once the group qualifies. - CommonProblem, - /// GR-E04: Bond networks score together. - BondedCoalitions, -} - -/// GR-E01..E04: the final scoring result. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Outcome { - /// GR-E01: summed printed values of all claimed Problems. - pub total: u32, - pub threshold: u32, - pub group_success: bool, - /// GR-E03: claimed value −1 per Blame held. - pub personal: BTreeMap, - /// GR-E04: each Bond-connected group and its combined score. - pub coalitions: Vec, - /// GR-E02: only meaningful in SHARED GROUND. - pub mastery: Option, - pub winners: Vec, -} - -/// GR-E04: one Bond-connected group. Unbonded players are solo. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Coalition { - pub members: Vec, - pub score: i32, -} - -/// GR-D03/D04: what a DARVO stage acts on this round. DENY names a -/// Problem, ATTACK names a player; REVERSE takes its target from the -/// Focus token placed by the ATTACK stage. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct DarvoTarget { - pub problem: Option, - pub player: Option, -} - -/// GR-A10..A12: the three GROUND modes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum GroundMode { - /// Ground & Restate (GR-A10). - Gr, - /// Observe & Uphold (GR-A11). - Ou, - /// Name & Decide (GR-A12). - Nd, -} - -/// GR-A11/A12: the sub-choice a GROUND—OU or GROUND—ND player makes -/// alongside the mode. GROUND—GR takes none. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "choice")] -pub enum GroundChoice { - /// GR-A11: restore one Denied Problem. - RestoreProblem { problem: u32 }, - /// GR-A11: cancel one Attack targeting this player this round. - CancelAttack { attacker: PlayerId }, - /// GR-A11: protect one face-up Problem from Deny this round. - ProtectProblem { problem: u32 }, - /// GR-A12: remove one Blame token from this player. - RemoveBlame { owner: PlayerId }, - /// GR-A12: break one relation involving this player. - BreakRelation { with: PlayerId }, - /// GR-A12: reject one Reverse targeting this player this round. - RejectReverse, -} - -impl GroundChoice { - /// GR-A11/A12: each choice belongs to exactly one mode. - fn mode(self) -> GroundMode { - match self { - GroundChoice::RestoreProblem { .. } - | GroundChoice::CancelAttack { .. } - | GroundChoice::ProtectProblem { .. } => GroundMode::Ou, - GroundChoice::RemoveBlame { .. } - | GroundChoice::BreakRelation { .. } - | GroundChoice::RejectReverse => GroundMode::Nd, - } - } - - fn parse(raw: &str, arg: Option) -> Result { - let need = |what: &str| { - arg.ok_or_else(|| format!("GROUND choice {raw:?} needs a {what} argument")) - }; - match raw { - "restore_problem" => Ok(GroundChoice::RestoreProblem { - problem: need("problem")? as u32, - }), - "cancel_attack" => Ok(GroundChoice::CancelAttack { - attacker: PlayerId(need("seat")? as u8), - }), - "protect_problem" => Ok(GroundChoice::ProtectProblem { - problem: need("problem")? as u32, - }), - "remove_blame" => Ok(GroundChoice::RemoveBlame { - owner: PlayerId(need("seat")? as u8), - }), - "break_relation" => Ok(GroundChoice::BreakRelation { - with: PlayerId(need("seat")? as u8), - }), - "reject_reverse" => Ok(GroundChoice::RejectReverse), - other => Err(format!("unknown GROUND choice {other:?}")), - } - } -} - -/// GR-L02/A05: how a Support target responds, chosen after Reveal. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum SupportResponse { - /// GR-L02: accept a Bond where no relation exists. - AcceptBond, - /// GR-L02: decline it; the Stress effect still applies. - DeclineBond, - /// GR-A05: turn an existing Rivalry into a Bond. - FlipToBond, - /// GR-A05: break the existing Rivalry. - BreakRivalry, -} - -impl SupportResponse { - fn parse(raw: &str) -> Result { - match raw { - "accept_bond" => Ok(SupportResponse::AcceptBond), - "decline_bond" => Ok(SupportResponse::DeclineBond), - "flip_to_bond" => Ok(SupportResponse::FlipToBond), - "break_rivalry" => Ok(SupportResponse::BreakRivalry), - other => Err(format!("unknown support response {other:?}")), - } - } -} - -impl GroundMode { - fn parse(raw: &str) -> Result { - match raw { - "GR" => Ok(GroundMode::Gr), - "OU" => Ok(GroundMode::Ou), - "ND" => Ok(GroundMode::Nd), - other => Err(format!( - "unknown GROUND mode {other:?} (expected GR, OU or ND)" - )), - } - } -} - -/// GR-R01: Select → Reveal → Resolve → End. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RoundStep { - Select, - Reveal, - Resolve, - End, -} - -/// GR-R02: one player's face-down choice, with its target where the -/// Action requires one (GR-A13). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct Selection { - pub action: Action, - pub target: Option, - pub problem: Option, -} - -/// The five Actions (GR-A01..A13). GROUND's mode is chosen at Reveal -/// (GR-R05), not at Select, so it is not part of the selection. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Action { - Investigate, - Solve, - Support, - Attack, - Ground, -} - -impl Action { - fn parse(raw: &str) -> Result { - match raw { - "INVESTIGATE" => Ok(Action::Investigate), - "SOLVE" => Ok(Action::Solve), - "SUPPORT" => Ok(Action::Support), - "ATTACK" => Ok(Action::Attack), - "GROUND" => Ok(Action::Ground), - other => Err(format!("unknown action {other:?}")), - } - } - - /// GR-R03: the stress gate admits only ATTACK and GROUND. - fn allowed_under_stress_gate(self) -> bool { - matches!(self, Action::Attack | Action::Ground) - } - - /// GR-A13: SUPPORT and ATTACK target another player; INVESTIGATE and - /// SOLVE target a Problem; GROUND targets neither at Select. - fn requires_player_target(self) -> bool { - matches!(self, Action::Support | Action::Attack) - } - - fn requires_problem_target(self) -> bool { - matches!(self, Action::Investigate | Action::Solve) - } -} - -/// Commands accepted by the GROUND aggregate. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum GroundCommand { - /// GR-R02: choose an Action face down. - SelectAction { - action: Action, - target: Option, - problem: Option, - }, - /// GR-R03: spend the READY Freedom token to bypass the stress gate. - SpendFreedom, - /// GR-R05: a player who revealed GROUND chooses its mode after - /// seeing all revealed Actions. - ChooseGroundMode { - mode: GroundMode, - choice: Option, - }, - /// GR-L02/A05: respond to a Support aimed at this player. - RespondToSupport { response: SupportResponse }, - /// GR-D03/D04: name the mandatory target of this round's stage. - ChooseDarvoTarget { target: DarvoTarget }, - /// GR-R04: reveal all selections simultaneously. System-driven. - Reveal, - /// GR-R06/R07: resolve revealed Actions in fixed step order, Lead - /// first. System-driven. - Resolve, - /// GR-R08: clamp Stress, trigger DARVO, rotate Lead, advance the - /// Round marker. System-driven. - EndRound, -} - -/// Events the aggregate emits. `fold` is total over these (K1). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind")] -pub enum GroundEvent { - ActionSelected { - player: PlayerId, - selection: Selection, - }, - FreedomSpent { - player: PlayerId, - }, - /// GR-R04. - Revealed, - /// GR-F01/U2: absolute post-clamp Stress, so `fold` stays trivial. - StressSet { - player: PlayerId, - stress: u8, - }, - /// GR-F04. - FreedomReadied { - player: PlayerId, - }, - /// GR-L02/L03: endpoints ordered low→high (GR-O05). - RelationFormed { - pair: Pair, - relation: Relation, - }, - /// GR-L04. - RelationBroken { - pair: Pair, - }, - /// GR-A09: Protection absorbed an Attack. - AttackCancelled { - attacker: PlayerId, - target: PlayerId, - }, - /// GR-R05/A11/A12. - GroundModeChosen { - player: PlayerId, - mode: GroundMode, - choice: Option, - }, - /// GR-L02/A05. - SupportAnswered { - player: PlayerId, - response: SupportResponse, - }, - /// GR-A11: a Denied Problem was restored face up. - ProblemRestored { - problem: u32, - }, - /// GR-A11: a face-up Problem is protected from Deny this round. - ProblemProtected { - problem: u32, - }, - /// GR-A12/T02: a Blame token was removed and returned to its owner. - BlameRemoved { - player: PlayerId, - owner: PlayerId, - }, - /// GR-A01: a hidden Problem was turned face up. - ProblemRevealed { - problem: u32, - }, - /// GR-A01: one Solution drawn from the deck. - SolutionDrawn { - player: PlayerId, - card: SolutionCard, - }, - /// GR-A02: a matching Solution was spent to claim a Problem. - SolutionDiscarded { - player: PlayerId, - card: SolutionCard, - }, - /// GR-A02. - ProblemClaimed { - problem: u32, - by: PlayerId, - }, - /// GR-A01 under the U4 default: the discard was reshuffled into the - /// deck. The resulting order travels in the event, so `fold` stays - /// deterministic without replaying the RNG. - DeckReshuffled { - order: Vec, - }, - /// GR-D01: Stress 5 at End with the marker OFF. - DarvoTriggered { - player: PlayerId, - }, - /// GR-D03/D04. - DarvoTargetChosen { - player: PlayerId, - target: DarvoTarget, - }, - /// GR-D03: a Problem was turned face down and Denied. - ProblemDenied { - problem: u32, - }, - /// GR-D04/T03: the sequence owner's Focus token was placed. - FocusPlaced { - owner: PlayerId, - target: PlayerId, - }, - /// GR-D05/T03: Focus flipped to Blame in front of the target. - FocusFlippedToBlame { - owner: PlayerId, - target: PlayerId, - }, - /// GR-D05/T01. - ProtectionGained { - player: PlayerId, - }, - /// GR-D02: the sequence moved to its next stage. - DarvoAdvanced { - player: PlayerId, - stage: DarvoStage, - }, - /// GR-D05/D06/D07: the sequence ended and the marker is OFF again. - DarvoEnded { - player: PlayerId, - }, - /// GR-R09/E01..E04: the game ended and scoring ran. - GameEnded { - outcome: Outcome, - }, - /// GR-R01: the round advanced to `step`. - StepAdvanced { - step: RoundStep, - }, - /// GR-R08: Lead rotated and the Round marker advanced. - RoundEnded { - round: u8, - next_lead: PlayerId, - }, -} - -impl GroundState { - /// GR-R03: a player at Stress 4–5 is gated unless Freedom is spent. - /// Spending flips the token to SPENT, so the gate returns next round. - fn stress_gated(&self, player: &PlayerState) -> bool { - let _ = self; - player.stress >= 4 && !player.freedom_gate_lifted - } - - fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option { - self.relations.get(&Pair::new(a, b)).copied() - } - - /// GR-L01: two relation slots per player. - fn has_free_slot(&self, player: PlayerId) -> bool { - let used = self - .relations - .keys() - .filter(|pair| pair.contains(player)) - .count(); - used < 2 - } - - /// GR-R07: resolution order starts at the Lead and continues - /// clockwise (ascending seat, wrapping). - fn seat_order(&self) -> Vec { - let seats: Vec = self.players.keys().copied().collect(); - let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0); - seats[start..] - .iter() - .chain(&seats[..start]) - .copied() - .collect() - } - - /// GR-F01 with the U2 default: clamp on every application, so no - /// intermediate value escapes 0–5. - fn stress_after(&self, player: PlayerId, delta: i16) -> u8 { - let current = self.players.get(&player).map_or(0, |p| i16::from(p.stress)); - current.saturating_add(delta).clamp(0, 5) as u8 - } - - fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> { - self.players.get(&id).ok_or(Rejection::Game { - code: "no-such-seat".into(), - detail: format!("player {id} is not in this game"), - }) - } -} - -impl Aggregate for GroundState { - type Command = GroundCommand; - type Event = GroundEvent; - - fn validate( - &self, - actor: Actor, - command: &Self::Command, - ) -> Result, Rejection> { - // GR-R04/R06/R08 are runtime-driven, not player-issued. - match command { - GroundCommand::Reveal => { - if self.step != RoundStep::Select || actor != Actor::System { - return Err(Rejection::NotAllowedNow); - } - if self.selections.len() != self.players.len() { - return Err(Rejection::Game { - code: "select-incomplete".into(), - detail: "GR-R04: every player must select before Reveal".into(), - }); - } - return Ok(vec![ - GroundEvent::Revealed, - GroundEvent::StepAdvanced { - step: RoundStep::Reveal, - }, - ]); - } - GroundCommand::Resolve => { - if self.step != RoundStep::Reveal || actor != Actor::System { - return Err(Rejection::NotAllowedNow); - } - // GR-R05: modes are chosen before resolution begins. - let pending: Vec = self - .selections - .iter() - .filter(|(seat, sel)| { - sel.action == Action::Ground && !self.ground_modes.contains_key(seat) - }) - .map(|(seat, _)| *seat) - .collect(); - if !pending.is_empty() { - return Err(Rejection::Game { - code: "ground-mode-pending".into(), - detail: format!("GR-R05: no GROUND mode chosen for {pending:?}"), - }); - } - return Ok(self.resolution_events()); - } - GroundCommand::EndRound => { - if self.step != RoundStep::Resolve || actor != Actor::System { - return Err(Rejection::NotAllowedNow); - } - return Ok(self.end_round_events()); - } - _ => {} - } - - let Actor::Player(id) = actor else { - return Err(Rejection::NotAllowedNow); - }; - let player = self.player(id)?; - - match command { - // GR-R02: one face-down choice per player, during Select only. - GroundCommand::SelectAction { - action, - target, - problem, - } => { - if self.step != RoundStep::Select { - return Err(Rejection::NotAllowedNow); - } - if self.selections.contains_key(&id) { - return Err(Rejection::DuplicateCommand); - } - if self.stress_gated(player) && !action.allowed_under_stress_gate() { - return Err(Rejection::Game { - code: "stress-gate".into(), - detail: "GR-R03: at Stress 4–5 only ATTACK or GROUND may be selected" - .into(), - }); - } - self.check_targeting(id, *action, *target, *problem)?; - Ok(vec![GroundEvent::ActionSelected { - player: id, - selection: Selection { - action: *action, - target: *target, - problem: *problem, - }, - }]) - } - // GR-R03: spendable during Select, before Reveal, once. - GroundCommand::SpendFreedom => { - if self.step != RoundStep::Select { - return Err(Rejection::NotAllowedNow); - } - if !player.freedom_ready { - return Err(Rejection::Game { - code: "freedom-spent".into(), - detail: "GR-F03: the Freedom token is already SPENT".into(), - }); - } - Ok(vec![GroundEvent::FreedomSpent { player: id }]) - } - // GR-R05: only after Reveal, only for a player who revealed - // GROUND, once. - GroundCommand::ChooseGroundMode { mode, choice } => { - if self.step != RoundStep::Reveal { - return Err(Rejection::NotAllowedNow); - } - let revealed_ground = self - .selections - .get(&id) - .is_some_and(|s| s.action == Action::Ground); - if !revealed_ground { - return Err(Rejection::Game { - code: "no-ground-revealed".into(), - detail: "GR-R05: only a player who revealed GROUND chooses a mode".into(), - }); - } - if self.ground_modes.contains_key(&id) { - return Err(Rejection::DuplicateCommand); - } - let _ = player; - // GR-A10..A12: GR takes no sub-choice; OU and ND each - // require one drawn from their own list. - match (mode, choice) { - (GroundMode::Gr, None) => {} - (GroundMode::Gr, Some(_)) => { - return Err(Rejection::Game { - code: "unexpected-choice".into(), - detail: "GR-A10: GROUND—GR takes no sub-choice".into(), - }) - } - (wanted, Some(choice)) if choice.mode() == *wanted => {} - (wanted, _) => { - return Err(Rejection::Game { - code: "bad-choice".into(), - detail: format!("GR-A11/A12: {wanted:?} needs one of its own choices"), - }) - } - } - self.check_ground_choice(id, *choice)?; - Ok(vec![GroundEvent::GroundModeChosen { - player: id, - mode: *mode, - choice: *choice, - }]) - } - // GR-L02/A05: only the target of a revealed Support answers, - // once, and only with a response its relation admits. - GroundCommand::RespondToSupport { response } => { - if self.step != RoundStep::Reveal { - return Err(Rejection::NotAllowedNow); - } - let supporter = self.selections.iter().find(|(seat, sel)| { - sel.action == Action::Support && sel.target == Some(id) && **seat != id - }); - let Some((supporter, _)) = supporter else { - return Err(Rejection::Game { - code: "no-support-received".into(), - detail: "GR-L02: no revealed Support targets this player".into(), - }); - }; - if self.support_responses.contains_key(&id) { - return Err(Rejection::DuplicateCommand); - } - let admitted = match self.relation_between(*supporter, id) { - // GR-L02: no relation — the target may accept a Bond. - None => matches!( - response, - SupportResponse::AcceptBond | SupportResponse::DeclineBond - ), - // GR-A05: through a Rivalry — flip it or break it. - Some(Relation::Rivalry) => matches!( - response, - SupportResponse::FlipToBond | SupportResponse::BreakRivalry - ), - // GR-A04: through a Bond — nothing to answer. - Some(Relation::Bond) => false, - }; - if !admitted { - return Err(Rejection::Game { - code: "bad-response".into(), - detail: format!("GR-L02/A05: {response:?} is not available here"), - }); - } - Ok(vec![GroundEvent::SupportAnswered { - player: id, - response: *response, - }]) - } - // GR-D03/D04: only a player with a live sequence, after - // Reveal, once per round. - GroundCommand::ChooseDarvoTarget { target } => { - if self.step != RoundStep::Reveal { - return Err(Rejection::NotAllowedNow); - } - if player.darvo == DarvoStage::Off { - return Err(Rejection::Game { - code: "no-darvo-sequence".into(), - detail: "GR-D02: this player has no live DARVO sequence".into(), - }); - } - if self.darvo_targets.contains_key(&id) { - return Err(Rejection::DuplicateCommand); - } - match player.darvo { - // GR-D03: a face-up, unsolved, unprotected Problem. - DarvoStage::Deny => { - let problem = target.problem.ok_or(Rejection::Game { - code: "bad-darvo-target".into(), - detail: "GR-D03: DENY names a Problem".into(), - })?; - let eligible = self.problems.get(&problem).is_some_and(|p| { - p.face_up - && !p.denied - && p.claimed_by.is_none() - && !p.protected_this_round - }); - if !eligible { - return Err(Rejection::Game { - code: "bad-darvo-target".into(), - detail: format!( - "GR-D03: Problem {problem} is not face-up, unsolved and unprotected" - ), - }); - } - } - // GR-D04: an extra Attack against another player. - DarvoStage::Attack => { - let other = target.player.ok_or(Rejection::Game { - code: "bad-darvo-target".into(), - detail: "GR-D04: ATTACK names a player".into(), - })?; - if other == id || !self.players.contains_key(&other) { - return Err(Rejection::Game { - code: "bad-darvo-target".into(), - detail: "GR-D04: ATTACK targets another player".into(), - }); - } - } - // GR-D05: REVERSE uses the placed Focus token. - DarvoStage::Reverse | DarvoStage::Off => {} - } - Ok(vec![GroundEvent::DarvoTargetChosen { - player: id, - target: *target, - }]) - } - GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => { - unreachable!("system commands are handled above") - } - } - } - - fn fold(&mut self, event: &Self::Event) { - match event { - GroundEvent::ActionSelected { player, selection } => { - self.selections.insert(*player, *selection); - } - GroundEvent::FreedomSpent { player } => { - if let Some(state) = self.players.get_mut(player) { - state.freedom_ready = false; - state.freedom_gate_lifted = true; - } - } - GroundEvent::Revealed => {} - GroundEvent::StressSet { player, stress } => { - if let Some(state) = self.players.get_mut(player) { - state.stress = *stress; - } - } - GroundEvent::FreedomReadied { player } => { - if let Some(state) = self.players.get_mut(player) { - state.freedom_ready = true; - } - } - GroundEvent::RelationFormed { pair, relation } => { - self.relations.insert(*pair, *relation); - } - GroundEvent::RelationBroken { pair } => { - self.relations.remove(pair); - } - GroundEvent::AttackCancelled { target, .. } => { - if let Some(state) = self.players.get_mut(target) { - state.protection = state.protection.saturating_sub(1); - } - } - GroundEvent::GroundModeChosen { - player, - mode, - choice, - } => { - self.ground_modes.insert(*player, *mode); - if let Some(choice) = choice { - self.ground_choices.insert(*player, *choice); - } - } - GroundEvent::SupportAnswered { player, response } => { - self.support_responses.insert(*player, *response); - } - GroundEvent::ProblemRestored { problem } => { - if let Some(state) = self.problems.get_mut(problem) { - state.denied = false; - state.face_up = true; - } - } - GroundEvent::ProblemProtected { problem } => { - if let Some(state) = self.problems.get_mut(problem) { - state.protected_this_round = true; - } - } - GroundEvent::BlameRemoved { player, owner } => { - if let Some(state) = self.players.get_mut(player) { - if let Some(pos) = state.blame_from.iter().position(|o| o == owner) { - state.blame_from.remove(pos); - } - } - } - GroundEvent::ProblemRevealed { problem } => { - if let Some(state) = self.problems.get_mut(problem) { - state.face_up = true; - } - } - GroundEvent::SolutionDrawn { player, card } => { - // The deck draws from its end, matching the GR-S02 deal. - self.solution_deck.pop(); - if let Some(state) = self.players.get_mut(player) { - state.hand.push(*card); - } - } - GroundEvent::SolutionDiscarded { player, card } => { - if let Some(state) = self.players.get_mut(player) { - if let Some(pos) = state.hand.iter().position(|c| c == card) { - state.hand.remove(pos); - } - } - self.solution_discard.push(*card); - } - GroundEvent::ProblemClaimed { problem, by } => { - if let Some(state) = self.problems.get_mut(problem) { - state.claimed_by = Some(*by); - } - } - GroundEvent::DeckReshuffled { order } => { - self.solution_deck = order.clone(); - self.solution_discard.clear(); - } - GroundEvent::DarvoTriggered { player } => { - if let Some(state) = self.players.get_mut(player) { - state.darvo = DarvoStage::Deny; - } - } - GroundEvent::DarvoTargetChosen { player, target } => { - self.darvo_targets.insert(*player, *target); - } - GroundEvent::ProblemDenied { problem } => { - if let Some(state) = self.problems.get_mut(problem) { - state.denied = true; - state.face_up = false; - } - } - GroundEvent::FocusPlaced { owner, target } => { - self.focus.insert(*owner, *target); - } - GroundEvent::FocusFlippedToBlame { owner, target } => { - self.focus.remove(owner); - if let Some(state) = self.players.get_mut(target) { - state.blame_from.push(*owner); - } - } - GroundEvent::ProtectionGained { player } => { - if let Some(state) = self.players.get_mut(player) { - state.protection = state.protection.saturating_add(1); - } - } - GroundEvent::DarvoAdvanced { player, stage } => { - if let Some(state) = self.players.get_mut(player) { - state.darvo = *stage; - } - } - GroundEvent::DarvoEnded { player } => { - if let Some(state) = self.players.get_mut(player) { - state.darvo = DarvoStage::Off; - } - // GR-D06: an unresolved Focus token comes back. - self.focus.remove(player); - } - GroundEvent::GameEnded { outcome } => { - self.outcome = Some(outcome.clone()); - self.step = RoundStep::End; - } - GroundEvent::StepAdvanced { step } => { - self.step = *step; - } - GroundEvent::RoundEnded { round, next_lead } => { - self.round = *round; - self.lead = *next_lead; - self.selections.clear(); - self.ground_modes.clear(); - self.ground_choices.clear(); - self.support_responses.clear(); - self.darvo_targets.clear(); - for state in self.players.values_mut() { - // GR-R03: the gate lift lasts one Select step only. - state.freedom_gate_lifted = false; - } - for problem in self.problems.values_mut() { - // GR-A11: OU protection lasts one round. - problem.protected_this_round = false; - } - } - } - } -} - -impl GroundState { - /// GR-R06/R07: resolve in fixed step order, Lead first within a step. - /// - /// Step 3 (active DARVO stages) is not yet implemented — the DARVO - /// machine lands with GR-D02..D07 and no scenario claims coverage of - /// it. GROUND—OU and GROUND—ND (GR-A11/A12) each offer a three-way - /// choice that needs its own command and are likewise pending. - fn resolution_events(&self) -> Vec { - let mut events = Vec::new(); - // A working copy so slot counts and Stress reflect earlier - // effects within the same resolution, per GR-R07 ordering. - let mut work = self.clone(); - - // Relations as they stood before this round's Support step - // (GR-L05): a Bond formed now is not "pre-existing". - let pre_existing = self.relations.clone(); - - // GR-A11: Attacks cancelled by a GROUND—OU choice this round. - let mut ou_cancels: std::collections::BTreeSet<(PlayerId, PlayerId)> = - std::collections::BTreeSet::new(); - - // Step 1 — GROUND (GR-A10..A12). - for actor in self.seat_order() { - if self.selections.get(&actor).map(|s| s.action) != Some(Action::Ground) { - continue; - } - match self.ground_modes.get(&actor) { - // GR-A10: Ground & Restate — self −2 Stress, Freedom READY. - Some(GroundMode::Gr) => { - let stress = work.stress_after(actor, -2); - events.push(GroundEvent::StressSet { - player: actor, - stress, - }); - work.fold(events.last().expect("just pushed")); - if !work.players[&actor].freedom_ready { - events.push(GroundEvent::FreedomReadied { player: actor }); - work.fold(events.last().expect("just pushed")); - } - } - // GR-A11: Observe & Uphold. - Some(GroundMode::Ou) => match work.ground_choices.get(&actor).copied() { - Some(GroundChoice::RestoreProblem { problem }) => { - events.push(GroundEvent::ProblemRestored { problem }); - work.fold(events.last().expect("just pushed")); - } - Some(GroundChoice::ProtectProblem { problem }) => { - events.push(GroundEvent::ProblemProtected { problem }); - work.fold(events.last().expect("just pushed")); - } - // Consumed in the Attack step below. - Some(GroundChoice::CancelAttack { attacker }) => { - ou_cancels.insert((attacker, actor)); - } - _ => {} - }, - // GR-A12: Name & Decide. - Some(GroundMode::Nd) => match work.ground_choices.get(&actor).copied() { - Some(GroundChoice::RemoveBlame { owner }) => { - events.push(GroundEvent::BlameRemoved { - player: actor, - owner, - }); - work.fold(events.last().expect("just pushed")); - } - Some(GroundChoice::BreakRelation { with }) => { - events.push(GroundEvent::RelationBroken { - pair: Pair::new(actor, with), - }); - work.fold(events.last().expect("just pushed")); - } - // GR-D05: consumed by the Reverse stage. - _ => {} - }, - None => {} - } - } - - // Step 2 — Support (GR-A03/A04/A05). - for actor in self.seat_order() { - let Some(selection) = self.selections.get(&actor) else { - continue; - }; - if selection.action != Action::Support { - continue; - } - let Some(target) = selection.target else { - continue; - }; - match pre_existing.get(&Pair::new(actor, target)).copied() { - // GR-A04: Support through an existing Bond. - Some(Relation::Bond) => { - let stress = work.stress_after(target, -2); - events.push(GroundEvent::StressSet { - player: target, - stress, - }); - work.fold(events.last().expect("just pushed")); - // GR-F04: a Bond Support readies the target's token. - if !work.players[&target].freedom_ready { - events.push(GroundEvent::FreedomReadied { player: target }); - work.fold(events.last().expect("just pushed")); - } - } - // GR-A05: Support through a Rivalry — −1 Stress, then - // the target flips it to a Bond or breaks it. - Some(Relation::Rivalry) => { - let stress = work.stress_after(target, -1); - events.push(GroundEvent::StressSet { - player: target, - stress, - }); - work.fold(events.last().expect("just pushed")); - let pair = Pair::new(actor, target); - match work.support_responses.get(&target).copied() { - Some(SupportResponse::FlipToBond) => { - events.push(GroundEvent::RelationFormed { - pair, - relation: Relation::Bond, - }); - work.fold(events.last().expect("just pushed")); - } - Some(SupportResponse::BreakRivalry) => { - events.push(GroundEvent::RelationBroken { pair }); - work.fold(events.last().expect("just pushed")); - } - // No answer: the Rivalry stands. - _ => {} - } - } - // GR-A03/L02: no relation — −1 Stress, and a Bond forms - // only if the target accepts and both have a free slot. - None => { - let stress = work.stress_after(target, -1); - events.push(GroundEvent::StressSet { - player: target, - stress, - }); - work.fold(events.last().expect("just pushed")); - let accepted = work.support_responses.get(&target).copied() - == Some(SupportResponse::AcceptBond); - if accepted && work.has_free_slot(actor) && work.has_free_slot(target) { - events.push(GroundEvent::RelationFormed { - pair: Pair::new(actor, target), - relation: Relation::Bond, - }); - work.fold(events.last().expect("just pushed")); - } - } - } - } - - // Step 3 — active DARVO stages (GR-D02..D07). - for owner in self.seat_order() { - let stage = work.players[&owner].darvo; - if stage == DarvoStage::Off { - continue; - } - - // GR-D06 + GR-A04: a Support through a Bond that existed - // before this round's Support step cancels the current stage - // and ends the sequence (GR-L05). - let bond_support = self.selections.iter().any(|(seat, sel)| { - sel.action == Action::Support - && sel.target == Some(owner) - && pre_existing.get(&Pair::new(*seat, owner)) == Some(&Relation::Bond) - }); - if bond_support { - events.push(GroundEvent::DarvoEnded { player: owner }); - work.fold(events.last().expect("just pushed")); - continue; - } - - match stage { - // GR-D03: turn one eligible Problem face down and Deny - // it. Under the U3 default, no legal target is a no-op - // and the sequence still advances. - DarvoStage::Deny => { - if let Some(problem) = work.darvo_targets.get(&owner).and_then(|t| t.problem) { - let eligible = work.problems.get(&problem).is_some_and(|p| { - p.face_up - && !p.denied - && p.claimed_by.is_none() - && !p.protected_this_round - }); - if eligible { - events.push(GroundEvent::ProblemDenied { problem }); - work.fold(events.last().expect("just pushed")); - } - } - } - // GR-D04: one extra Attack under the normal relation - // rules, then place the Focus token beside the target — - // even if the Attack was cancelled. - DarvoStage::Attack => { - if let Some(target) = work.darvo_targets.get(&owner).and_then(|t| t.player) { - work.resolve_attack(owner, target, &ou_cancels, &mut events); - events.push(GroundEvent::FocusPlaced { owner, target }); - work.fold(events.last().expect("just pushed")); - } - } - // GR-D05: targets the Focus holder. - DarvoStage::Reverse => { - if let Some(target) = work.focus.get(&owner).copied() { - // GR-A12: the target's GROUND—ND may reject it. - let rejected = - work.ground_choices.get(&target) == Some(&GroundChoice::RejectReverse); - if !rejected { - events.push(GroundEvent::FocusFlippedToBlame { owner, target }); - work.fold(events.last().expect("just pushed")); - let stress = work.stress_after(target, 1); - events.push(GroundEvent::StressSet { - player: target, - stress, - }); - work.fold(events.last().expect("just pushed")); - events.push(GroundEvent::ProtectionGained { player: owner }); - work.fold(events.last().expect("just pushed")); - } - // U5: rejected or not, the owner still takes −2 - // and the sequence ends. - let stress = work.stress_after(owner, -2); - events.push(GroundEvent::StressSet { - player: owner, - stress, - }); - work.fold(events.last().expect("just pushed")); - } - } - DarvoStage::Off => {} - } - - // GR-A10 + GR-D06: GROUND—GR ends the sequence after the - // current stage resolves. GR-D05: REVERSE ends it anyway. - let ground_gr = work.ground_modes.get(&owner) == Some(&GroundMode::Gr); - if ground_gr || stage == DarvoStage::Reverse { - events.push(GroundEvent::DarvoEnded { player: owner }); - work.fold(events.last().expect("just pushed")); - } else { - // GR-D02: one stage per consecutive round. - let next = match stage { - DarvoStage::Deny => DarvoStage::Attack, - _ => DarvoStage::Reverse, - }; - events.push(GroundEvent::DarvoAdvanced { - player: owner, - stage: next, - }); - work.fold(events.last().expect("just pushed")); - } - } - - // Step 5 — Attack (GR-A06..A09). - for actor in self.seat_order() { - let Some(selection) = self.selections.get(&actor) else { - continue; - }; - if selection.action != Action::Attack { - continue; - } - let Some(target) = selection.target else { - continue; - }; - - work.resolve_attack(actor, target, &ou_cancels, &mut events); - } - - // Step 4 — INVESTIGATE (GR-A01). - for actor in self.seat_order() { - let Some(selection) = self.selections.get(&actor) else { - continue; - }; - if selection.action != Action::Investigate { - continue; - } - // Reveal the chosen Problem if it is still hidden and not - // Denied; otherwise the draw happens on its own. - if let Some(problem) = selection.problem { - let eligible = work - .problems - .get(&problem) - .is_some_and(|p| !p.face_up && !p.denied); - if eligible { - events.push(GroundEvent::ProblemRevealed { problem }); - work.fold(events.last().expect("just pushed")); - } - } - work.draw_solution(actor, &mut events); - } - - // Step 6 — SOLVE (GR-A02). - for actor in self.seat_order() { - let Some(selection) = self.selections.get(&actor) else { - continue; - }; - if selection.action != Action::Solve { - continue; - } - let Some(problem) = selection.problem else { - continue; - }; - let Some(target) = work.problems.get(&problem) else { - continue; - }; - // GR-A02: an earlier resolver this round already claimed it, - // so no Solution is spent and nothing happens. - if target.claimed_by.is_some() || target.denied || !target.face_up { - continue; - } - let required = target.suit; - let Some(card) = work.players[&actor] - .hand - .iter() - .find(|c| c.suit == required) - .copied() - else { - continue; - }; - events.push(GroundEvent::SolutionDiscarded { - player: actor, - card, - }); - work.fold(events.last().expect("just pushed")); - events.push(GroundEvent::ProblemClaimed { problem, by: actor }); - work.fold(events.last().expect("just pushed")); - } - - events.push(GroundEvent::StepAdvanced { - step: RoundStep::Resolve, - }); - events - } - - /// GR-A06..A09: one Attack under the normal relation rules. Shared - /// by the chosen ATTACK Action (step 5) and the DARVO ATTACK stage's - /// extra Attack (GR-D04). - fn resolve_attack( - &mut self, - attacker: PlayerId, - target: PlayerId, - ou_cancels: &std::collections::BTreeSet<(PlayerId, PlayerId)>, - events: &mut Vec, - ) { - // GR-A09 under the U8 default: a GROUND—OU cancellation is - // chosen at step 1 and applies first, so Protection is only - // consumed when it is what actually cancels. - if ou_cancels.contains(&(attacker, target)) { - return; - } - // GR-A09/T01: Protection absorbs the Attack entirely. - if self.players[&target].protection > 0 { - events.push(GroundEvent::AttackCancelled { attacker, target }); - self.fold(events.last().expect("just pushed")); - return; - } - - let pair = Pair::new(attacker, target); - let (delta, after) = match self.relation_between(attacker, target) { - // GR-A07: through a Bond — +2 and the Bond flips. - Some(Relation::Bond) => ( - 2, - Some(GroundEvent::RelationFormed { - pair, - relation: Relation::Rivalry, - }), - ), - // GR-A08: through a Rivalry — +2 and it breaks. - Some(Relation::Rivalry) => (2, Some(GroundEvent::RelationBroken { pair })), - // GR-A06: no relation — +1, and a Rivalry forms without - // consent if both endpoints have a slot (GR-L01/L03). - None => { - let forms = self.has_free_slot(attacker) && self.has_free_slot(target); - ( - 1, - forms.then_some(GroundEvent::RelationFormed { - pair, - relation: Relation::Rivalry, - }), - ) - } - }; - - let stress = self.stress_after(target, delta); - events.push(GroundEvent::StressSet { - player: target, - stress, - }); - self.fold(events.last().expect("just pushed")); - if let Some(event) = after { - events.push(event); - self.fold(events.last().expect("just pushed")); - } - } - - /// GR-A01: draw one Solution, reshuffling the discard first if the - /// deck is empty (the U4 default). The reshuffled order travels in - /// the event so replay never re-derives it. - fn draw_solution(&mut self, player: PlayerId, events: &mut Vec) { - if self.solution_deck.is_empty() { - if self.solution_discard.is_empty() { - return; - } - let mut order = self.solution_discard.clone(); - // Derived from the game seed and round, so the reshuffle is - // a pure function of state (GameKernel K5). - let mut rng = ChaChaRng::from_seed(Seed(self.seed ^ u64::from(self.round))); - rng.shuffle(&mut order); - events.push(GroundEvent::DeckReshuffled { order }); - self.fold(events.last().expect("just pushed")); - } - if let Some(card) = self.solution_deck.last().copied() { - events.push(GroundEvent::SolutionDrawn { player, card }); - self.fold(events.last().expect("just pushed")); - } - } - - /// GR-R08: Stress is already clamped on every application (U2), so - /// End only triggers DARVO, rotates the Lead, and advances the Round. - fn end_round_events(&self) -> Vec { - let mut events = Vec::new(); - - // GR-D01: Stress 5 with the marker OFF starts a sequence. In - // Lead order, so two simultaneous triggers are ordered (U9). - for seat in self.seat_order() { - let player = &self.players[&seat]; - if player.stress == 5 && player.darvo == DarvoStage::Off { - events.push(GroundEvent::DarvoTriggered { player: seat }); - } - } - - let seats: Vec = self.players.keys().copied().collect(); - let next_lead = seats - .iter() - .position(|s| *s == self.lead) - .map_or(self.lead, |i| seats[(i + 1) % seats.len()]); - - // GR-R09: after Round 5's End the game ends and scoring applies. - if self.round >= 5 { - events.push(GroundEvent::GameEnded { - outcome: self.score(), - }); - return events; - } - - events.push(GroundEvent::RoundEnded { - round: self.round + 1, - next_lead, - }); - events.push(GroundEvent::StepAdvanced { - step: RoundStep::Select, - }); - events - } - - /// GR-E01: the Scenario threshold by player count (dataset 0.1). - fn threshold(&self) -> u32 { - match self.players.len() { - 0..=2 => 5, - 3..=4 => 7, - _ => 9, - } - } - - /// GR-E01..E04: final scoring for the configured mode. - fn score(&self) -> Outcome { - // GR-E01/P03: a claimed Problem counts its printed value. - let total: u32 = self - .problems - .values() - .filter(|p| p.claimed_by.is_some()) - .map(|p| u32::from(p.value)) - .sum(); - let threshold = self.threshold(); - let group_success = total >= threshold; - - // GR-E03/T02: claimed value −1 per Blame token held. - let personal: BTreeMap = self - .players - .keys() - .map(|seat| { - let claimed: i32 = self - .problems - .values() - .filter(|p| p.claimed_by == Some(*seat)) - .map(|p| i32::from(p.value)) - .sum(); - let blame = self.players[seat].blame_from.len() as i32; - (*seat, claimed - blame) - }) - .collect(); - - let coalitions = self.coalitions(&personal); - - let (mastery, winners) = match self.mode { - // GR-E02: one shared score; no individual winner. - ScoringMode::SharedGround => { - let blame: i32 = self - .players - .values() - .map(|p| p.blame_from.len() as i32) - .sum(); - let denied = self.problems.values().filter(|p| p.denied).count() as i32; - let claimed = self - .problems - .values() - .filter(|p| p.claimed_by.is_some()) - .count() as i32; - let mastery = claimed - blame - denied; - let winners = if group_success { - self.players.keys().copied().collect() - } else { - Vec::new() - }; - (Some(mastery), winners) - } - // GR-E03: highest personal score, once the group qualifies. - // Tiebreak: lower Stress, then more Bonds, then shared. - ScoringMode::CommonProblem => { - let winners = if group_success { - self.best(personal.keys().copied().collect(), |seat| { - ( - personal[&seat], - -i32::from(self.players[&seat].stress), - self.bond_count(seat), - ) - }) - } else { - Vec::new() - }; - (None, winners) - } - // GR-E04: highest coalition. Tiebreak: lower combined - // Stress, then fewer Blame tokens, then shared. - ScoringMode::BondedCoalitions => { - let winners = if group_success { - let best = self.best((0..coalitions.len()).collect(), |i| { - let c = &coalitions[i]; - let stress: i32 = c - .members - .iter() - .map(|m| i32::from(self.players[m].stress)) - .sum(); - let blame: i32 = c - .members - .iter() - .map(|m| self.players[m].blame_from.len() as i32) - .sum(); - (c.score, -stress, -blame) - }); - let mut winners: Vec = best - .into_iter() - .flat_map(|i| coalitions[i].members.clone()) - .collect(); - winners.sort(); - winners - } else { - Vec::new() - }; - (None, winners) - } - }; - - Outcome { - total, - threshold, - group_success, - personal, - coalitions, - mastery, - winners, - } - } - - /// Every candidate tied on the ranking key, so a tie is a shared - /// result rather than an arbitrary pick (GR-E03/E04). - fn best(&self, candidates: Vec, key: impl Fn(T) -> K) -> Vec { - let Some(top) = candidates.iter().map(|c| key(*c)).max() else { - return Vec::new(); - }; - candidates.into_iter().filter(|c| key(*c) == top).collect() - } - - fn bond_count(&self, seat: PlayerId) -> i32 { - self.relations - .iter() - .filter(|(pair, rel)| **rel == Relation::Bond && pair.contains(seat)) - .count() as i32 - } - - /// GR-E04: connected components over Bonds only; Rivalries do not - /// connect, and an unbonded player is a coalition of one. - fn coalitions(&self, personal: &BTreeMap) -> Vec { - let mut remaining: Vec = self.players.keys().copied().collect(); - let mut out = Vec::new(); - while let Some(seed) = remaining.first().copied() { - let mut members = vec![seed]; - let mut frontier = vec![seed]; - remaining.retain(|p| *p != seed); - while let Some(current) = frontier.pop() { - let neighbours: Vec = self - .relations - .iter() - .filter(|(_, rel)| **rel == Relation::Bond) - .filter_map(|(pair, _)| { - if pair.0 == current { - Some(pair.1) - } else if pair.1 == current { - Some(pair.0) - } else { - None - } - }) - .filter(|n| remaining.contains(n)) - .collect(); - for n in neighbours { - remaining.retain(|p| *p != n); - members.push(n); - frontier.push(n); - } - } - members.sort(); - let score = members.iter().map(|m| personal[m]).sum(); - out.push(Coalition { members, score }); - } - out - } - - /// GR-A11/A12: the sub-choice must name something that exists and - /// is in a state the choice can act on. - fn check_ground_choice( - &self, - actor: PlayerId, - choice: Option, - ) -> Result<(), Rejection> { - let bad = |detail: String| Rejection::Game { - code: "bad-choice".into(), - detail, - }; - let problem = |id: u32| { - self.problems - .get(&id) - .ok_or_else(|| bad(format!("no Problem {id}"))) - }; - match choice { - None => Ok(()), - // GR-A11: only a Denied Problem can be restored. - Some(GroundChoice::RestoreProblem { problem: id }) => { - if !problem(id)?.denied { - return Err(bad(format!("GR-A11: Problem {id} is not Denied"))); - } - Ok(()) - } - // GR-A11: only a face-up Problem can be protected. - Some(GroundChoice::ProtectProblem { problem: id }) => { - if !problem(id)?.face_up || problem(id)?.denied { - return Err(bad(format!("GR-A11: Problem {id} is not face up"))); - } - Ok(()) - } - // GR-A11: the cancelled Attack must actually target us. - Some(GroundChoice::CancelAttack { attacker }) => { - let aimed_here = self - .selections - .get(&attacker) - .is_some_and(|s| s.action == Action::Attack && s.target == Some(actor)); - if !aimed_here { - return Err(bad(format!( - "GR-A11: {attacker} is not attacking this player" - ))); - } - Ok(()) - } - // GR-A12/T02: the Blame token must be in front of us. - Some(GroundChoice::RemoveBlame { owner }) => { - let held = self - .players - .get(&actor) - .is_some_and(|p| p.blame_from.contains(&owner)); - if !held { - return Err(bad(format!("GR-T02: no Blame token from {owner} here"))); - } - Ok(()) - } - // GR-A12: the relation must exist and involve us. - Some(GroundChoice::BreakRelation { with }) => { - if self.relation_between(actor, with).is_none() { - return Err(bad(format!("GR-A12: no relation with {with}"))); - } - Ok(()) - } - Some(GroundChoice::RejectReverse) => Ok(()), - } - } - - /// GR-A13 targeting legality, shared by every Action. - fn check_targeting( - &self, - actor: PlayerId, - action: Action, - target: Option, - problem: Option, - ) -> Result<(), Rejection> { - let bad = |detail: String| Rejection::Game { - code: "bad-target".into(), - detail, - }; - - if action.requires_player_target() { - let target = target.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a target")))?; - if target == actor { - return Err(bad( - "GR-A13: SUPPORT and ATTACK target another player".into() - )); - } - if !self.players.contains_key(&target) { - return Err(bad(format!("GR-A13: player {target} is not in this game"))); - } - } else if target.is_some() { - return Err(bad(format!("GR-A13: {action:?} takes no player target"))); - } - - if action.requires_problem_target() { - let problem = - problem.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a Problem")))?; - let target = self - .problems - .get(&problem) - .ok_or_else(|| bad(format!("GR-A13: no Problem {problem}")))?; - match action { - // GR-A13: INVESTIGATE targets a hidden Problem. - Action::Investigate if target.face_up => { - return Err(bad(format!("GR-A13: Problem {problem} is already face up"))); - } - // GR-A13: SOLVE targets a face-up, non-Denied Problem. - Action::Solve if !target.face_up || target.denied => { - return Err(bad(format!( - "GR-A13: Problem {problem} is not a face-up, non-Denied Problem" - ))); - } - _ => {} - } - } else if problem.is_some() { - return Err(bad(format!("GR-A13: {action:?} takes no Problem target"))); - } - - Ok(()) - } -} - -/// GR-S01: hidden-Problem priorities admitted per player count. -fn problem_priorities(players: u8) -> Result { - match players { - 2 => Ok(2), - 3..=4 => Ok(3), - 5..=6 => Ok(4), - other => Err(format!("GR-S01: unsupported player count {other}")), - } -} - -/// GR-S04: the 24 core Solution cards, 6 per suit, in canonical order -/// before the seeded shuffle. -fn core_solution_deck() -> Vec { - [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change] - .into_iter() - .flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6)) - .collect() -} - -impl ScenarioGame for GroundState { - /// GR-S01..S04. The `standard-Np` presets differ only in seat count; - /// Problem content is scenario data, so the preset uses the canonical - /// fixture below (suit cycling by priority) until scenario decks are - /// modelled. - fn setup(setup: &Setup, seed: u64) -> Result { - let seats = setup.players; - let expected = format!("standard-{seats}p"); - if setup.preset != expected { - return Err(format!( - "preset {:?} does not match {seats} players (expected {expected:?})", - setup.preset - )); - } - let priorities = problem_priorities(seats)?; - let mut rng = ChaChaRng::from_seed(Seed(seed)); - - // GR-S04: shuffle first, then deal, so the deal is seed-derived. - let mut deck = core_solution_deck(); - rng.shuffle(&mut deck); - - // GR-S02: Stress 2, Freedom READY, DARVO OFF, two Solution cards. - let mut players = BTreeMap::new(); - for seat in 0..seats { - let hand = deck.split_off(deck.len() - 2); - players.insert( - PlayerId(seat), - PlayerState { - stress: 2, - freedom_ready: true, - freedom_gate_lifted: false, - darvo: DarvoStage::Off, - hand, - protection: 0, - blame_from: vec![], - }, - ); - } - - // GR-S01: priority 1 is the Surface Problem, face up; the rest - // start face down. - let suits = [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]; - let problems = (1..=u32::from(priorities)) - .map(|priority| { - ( - priority, - ProblemState { - suit: suits[(priority as usize - 1) % suits.len()], - value: priority as u8, - face_up: priority == 1, - denied: false, - claimed_by: None, - protected_this_round: false, - }, - ) - }) - .collect(); - - // GR-S03: seeded-random Lead, Round 1. - let lead = PlayerId(rng.draw(u32::from(seats)) as u8); - - Ok(GroundState { - round: 1, - lead, - players, - relations: BTreeMap::new(), - problems, - solution_deck: deck, - solution_discard: vec![], - focus: BTreeMap::new(), - step: RoundStep::Select, - selections: BTreeMap::new(), - ground_modes: BTreeMap::new(), - ground_choices: BTreeMap::new(), - support_responses: BTreeMap::new(), - darvo_targets: BTreeMap::new(), - mode: ScoringMode::SharedGround, - outcome: None, - seed, - }) - } - - fn parse_command(step: &CommandStep) -> Result<(Actor, Self::Command), String> { - let actor = parse_actor(&step.actor)?; - let arg_str = |key: &str| -> Result { - step.args - .get(key) - .and_then(|v| v.as_str().map(str::to_string)) - .ok_or_else(|| format!("{}: missing string arg {key:?}", step.cmd)) - }; - let arg_u64 = |key: &str| -> Option { step.args.get(key).and_then(|v| v.as_u64()) }; - - let command = match step.cmd.as_str() { - "select_action" => { - let action = Action::parse(&arg_str("action")?)?; - let target = match step.args.get("target") { - Some(_) => match parse_actor(&arg_str("target")?)? { - Actor::Player(id) => Some(id), - Actor::System => return Err("target may not be SYSTEM".into()), - }, - None => None, - }; - GroundCommand::SelectAction { - action, - target, - problem: arg_u64("problem").map(|p| p as u32), - } - } - "spend_freedom" => GroundCommand::SpendFreedom, - "choose_ground_mode" => GroundCommand::ChooseGroundMode { - mode: GroundMode::parse(&arg_str("mode")?)?, - choice: match step.args.get("choice") { - Some(_) => Some(GroundChoice::parse( - &arg_str("choice")?, - arg_u64("problem").or_else(|| arg_u64("seat")), - )?), - None => None, - }, - }, - "choose_darvo_target" => GroundCommand::ChooseDarvoTarget { - target: DarvoTarget { - problem: arg_u64("problem").map(|p| p as u32), - player: match step.args.get("target") { - Some(_) => match parse_actor(&arg_str("target")?)? { - Actor::Player(seat) => Some(seat), - Actor::System => return Err("target may not be SYSTEM".into()), - }, - None => None, - }, - }, - }, - "respond_to_support" => GroundCommand::RespondToSupport { - response: SupportResponse::parse(&arg_str("response")?)?, - }, - "reveal" => GroundCommand::Reveal, - "resolve" => GroundCommand::Resolve, - "end_round" => GroundCommand::EndRound, - other => return Err(format!("unknown command {other:?}")), - }; - Ok((actor, command)) - } - - fn round(&self) -> u8 { - self.round - } } #[cfg(test)] @@ -1920,7 +91,6 @@ mod tests { PlayerState { stress: 2, freedom_ready: true, - freedom_gate_lifted: false, darvo: DarvoStage::Off, hand: vec![SolutionCard { suit: Suit::Repair }], protection: 0, @@ -1932,149 +102,9 @@ mod tests { solution_deck: vec![], solution_discard: vec![], focus: BTreeMap::new(), - step: RoundStep::Select, - selections: BTreeMap::new(), - ground_modes: BTreeMap::new(), - ground_choices: BTreeMap::new(), - support_responses: BTreeMap::new(), - darvo_targets: BTreeMap::new(), - mode: ScoringMode::SharedGround, - outcome: None, - seed: 0, } } - /// Regression: relation keys must serialize as JSON object keys. - /// With a tuple key, `state_hash` panicked on any state holding a - /// relation — that is, on almost every real game state. - #[test] - fn state_with_relations_hashes() { - let mut state = tiny_state(); - state - .relations - .insert(Pair::new(PlayerId(1), PlayerId(0)), Relation::Bond); - let hash = state_hash_hex(&state); - assert_eq!(hash.len(), 64); - // GR-O05: the key is canonically ordered, so either construction - // order yields the same state and the same hash. - let mut mirrored = tiny_state(); - mirrored - .relations - .insert(Pair::new(PlayerId(0), PlayerId(1)), Relation::Bond); - assert_eq!(hash, state_hash_hex(&mirrored)); - } - - fn setup_3p(seed: u64) -> GroundState { - GroundState::setup( - &Setup { - players: 3, - preset: "standard-3p".into(), - patch: BTreeMap::new(), - }, - seed, - ) - .unwrap() - } - - /// GR-S02/S04: every seat starts at Stress 2 with two dealt cards, - /// and the deck loses exactly what was dealt. - #[test] - fn setup_deals_per_gr_s02_and_s04() { - let state = setup_3p(42); - assert_eq!(state.players.len(), 3); - assert_eq!(state.round, 1); - for player in state.players.values() { - assert_eq!(player.stress, 2); - assert!(player.freedom_ready); - assert_eq!(player.darvo, DarvoStage::Off); - assert_eq!(player.hand.len(), 2); - } - assert_eq!(state.solution_deck.len(), 24 - 6); - // GR-S01: 3 players → priorities 1–3, priority 1 face up. - assert_eq!(state.problems.len(), 3); - assert!(state.problems[&1].face_up); - assert!(!state.problems[&2].face_up); - } - - /// GR-S03/S04: the same seed reproduces setup exactly; a different - /// seed does not. - #[test] - fn setup_is_seed_deterministic() { - assert_eq!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(42))); - assert_ne!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(7))); - } - - /// GR-R02: one selection per player per round. - #[test] - fn second_selection_is_a_duplicate() { - let mut state = setup_3p(42); - let cmd = GroundCommand::SelectAction { - action: Action::Attack, - target: Some(PlayerId(1)), - problem: None, - }; - let events = state.validate(Actor::Player(PlayerId(0)), &cmd).unwrap(); - for event in &events { - state.fold(event); - } - assert_eq!( - state.validate(Actor::Player(PlayerId(0)), &cmd), - Err(Rejection::DuplicateCommand) - ); - } - - /// GR-R03: the stress gate blocks SUPPORT at Stress 4, and spending - /// Freedom lifts it. - #[test] - fn stress_gate_blocks_until_freedom_is_spent() { - let mut state = setup_3p(42); - state.players.get_mut(&PlayerId(0)).unwrap().stress = 4; - let support = GroundCommand::SelectAction { - action: Action::Support, - target: Some(PlayerId(1)), - problem: None, - }; - let attack = GroundCommand::SelectAction { - action: Action::Attack, - target: Some(PlayerId(1)), - problem: None, - }; - - assert!(matches!( - state.validate(Actor::Player(PlayerId(0)), &support), - Err(Rejection::Game { ref code, .. }) if code == "stress-gate" - )); - // ATTACK is always admitted by the gate. - assert!(state.validate(Actor::Player(PlayerId(0)), &attack).is_ok()); - - let spent = state - .validate(Actor::Player(PlayerId(0)), &GroundCommand::SpendFreedom) - .unwrap(); - for event in &spent { - state.fold(event); - } - assert!(!state.players[&PlayerId(0)].freedom_ready); - assert!(state.validate(Actor::Player(PlayerId(0)), &support).is_ok()); - } - - /// GR-A13: SUPPORT and ATTACK may not target their own player. - #[test] - fn self_targeting_is_rejected() { - let state = setup_3p(42); - let result = state.validate( - Actor::Player(PlayerId(0)), - &GroundCommand::SelectAction { - action: Action::Attack, - target: Some(PlayerId(0)), - problem: None, - }, - ); - assert!(matches!( - result, - Err(Rejection::Game { ref code, .. }) if code == "bad-target" - )); - } - /// K7 on the real aggregate: hash stable across clones, sensitive to /// semantic change. #[test] @@ -2087,164 +117,3 @@ mod tests { assert_ne!(state_hash_hex(&a), state_hash_hex(&c)); } } - -#[cfg(test)] -mod bench_shape { - use super::*; - use cb_game_runtime::{ScenarioGame, Setup}; - - /// AM-6 reports events/second; the evidence file converts that to - /// rounds and commands per second. Both divisors are pinned here so - /// a change to the workload cannot silently rescale the metric. - #[test] - fn synthetic_round_shape_is_pinned() { - let mut state = GroundState::setup( - &Setup { - players: 3, - preset: "standard-3p".into(), - patch: BTreeMap::new(), - }, - 1, - ) - .unwrap(); - - let mut events = 0; - let mut commands = 0; - let mut run = |state: &mut GroundState, actor: Actor, cmd: &GroundCommand| { - commands += 1; - if let Ok(produced) = state.validate(actor, cmd) { - for e in &produced { - state.fold(e); - } - events += produced.len(); - } - }; - - for (seat, action, target) in [ - (0u8, Action::Attack, Some(PlayerId(1))), - (2, Action::Support, Some(PlayerId(1))), - (1, Action::Ground, None), - ] { - run( - &mut state, - Actor::Player(PlayerId(seat)), - &GroundCommand::SelectAction { - action, - target, - problem: None, - }, - ); - } - run(&mut state, Actor::System, &GroundCommand::Reveal); - run( - &mut state, - Actor::Player(PlayerId(1)), - &GroundCommand::ChooseGroundMode { - mode: GroundMode::Gr, - choice: None, - }, - ); - run(&mut state, Actor::System, &GroundCommand::Resolve); - run(&mut state, Actor::System, &GroundCommand::EndRound); - - assert_eq!(commands, 7, "commands per synthetic round"); - assert_eq!(events, 13, "events per synthetic round"); - } -} - -#[cfg(test)] -mod replay_probe { - use super::*; - use cb_events::state_hash_hex; - use cb_game_runtime::{ScenarioGame, Setup}; - use std::time::Instant; - - fn fresh(seed: u64) -> GroundState { - GroundState::setup( - &Setup { - players: 3, - preset: "standard-3p".into(), - patch: BTreeMap::new(), - }, - seed, - ) - .unwrap() - } - - fn record_round(state: &mut GroundState, log: &mut Vec) -> usize { - let mut n = 0; - let mut run = |state: &mut GroundState, actor: Actor, cmd: &GroundCommand| { - if let Ok(produced) = state.validate(actor, cmd) { - for e in &produced { - state.fold(e); - log.push(e.clone()); - n += 1; - } - } - }; - for (seat, action, target) in [ - (0u8, Action::Attack, Some(PlayerId(1))), - (2, Action::Support, Some(PlayerId(1))), - (1, Action::Ground, None), - ] { - run( - state, - Actor::Player(PlayerId(seat)), - &GroundCommand::SelectAction { - action, - target, - problem: None, - }, - ); - } - run(state, Actor::System, &GroundCommand::Reveal); - run( - state, - Actor::Player(PlayerId(1)), - &GroundCommand::ChooseGroundMode { - mode: GroundMode::Gr, - choice: None, - }, - ); - run(state, Actor::System, &GroundCommand::Resolve); - run(state, Actor::System, &GroundCommand::EndRound); - n - } - - /// AM-7: folding a 100k-event log back into state must stay well - /// under the 5s budget, and must be linear in log length. - #[test] - fn replay_100k_events_is_linear_and_fast() { - for target in [10_000usize, 100_000] { - let mut log = Vec::with_capacity(target); - let mut source = fresh(42); - let mut games = 0u64; - let mut stalls = 0; - while log.len() < target { - if source.outcome.is_some() { - games += 1; - source = fresh(42 + games); - } - if record_round(&mut source, &mut log) == 0 { - stalls += 1; - assert!(stalls < 10, "round produced no events; builder stalled"); - } - } - let start = Instant::now(); - let mut state = fresh(42); - for event in &log { - state.fold(event); - } - let hash = state_hash_hex(&state); - let elapsed = start.elapsed(); - println!( - "replay {} events in {:?} ({:.0} events/s), hash {}", - log.len(), - elapsed, - log.len() as f64 / elapsed.as_secs_f64(), - &hash[..8] - ); - assert!(elapsed.as_secs_f64() < 5.0, "AM-7: 100k replay under 5s"); - } - } -} diff --git a/history/260731-inner-loop-retrospective.md b/history/260731-inner-loop-retrospective.md deleted file mode 100644 index c6c6f8a..0000000 --- a/history/260731-inner-loop-retrospective.md +++ /dev/null @@ -1,116 +0,0 @@ -# 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. diff --git a/scenarios/ground/gr-a01-investigate.yaml b/scenarios/ground/gr-a01-investigate.yaml deleted file mode 100644 index e5de082..0000000 --- a/scenarios/ground/gr-a01-investigate.yaml +++ /dev/null @@ -1,43 +0,0 @@ -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] diff --git a/scenarios/ground/gr-a02-solve.yaml b/scenarios/ground/gr-a02-solve.yaml deleted file mode 100644 index 86874f3..0000000 --- a/scenarios/ground/gr-a02-solve.yaml +++ /dev/null @@ -1,43 +0,0 @@ -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: [] diff --git a/scenarios/ground/gr-a04-bond-support.yaml b/scenarios/ground/gr-a04-bond-support.yaml deleted file mode 100644 index d70d957..0000000 --- a/scenarios/ground/gr-a04-bond-support.yaml +++ /dev/null @@ -1,48 +0,0 @@ -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: [] diff --git a/scenarios/ground/gr-a05-support-response.yaml b/scenarios/ground/gr-a05-support-response.yaml deleted file mode 100644 index 1208435..0000000 --- a/scenarios/ground/gr-a05-support-response.yaml +++ /dev/null @@ -1,57 +0,0 @@ -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] diff --git a/scenarios/ground/gr-a07-bond-flip.yaml b/scenarios/ground/gr-a07-bond-flip.yaml deleted file mode 100644 index b90030b..0000000 --- a/scenarios/ground/gr-a07-bond-flip.yaml +++ /dev/null @@ -1,38 +0,0 @@ -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: [] diff --git a/scenarios/ground/gr-a08-rivalry-break.yaml b/scenarios/ground/gr-a08-rivalry-break.yaml deleted file mode 100644 index 2ea741f..0000000 --- a/scenarios/ground/gr-a08-rivalry-break.yaml +++ /dev/null @@ -1,42 +0,0 @@ -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: [] diff --git a/scenarios/ground/gr-a10-ground-gr.yaml b/scenarios/ground/gr-a10-ground-gr.yaml deleted file mode 100644 index bb93c1a..0000000 --- a/scenarios/ground/gr-a10-ground-gr.yaml +++ /dev/null @@ -1,55 +0,0 @@ -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] diff --git a/scenarios/ground/gr-a11-ground-ou.yaml b/scenarios/ground/gr-a11-ground-ou.yaml deleted file mode 100644 index 97c2167..0000000 --- a/scenarios/ground/gr-a11-ground-ou.yaml +++ /dev/null @@ -1,43 +0,0 @@ -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] diff --git a/scenarios/ground/gr-a12-ground-nd.yaml b/scenarios/ground/gr-a12-ground-nd.yaml deleted file mode 100644 index e363f01..0000000 --- a/scenarios/ground/gr-a12-ground-nd.yaml +++ /dev/null @@ -1,47 +0,0 @@ -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] diff --git a/scenarios/ground/gr-d01-darvo-trigger.yaml b/scenarios/ground/gr-d01-darvo-trigger.yaml deleted file mode 100644 index 19ed4ab..0000000 --- a/scenarios/ground/gr-d01-darvo-trigger.yaml +++ /dev/null @@ -1,42 +0,0 @@ -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: [] diff --git a/scenarios/ground/gr-d03-darvo-deny.yaml b/scenarios/ground/gr-d03-darvo-deny.yaml deleted file mode 100644 index 2ca341a..0000000 --- a/scenarios/ground/gr-d03-darvo-deny.yaml +++ /dev/null @@ -1,48 +0,0 @@ -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] diff --git a/scenarios/ground/gr-d04-darvo-attack.yaml b/scenarios/ground/gr-d04-darvo-attack.yaml deleted file mode 100644 index d6e12f8..0000000 --- a/scenarios/ground/gr-d04-darvo-attack.yaml +++ /dev/null @@ -1,53 +0,0 @@ -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] diff --git a/scenarios/ground/gr-d05-darvo-reverse.yaml b/scenarios/ground/gr-d05-darvo-reverse.yaml deleted file mode 100644 index 77efb18..0000000 --- a/scenarios/ground/gr-d05-darvo-reverse.yaml +++ /dev/null @@ -1,54 +0,0 @@ -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: [] diff --git a/scenarios/ground/gr-d06-darvo-early-end.yaml b/scenarios/ground/gr-d06-darvo-early-end.yaml deleted file mode 100644 index a5dd95a..0000000 --- a/scenarios/ground/gr-d06-darvo-early-end.yaml +++ /dev/null @@ -1,43 +0,0 @@ -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: [] diff --git a/scenarios/ground/gr-e02-shared-ground.yaml b/scenarios/ground/gr-e02-shared-ground.yaml deleted file mode 100644 index 8182ef2..0000000 --- a/scenarios/ground/gr-e02-shared-ground.yaml +++ /dev/null @@ -1,50 +0,0 @@ -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: [] diff --git a/scenarios/ground/gr-e04-coalitions.yaml b/scenarios/ground/gr-e04-coalitions.yaml deleted file mode 100644 index d9771ec..0000000 --- a/scenarios/ground/gr-e04-coalitions.yaml +++ /dev/null @@ -1,54 +0,0 @@ -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: [] diff --git a/scenarios/ground/gr-f02-no-gate.yaml b/scenarios/ground/gr-f02-no-gate.yaml deleted file mode 100644 index 105a368..0000000 --- a/scenarios/ground/gr-f02-no-gate.yaml +++ /dev/null @@ -1,29 +0,0 @@ -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] diff --git a/scenarios/ground/gr-r02-select.yaml b/scenarios/ground/gr-r02-select.yaml deleted file mode 100644 index 117d9c7..0000000 --- a/scenarios/ground/gr-r02-select.yaml +++ /dev/null @@ -1,35 +0,0 @@ -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] diff --git a/scenarios/ground/gr-r03-stress-gate.yaml b/scenarios/ground/gr-r03-stress-gate.yaml deleted file mode 100644 index 7562ff4..0000000 --- a/scenarios/ground/gr-r03-stress-gate.yaml +++ /dev/null @@ -1,34 +0,0 @@ -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] diff --git a/scenarios/ground/gr-r06-round-resolve.yaml b/scenarios/ground/gr-r06-round-resolve.yaml deleted file mode 100644 index 679a40a..0000000 --- a/scenarios/ground/gr-r06-round-resolve.yaml +++ /dev/null @@ -1,50 +0,0 @@ -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: [] diff --git a/scenarios/ground/smoke-setup.yaml b/scenarios/ground/smoke-setup.yaml index a5304fc..c665ac3 100644 --- a/scenarios/ground/smoke-setup.yaml +++ b/scenarios/ground/smoke-setup.yaml @@ -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-S04, GR-O01, GR-O02] +covers: [GR-S02, GR-S03, GR-O01] provisional: false seed: 42 setup: @@ -14,10 +14,4 @@ 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: [] diff --git a/specs/InnerLoop.md b/specs/InnerLoop.md index e5c35a2..84b4591 100644 --- a/specs/InnerLoop.md +++ b/specs/InnerLoop.md @@ -1,11 +1,9 @@ # The Inner Loop — Assimilate and Surpass -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`. +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). Normative process for building every Clay-Borg capability. Referenced by all workplans. The loop's own optimization target is **agentic efficiency**: @@ -44,10 +42,7 @@ 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)`). **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 +(`tier: M (structural L, chaos 10→M)`). 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 @@ -120,18 +115,6 @@ 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: @@ -146,40 +129,6 @@ comparison numbers are committed as an evidence file (`evidence/CB-EV-NNNN-.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 @@ -222,33 +171,6 @@ 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: @@ -280,12 +202,6 @@ A capability has completed the loop when all of the following are committed: - [ ] specs/.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 ``` diff --git a/tools/cb-sim/src/main.rs b/tools/cb-sim/src/main.rs index 633d1aa..d881eca 100644 --- a/tools/cb-sim/src/main.rs +++ b/tools/cb-sim/src/main.rs @@ -1,10 +1,9 @@ //! cb-sim — scenario runner binary (GameKernel K17; precursor of `cb sim`). -//! Parses scenario files, dispatches each to its game by the `/` -//! prefix of its `scenario` field, and executes it. Exit codes: 0 all -//! passed, 1 failures, 2 unknown game, 64 usage error. +//! 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. use cb_game_runtime::{scenario, RunOutcome, ScenarioFile}; -use games_ground::GroundState; fn main() { let args: Vec = std::env::args().skip(1).collect(); @@ -13,10 +12,8 @@ fn main() { std::process::exit(64); } - let mut unknown_game = false; + let mut unimplemented = false; let mut failed = false; - let mut passed = 0usize; - let mut covered: Vec = Vec::new(); for path in &args { let text = match std::fs::read_to_string(path) { @@ -27,50 +24,36 @@ fn main() { continue; } }; - let sc = match ScenarioFile::from_yaml(&text) { + 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::(&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 unknown_game { + if unimplemented { std::process::exit(2); } } diff --git a/tools/rule-coverage.py b/tools/rule-coverage.py deleted file mode 100755 index 306fa99..0000000 --- a/tools/rule-coverage.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/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) diff --git a/workplans/CB-WP-0001-inner-loop.md b/workplans/CB-WP-0001-inner-loop.md index 009b2a1..8baed9c 100644 --- a/workplans/CB-WP-0001-inner-loop.md +++ b/workplans/CB-WP-0001-inner-loop.md @@ -1,7 +1,7 @@ --- id: CB-WP-0001 title: "Establish the assimilate-and-surpass inner loop via the GROUND game kernel" -status: done +status: active 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: done +status: todo priority: medium state_hub_task_id: "3a42ff70-f3c6-4e3e-b022-f701729e71ff" ``` @@ -172,18 +172,11 @@ 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: done +status: todo priority: low state_hub_task_id: "e99b107e-087e-49e1-96b5-67805deb242f" ``` @@ -193,11 +186,3 @@ 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.