2026-07-31 01:57:13 +02:00
|
|
|
|
//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
//! GameKernel K15–K16). Every rule realized here names its GR-id in a doc
|
|
|
|
|
|
//! comment, giving a greppable rule→code→scenario chain.
|
2026-07-31 01:57:13 +02:00
|
|
|
|
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
A Policy trait, a seeded random policy and a greedy one with a stated
heuristic, a legal-command generator that filters candidates through
validate, and a driver that runs a 3-player all-bot game to GameEnded.
Same-seed runs are hash-identical (K8), and a different policy seed
produces a different game — without that second assertion the first is
satisfied by a bot that ignores its RNG.
Every failure is loud, because the one a bot driver must not have is the
silent one: no legal move, passing where an action is required, an
out-of-range index (not clamped), a rejected command, and a stall guard.
What the second consumer found, none of it fixed here:
- GR-A13 admits SOLVE against an already-claimed Problem and resolution
then does nothing — the action is silently wasted. The policy avoids
it; the rule is left for a ruling.
- The 3-player standard fixture cannot reach GR-E01's threshold of 7:
three Problems valued 1,2,3 cap the total at 6. No scenario noticed
because none plays to scoring.
- K13's Project trait still has zero implementors. T02 is its first.
Mutation-checked by hand. The first mutation was a no-op and survived;
removing the Resolve call outright turned three tests red for the stated
reason. Third instance of the weak-mutation class.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
/// Bots (CB-WP-0008 T01) — the kernel's first non-scenario consumer.
|
|
|
|
|
|
/// Deliberately **not** gated on `scenarios`: a bot needs only the
|
|
|
|
|
|
/// aggregate. That its tests do need `scenarios` is a real seam — setup
|
|
|
|
|
|
/// presets currently live behind that feature (see `bot.rs`).
|
|
|
|
|
|
pub mod bot;
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
/// The vendored edition data (ADR-0011, ADR-0015).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **Not behind `scenarios`.** It was, because its only consumer —
|
|
|
|
|
|
/// `setup` — is; but the edition is the *game's own data*, and since
|
|
|
|
|
|
/// CB-WP-0028 the shipped runtime reads it too, to show a player what a
|
|
|
|
|
|
/// card says. Test machinery and game content are different things and
|
|
|
|
|
|
/// only one of them is optional.
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
pub mod edition;
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
A Policy trait, a seeded random policy and a greedy one with a stated
heuristic, a legal-command generator that filters candidates through
validate, and a driver that runs a 3-player all-bot game to GameEnded.
Same-seed runs are hash-identical (K8), and a different policy seed
produces a different game — without that second assertion the first is
satisfied by a bot that ignores its RNG.
Every failure is loud, because the one a bot driver must not have is the
silent one: no legal move, passing where an action is required, an
out-of-range index (not clamped), a rejected command, and a stall guard.
What the second consumer found, none of it fixed here:
- GR-A13 admits SOLVE against an already-claimed Problem and resolution
then does nothing — the action is silently wasted. The policy avoids
it; the rule is left for a ruling.
- The 3-player standard fixture cannot reach GR-E01's threshold of 7:
three Problems valued 1,2,3 cap the total at 6. No scenario noticed
because none plays to scoring.
- K13's Project trait still has zero implementors. T02 is its first.
Mutation-checked by hand. The first mutation was a no-op and survived;
removing the Resolve call outright turned three tests red for the stated
reason. Third instance of the weak-mutation class.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:29:45 +02:00
|
|
|
|
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
/// K13's per-player projection (CB-WP-0008 T02) — the trait's first
|
|
|
|
|
|
/// implementor. Needs the runtime's `Project`, which the game already
|
|
|
|
|
|
/// depends on, so it is not feature-gated either.
|
|
|
|
|
|
pub mod view;
|
|
|
|
|
|
|
|
|
|
|
|
/// The inverse of `parse_command` (CB-WP-0008 T02): a played game becomes
|
|
|
|
|
|
/// a scenario. Needs the scenario vocabulary, so it is gated with it.
|
|
|
|
|
|
#[cfg(feature = "scenarios")]
|
|
|
|
|
|
pub mod record;
|
|
|
|
|
|
|
CB-WP-0025 T05: the search works, and it falsified this pass's own
affordability projection
games/ground/src/search.rs, five tests. It finds real winning lines and
replays them through validate/fold to group_success.
Two bugs in my own work, found and fixed here.
THE TRAVERSAL WAS WRONG. It branched on "the first seat with any legal
command" and stopped there, so a later seat never acted if an earlier one
was already selected but still had a legal move. Restructured around what
the rules oblige: a seat without a selection MUST select (GR-R02) and
nothing else can happen first; after Reveal the optional actions branch
freely and the aggregate rejects Resolve until the obligatory ones are
done -- so the search needs no phase logic of its own.
AND MY REWIND WAS OFF BY ONE ROUND, replaying the round it was meant to
search. That is why the first run reported 3 nodes and looked like a
working search.
Measured with the real search, rewinding real games to the start of their
last K rounds:
2p K=1 exhausted, 8,103 nodes, ~29 ms
2p K=2 budget cut at 2,000,000 nodes, ~5 s
3p K=2 win found, 41 nodes, ~157 us
The spec's own falsifier said "§3 fails if K=2 proves unaffordable at four
seats". IT FAILED AT TWO. The projection assumed a joint product per
round; the search explores sequential per-seat decisions, so orderings
multiply the tree far beyond width^seats. That is the second projection
this pass published in place of a measurement -- C1's timer was the first.
THE ASYMMETRY IS THE OPERATIVE FINDING. Finding a win is cheap: DFS
stumbles onto one in tens of nodes. Proving none exists needs exhaustion.
So the witness feature is affordable now at any K a player would ask
about, and the winnable fraction (ADR-0013 D4) is NOT, because its
negative half must exhaust every deal it counts. K=1 is the honest default
for exhaustive answers today; making K=2 exhaustible needs transposition
or move-ordering, neither of which this pass built. specs §3 and §3.1
corrected accordingly, and the K=2 default withdrawn.
The negative control that makes "winnable" falsifiable: 2p seed 7 over its
last round returns NoneFound with exhausted=true in ~8k nodes -- a real
negative, not a budget cut wearing a verdict's clothes. And the visible/
hidden marking is tested both ways, since a marking that can only say YES
is decoration.
make all: exit 0. loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:10:25 +02:00
|
|
|
|
/// *Was this deal winnable?* — the retrospective search (CB-WP-0025 T05,
|
|
|
|
|
|
/// ADR-0013). Uses only `validate`/`fold`/`legal_commands`, so it lives
|
|
|
|
|
|
/// beside the aggregate rather than in a crate that would re-export it
|
|
|
|
|
|
/// (ADR-0013 D6).
|
|
|
|
|
|
pub mod search;
|
|
|
|
|
|
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
|
|
|
|
|
|
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
|
2026-07-31 01:57:13 +02:00
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-O02: per-player state.
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct PlayerState {
|
|
|
|
|
|
/// GR-F01: clamped 0–5.
|
|
|
|
|
|
pub stress: u8,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// GR-F03: the Freedom token is READY until spent.
|
2026-07-31 01:57:13 +02:00
|
|
|
|
pub freedom_ready: bool,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// 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,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
/// GR-D01/D02: OFF or the pending/active stage.
|
|
|
|
|
|
pub darvo: DarvoStage,
|
|
|
|
|
|
pub hand: Vec<SolutionCard>,
|
|
|
|
|
|
pub protection: u8,
|
|
|
|
|
|
/// Blame tokens in front of this player (GR-T02), keyed by owner.
|
|
|
|
|
|
pub blame_from: Vec<PlayerId>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub enum DarvoStage {
|
|
|
|
|
|
Off,
|
|
|
|
|
|
Deny,
|
|
|
|
|
|
Attack,
|
|
|
|
|
|
Reverse,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-O04: one Problem card's live state.
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct ProblemState {
|
|
|
|
|
|
pub suit: Suit,
|
|
|
|
|
|
pub value: u8,
|
|
|
|
|
|
pub face_up: bool,
|
|
|
|
|
|
pub denied: bool,
|
|
|
|
|
|
pub claimed_by: Option<PlayerId>,
|
|
|
|
|
|
/// GR-A11: protected from Deny this round by GROUND—OU.
|
|
|
|
|
|
pub protected_this_round: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
|
|
|
|
pub enum Suit {
|
|
|
|
|
|
Clarify,
|
|
|
|
|
|
Repair,
|
|
|
|
|
|
Boundary,
|
|
|
|
|
|
Change,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct SolutionCard {
|
|
|
|
|
|
pub suit: Suit,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-O05: at most one relation per pair; endpoints ordered low→high.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub enum Relation {
|
|
|
|
|
|
Bond,
|
|
|
|
|
|
Rivalry,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// 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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
|
|
|
|
serializer.collect_str(self)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl<'de> Deserialize<'de> for Pair {
|
|
|
|
|
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
|
|
|
|
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::<u8>()
|
|
|
|
|
|
.map_err(|e| serde::de::Error::custom(format!("bad seat in {raw:?}: {e}")))
|
|
|
|
|
|
};
|
|
|
|
|
|
Ok(Pair::new(PlayerId(parse(a)?), PlayerId(parse(b)?)))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 01:57:13 +02:00
|
|
|
|
/// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered
|
|
|
|
|
|
/// collections only (GameKernel K6).
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct GroundState {
|
|
|
|
|
|
pub round: u8,
|
|
|
|
|
|
pub lead: PlayerId,
|
|
|
|
|
|
pub players: BTreeMap<PlayerId, PlayerState>,
|
|
|
|
|
|
/// Keyed by (low, high) player pair.
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
pub relations: BTreeMap<Pair, Relation>,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
pub problems: BTreeMap<u32, ProblemState>,
|
|
|
|
|
|
pub solution_deck: Vec<SolutionCard>,
|
|
|
|
|
|
pub solution_discard: Vec<SolutionCard>,
|
|
|
|
|
|
/// Focus placements: sequence owner → target (GR-T03).
|
|
|
|
|
|
pub focus: BTreeMap<PlayerId, PlayerId>,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// 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<PlayerId, Selection>,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// GR-R05: GROUND modes chosen after Reveal, before Resolve.
|
|
|
|
|
|
pub ground_modes: BTreeMap<PlayerId, GroundMode>,
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
/// GR-A11/A12: the sub-choice accompanying an OU or ND mode.
|
|
|
|
|
|
pub ground_choices: BTreeMap<PlayerId, GroundChoice>,
|
|
|
|
|
|
/// GR-L02/A05: a Support target's response, keyed by target.
|
|
|
|
|
|
pub support_responses: BTreeMap<PlayerId, SupportResponse>,
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// GR-D03/D04: the mandatory target a DARVO stage needs this round.
|
|
|
|
|
|
pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>,
|
2026-07-31 02:33:27 +02:00
|
|
|
|
/// 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<Outcome>,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// GR-S04/U4: retained so a deck reshuffle stays a pure function of
|
|
|
|
|
|
/// state, keeping `validate` deterministic without holding RNG state.
|
|
|
|
|
|
pub seed: u64,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 02:33:27 +02:00
|
|
|
|
/// 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<PlayerId, i32>,
|
|
|
|
|
|
/// GR-E04: each Bond-connected group and its combined score.
|
|
|
|
|
|
pub coalitions: Vec<Coalition>,
|
|
|
|
|
|
/// GR-E02: only meaningful in SHARED GROUND.
|
|
|
|
|
|
pub mastery: Option<i32>,
|
|
|
|
|
|
pub winners: Vec<PlayerId>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-E04: one Bond-connected group. Unbonded players are solo.
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct Coalition {
|
|
|
|
|
|
pub members: Vec<PlayerId>,
|
|
|
|
|
|
pub score: i32,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// 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<u32>,
|
|
|
|
|
|
pub player: Option<PlayerId>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
fn parse(raw: &str, arg: Option<u64>) -> Result<Self, String> {
|
|
|
|
|
|
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 {
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
fn parse(raw: &str) -> Result<Self, String> {
|
|
|
|
|
|
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:?}")),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
impl GroundMode {
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
fn parse(raw: &str) -> Result<Self, String> {
|
|
|
|
|
|
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)"
|
|
|
|
|
|
)),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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<PlayerId>,
|
|
|
|
|
|
pub problem: Option<u32>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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 {
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
fn parse(raw: &str) -> Result<Self, String> {
|
|
|
|
|
|
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<PlayerId>,
|
|
|
|
|
|
problem: Option<u32>,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// GR-R03: spend the READY Freedom token to bypass the stress gate.
|
|
|
|
|
|
SpendFreedom,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// GR-R05: a player who revealed GROUND chooses its mode after
|
|
|
|
|
|
/// seeing all revealed Actions.
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
ChooseGroundMode {
|
|
|
|
|
|
mode: GroundMode,
|
|
|
|
|
|
choice: Option<GroundChoice>,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// GR-L02/A05: respond to a Support aimed at this player.
|
|
|
|
|
|
RespondToSupport { response: SupportResponse },
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// GR-D03/D04: name the mandatory target of this round's stage.
|
|
|
|
|
|
ChooseDarvoTarget { target: DarvoTarget },
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// 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,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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,
|
|
|
|
|
|
},
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
},
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
/// GR-R05/A11/A12.
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
GroundModeChosen {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
mode: GroundMode,
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
choice: Option<GroundChoice>,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// 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,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
},
|
|
|
|
|
|
/// 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<SolutionCard>,
|
|
|
|
|
|
},
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// GR-D01: Stress 5 at End with the marker OFF.
|
|
|
|
|
|
DarvoTriggered {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
},
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
},
|
2026-07-31 02:33:27 +02:00
|
|
|
|
/// GR-R09/E01..E04: the game ended and scoring ran.
|
|
|
|
|
|
GameEnded {
|
|
|
|
|
|
outcome: Outcome,
|
|
|
|
|
|
},
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
},
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option<Relation> {
|
|
|
|
|
|
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<PlayerId> {
|
|
|
|
|
|
let seats: Vec<PlayerId> = 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
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<Vec<Self::Event>, Rejection> {
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
// GR-R05: modes are chosen before resolution begins.
|
|
|
|
|
|
let pending: Vec<PlayerId> = 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:?}"),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
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 }])
|
|
|
|
|
|
}
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
// GR-R05: only after Reveal, only for a player who revealed
|
|
|
|
|
|
// GROUND, once.
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
GroundCommand::ChooseGroundMode { mode, choice } => {
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
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;
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// 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)?;
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
Ok(vec![GroundEvent::GroundModeChosen {
|
|
|
|
|
|
player: id,
|
|
|
|
|
|
mode: *mode,
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
}])
|
|
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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,
|
|
|
|
|
|
}])
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => {
|
|
|
|
|
|
unreachable!("system commands are handled above")
|
|
|
|
|
|
}
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
GroundEvent::GroundModeChosen {
|
|
|
|
|
|
player,
|
|
|
|
|
|
mode,
|
|
|
|
|
|
choice,
|
|
|
|
|
|
} => {
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
self.ground_modes.insert(*player, *mode);
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
GroundEvent::DarvoTriggered { player } => {
|
|
|
|
|
|
if let Some(state) = self.players.get_mut(player) {
|
|
|
|
|
|
state.darvo = DarvoStage::Deny;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-31 02:33:27 +02:00
|
|
|
|
GroundEvent::GameEnded { outcome } => {
|
|
|
|
|
|
self.outcome = Some(outcome.clone());
|
|
|
|
|
|
self.step = RoundStep::End;
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
GroundEvent::StepAdvanced { step } => {
|
|
|
|
|
|
self.step = *step;
|
|
|
|
|
|
}
|
|
|
|
|
|
GroundEvent::RoundEnded { round, next_lead } => {
|
|
|
|
|
|
self.round = *round;
|
|
|
|
|
|
self.lead = *next_lead;
|
|
|
|
|
|
self.selections.clear();
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
self.ground_modes.clear();
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
self.ground_choices.clear();
|
|
|
|
|
|
self.support_responses.clear();
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
self.darvo_targets.clear();
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl GroundState {
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// GR-R06/R07: resolve in fixed step order, Lead first within a step.
|
|
|
|
|
|
///
|
CB-WP-0008-T04: CB-EV-0007 — stage 0 is shipped, and the spend curve is a V
Stage 0's ten items are met. Three qualifications are recorded rather
than hidden behind checkmarks: GR-E01's threshold is unreachable below
five seats with the placeholder fixture, GR-A13 admits a SOLVE that
resolves to nothing, and GR-E03 has no scenario at all — implemented,
unreferenced, and would not fail if deleted.
The second-consumer verdict INTENT was waiting for: every abstraction
with a consumer fits, and the one with none is still unused. Bots and
the CLI drove Aggregate, Project, parse_command and the replay bundles
unchanged. CommitWindow had its best chance at a second user and did not
get one; its delete-by date stands.
The retrospective answers with the curve: 0.123, 0.228, 0.362, 0.298,
0.123 dollars per response across the last five passes. Neither
"meta is expensive" nor "compaction did it" survives the data. What does:
cost per response tracks how far the work is from a runnable check. The
meta passes that shipped a command were cheap; the ones that argued about
what a number means were not.
The meta budget reads 61% OVER on a pass that is 100% product, because
it aggregates over every task ever closed — the same defect CB-RES-0005
found in SH-1/SH-2 and that CB-WP-0007 T01 fixed for session shape. Not
fixed here: v1.5 forbids opening meta work above the line, so the
budget's first real act was to stop me from improving the budget.
Also fixes a doc comment that claimed DARVO was unimplemented for weeks
after it shipped. facts-check gates duplicated numbers; nothing gates a
prose claim about code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:25:19 +02:00
|
|
|
|
/// Steps run: GROUND (GR-A10..A12), Support (GR-A03..A05), Attack
|
|
|
|
|
|
/// (GR-A06), DARVO (GR-D02..D07), INVESTIGATE (GR-A01), SOLVE
|
|
|
|
|
|
/// (GR-A02).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **This comment claimed DARVO and GROUND—OU/ND were "not yet
|
|
|
|
|
|
/// implemented" until 2026-08-01**, long after both landed with their
|
|
|
|
|
|
/// scenarios (`gr-d01`…`gr-d06`, `gr-a11`, `gr-a12`). Nothing checks
|
|
|
|
|
|
/// prose against code, which is the DFD class `make facts-check`
|
|
|
|
|
|
/// gates for *numbers* and cannot gate for claims like this one.
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
fn resolution_events(&self) -> Vec<GroundEvent> {
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// GR-A11: Attacks cancelled by a GROUND—OU choice this round.
|
|
|
|
|
|
let mut ou_cancels: std::collections::BTreeSet<(PlayerId, PlayerId)> =
|
|
|
|
|
|
std::collections::BTreeSet::new();
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// 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 => {}
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// GR-A05: Support through a Rivalry — −1 Stress, then
|
|
|
|
|
|
// the target flips it to a Bond or breaks it.
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
Some(Relation::Rivalry) => {
|
|
|
|
|
|
let stress = work.stress_after(target, -1);
|
|
|
|
|
|
events.push(GroundEvent::StressSet {
|
|
|
|
|
|
player: target,
|
|
|
|
|
|
stress,
|
|
|
|
|
|
});
|
|
|
|
|
|
work.fold(events.last().expect("just pushed"));
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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.
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// GR-A03/L02: no relation — −1 Stress, and a Bond forms
|
|
|
|
|
|
// only if the target accepts and both have a free slot.
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
None => {
|
|
|
|
|
|
let stress = work.stress_after(target, -1);
|
|
|
|
|
|
events.push(GroundEvent::StressSet {
|
|
|
|
|
|
player: target,
|
|
|
|
|
|
stress,
|
|
|
|
|
|
});
|
|
|
|
|
|
work.fold(events.last().expect("just pushed"));
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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"));
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// Step 3 — active DARVO stages (GR-D02..D07).
|
|
|
|
|
|
for owner in self.seat_order() {
|
|
|
|
|
|
let stage = work.players[&owner].darvo;
|
|
|
|
|
|
if stage == DarvoStage::Off {
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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 });
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
work.fold(events.last().expect("just pushed"));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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,
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
});
|
|
|
|
|
|
work.fold(events.last().expect("just pushed"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
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"));
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
events.push(GroundEvent::StepAdvanced {
|
|
|
|
|
|
step: RoundStep::Resolve,
|
|
|
|
|
|
});
|
|
|
|
|
|
events
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// 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<GroundEvent>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// 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<GroundEvent>) {
|
|
|
|
|
|
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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// 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<GroundEvent> {
|
|
|
|
|
|
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<PlayerId> = 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()]);
|
|
|
|
|
|
|
2026-07-31 02:33:27 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
events.push(GroundEvent::RoundEnded {
|
|
|
|
|
|
round: self.round + 1,
|
|
|
|
|
|
next_lead,
|
|
|
|
|
|
});
|
|
|
|
|
|
events.push(GroundEvent::StepAdvanced {
|
|
|
|
|
|
step: RoundStep::Select,
|
|
|
|
|
|
});
|
|
|
|
|
|
events
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 02:33:27 +02:00
|
|
|
|
/// 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<PlayerId, i32> = 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<PlayerId> = 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<T: Copy, K: Ord>(&self, candidates: Vec<T>, key: impl Fn(T) -> K) -> Vec<T> {
|
|
|
|
|
|
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<PlayerId, i32>) -> Vec<Coalition> {
|
|
|
|
|
|
let mut remaining: Vec<PlayerId> = 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<PlayerId> = 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
/// 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<GroundChoice>,
|
|
|
|
|
|
) -> 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(()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// GR-A13 targeting legality, shared by every Action.
|
|
|
|
|
|
fn check_targeting(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
actor: PlayerId,
|
|
|
|
|
|
action: Action,
|
|
|
|
|
|
target: Option<PlayerId>,
|
|
|
|
|
|
problem: Option<u32>,
|
|
|
|
|
|
) -> 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")))?;
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
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"
|
|
|
|
|
|
)));
|
|
|
|
|
|
}
|
CB-WP-0023: SOLVE is legal only where it can do something
Implements ground-game's ruling of 2026-08-03. make all exits 0, 26
scenarios, rule coverage 59/59, and no scenario encoded the bug.
The rule ended up somewhere other than where I put it, and a gate moved
it. It went into legal_commands first; the AM-1 coverage gate then
demanded a scenario for the new GR-P05, and scenarios drive validate, not
the offer layer. A rule enforced only by the offer is enforced only for
clients that ask what is legal -- the browser would be filtered and a
scenario file would walk straight past it. Once GR-P05 moved into
validate, every condition in legal_commands was dead code, and the
layering test said so in those words.
And the reported case was not the one I reported. CB-WP-0018 and the
message to ground-game described SOLVE offered on a FACE-DOWN Problem.
Measured: validate already rejected face-down, so it never was offered.
Problem 1 is the Surface Problem, face-up from the deal -- the
maintainer's three inert SOLVEs were the HAND case, holding no Clarify
for a Clarify Problem. The ruling covers both so nothing is invalidated,
but the record was wrong.
Four conditions asserted separately, because one 'SOLVE is filtered' test
would pass with three of four implemented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:18:40 +02:00
|
|
|
|
// GR-P05, ruled by ground-game 2026-08-03: SOLVE is legal
|
|
|
|
|
|
// only where it can do something. This lives in `validate`
|
|
|
|
|
|
// and not only in `legal_commands` because a rule enforced
|
|
|
|
|
|
// by the offer alone is enforced only for clients that ask
|
|
|
|
|
|
// what is legal — the browser would be filtered and a
|
|
|
|
|
|
// scenario file would not.
|
|
|
|
|
|
Action::Solve if target.claimed_by.is_some() => {
|
|
|
|
|
|
return Err(bad(format!(
|
|
|
|
|
|
"GR-P05: Problem {problem} was claimed in an earlier round"
|
|
|
|
|
|
)));
|
|
|
|
|
|
}
|
|
|
|
|
|
Action::Solve
|
|
|
|
|
|
if !self
|
|
|
|
|
|
.players
|
|
|
|
|
|
.get(&actor)
|
|
|
|
|
|
.is_some_and(|p| p.hand.iter().any(|c| c.suit == target.suit)) =>
|
|
|
|
|
|
{
|
|
|
|
|
|
return Err(bad(format!(
|
|
|
|
|
|
"GR-P05: no {:?} Solution in hand for Problem {problem}",
|
|
|
|
|
|
target.suit
|
|
|
|
|
|
)));
|
|
|
|
|
|
}
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
_ => {}
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
} else if problem.is_some() {
|
|
|
|
|
|
return Err(bad(format!("GR-A13: {action:?} takes no Problem target")));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
// GR-S01's deal now lives in `edition::hidden_depth` (ADR-0011): the
|
|
|
|
|
|
// ruled shape is Surface + hidden 1..=k, and k belongs beside the data
|
|
|
|
|
|
// it indexes into.
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
// GR-S04's deck now lives in `edition::solution_deck` (ADR-0011),
|
|
|
|
|
|
// beside the Problem data it is dealt against.
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
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<Self, String> {
|
|
|
|
|
|
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
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
// GR-S01 as ruled 2026-08-04: Surface always, plus hidden
|
|
|
|
|
|
// priorities 1..=k. The values and suits come from the edition
|
|
|
|
|
|
// (ADR-0011); the engine used to invent both.
|
|
|
|
|
|
let dealt = crate::edition::deal("SCN_01", seats)?;
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
let mut rng = ChaChaRng::from_seed(Seed(seed));
|
|
|
|
|
|
|
|
|
|
|
|
// GR-S04: shuffle first, then deal, so the deal is seed-derived.
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
let mut deck = crate::edition::solution_deck();
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
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![],
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
// Surface is dealt face up; hidden Problems face down (GR-S01).
|
|
|
|
|
|
// Keyed by 1-based position so scenario dot-paths stay stable.
|
|
|
|
|
|
let problems: BTreeMap<u32, ProblemState> = dealt
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.map(|(i, p)| {
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
(
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
(i + 1) as u32,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
ProblemState {
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
suit: p.suit,
|
|
|
|
|
|
value: p.value,
|
|
|
|
|
|
face_up: p.surface,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
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(),
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
ground_modes: BTreeMap::new(),
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
ground_choices: BTreeMap::new(),
|
|
|
|
|
|
support_responses: BTreeMap::new(),
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
darvo_targets: BTreeMap::new(),
|
2026-07-31 02:33:27 +02:00
|
|
|
|
mode: ScoringMode::SharedGround,
|
|
|
|
|
|
outcome: None,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
seed,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn parse_command(step: &CommandStep) -> Result<(Actor, Self::Command), String> {
|
|
|
|
|
|
let actor = parse_actor(&step.actor)?;
|
|
|
|
|
|
let arg_str = |key: &str| -> Result<String, String> {
|
|
|
|
|
|
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<u64> { 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,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
"choose_ground_mode" => GroundCommand::ChooseGroundMode {
|
|
|
|
|
|
mode: GroundMode::parse(&arg_str("mode")?)?,
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
choice: match step.args.get("choice") {
|
|
|
|
|
|
Some(_) => Some(GroundChoice::parse(
|
|
|
|
|
|
&arg_str("choice")?,
|
|
|
|
|
|
arg_u64("problem").or_else(|| arg_u64("seat")),
|
|
|
|
|
|
)?),
|
|
|
|
|
|
None => None,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
"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,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
"respond_to_support" => GroundCommand::RespondToSupport {
|
|
|
|
|
|
response: SupportResponse::parse(&arg_str("response")?)?,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
},
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
"reveal" => GroundCommand::Reveal,
|
|
|
|
|
|
"resolve" => GroundCommand::Resolve,
|
|
|
|
|
|
"end_round" => GroundCommand::EndRound,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
other => return Err(format!("unknown command {other:?}")),
|
|
|
|
|
|
};
|
|
|
|
|
|
Ok((actor, command))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn round(&self) -> u8 {
|
|
|
|
|
|
self.round
|
|
|
|
|
|
}
|
2026-07-31 01:57:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use cb_events::state_hash_hex;
|
|
|
|
|
|
|
|
|
|
|
|
fn tiny_state() -> GroundState {
|
|
|
|
|
|
GroundState {
|
|
|
|
|
|
round: 1,
|
|
|
|
|
|
lead: PlayerId(0),
|
|
|
|
|
|
players: BTreeMap::from([(
|
|
|
|
|
|
PlayerId(0),
|
|
|
|
|
|
PlayerState {
|
|
|
|
|
|
stress: 2,
|
|
|
|
|
|
freedom_ready: true,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
freedom_gate_lifted: false,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
darvo: DarvoStage::Off,
|
|
|
|
|
|
hand: vec![SolutionCard { suit: Suit::Repair }],
|
|
|
|
|
|
protection: 0,
|
|
|
|
|
|
blame_from: vec![],
|
|
|
|
|
|
},
|
|
|
|
|
|
)]),
|
|
|
|
|
|
relations: BTreeMap::new(),
|
|
|
|
|
|
problems: BTreeMap::new(),
|
|
|
|
|
|
solution_deck: vec![],
|
|
|
|
|
|
solution_discard: vec![],
|
|
|
|
|
|
focus: BTreeMap::new(),
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
step: RoundStep::Select,
|
|
|
|
|
|
selections: BTreeMap::new(),
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
ground_modes: BTreeMap::new(),
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
ground_choices: BTreeMap::new(),
|
|
|
|
|
|
support_responses: BTreeMap::new(),
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
darvo_targets: BTreeMap::new(),
|
2026-07-31 02:33:27 +02:00
|
|
|
|
mode: ScoringMode::SharedGround,
|
|
|
|
|
|
outcome: None,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
seed: 0,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
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);
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
// GR-S01 as ruled 2026-08-04: Surface always, plus hidden
|
|
|
|
|
|
// priorities 1..=k. At 3 players k = 3, so FOUR Problems are
|
|
|
|
|
|
// dealt — Surface face up, the three hidden ones face down. The
|
|
|
|
|
|
// engine used to deal Surface + (k-1), which is what made the
|
|
|
|
|
|
// group unable to reach GR-E01's threshold.
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
state.problems.len(),
|
|
|
|
|
|
4,
|
|
|
|
|
|
"GR-S01 deals Surface + hidden 1..=3"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(state.problems[&1].face_up, "the Surface Problem is face up");
|
|
|
|
|
|
for slot in 2..=4 {
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!state.problems[&slot].face_up,
|
|
|
|
|
|
"hidden Problem {slot} is face up"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
// The edition's values, not the stand-in's `value = priority`.
|
|
|
|
|
|
let dealt: Vec<u8> = (1..=4).map(|n| state.problems[&n].value).collect();
|
|
|
|
|
|
assert_eq!(dealt, vec![2, 2, 2, 3], "edition point_value not loaded");
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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"
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 01:57:13 +02:00
|
|
|
|
/// K7 on the real aggregate: hash stable across clones, sensitive to
|
|
|
|
|
|
/// semantic change.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn ground_state_hashes_canonically() {
|
|
|
|
|
|
let a = tiny_state();
|
|
|
|
|
|
let b = a.clone();
|
|
|
|
|
|
assert_eq!(state_hash_hex(&a), state_hash_hex(&b));
|
|
|
|
|
|
let mut c = a.clone();
|
|
|
|
|
|
c.players.get_mut(&PlayerId(0)).unwrap().stress = 5;
|
|
|
|
|
|
assert_ne!(state_hash_hex(&a), state_hash_hex(&c));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod bench_shape {
|
|
|
|
|
|
use super::*;
|
CB-WP-0006 T07: implement K18, amend K14
Two rules, two different answers, which is the point of a task phrased
"implement, or amend and say why".
K18 is implemented. "Criterion benches driving the same scenario format at
scale" was false — the bench hardcoded its commands and never touched
ScenarioFile, while MetricsAndScenarios §3 pointed at a benchmarks/
directory containing only baselines/. benchmarks/synthetic-3p.yaml now
holds the workload and both the bench and bench_shape read it: the
workload is data, not code.
A second defect surfaced while fixing the first. After the bench switched
to the file, bench_shape still hardcoded the same sequence, so the
workload existed twice — deleting end_round from the YAML broke bench-test
while bench_shape kept passing. Duplicated-fact drift in executable form.
Both now read the same include_str! and deleting a command breaks both.
Explicitly not claimed: this does not unblock AM-3. AM-3's baseline is a
declarative game object — moves, turn order, rules. synthetic-3p.yaml is a
command list; the rules live in games/ground. Marking it as AM-3's subject
would compare a script to a game definition, which is the category error
AM-3 is blocked on. The file says so in its own header, where the next
person will be tempted.
K14 is amended. CommitWindow had zero non-test users and GROUND enforces
the same contract inline. Wiring GROUND through it was rejected: it would
change the serialized shape of `selections`, which four scenario files
assert by dot-path and every state hash depends on, for the sole benefit
of making a sentence literally true.
The deciding argument is INTENT's, not convenience: abstractions are
extracted from working games rather than invented in isolation, and no
concept becomes canonical until it survives a second concrete use.
CommitWindow was invented before any game needed it and has survived none.
Imposing it on GROUND would manufacture the first use rather than discover
it. So K14 states what is actually guaranteed, CommitWindow is marked
provisional in the source, and it carries a delete-by date of 2026-12-31.
Kernel spec->code link 16/18 -> 18/18, stated with the caveat the gate
prints every run: that is about names, not assertions.
Two self-tests broke and both broke correctly. rule-coverage's gate test
hardcoded "unlinked rules exist today" and failed when the last one was
linked; it now computes that and asserts the gate fails iff rules are
unlinked. facts' text check rejected k_unlinked once it became
legitimately empty; empty now renders as "(none)" and the check
distinguishes absent from empty.
M-D1-MUT: 8 of 14, unchanged — K14 and K18 are kernel rules, not
acceptance rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:47:16 +02:00
|
|
|
|
use cb_game_runtime::{ScenarioFile, ScenarioGame};
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
|
|
|
|
|
|
/// 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() {
|
CB-WP-0006 T07: implement K18, amend K14
Two rules, two different answers, which is the point of a task phrased
"implement, or amend and say why".
K18 is implemented. "Criterion benches driving the same scenario format at
scale" was false — the bench hardcoded its commands and never touched
ScenarioFile, while MetricsAndScenarios §3 pointed at a benchmarks/
directory containing only baselines/. benchmarks/synthetic-3p.yaml now
holds the workload and both the bench and bench_shape read it: the
workload is data, not code.
A second defect surfaced while fixing the first. After the bench switched
to the file, bench_shape still hardcoded the same sequence, so the
workload existed twice — deleting end_round from the YAML broke bench-test
while bench_shape kept passing. Duplicated-fact drift in executable form.
Both now read the same include_str! and deleting a command breaks both.
Explicitly not claimed: this does not unblock AM-3. AM-3's baseline is a
declarative game object — moves, turn order, rules. synthetic-3p.yaml is a
command list; the rules live in games/ground. Marking it as AM-3's subject
would compare a script to a game definition, which is the category error
AM-3 is blocked on. The file says so in its own header, where the next
person will be tempted.
K14 is amended. CommitWindow had zero non-test users and GROUND enforces
the same contract inline. Wiring GROUND through it was rejected: it would
change the serialized shape of `selections`, which four scenario files
assert by dot-path and every state hash depends on, for the sole benefit
of making a sentence literally true.
The deciding argument is INTENT's, not convenience: abstractions are
extracted from working games rather than invented in isolation, and no
concept becomes canonical until it survives a second concrete use.
CommitWindow was invented before any game needed it and has survived none.
Imposing it on GROUND would manufacture the first use rather than discover
it. So K14 states what is actually guaranteed, CommitWindow is marked
provisional in the source, and it carries a delete-by date of 2026-12-31.
Kernel spec->code link 16/18 -> 18/18, stated with the caveat the gate
prints every run: that is about names, not assertions.
Two self-tests broke and both broke correctly. rule-coverage's gate test
hardcoded "unlinked rules exist today" and failed when the last one was
linked; it now computes that and asserts the gate fails iff rules are
unlinked. facts' text check rejected k_unlinked once it became
legitimately empty; empty now renders as "(none)" and the check
distinguishes absent from empty.
M-D1-MUT: 8 of 14, unchanged — K14 and K18 are kernel rules, not
acceptance rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:47:16 +02:00
|
|
|
|
// K18 / single source of fact (InnerLoop v1.3): the workload is
|
|
|
|
|
|
// read from the SAME file the Criterion bench replays. It used to
|
|
|
|
|
|
// be hardcoded here as well, so the round existed twice and the
|
|
|
|
|
|
// two copies could drift — editing the YAML broke `bench-test`
|
|
|
|
|
|
// while this test kept passing.
|
|
|
|
|
|
let yaml = include_str!("../../../benchmarks/synthetic-3p.yaml");
|
|
|
|
|
|
let sc = ScenarioFile::from_yaml(yaml).expect("bench workload parses");
|
|
|
|
|
|
let mut state = GroundState::setup(&sc.setup, sc.seed).unwrap();
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
|
|
|
|
|
|
let mut events = 0;
|
|
|
|
|
|
let mut commands = 0;
|
CB-WP-0006 T07: implement K18, amend K14
Two rules, two different answers, which is the point of a task phrased
"implement, or amend and say why".
K18 is implemented. "Criterion benches driving the same scenario format at
scale" was false — the bench hardcoded its commands and never touched
ScenarioFile, while MetricsAndScenarios §3 pointed at a benchmarks/
directory containing only baselines/. benchmarks/synthetic-3p.yaml now
holds the workload and both the bench and bench_shape read it: the
workload is data, not code.
A second defect surfaced while fixing the first. After the bench switched
to the file, bench_shape still hardcoded the same sequence, so the
workload existed twice — deleting end_round from the YAML broke bench-test
while bench_shape kept passing. Duplicated-fact drift in executable form.
Both now read the same include_str! and deleting a command breaks both.
Explicitly not claimed: this does not unblock AM-3. AM-3's baseline is a
declarative game object — moves, turn order, rules. synthetic-3p.yaml is a
command list; the rules live in games/ground. Marking it as AM-3's subject
would compare a script to a game definition, which is the category error
AM-3 is blocked on. The file says so in its own header, where the next
person will be tempted.
K14 is amended. CommitWindow had zero non-test users and GROUND enforces
the same contract inline. Wiring GROUND through it was rejected: it would
change the serialized shape of `selections`, which four scenario files
assert by dot-path and every state hash depends on, for the sole benefit
of making a sentence literally true.
The deciding argument is INTENT's, not convenience: abstractions are
extracted from working games rather than invented in isolation, and no
concept becomes canonical until it survives a second concrete use.
CommitWindow was invented before any game needed it and has survived none.
Imposing it on GROUND would manufacture the first use rather than discover
it. So K14 states what is actually guaranteed, CommitWindow is marked
provisional in the source, and it carries a delete-by date of 2026-12-31.
Kernel spec->code link 16/18 -> 18/18, stated with the caveat the gate
prints every run: that is about names, not assertions.
Two self-tests broke and both broke correctly. rule-coverage's gate test
hardcoded "unlinked rules exist today" and failed when the last one was
linked; it now computes that and asserts the gate fails iff rules are
unlinked. facts' text check rejected k_unlinked once it became
legitimately empty; empty now renders as "(none)" and the check
distinguishes absent from empty.
M-D1-MUT: 8 of 14, unchanged — K14 and K18 are kernel rules, not
acceptance rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:47:16 +02:00
|
|
|
|
for step in &sc.commands {
|
|
|
|
|
|
let (actor, cmd) = GroundState::parse_command(step).expect("workload command");
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
commands += 1;
|
CB-WP-0006 T07: implement K18, amend K14
Two rules, two different answers, which is the point of a task phrased
"implement, or amend and say why".
K18 is implemented. "Criterion benches driving the same scenario format at
scale" was false — the bench hardcoded its commands and never touched
ScenarioFile, while MetricsAndScenarios §3 pointed at a benchmarks/
directory containing only baselines/. benchmarks/synthetic-3p.yaml now
holds the workload and both the bench and bench_shape read it: the
workload is data, not code.
A second defect surfaced while fixing the first. After the bench switched
to the file, bench_shape still hardcoded the same sequence, so the
workload existed twice — deleting end_round from the YAML broke bench-test
while bench_shape kept passing. Duplicated-fact drift in executable form.
Both now read the same include_str! and deleting a command breaks both.
Explicitly not claimed: this does not unblock AM-3. AM-3's baseline is a
declarative game object — moves, turn order, rules. synthetic-3p.yaml is a
command list; the rules live in games/ground. Marking it as AM-3's subject
would compare a script to a game definition, which is the category error
AM-3 is blocked on. The file says so in its own header, where the next
person will be tempted.
K14 is amended. CommitWindow had zero non-test users and GROUND enforces
the same contract inline. Wiring GROUND through it was rejected: it would
change the serialized shape of `selections`, which four scenario files
assert by dot-path and every state hash depends on, for the sole benefit
of making a sentence literally true.
The deciding argument is INTENT's, not convenience: abstractions are
extracted from working games rather than invented in isolation, and no
concept becomes canonical until it survives a second concrete use.
CommitWindow was invented before any game needed it and has survived none.
Imposing it on GROUND would manufacture the first use rather than discover
it. So K14 states what is actually guaranteed, CommitWindow is marked
provisional in the source, and it carries a delete-by date of 2026-12-31.
Kernel spec->code link 16/18 -> 18/18, stated with the caveat the gate
prints every run: that is about names, not assertions.
Two self-tests broke and both broke correctly. rule-coverage's gate test
hardcoded "unlinked rules exist today" and failed when the last one was
linked; it now computes that and asserts the gate fails iff rules are
unlinked. facts' text check rejected k_unlinked once it became
legitimately empty; empty now renders as "(none)" and the check
distinguishes absent from empty.
M-D1-MUT: 8 of 14, unchanged — K14 and K18 are kernel rules, not
acceptance rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:47:16 +02:00
|
|
|
|
if let Ok(produced) = state.validate(actor, &cmd) {
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
for e in &produced {
|
|
|
|
|
|
state.fold(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
events += produced.len();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(commands, 7, "commands per synthetic round");
|
|
|
|
|
|
assert_eq!(events, 13, "events per synthetic round");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod replay_probe {
|
|
|
|
|
|
use super::*;
|
CB-WP-0006 T05: K9's assertion, K11's format, and the AM-11 suites
K11 is implemented: crates/cb-events/src/store.rs, magic + version header,
4-byte little-endian length prefix, append-only. Reimplemented not
assimilated per ADR-0005 §2 — no new dependency, and AM-4a/AM-4b are
unchanged at 246,250 / 317,021 because nothing entered the graph.
The operative clause is "detected", so corruption is tested rather than
assumed: a tail short by one byte, a half-written length prefix, a length
prefix corrupted to claim more than the file holds, foreign magic, and a
future format version are each rejected with a distinct error. A reader
that accepts a truncated tail is worse than no format, because it silently
returns a short history that looks complete.
AM-11 is earned. LogStore has two impls — MemLogStore and FileLogStore —
driven through ONE conformance(). The trait carries raw/set_raw precisely
so the corruption controls live in the shared suite: a format contract
that only one impl enforces is not a contract. The same shape is
retro-fitted to KernelRng, which is what AM-11 actually names: ChaChaRng
and NullRng now pass one suite asserting bounds, draw(1) == 0, determinism
across fresh instances, and shuffle preserving the multiset. They were
previously exercised by two separate tests, which is why "met, narrow" was
never earned and ADR-0005 §4 downgraded it.
K9 gets the assertion it did not have: snapshot at seq N + events N+1..M
must equal the from-genesis fold, hash-compared, on GroundState,
single-seed on purpose — AM-7's probe folds a multi-seed log, which is not
a replay of anything, and that defect is not repeated. Two positive
controls: the log must exceed 50 events, and the mid-log snapshot must
differ from the end state or "apply the remainder" is vacuous.
Proof it works: the exact mutation that SURVIVED in CB-WP-0005 — making
Snapshot::take discard its EventSeq — now fails on the K9 assertion.
AM-11's mutation breaks NullRng::draw to return its bound and the shared
suite fails. That is what M-D4-SWAP claims — either impl substitutable —
and exactly what two separate per-impl tests could never demonstrate.
M-D1-MUT: 7 -> 8 of 14. CB-EV-0001's scoreboard is refreshed: AM-2, AM-5
and AM-9 added, AM-6 moved to enforced, and the headline total corrected
from 4 to 8 — it had gone stale inside the same workplan that produced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:50:52 +02:00
|
|
|
|
use cb_events::{state_hash_hex, Snapshot};
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
use cb_game_runtime::{ScenarioGame, Setup};
|
CB-WP-0006 T05: K9's assertion, K11's format, and the AM-11 suites
K11 is implemented: crates/cb-events/src/store.rs, magic + version header,
4-byte little-endian length prefix, append-only. Reimplemented not
assimilated per ADR-0005 §2 — no new dependency, and AM-4a/AM-4b are
unchanged at 246,250 / 317,021 because nothing entered the graph.
The operative clause is "detected", so corruption is tested rather than
assumed: a tail short by one byte, a half-written length prefix, a length
prefix corrupted to claim more than the file holds, foreign magic, and a
future format version are each rejected with a distinct error. A reader
that accepts a truncated tail is worse than no format, because it silently
returns a short history that looks complete.
AM-11 is earned. LogStore has two impls — MemLogStore and FileLogStore —
driven through ONE conformance(). The trait carries raw/set_raw precisely
so the corruption controls live in the shared suite: a format contract
that only one impl enforces is not a contract. The same shape is
retro-fitted to KernelRng, which is what AM-11 actually names: ChaChaRng
and NullRng now pass one suite asserting bounds, draw(1) == 0, determinism
across fresh instances, and shuffle preserving the multiset. They were
previously exercised by two separate tests, which is why "met, narrow" was
never earned and ADR-0005 §4 downgraded it.
K9 gets the assertion it did not have: snapshot at seq N + events N+1..M
must equal the from-genesis fold, hash-compared, on GroundState,
single-seed on purpose — AM-7's probe folds a multi-seed log, which is not
a replay of anything, and that defect is not repeated. Two positive
controls: the log must exceed 50 events, and the mid-log snapshot must
differ from the end state or "apply the remainder" is vacuous.
Proof it works: the exact mutation that SURVIVED in CB-WP-0005 — making
Snapshot::take discard its EventSeq — now fails on the K9 assertion.
AM-11's mutation breaks NullRng::draw to return its bound and the shared
suite fails. That is what M-D4-SWAP claims — either impl substitutable —
and exactly what two separate per-impl tests could never demonstrate.
M-D1-MUT: 7 -> 8 of 14. CB-EV-0001's scoreboard is refreshed: AM-2, AM-5
and AM-9 added, AM-6 moved to enforced, and the headline total corrected
from 4 to 8 — it had gone stale inside the same workplan that produced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:50:52 +02:00
|
|
|
|
use cb_kernel::EventSeq;
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
use std::time::Instant;
|
|
|
|
|
|
|
2026-08-03 23:49:49 +02:00
|
|
|
|
/// A fresh game at `seats` players, for the seat-count sweep.
|
|
|
|
|
|
fn fresh_n(seats: u8) -> GroundState {
|
|
|
|
|
|
GroundState::setup(
|
|
|
|
|
|
&Setup {
|
|
|
|
|
|
players: seats,
|
|
|
|
|
|
preset: format!("standard-{seats}p"),
|
|
|
|
|
|
patch: BTreeMap::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
42,
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("a standard deal")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
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<GroundEvent>) -> 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0006 T05: K9's assertion, K11's format, and the AM-11 suites
K11 is implemented: crates/cb-events/src/store.rs, magic + version header,
4-byte little-endian length prefix, append-only. Reimplemented not
assimilated per ADR-0005 §2 — no new dependency, and AM-4a/AM-4b are
unchanged at 246,250 / 317,021 because nothing entered the graph.
The operative clause is "detected", so corruption is tested rather than
assumed: a tail short by one byte, a half-written length prefix, a length
prefix corrupted to claim more than the file holds, foreign magic, and a
future format version are each rejected with a distinct error. A reader
that accepts a truncated tail is worse than no format, because it silently
returns a short history that looks complete.
AM-11 is earned. LogStore has two impls — MemLogStore and FileLogStore —
driven through ONE conformance(). The trait carries raw/set_raw precisely
so the corruption controls live in the shared suite: a format contract
that only one impl enforces is not a contract. The same shape is
retro-fitted to KernelRng, which is what AM-11 actually names: ChaChaRng
and NullRng now pass one suite asserting bounds, draw(1) == 0, determinism
across fresh instances, and shuffle preserving the multiset. They were
previously exercised by two separate tests, which is why "met, narrow" was
never earned and ADR-0005 §4 downgraded it.
K9 gets the assertion it did not have: snapshot at seq N + events N+1..M
must equal the from-genesis fold, hash-compared, on GroundState,
single-seed on purpose — AM-7's probe folds a multi-seed log, which is not
a replay of anything, and that defect is not repeated. Two positive
controls: the log must exceed 50 events, and the mid-log snapshot must
differ from the end state or "apply the remainder" is vacuous.
Proof it works: the exact mutation that SURVIVED in CB-WP-0005 — making
Snapshot::take discard its EventSeq — now fails on the K9 assertion.
AM-11's mutation breaks NullRng::draw to return its bound and the shared
suite fails. That is what M-D4-SWAP claims — either impl substitutable —
and exactly what two separate per-impl tests could never demonstrate.
M-D1-MUT: 7 -> 8 of 14. CB-EV-0001's scoreboard is refreshed: AM-2, AM-5
and AM-9 added, AM-6 moved to enforced, and the headline total corrected
from 4 to 8 — it had gone stale inside the same workplan that produced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:50:52 +02:00
|
|
|
|
/// K9: `snapshot + remaining events -> state` must be hash-identical to
|
|
|
|
|
|
/// a from-genesis fold.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **This is the assertion K9 did not have.** Its entire evidence was
|
|
|
|
|
|
/// one test round-tripping a `BTreeMap<String, u8>` with `EventSeq(17)`
|
|
|
|
|
|
/// as a literal — no game aggregate, no events applied, no
|
|
|
|
|
|
/// from-genesis comparison. CB-WP-0005 proved it inert by mutation:
|
|
|
|
|
|
/// making `Snapshot::take` discard its `EventSeq` and store 0 left the
|
|
|
|
|
|
/// test green, so the half of K9 that says "**+ the EventId it
|
|
|
|
|
|
/// includes**" was unverified.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Single-seed on purpose. AM-7's probe folds a log built across games
|
|
|
|
|
|
/// seeded 42, 43, 44... into a state from `fresh(42)`, which is not a
|
|
|
|
|
|
/// replay of anything; that defect is not repeated here.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn k9_snapshot_plus_remaining_events_equals_genesis_fold() {
|
|
|
|
|
|
let mut source = fresh(42);
|
|
|
|
|
|
let mut log = Vec::new();
|
|
|
|
|
|
while log.len() < 400 && source.outcome.is_none() {
|
|
|
|
|
|
if record_round(&mut source, &mut log) == 0 {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Positive control: a trivial log would make the comparison pass
|
|
|
|
|
|
// for the wrong reason.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
log.len() >= 50,
|
|
|
|
|
|
"K9 needs a non-trivial single-game log, got {} events",
|
|
|
|
|
|
log.len()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let mut genesis = fresh(42);
|
|
|
|
|
|
for e in &log {
|
|
|
|
|
|
genesis.fold(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
let genesis_hash = state_hash_hex(&genesis);
|
|
|
|
|
|
|
|
|
|
|
|
let n = log.len() / 2;
|
|
|
|
|
|
let mut mid = fresh(42);
|
|
|
|
|
|
for e in &log[..n] {
|
|
|
|
|
|
mid.fold(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
let snap = Snapshot::take(&mid, EventSeq(n as u64));
|
|
|
|
|
|
|
|
|
|
|
|
// The clause the mutation exposed: a snapshot is the aggregate
|
|
|
|
|
|
// **plus the EventId it includes**. Without this, `take` could
|
|
|
|
|
|
// discard `through` entirely and nothing would notice.
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
snap.through,
|
|
|
|
|
|
EventSeq(n as u64),
|
|
|
|
|
|
"K9: the snapshot must carry the EventSeq it includes"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let mut restored: GroundState = snap.restore().unwrap();
|
|
|
|
|
|
// And the snapshot must not already equal the end state, or
|
|
|
|
|
|
// "apply the remainder" would be vacuous.
|
|
|
|
|
|
assert_ne!(
|
|
|
|
|
|
state_hash_hex(&restored),
|
|
|
|
|
|
genesis_hash,
|
|
|
|
|
|
"K9: the mid-log snapshot must differ from the end state"
|
|
|
|
|
|
);
|
|
|
|
|
|
for e in &log[n..] {
|
|
|
|
|
|
restored.fold(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
state_hash_hex(&restored),
|
|
|
|
|
|
genesis_hash,
|
|
|
|
|
|
"K9 UNMET: snapshot at seq {n} + {} remaining events did not \
|
|
|
|
|
|
reproduce the from-genesis fold over {} events",
|
|
|
|
|
|
log.len() - n,
|
|
|
|
|
|
log.len()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0006 T01: assert the AM-6 throughput target
Nothing in the workspace compared any number to 100,000 events/s while
the evidence file reported "AM-6 | met, 16.5x". Now a test does — a test,
not a bench, because Criterion reports throughput and asserts nothing,
which is why this row measured nothing for six passes.
Measured on bnt-lap001: 341,280 ev/s in debug (3.4x the target), ~2.4-3.1M
in release. The spec target holds even in an unoptimized build, so the
gate needs no cfg split and runs in the ordinary `make test`.
The trap this task named — loosening a flaky timing assertion until it
never fires — is avoided by construction. The threshold is the spec value,
untouched; the constant says lowering it requires an ADR; and the failure
message repeats that, states measured headroom, and names reference
figures, so an agent hitting a red AM-6 is told not to tune it in the
place they are actually reading. Robustness comes from best-of-N, not from
a lower bar: a throughput floor asks whether the machine is capable, so
transient load should not fail the build.
Two positive controls in the test: a run that applied fewer than 50,000
events, or measured zero elapsed time, fails rather than scoring as
infinite throughput.
Verified by a PROPERTY mutation — 4,000 black_box iterations injected into
GroundState::fold, the hot path — not a threshold tweak, which would only
prove the comparison runs.
And the FA class found last pass is now gated. mutation-check rows gained
an `expect` field: the mutant's output must contain the row's stated
failure string or the verdict is WRONG-REASON, not red. Without it a
mutation that merely failed to compile would credit its row with an
assertion it does not have. Verified by pointing expect at a string the
verifier never prints and watching the verdict flip. This is remedy (2)
from the CB-WP-0005 retrospective, built a task earlier than planned
because the class it guards is the newest and most dangerous.
M-D1-MUT: 4 -> 5 of 14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:32:16 +02:00
|
|
|
|
/// AM-6 target from GameKernel §5, in applied events per second.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **Pinned, not tuned.** CB-WP-0006 T01 named the trap up front: a
|
|
|
|
|
|
/// timing assertion is flaky by nature and the reflex is to loosen it
|
|
|
|
|
|
/// until it never fires, which reproduces the defect being fixed —
|
|
|
|
|
|
/// this row was `unmutatable` because *nothing in the workspace
|
|
|
|
|
|
/// compared any number to 100,000*, while the evidence file reported
|
|
|
|
|
|
/// `AM-6 | met, 16.5×`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Measured on bnt-lap001 2026-07-31: **~182k–212k ev/s in debug**,
|
|
|
|
|
|
/// **~2.4M–3.1M ev/s in release**. So the spec target holds even in an
|
|
|
|
|
|
/// unoptimized build, with ~1.8× headroom there and ~24× in release.
|
|
|
|
|
|
/// **Lowering this constant requires an ADR.**
|
|
|
|
|
|
const AM6_EVENTS_PER_SEC: f64 = 100_000.0;
|
|
|
|
|
|
|
|
|
|
|
|
/// Best of N samples. A throughput *floor* asks "is this machine
|
|
|
|
|
|
/// capable", so transient load should not fail the build; taking the
|
|
|
|
|
|
/// max makes the gate robust without loosening the threshold, which is
|
|
|
|
|
|
/// the trade this task was told to avoid making on the threshold.
|
|
|
|
|
|
const AM6_SAMPLES: usize = 3;
|
|
|
|
|
|
|
|
|
|
|
|
/// AM-6: applied events/s on the synthetic workload must clear the
|
|
|
|
|
|
/// spec target. A test, not a bench — Criterion reports throughput and
|
|
|
|
|
|
/// asserts nothing, which is why this row measured nothing for six
|
|
|
|
|
|
/// passes.
|
CB-WP-0006 T04: withdraw AM-4c; and fix where AM-6 is measured
AM-4c is withdrawn from the acceptance table and retained as a reported
diagnostic. GameKernel §5a carries the argument.
The ratio has no monotone better direction. INTENT's rule is "own the
semantics, assimilate the implementation": rising can mean owning
semantics properly or reimplementing what should have been assimilated;
falling can mean leverage or dependency bloat. A target requires knowing
which way is better. It is also redundant — AM-4a/AM-4b bound the
denominator and AM-2 bounds own-source density, so AM-4c is a ratio of two
already-targeted quantities.
Measured at withdrawal: 1,426 own lines per 100k third-party (shipped),
1,107 (dev). make dep-weight now prints both, labelled diagnostic — the
row was never actually reported before.
M-D1-MUT keeps AM-4c in its denominator on purpose and says so in the
output. Dropping it would move the score 7/14 -> 7/13 without enforcing
anything: a score improved by deleting the question.
Decided before Phase B deliberately, since ADR-0005 predicts own-source
growth that will move this ratio; deciding after would be the retarget
§Step 4 forbids.
A T01 correction found here. The AM-6 gate failed inside `make all` at
38,753 ev/s against 341,280 in isolation — a 9x drop, because cargo test
runs binaries and threads concurrently. A throughput assertion inside a
parallel harness measures contention, not throughput. T01's measurement
was valid; its gate placement was not.
Fixed by running it only where valid — #[ignore] plus `make am6` in
release with --test-threads=1, now 2.0M ev/s at 20.2x headroom — and not
by lowering the target, which T01 forbade. My first attempt did drift that
way, adding a debug "sanity floor" of 50,000, and was backed out: a second
threshold is still a second chance to tune.
The mutation then went SURVIVED on the first run after the move. 4,000
black_box iterations were calibrated against debug's 3.4x headroom and are
invisible against release's 20x. Raised to 100,000; back to red. A weak
mutation is not a fixed property of a row — it can become weak when the
row's measurement conditions change.
Tier S (amends one row, creates no capability), chaos d4=2, no override.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:06:00 +02:00
|
|
|
|
///
|
|
|
|
|
|
/// **`#[ignore]` on purpose, and this is the T04 correction.** The
|
|
|
|
|
|
/// assertion first ran inside `make all` and failed at 38,753 ev/s
|
|
|
|
|
|
/// against 341,280 measured in isolation — a 9x drop, because
|
|
|
|
|
|
/// `cargo test` runs test binaries and threads **concurrently**. A
|
|
|
|
|
|
/// throughput assertion inside a parallel harness measures contention,
|
|
|
|
|
|
/// not throughput. The fix is not a lower target (T01 forbade that,
|
|
|
|
|
|
/// and it would reproduce the defect being fixed) but a measurement
|
|
|
|
|
|
/// that only runs where it is valid: `make am6`, release,
|
|
|
|
|
|
/// `--test-threads=1`.
|
CB-WP-0006 T01: assert the AM-6 throughput target
Nothing in the workspace compared any number to 100,000 events/s while
the evidence file reported "AM-6 | met, 16.5x". Now a test does — a test,
not a bench, because Criterion reports throughput and asserts nothing,
which is why this row measured nothing for six passes.
Measured on bnt-lap001: 341,280 ev/s in debug (3.4x the target), ~2.4-3.1M
in release. The spec target holds even in an unoptimized build, so the
gate needs no cfg split and runs in the ordinary `make test`.
The trap this task named — loosening a flaky timing assertion until it
never fires — is avoided by construction. The threshold is the spec value,
untouched; the constant says lowering it requires an ADR; and the failure
message repeats that, states measured headroom, and names reference
figures, so an agent hitting a red AM-6 is told not to tune it in the
place they are actually reading. Robustness comes from best-of-N, not from
a lower bar: a throughput floor asks whether the machine is capable, so
transient load should not fail the build.
Two positive controls in the test: a run that applied fewer than 50,000
events, or measured zero elapsed time, fails rather than scoring as
infinite throughput.
Verified by a PROPERTY mutation — 4,000 black_box iterations injected into
GroundState::fold, the hot path — not a threshold tweak, which would only
prove the comparison runs.
And the FA class found last pass is now gated. mutation-check rows gained
an `expect` field: the mutant's output must contain the row's stated
failure string or the verdict is WRONG-REASON, not red. Without it a
mutation that merely failed to compile would credit its row with an
assertion it does not have. Verified by pointing expect at a string the
verifier never prints and watching the verdict flip. This is remedy (2)
from the CB-WP-0005 retrospective, built a task earlier than planned
because the class it guards is the newest and most dangerous.
M-D1-MUT: 4 -> 5 of 14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:32:16 +02:00
|
|
|
|
#[test]
|
CB-WP-0006 T04: withdraw AM-4c; and fix where AM-6 is measured
AM-4c is withdrawn from the acceptance table and retained as a reported
diagnostic. GameKernel §5a carries the argument.
The ratio has no monotone better direction. INTENT's rule is "own the
semantics, assimilate the implementation": rising can mean owning
semantics properly or reimplementing what should have been assimilated;
falling can mean leverage or dependency bloat. A target requires knowing
which way is better. It is also redundant — AM-4a/AM-4b bound the
denominator and AM-2 bounds own-source density, so AM-4c is a ratio of two
already-targeted quantities.
Measured at withdrawal: 1,426 own lines per 100k third-party (shipped),
1,107 (dev). make dep-weight now prints both, labelled diagnostic — the
row was never actually reported before.
M-D1-MUT keeps AM-4c in its denominator on purpose and says so in the
output. Dropping it would move the score 7/14 -> 7/13 without enforcing
anything: a score improved by deleting the question.
Decided before Phase B deliberately, since ADR-0005 predicts own-source
growth that will move this ratio; deciding after would be the retarget
§Step 4 forbids.
A T01 correction found here. The AM-6 gate failed inside `make all` at
38,753 ev/s against 341,280 in isolation — a 9x drop, because cargo test
runs binaries and threads concurrently. A throughput assertion inside a
parallel harness measures contention, not throughput. T01's measurement
was valid; its gate placement was not.
Fixed by running it only where valid — #[ignore] plus `make am6` in
release with --test-threads=1, now 2.0M ev/s at 20.2x headroom — and not
by lowering the target, which T01 forbade. My first attempt did drift that
way, adding a debug "sanity floor" of 50,000, and was backed out: a second
threshold is still a second chance to tune.
The mutation then went SURVIVED on the first run after the move. 4,000
black_box iterations were calibrated against debug's 3.4x headroom and are
invisible against release's 20x. Raised to 100,000; back to red. A weak
mutation is not a fixed property of a row — it can become weak when the
row's measurement conditions change.
Tier S (amends one row, creates no capability), chaos d4=2, no override.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:06:00 +02:00
|
|
|
|
#[ignore = "throughput measurement — invalid under a parallel harness; run `make am6`"]
|
CB-WP-0006 T01: assert the AM-6 throughput target
Nothing in the workspace compared any number to 100,000 events/s while
the evidence file reported "AM-6 | met, 16.5x". Now a test does — a test,
not a bench, because Criterion reports throughput and asserts nothing,
which is why this row measured nothing for six passes.
Measured on bnt-lap001: 341,280 ev/s in debug (3.4x the target), ~2.4-3.1M
in release. The spec target holds even in an unoptimized build, so the
gate needs no cfg split and runs in the ordinary `make test`.
The trap this task named — loosening a flaky timing assertion until it
never fires — is avoided by construction. The threshold is the spec value,
untouched; the constant says lowering it requires an ADR; and the failure
message repeats that, states measured headroom, and names reference
figures, so an agent hitting a red AM-6 is told not to tune it in the
place they are actually reading. Robustness comes from best-of-N, not from
a lower bar: a throughput floor asks whether the machine is capable, so
transient load should not fail the build.
Two positive controls in the test: a run that applied fewer than 50,000
events, or measured zero elapsed time, fails rather than scoring as
infinite throughput.
Verified by a PROPERTY mutation — 4,000 black_box iterations injected into
GroundState::fold, the hot path — not a threshold tweak, which would only
prove the comparison runs.
And the FA class found last pass is now gated. mutation-check rows gained
an `expect` field: the mutant's output must contain the row's stated
failure string or the verdict is WRONG-REASON, not red. Without it a
mutation that merely failed to compile would credit its row with an
assertion it does not have. Verified by pointing expect at a string the
verifier never prints and watching the verdict flip. This is remedy (2)
from the CB-WP-0005 retrospective, built a task earlier than planned
because the class it guards is the newest and most dangerous.
M-D1-MUT: 4 -> 5 of 14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:32:16 +02:00
|
|
|
|
fn am6_throughput_clears_the_spec_target() {
|
|
|
|
|
|
let mut best = 0.0f64;
|
|
|
|
|
|
let mut sampled = 0usize;
|
|
|
|
|
|
for s in 0..AM6_SAMPLES {
|
|
|
|
|
|
let mut state = fresh(7 + s as u64);
|
|
|
|
|
|
let mut log = Vec::new();
|
|
|
|
|
|
let mut n = 0usize;
|
|
|
|
|
|
let t = Instant::now();
|
|
|
|
|
|
while n < 50_000 {
|
|
|
|
|
|
if state.outcome.is_some() {
|
|
|
|
|
|
state = fresh(7 + (s * 1_000_000 + n) as u64);
|
|
|
|
|
|
}
|
|
|
|
|
|
n += record_round(&mut state, &mut log);
|
|
|
|
|
|
log.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
let secs = t.elapsed().as_secs_f64();
|
|
|
|
|
|
// Positive control: a run that applied no events, or took no
|
|
|
|
|
|
// measurable time, must not be scored as infinite throughput.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
n >= 50_000,
|
|
|
|
|
|
"AM-6 harness applied {n} events, expected >= 50000"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(secs > 0.0, "AM-6 harness measured zero elapsed time");
|
|
|
|
|
|
best = best.max(n as f64 / secs);
|
|
|
|
|
|
sampled += n;
|
|
|
|
|
|
}
|
|
|
|
|
|
let headroom = best / AM6_EVENTS_PER_SEC;
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"AM-6: {best:.0} events/s (best of {AM6_SAMPLES}, {sampled} events, \
|
|
|
|
|
|
debug_assertions={}) — {headroom:.1}x the {AM6_EVENTS_PER_SEC:.0} target",
|
|
|
|
|
|
cfg!(debug_assertions)
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
best >= AM6_EVENTS_PER_SEC,
|
CB-WP-0006 T04: withdraw AM-4c; and fix where AM-6 is measured
AM-4c is withdrawn from the acceptance table and retained as a reported
diagnostic. GameKernel §5a carries the argument.
The ratio has no monotone better direction. INTENT's rule is "own the
semantics, assimilate the implementation": rising can mean owning
semantics properly or reimplementing what should have been assimilated;
falling can mean leverage or dependency bloat. A target requires knowing
which way is better. It is also redundant — AM-4a/AM-4b bound the
denominator and AM-2 bounds own-source density, so AM-4c is a ratio of two
already-targeted quantities.
Measured at withdrawal: 1,426 own lines per 100k third-party (shipped),
1,107 (dev). make dep-weight now prints both, labelled diagnostic — the
row was never actually reported before.
M-D1-MUT keeps AM-4c in its denominator on purpose and says so in the
output. Dropping it would move the score 7/14 -> 7/13 without enforcing
anything: a score improved by deleting the question.
Decided before Phase B deliberately, since ADR-0005 predicts own-source
growth that will move this ratio; deciding after would be the retarget
§Step 4 forbids.
A T01 correction found here. The AM-6 gate failed inside `make all` at
38,753 ev/s against 341,280 in isolation — a 9x drop, because cargo test
runs binaries and threads concurrently. A throughput assertion inside a
parallel harness measures contention, not throughput. T01's measurement
was valid; its gate placement was not.
Fixed by running it only where valid — #[ignore] plus `make am6` in
release with --test-threads=1, now 2.0M ev/s at 20.2x headroom — and not
by lowering the target, which T01 forbade. My first attempt did drift that
way, adding a debug "sanity floor" of 50,000, and was backed out: a second
threshold is still a second chance to tune.
The mutation then went SURVIVED on the first run after the move. 4,000
black_box iterations were calibrated against debug's 3.4x headroom and are
invisible against release's 20x. Raised to 100,000; back to red. A weak
mutation is not a fixed property of a row — it can become weak when the
row's measurement conditions change.
Tier S (amends one row, creates no capability), chaos d4=2, no override.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:06:00 +02:00
|
|
|
|
"AM-6 UNMET: {best:.0} events/s < {AM6_EVENTS_PER_SEC:.0} \
|
|
|
|
|
|
({headroom:.2}x). Reference: ~1.7M via `make am6` on \
|
|
|
|
|
|
bnt-lap001. Do NOT lower the target to pass — GameKernel §5 \
|
|
|
|
|
|
AM-6 is a spec value and lowering it needs an ADR. If this \
|
|
|
|
|
|
fired under a parallel harness, the measurement is invalid \
|
|
|
|
|
|
rather than the target: run `make am6`.",
|
CB-WP-0006 T01: assert the AM-6 throughput target
Nothing in the workspace compared any number to 100,000 events/s while
the evidence file reported "AM-6 | met, 16.5x". Now a test does — a test,
not a bench, because Criterion reports throughput and asserts nothing,
which is why this row measured nothing for six passes.
Measured on bnt-lap001: 341,280 ev/s in debug (3.4x the target), ~2.4-3.1M
in release. The spec target holds even in an unoptimized build, so the
gate needs no cfg split and runs in the ordinary `make test`.
The trap this task named — loosening a flaky timing assertion until it
never fires — is avoided by construction. The threshold is the spec value,
untouched; the constant says lowering it requires an ADR; and the failure
message repeats that, states measured headroom, and names reference
figures, so an agent hitting a red AM-6 is told not to tune it in the
place they are actually reading. Robustness comes from best-of-N, not from
a lower bar: a throughput floor asks whether the machine is capable, so
transient load should not fail the build.
Two positive controls in the test: a run that applied fewer than 50,000
events, or measured zero elapsed time, fails rather than scoring as
infinite throughput.
Verified by a PROPERTY mutation — 4,000 black_box iterations injected into
GroundState::fold, the hot path — not a threshold tweak, which would only
prove the comparison runs.
And the FA class found last pass is now gated. mutation-check rows gained
an `expect` field: the mutant's output must contain the row's stated
failure string or the verdict is WRONG-REASON, not red. Without it a
mutation that merely failed to compile would credit its row with an
assertion it does not have. Verified by pointing expect at a string the
verifier never prints and watching the verdict flip. This is remedy (2)
from the CB-WP-0005 retrospective, built a task earlier than planned
because the class it guards is the newest and most dangerous.
M-D1-MUT: 4 -> 5 of 14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:32:16 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
/// 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);
|
CB-WP-0006 T06: K10 replay bundles, --replay, and AM-7 re-earned
INTENT design decision 8 of 10, unimplemented for six passes. cb-sim had
no flag parsing at all, so --replay had nowhere to go.
The bundle is manifest + commands.log + initial.snapshot + expected.yaml,
dev-only behind the scenarios feature and charged to AM-4b. The command
stream goes through the K11 framing built in T05, so a truncated bundle is
detected rather than replayed short — the two tasks compose rather than
duplicating.
The reviewer's D2 correction was real: this was not "a directory of four
files". Pass carried only the end state, RunOutcome::Failed was a
formatted String, and scenario.rs created an EventLog, appended to it and
never read it. All three had to change.
The first round trip failed to reproduce, and the cause is worth keeping:
state_hash_hex over a serde_json::Value is a different canonical form than
over the typed aggregate — Value's map is key-sorted, a struct serializes
in declaration order. The bundle was written with one basis and verified
with the other. A round trip written to recompute its own comparison value
would have PASSED this bug; it failed because the recorded hash came from
the producing process, which is control 2's entire purpose.
make replay-test implements ADR-0005 §6's four controls, 14/14: a
committed deliberately-failing fixture outside the corpus with covers: []
so it neither fails `make sim` nor inflates AM-1; a tampered recorded hash
must fail; a log short by one byte and a corrupted length prefix must be
rejected; and a mutated manifest seed must fail — which bites only because
replay re-derives the initial state from seed+setup and checks it against
the recorded snapshot, since restoring from the snapshot alone would leave
the seed inert. Plus a control on the controls: the bundle must still
replay after every mutation is reverted.
AM-7's hash-identical clause is re-earned. The probe records a hash per
per-game segment and replays each from its own genesis; folding from the
wrong seed now fails. That is the clause ADR-0005 §4 withdrew as
mutation-proven inert. The scaling >= 0.9x clause is still unenforced, so
AM-7 stays PARTIAL — reported, not rounded up.
Kernel coverage 15/18 -> 16/18. facts-check immediately caught the spec's
copy of that number going stale, on a number that moved the same hour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:05:37 +02:00
|
|
|
|
// Per-game segments with the hash of the state that produced
|
|
|
|
|
|
// them. AM-7's `hash-identical` clause was withdrawn because
|
|
|
|
|
|
// the fold ran a multi-seed log into a single genesis state
|
|
|
|
|
|
// and then never compared the hash to anything. Segments make
|
|
|
|
|
|
// the comparison meaningful: each is a real replay.
|
|
|
|
|
|
let mut segments: Vec<(u64, usize, String)> = Vec::new();
|
|
|
|
|
|
let mut seed = 42u64;
|
|
|
|
|
|
let mut source = fresh(seed);
|
|
|
|
|
|
let mut seg_start = 0usize;
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
let mut stalls = 0;
|
|
|
|
|
|
while log.len() < target {
|
|
|
|
|
|
if source.outcome.is_some() {
|
CB-WP-0006 T06: K10 replay bundles, --replay, and AM-7 re-earned
INTENT design decision 8 of 10, unimplemented for six passes. cb-sim had
no flag parsing at all, so --replay had nowhere to go.
The bundle is manifest + commands.log + initial.snapshot + expected.yaml,
dev-only behind the scenarios feature and charged to AM-4b. The command
stream goes through the K11 framing built in T05, so a truncated bundle is
detected rather than replayed short — the two tasks compose rather than
duplicating.
The reviewer's D2 correction was real: this was not "a directory of four
files". Pass carried only the end state, RunOutcome::Failed was a
formatted String, and scenario.rs created an EventLog, appended to it and
never read it. All three had to change.
The first round trip failed to reproduce, and the cause is worth keeping:
state_hash_hex over a serde_json::Value is a different canonical form than
over the typed aggregate — Value's map is key-sorted, a struct serializes
in declaration order. The bundle was written with one basis and verified
with the other. A round trip written to recompute its own comparison value
would have PASSED this bug; it failed because the recorded hash came from
the producing process, which is control 2's entire purpose.
make replay-test implements ADR-0005 §6's four controls, 14/14: a
committed deliberately-failing fixture outside the corpus with covers: []
so it neither fails `make sim` nor inflates AM-1; a tampered recorded hash
must fail; a log short by one byte and a corrupted length prefix must be
rejected; and a mutated manifest seed must fail — which bites only because
replay re-derives the initial state from seed+setup and checks it against
the recorded snapshot, since restoring from the snapshot alone would leave
the seed inert. Plus a control on the controls: the bundle must still
replay after every mutation is reverted.
AM-7's hash-identical clause is re-earned. The probe records a hash per
per-game segment and replays each from its own genesis; folding from the
wrong seed now fails. That is the clause ADR-0005 §4 withdrew as
mutation-proven inert. The scaling >= 0.9x clause is still unenforced, so
AM-7 stays PARTIAL — reported, not rounded up.
Kernel coverage 15/18 -> 16/18. facts-check immediately caught the spec's
copy of that number going stale, on a number that moved the same hour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:05:37 +02:00
|
|
|
|
segments.push((seed, seg_start, state_hash_hex(&source)));
|
|
|
|
|
|
seg_start = log.len();
|
|
|
|
|
|
seed += 1;
|
|
|
|
|
|
source = fresh(seed);
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
if record_round(&mut source, &mut log) == 0 {
|
|
|
|
|
|
stalls += 1;
|
|
|
|
|
|
assert!(stalls < 10, "round produced no events; builder stalled");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
CB-WP-0006 T06: K10 replay bundles, --replay, and AM-7 re-earned
INTENT design decision 8 of 10, unimplemented for six passes. cb-sim had
no flag parsing at all, so --replay had nowhere to go.
The bundle is manifest + commands.log + initial.snapshot + expected.yaml,
dev-only behind the scenarios feature and charged to AM-4b. The command
stream goes through the K11 framing built in T05, so a truncated bundle is
detected rather than replayed short — the two tasks compose rather than
duplicating.
The reviewer's D2 correction was real: this was not "a directory of four
files". Pass carried only the end state, RunOutcome::Failed was a
formatted String, and scenario.rs created an EventLog, appended to it and
never read it. All three had to change.
The first round trip failed to reproduce, and the cause is worth keeping:
state_hash_hex over a serde_json::Value is a different canonical form than
over the typed aggregate — Value's map is key-sorted, a struct serializes
in declaration order. The bundle was written with one basis and verified
with the other. A round trip written to recompute its own comparison value
would have PASSED this bug; it failed because the recorded hash came from
the producing process, which is control 2's entire purpose.
make replay-test implements ADR-0005 §6's four controls, 14/14: a
committed deliberately-failing fixture outside the corpus with covers: []
so it neither fails `make sim` nor inflates AM-1; a tampered recorded hash
must fail; a log short by one byte and a corrupted length prefix must be
rejected; and a mutated manifest seed must fail — which bites only because
replay re-derives the initial state from seed+setup and checks it against
the recorded snapshot, since restoring from the snapshot alone would leave
the seed inert. Plus a control on the controls: the bundle must still
replay after every mutation is reverted.
AM-7's hash-identical clause is re-earned. The probe records a hash per
per-game segment and replays each from its own genesis; folding from the
wrong seed now fails. That is the clause ADR-0005 §4 withdrew as
mutation-proven inert. The scaling >= 0.9x clause is still unenforced, so
AM-7 stays PARTIAL — reported, not rounded up.
Kernel coverage 15/18 -> 16/18. facts-check immediately caught the spec's
copy of that number going stale, on a number that moved the same hour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:05:37 +02:00
|
|
|
|
segments.push((seed, seg_start, state_hash_hex(&source)));
|
|
|
|
|
|
|
|
|
|
|
|
// AM-7 hash-identical, re-earned (CB-WP-0006 T06). Replay each
|
|
|
|
|
|
// segment from its own genesis and require the recorded hash.
|
|
|
|
|
|
let mut ends: Vec<usize> = segments.iter().skip(1).map(|s| s.1).collect();
|
|
|
|
|
|
ends.push(log.len());
|
|
|
|
|
|
let mut verified = 0usize;
|
|
|
|
|
|
for ((seg_seed, start, want), end) in segments.iter().zip(ends) {
|
|
|
|
|
|
let mut st = fresh(*seg_seed);
|
|
|
|
|
|
for e in &log[*start..end] {
|
|
|
|
|
|
st.fold(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
&state_hash_hex(&st),
|
|
|
|
|
|
want,
|
|
|
|
|
|
"AM-7 hash-identical UNMET: replaying segment seeded \
|
|
|
|
|
|
{seg_seed} ({start}..{end}) did not reproduce its \
|
|
|
|
|
|
recorded state hash"
|
|
|
|
|
|
);
|
|
|
|
|
|
verified += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Positive control: verifying zero segments would pass vacuously.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
verified >= 2,
|
|
|
|
|
|
"AM-7 needs several segments to verify, got {verified}"
|
|
|
|
|
|
);
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
/// **GD-0001, INVERTED 2026-08-04.** Group success is reachable at
|
|
|
|
|
|
/// every seat count.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This test used to assert the opposite, and it was right to: the
|
|
|
|
|
|
/// maintainer played several 3-player games on 2026-08-03 and could
|
|
|
|
|
|
/// not win any of them, because GR-S01 dealt 2/3/4 Problems worth
|
|
|
|
|
|
/// 3/6/10 against thresholds of 5/7/9.
|
2026-08-03 23:49:49 +02:00
|
|
|
|
///
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
/// ground-game ruled the deal on 2026-08-04 — **Surface always, plus
|
|
|
|
|
|
/// hidden priorities 1..=k** — which with this edition's values gives
|
|
|
|
|
|
/// **6 / 9 / 12**. The ruling said to invert this test rather than
|
|
|
|
|
|
/// retire it, and that is why it is still here: a reader learns the
|
|
|
|
|
|
/// game *became* winnable, not that a test quietly vanished.
|
2026-08-03 23:49:49 +02:00
|
|
|
|
///
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
/// It reads both numbers out of the engine — the deal and
|
|
|
|
|
|
/// `threshold` — so it cannot drift from the rules it tests.
|
2026-08-03 23:49:49 +02:00
|
|
|
|
///
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
/// **The 2p case is the one to watch.** 2+2+2 against a threshold of
|
|
|
|
|
|
/// 5 means a full clear: any two Problems sum to 4. Reachable is not
|
|
|
|
|
|
/// forgiving, and ground-game kept that deliberately.
|
2026-08-03 23:49:49 +02:00
|
|
|
|
#[test]
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
fn gd0001_group_success_is_reachable_at_every_seat_count() {
|
2026-08-03 23:49:49 +02:00
|
|
|
|
let mut verdicts = Vec::new();
|
|
|
|
|
|
for seats in 2..=6u8 {
|
|
|
|
|
|
let state = fresh_n(seats);
|
|
|
|
|
|
let best: u32 = state.problems.values().map(|p| u32::from(p.value)).sum();
|
|
|
|
|
|
let need = state.threshold();
|
|
|
|
|
|
verdicts.push((seats, state.problems.len(), best, need, best >= need));
|
|
|
|
|
|
println!(
|
|
|
|
|
|
" {seats}p: {} problem(s) worth {best} against a threshold of {need} \u{2014} {}",
|
|
|
|
|
|
state.problems.len(),
|
CB-WP-0023: SOLVE is legal only where it can do something
Implements ground-game's ruling of 2026-08-03. make all exits 0, 26
scenarios, rule coverage 59/59, and no scenario encoded the bug.
The rule ended up somewhere other than where I put it, and a gate moved
it. It went into legal_commands first; the AM-1 coverage gate then
demanded a scenario for the new GR-P05, and scenarios drive validate, not
the offer layer. A rule enforced only by the offer is enforced only for
clients that ask what is legal -- the browser would be filtered and a
scenario file would walk straight past it. Once GR-P05 moved into
validate, every condition in legal_commands was dead code, and the
layering test said so in those words.
And the reported case was not the one I reported. CB-WP-0018 and the
message to ground-game described SOLVE offered on a FACE-DOWN Problem.
Measured: validate already rejected face-down, so it never was offered.
Problem 1 is the Surface Problem, face-up from the deal -- the
maintainer's three inert SOLVEs were the HAND case, holding no Clarify
for a Clarify Problem. The ruling covers both so nothing is invalidated,
but the record was wrong.
Four conditions asserted separately, because one 'SOLVE is filtered' test
would pass with three of four implemented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:18:40 +02:00
|
|
|
|
if best >= need {
|
|
|
|
|
|
"reachable"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"UNREACHABLE"
|
|
|
|
|
|
}
|
2026-08-03 23:49:49 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
let unreachable: Vec<u8> = verdicts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|(_, _, _, _, ok)| !ok)
|
|
|
|
|
|
.map(|(s, _, _, _, _)| *s)
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
unreachable.is_empty(),
|
|
|
|
|
|
"group success is unreachable at {unreachable:?} seats — the \
|
|
|
|
|
|
ruled deal (Surface + hidden 1..=k) is not what the engine \
|
|
|
|
|
|
deals, or the edition values changed"
|
2026-08-03 23:49:49 +02:00
|
|
|
|
);
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
// The ruled numbers, asserted rather than implied: 6/9/12 against
|
|
|
|
|
|
// 5/7/9. A deal that was reachable for the wrong reason — more
|
|
|
|
|
|
// Problems, or richer ones — would pass the check above.
|
|
|
|
|
|
let available: Vec<u32> = verdicts.iter().map(|(_, _, b, _, _)| *b).collect();
|
2026-08-03 23:49:49 +02:00
|
|
|
|
assert_eq!(
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
available,
|
|
|
|
|
|
vec![6, 9, 9, 12, 12],
|
|
|
|
|
|
"available points are not the 6/9/12 ground-game ruled against"
|
2026-08-03 23:49:49 +02:00
|
|
|
|
);
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
// Positive control: a harness that measured nothing would report
|
|
|
|
|
|
// an empty `unreachable` and pass.
|
|
|
|
|
|
assert_eq!(verdicts.len(), 5, "the sweep did not cover 2..=6 seats");
|
2026-08-03 23:49:49 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
/// AM-7 scaling floor from GameKernel §5: fold throughput at 100k
|
|
|
|
|
|
/// events must be at least this fraction of throughput at 5k.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **Pinned, not tuned** — same rule as `AM6_EVENTS_PER_SEC`. The
|
|
|
|
|
|
/// baseline it was written against is boardgame.io at 0.45–0.66×,
|
|
|
|
|
|
/// degrading to DNF at 100k. Lowering it requires an ADR.
|
|
|
|
|
|
const AM7_SCALING_FLOOR: f64 = 0.9;
|
|
|
|
|
|
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
/// The window that is timed, and how deep the late one sits.
|
|
|
|
|
|
/// **Both timed windows are the same size** — that is the correction
|
|
|
|
|
|
/// (CB-WP-0021 T06); see `paired_ratio`.
|
|
|
|
|
|
const AM7_WINDOW: usize = 5_000;
|
|
|
|
|
|
const AM7_DEPTH: usize = 100_000;
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
|
|
|
|
|
|
/// Events applied **per leg** per sample.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Sized against the machine's drift, not against timer resolution.
|
|
|
|
|
|
/// At 2,000,000 a sample took ~50 ms, and this machine's throughput
|
|
|
|
|
|
/// wanders by 2.5× over a few seconds (CB-EV-0013 §1) — so a 50 ms
|
|
|
|
|
|
/// sample measures whatever the clock happened to be doing. At 10 M
|
|
|
|
|
|
/// each leg runs ~0.35 s and averages over the drift instead of
|
|
|
|
|
|
/// sampling a point on it. Measured: medians 0.987 / 0.991 / 0.989
|
|
|
|
|
|
/// across three runs, and 0.989 under 8-way CPU contention. Doubling
|
|
|
|
|
|
/// this to 20 M cost 9 s more per run and did not tighten them.
|
|
|
|
|
|
const AM7_EVENTS_PER_LEG: usize = 10_000_000;
|
|
|
|
|
|
|
|
|
|
|
|
/// Paired samples per run. Nine rather than AM-6's three because the
|
|
|
|
|
|
/// verdict is a **median**, not a best-of: a median needs enough
|
|
|
|
|
|
/// samples that one excursion cannot move it.
|
|
|
|
|
|
const AM7_SAMPLES: usize = 9;
|
|
|
|
|
|
|
|
|
|
|
|
/// Fraction of samples that must agree with the median's verdict for
|
|
|
|
|
|
/// the run to be a measurement rather than noise.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **This replaced a unanimity guard that was measurably wrong.** The
|
|
|
|
|
|
/// first version declared INDETERMINATE whenever any sample fell on
|
|
|
|
|
|
/// the other side of the floor. Under deliberate 8-way CPU contention
|
|
|
|
|
|
/// the ratio held at a median of 0.971 — the pairing works, and
|
|
|
|
|
|
/// absolute throughput had dropped 4× — but one sample read 0.899,
|
|
|
|
|
|
/// a thousandth under the floor, and the guard turned a good
|
|
|
|
|
|
/// measurement into a failed build. It also fired intermittently
|
|
|
|
|
|
/// inside `mutation-check`, where this test runs straight after a
|
|
|
|
|
|
/// 50-second rebuild.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// A gate that fails when the machine is busy is a flake, and a flake
|
|
|
|
|
|
/// gets suppressed rather than fixed. Requiring a two-thirds majority
|
|
|
|
|
|
/// keeps the guard's purpose — refusing to read a coin-flip as a
|
|
|
|
|
|
/// verdict — without treating a single outlier as one.
|
|
|
|
|
|
const AM7_AGREEMENT: f64 = 2.0 / 3.0;
|
|
|
|
|
|
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
/// Fold `n` events from `log` starting at `from`, on a state already
|
|
|
|
|
|
/// advanced to `from`, returning only the time inside the fold loop.
|
|
|
|
|
|
/// The state after folding `log[..depth]` — the history the window
|
|
|
|
|
|
/// will be folded on top of.
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
///
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
/// Built **once per sample**, not once per repetition. The first
|
|
|
|
|
|
/// version re-walked the prefix every rep: 2,000 reps x 100,000
|
|
|
|
|
|
/// events is 200M untimed folds per sample, and under the
|
|
|
|
|
|
/// history-proportional mutation that is quadratic and never
|
|
|
|
|
|
/// finishes. A control that cannot be run is not a control.
|
|
|
|
|
|
fn state_at(log: &[GroundEvent], depth: usize) -> GroundState {
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
let mut state = fresh(42);
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
for e in &log[..depth] {
|
|
|
|
|
|
state.fold(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
state
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Fold `n` events from `at` onto a clone of `state`, returning only
|
|
|
|
|
|
/// the time inside the fold loop. The clone is outside the clock.
|
|
|
|
|
|
fn fold_window(
|
|
|
|
|
|
state: &GroundState,
|
|
|
|
|
|
log: &[GroundEvent],
|
|
|
|
|
|
at: usize,
|
|
|
|
|
|
n: usize,
|
|
|
|
|
|
) -> std::time::Duration {
|
|
|
|
|
|
let mut st = state.clone();
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
let t = Instant::now();
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
for e in &log[at..at + n] {
|
|
|
|
|
|
st.fold(e);
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
}
|
|
|
|
|
|
let dt = t.elapsed();
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
std::hint::black_box(&st);
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
dt
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
/// One paired sample: the SAME window size at two history depths.
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
///
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
/// **The corrected measurement (CB-WP-0021 T06).** The previous one
|
|
|
|
|
|
/// folded a 5,000-event log and a 100,000-event log and compared
|
|
|
|
|
|
/// their throughputs, which confounds two different things:
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
///
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
/// 1. does cost per event grow with how many events have already
|
|
|
|
|
|
/// been folded? — the property AM-7 claims; and
|
|
|
|
|
|
/// 2. does streaming a 20x longer `Vec` cost more per element? — a
|
|
|
|
|
|
/// memory-hierarchy fact true of any program.
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
///
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
/// It measured (2) and reported it as (1). Importing the edition data
|
|
|
|
|
|
/// enlarged the aggregate — four Problems instead of three, real
|
|
|
|
|
|
/// values — and the ratio fell 0.97 -> 0.845 against a 0.9 floor
|
|
|
|
|
|
/// **with the state provably bounded**: identical deck, discard,
|
|
|
|
|
|
/// Problem and hand sizes after 5k and 100k events. A row that fails
|
|
|
|
|
|
/// because the game got bigger, while the property it names is
|
|
|
|
|
|
/// untouched, is measuring the wrong thing.
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
///
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
/// So: time a 5,000-event window at depth 0, and the same-sized window
|
|
|
|
|
|
/// at depth 100,000. Equal windows mean equal streaming cost, and the
|
|
|
|
|
|
/// only difference left is history depth — which is the claim.
|
|
|
|
|
|
fn paired_ratio(log: &[GroundEvent]) -> (f64, f64, f64) {
|
|
|
|
|
|
let reps = AM7_EVENTS_PER_LEG.div_ceil(AM7_WINDOW);
|
|
|
|
|
|
let early = state_at(log, 0);
|
|
|
|
|
|
let late = state_at(log, AM7_DEPTH);
|
|
|
|
|
|
let (mut t_early, mut t_late) = (std::time::Duration::ZERO, std::time::Duration::ZERO);
|
|
|
|
|
|
for _ in 0..reps {
|
|
|
|
|
|
// Interleaved, so this machine's 2.5x drift is common-mode
|
|
|
|
|
|
// and divides out (CB-EV-0013 section 1).
|
|
|
|
|
|
// THE SAME EVENTS on both legs. Timing log[0..W] against
|
|
|
|
|
|
// log[DEPTH..DEPTH+W] compared two different event mixes and
|
|
|
|
|
|
// read 0.573 on code whose state is provably bounded — a
|
|
|
|
|
|
// second confound, introduced while removing the first.
|
|
|
|
|
|
// Identical events mean the only difference left is how much
|
|
|
|
|
|
// history the state carries, which is the claim.
|
|
|
|
|
|
t_early += fold_window(&early, log, 0, AM7_WINDOW);
|
|
|
|
|
|
t_late += fold_window(&late, log, 0, AM7_WINDOW);
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
}
|
|
|
|
|
|
assert!(
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
t_early.as_secs_f64() > 0.0 && t_late.as_secs_f64() > 0.0,
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
"AM-7 measured zero elapsed time"
|
|
|
|
|
|
);
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
let n = (reps * AM7_WINDOW) as f64;
|
|
|
|
|
|
let tp_early = n / t_early.as_secs_f64();
|
|
|
|
|
|
let tp_late = n / t_late.as_secs_f64();
|
|
|
|
|
|
(tp_early, tp_late, tp_late / tp_early)
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Build one growing log of at least `target` events, the same way
|
|
|
|
|
|
/// `replay_100k_events_is_linear_and_fast` does.
|
|
|
|
|
|
fn growing_log(target: usize) -> Vec<GroundEvent> {
|
|
|
|
|
|
let mut log = Vec::with_capacity(target);
|
|
|
|
|
|
let mut seed = 42u64;
|
|
|
|
|
|
let mut source = fresh(seed);
|
|
|
|
|
|
let mut stalls = 0;
|
|
|
|
|
|
while log.len() < target {
|
|
|
|
|
|
if source.outcome.is_some() {
|
|
|
|
|
|
seed += 1;
|
|
|
|
|
|
source = fresh(seed);
|
|
|
|
|
|
}
|
|
|
|
|
|
if record_round(&mut source, &mut log) == 0 {
|
|
|
|
|
|
stalls += 1;
|
|
|
|
|
|
assert!(stalls < 10, "round produced no events; builder stalled");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
log
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// AM-7's `scaling >= 0.9x` clause, which was inert for nine passes.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `mutation-check.py` said it plainly every run: *"no code computes
|
|
|
|
|
|
/// the ratio of throughput @100k to @5k or compares it to 0.9;
|
|
|
|
|
|
/// Criterion reports both and nothing relates them."* The sibling test
|
|
|
|
|
|
/// above is even named `replay_100k_events_is_linear_and_fast` and
|
|
|
|
|
|
/// checks the two sizes **independently** — it computes both numbers,
|
|
|
|
|
|
/// prints both, and never divides one by the other.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **`#[ignore]` for the same reason AM-6 is** (CB-WP-0006 T04): a
|
|
|
|
|
|
/// throughput assertion inside a parallel `cargo test` harness
|
|
|
|
|
|
/// measures contention. A *ratio* of two such timings is worse, not
|
|
|
|
|
|
/// better — the noise multiplies rather than cancels. Run `make am7`.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
#[ignore = "throughput ratio — invalid under a parallel harness; run `make am7`"]
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
fn am7_cost_per_event_does_not_grow_with_history() {
|
|
|
|
|
|
let log = growing_log(AM7_DEPTH + AM7_WINDOW);
|
|
|
|
|
|
|
|
|
|
|
|
// Positive control on the shape of the measurement. The windows
|
|
|
|
|
|
// must be the same size — that is the correction — and the late
|
|
|
|
|
|
// one must actually sit deep in the log. A harness that measured
|
|
|
|
|
|
// depth 0 twice would report ~1.0 and look excellent.
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
assert!(
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
log.len() >= AM7_DEPTH + AM7_WINDOW,
|
|
|
|
|
|
"log is {} events, too short for a window at depth {AM7_DEPTH}",
|
|
|
|
|
|
log.len()
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
);
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
const { assert!(AM7_DEPTH >= 15 * AM7_WINDOW) };
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
|
|
|
|
|
|
let mut ratios = Vec::with_capacity(AM7_SAMPLES);
|
|
|
|
|
|
for _ in 0..AM7_SAMPLES {
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
let (tp_early, tp_late, ratio) = paired_ratio(&log);
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
println!(
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
" AM-7 sample: {tp_early:.0} ev/s at depth 0 → \
|
|
|
|
|
|
{tp_late:.0} ev/s at depth {AM7_DEPTH} = {ratio:.3}x"
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
);
|
|
|
|
|
|
ratios.push(ratio);
|
|
|
|
|
|
}
|
|
|
|
|
|
ratios.sort_by(|a, b| a.partial_cmp(b).expect("no NaN ratios"));
|
|
|
|
|
|
let (worst, median, best) = (
|
|
|
|
|
|
ratios[0],
|
|
|
|
|
|
ratios[ratios.len() / 2],
|
|
|
|
|
|
ratios[ratios.len() - 1],
|
|
|
|
|
|
);
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"AM-7 scaling: {worst:.3}x / {median:.3}x / {best:.3}x \
|
|
|
|
|
|
(worst/median/best of {AM7_SAMPLES}, floor {AM7_SCALING_FLOOR})"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// The spread is reported, not hidden behind a best-of. The verdict
|
|
|
|
|
|
// is the median, and it counts as a measurement only if a
|
|
|
|
|
|
// two-thirds majority of samples agree with it — see
|
|
|
|
|
|
// AM7_AGREEMENT for the unanimity guard this replaced and why.
|
|
|
|
|
|
let agreeing = ratios
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|r| (**r >= AM7_SCALING_FLOOR) == (median >= AM7_SCALING_FLOOR))
|
|
|
|
|
|
.count();
|
|
|
|
|
|
let agreement = agreeing as f64 / ratios.len() as f64;
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
agreement >= AM7_AGREEMENT,
|
|
|
|
|
|
"AM-7 INDETERMINATE: only {agreeing} of {} samples agree with \
|
|
|
|
|
|
the median ({worst:.3}x..{best:.3}x around \
|
|
|
|
|
|
{AM7_SCALING_FLOOR}). The machine is too noisy for this to be \
|
|
|
|
|
|
a measurement; do not read the best sample as a pass.",
|
|
|
|
|
|
ratios.len()
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
median >= AM7_SCALING_FLOOR,
|
CB-WP-0021 T06: fix AM-7's measurement, not its floor
The row folded a 5,000-event log against a 100,000-event log and compared
throughputs, which confounds 'does cost per event grow with history'
(the property it claims) with 'does streaming a 20x longer Vec cost more
per element' (a memory-hierarchy fact true of any program). It measured
the second and reported it as the first: importing the edition enlarged
the aggregate and the ratio fell to 0.845 with the state bounded.
Corrected to time the SAME 5,000 events on a state at depth 0 and on a
state at depth 100,000. Equal windows, equal event mix, so the only
difference left is history depth.
corrected: clean 1.004, mutated 0.589 (red)
old: clean 0.845 (red on healthy code), mutated 0.751
It also runs in 8.5s instead of timing out: the first version re-walked
the 100k prefix every repetition, 200M untimed folds per sample, which
under the mutation never finished. A control that cannot be run is not a
control. It now advances to depth once per sample and clones.
Two of my own measurements here were wrong and both were caught by
measuring again. A 2-minute timeout killed the shell line before its
restoring cp ran, so three readings were taken on MUTATED code -- I
diagnosed an event-mix confound that did not exist and 'fixed' it. The
fix is kept on its merits; the justification was fiction. And the probe
that proved state was bounded had checked four of eleven collections.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:38 +02:00
|
|
|
|
"AM-7 UNMET: folding a {AM7_WINDOW}-event window at history \
|
|
|
|
|
|
depth {AM7_DEPTH} runs at {median:.3}x the same window at \
|
|
|
|
|
|
depth 0 (worst {worst:.3}x, best {best:.3}x), below the \
|
|
|
|
|
|
{AM7_SCALING_FLOOR} floor. Cost per event is growing with \
|
|
|
|
|
|
history — check whether something in the aggregate grows \
|
|
|
|
|
|
without bound. Baseline: boardgame.io 0.45-0.66x, DNF at \
|
|
|
|
|
|
100k. Do NOT lower the floor to pass — GameKernel §5 AM-7 is \
|
|
|
|
|
|
a spec value and lowering it needs an ADR."
|
CB-WP-0015: the two inert clauses, AM-7 scaling and AM-8 N=10
Provenance (tier S, one paragraph in lieu of survey and ADR): the two
clauses mutation-check has reported inert since CB-WP-0005. AM-7's
scaling ratio was held up by a test literally named
replay_100k_events_is_linear_and_fast that computed both throughputs,
printed both, and never divided one by the other. AM-8's N=10 was held
up by a runner that does two.
Both are now red. AM-7 3/3, AM-8 2/2, M-D1-MUT 10/14, and ADR-0005's
>=10-of-14 prediction MET for the first time. Neither was closed by
amending the question away, which was the live risk: the denominator is
unchanged and the four unenforced rows are the four already
unenforceable.
AM-7 needed three estimators. Best-of-N per leg then divide (AM-6's,
correct for a floor on one number) gave 0.581-1.085 on an unchanged
binary; legs back-to-back gave medians 0.931-1.004; legs interleaved at
fold granularity give 0.987/0.991/0.989, and 0.989 under 8-way CPU
contention while absolute throughput fell 4x. The INDETERMINATE guard
demanded unanimity and failed a good measurement over one sample
0.001 under the floor; it now requires a two-thirds majority. The
control that matters: AM-6's constant-cost mutation halves throughput
and leaves this ratio at 0.999x green, so AM-7 is not a second AM-6.
AM-8 kept N=10 because the measurement said so. Perturbing the RNG only
from its fourth construction on: --runs 2 PASSES, --runs 10 fails. A
late-onset divergence is deterministic, not flaky, so it is a control
rather than a coin flip. Ten runs live on one scenario (make am8, ~2s)
rather than all 25 (47s a build). GameKernel 5b records it.
The full run also found AM-4a's own mutation stale since ADR-0008 D3
moved the target 250,000 -> 161,000 in CB-WP-0013 -- reported
HARNESS-BROKEN, no score published. The build-free half of that check
is now a --self-test assertion, so make all catches the next one.
mutation-check clauses may now carry their own verify and mutation, and
then the enforced flag is measured rather than declared; a declaration
disagreeing with its measurement is refused.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:07:08 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
}
|