diff --git a/evidence/CB-EV-0032-h2-measured.md b/evidence/CB-EV-0032-h2-measured.md index 63ce1af..68a9da2 100644 --- a/evidence/CB-EV-0032-h2-measured.md +++ b/evidence/CB-EV-0032-h2-measured.md @@ -96,7 +96,57 @@ the best evidence in this file: **the scoping is what saves it**, not any other difference between the packages. Their optional A/B was worth running. -## 5. What this does not say +## 5. ATTACK — affordable under H2, still not profitable + +Added after the first report, because the H2 panel measured wins, variance +and DARVO and **not ATTACK selection**, which is F17's actual question. + +`attack-value`'s rank-75 policy — *attack when convenient* — SHARED +GROUND, wins with attacks and DARVO arms in brackets: + +| seats | baseline | H1 | H2 | +|---|---|---|---| +| 2p | 132 (604 atk, 81 arms) | 66 (180, 59) | **65 (248, 102)** | +| 3p | 165 (592, 125) | **0** (0, 0) | **112 (250, 52)** | +| 4p | 190 (923, 218) | **0** (0, 0) | **173 (626, 165)** | +| 6p | 200 (389, 31) | **0** (0, 0) | **199 (299, 45)** | + +**The shape is the finding.** Under H1 a seat that sometimes attacks loses +everything at 3p and above. Under H2 it wins **112 / 173 / 199** — against +greedy's 120 / 175 / 199 — *while* attacking and arming DARVO. + +> **H2 makes occasional ATTACK affordable. It does not make it pay.** + +rank-75 never beats greedy in any cell; at best it draws level (199 vs 199 +at 6p). And **rank-95 — always attack — still wins 0 everywhere**, in all +three variants, so the *"not always-attack-optimal"* property holds. + +**F17 therefore stands.** ATTACK earns its place in no mode; what changed +is that choosing it is no longer catastrophic. Whether "affordable" is +what the design wants, or whether ATTACK should actually reward, is +ground-game's judgement. + +## 6. A construction hazard, found while measuring this + +`GroundState::with_variant()` exists because H2 assigns Problem owners at +setup and `state.variant = v` leaves them unassigned — **scoped pressure +then ticks nobody and H2 measures as inert**. + +**Three call sites had the bare write**, including `cb-play`'s driver. + +**No published figure is affected**, and that was checked rather than +assumed: `h2-panel.rs` used the builder, and the two harnesses with the +bare write had only ever run baseline and H1, neither of which has a +setup step. The driver has never played H2. + +All three are fixed, and `a_bare_variant_write_leaves_h2_inert` now states +the difference — so a regression is caught by a named test rather than by +a reader wondering why H2 did nothing. + +**The builder was not enough**: the field is public, so the old form still +compiles. That is worth knowing before the next variant. + +## 7. What this does not say - **No felt-play.** H2's central claim is about *motivation* — a bonded pair caring about each other's card — and §3 says as much. 200-game diff --git a/games/ground/examples/attack-value.rs b/games/ground/examples/attack-value.rs index 47e7d1b..19be94d 100644 --- a/games/ground/examples/attack-value.rs +++ b/games/ground/examples/attack-value.rs @@ -120,7 +120,8 @@ fn sweep( st.mode = mode; // CB-WP-0038 T03: the SAME instrument measures both rule sets in // the same run. Two harnesses would compare harnesses. - st.variant = variant; + // `with_variant`, never a field write (see regulation.rs). + let st = st.with_variant(variant); let mut ps = mk(players); // CB-WP-0038 T03: a game the engine REFUSES is not a game the // table lost, and `else { continue }` made the two identical. @@ -211,6 +212,10 @@ fn main() { "H1 h1-problem-stress", games_ground::Variant::H1ProblemStress, ), + ( + "H2 h2-scoped-problem-stress", + games_ground::Variant::H2ScopedProblemStress, + ), ] { println!("================ {vlabel} ================\n"); for (label, mode) in [ diff --git a/games/ground/examples/regulation.rs b/games/ground/examples/regulation.rs index c015448..4ea769d 100644 --- a/games/ground/examples/regulation.rs +++ b/games/ground/examples/regulation.rs @@ -133,7 +133,10 @@ fn sweep(mode: ScoringMode, variant: Variant, players: u8, reactive: bool) -> Ce continue; }; st.mode = mode; - st.variant = variant; + // `with_variant`, never a field write: H2 assigns Problem + // owners at setup, and the bare write leaves them unassigned — + // scoped pressure then ticks nobody and H2 measures as inert. + let st = st.with_variant(variant); // The dealt state, kept so the peak metric can read the real // starting Stress rather than trust a constant. let started = st.clone(); diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 787909a..d785e83 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -2288,6 +2288,60 @@ mod tests { } } + /// **The hazard `with_variant` exists for, made visible.** + /// + /// Setting `state.variant = H2` directly compiles and produces a + /// game where **no Problem has a scope**, so scoped pressure ticks + /// nobody and H2 measures as inert — a silently wrong result, not + /// a failure. + /// + /// Three call sites had the bare write when this was found, one of + /// them the driver. This test states the difference so a + /// regression is caught by a name rather than by a puzzled reader + /// wondering why H2 did nothing. + #[test] + fn a_bare_variant_write_leaves_h2_inert() { + let base = GroundState::setup( + &Setup { + players: 4, + preset: "standard-4p".into(), + patch: Default::default(), + }, + 5, + ) + .expect("setup"); + + // The wrong way. + let mut bare = base.clone(); + bare.variant = Variant::H2ScopedProblemStress; + assert!( + bare.problems.values().all(|p| p.scope.is_none()), + "a bare write should leave scopes unassigned — if it does not, \ + `with_variant` is no longer load-bearing and this test is stale" + ); + assert!( + !bare + .end_round_events() + .iter() + .any(|e| matches!(e, GroundEvent::StressSet { .. })), + "a bare-write H2 produced pressure, so the hazard has changed shape" + ); + + // The right way. + let built = base.with_variant(Variant::H2ScopedProblemStress); + assert!( + built.problems.values().all(|p| p.scope.is_some()), + "`with_variant` did not assign scopes" + ); + assert!( + built + .end_round_events() + .iter() + .any(|e| matches!(e, GroundEvent::StressSet { .. })), + "`with_variant` H2 produced no pressure with Problems unclaimed" + ); + } + /// **The baseline is untouched by H2's new state** (T02). /// /// `owner` and `scope` are new fields on `ProblemState`. Both are diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 35e6417..83239c4 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -408,7 +408,10 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( initial.mode = config.mode; // CB-WP-0038: set beside `mode` and before the hash, so a recorded // session replays under the variant it was played under. - initial.variant = config.variant; + // `with_variant`, never a field write: H2 assigns Problem owners at + // setup, and the bare write would leave them unassigned — a silently + // wrong game rather than a failing one. + let initial = initial.with_variant(config.variant); let initial_json = serde_json::to_value(&initial).map_err(|e| e.to_string())?; let initial_hash = cb_events::state_hash_hex(&initial); diff --git a/trials/2026-08-08-1648-2.yaml b/trials/2026-08-08-1648-2.yaml new file mode 100644 index 0000000..2e1e3e9 --- /dev/null +++ b/trials/2026-08-08-1648-2.yaml @@ -0,0 +1,141 @@ +scenario: ground/cb-play-session +description: recorded by cb-play (CB-WP-0008 T02) +covers: [] +provisional: false +provisional_owner: '' +provisional_raised: '' +ruled: '' +ruled_by: '' +ruled_note: '' +encodes_u_item: '' +seed: 2 +setup: + players: 3 + preset: standard-3p + patch: {} +commands: +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +expect: + events: [] + state: {} + rejects: [] + state_hash: 71b243fdd585e678b6876a6bd8aec74f2490438db5ce852c8d0320f24550070f diff --git a/trials/2026-08-08-1648.yaml b/trials/2026-08-08-1648.yaml new file mode 100644 index 0000000..d61dd3c --- /dev/null +++ b/trials/2026-08-08-1648.yaml @@ -0,0 +1,149 @@ +scenario: ground/cb-play-session +description: recorded by cb-play (CB-WP-0008 T02) +covers: [] +provisional: false +provisional_owner: '' +provisional_raised: '' +ruled: '' +ruled_by: '' +ruled_note: '' +encodes_u_item: '' +seed: 1 +setup: + players: 3 + preset: standard-3p + patch: {} +commands: +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SUPPORT + target: P2 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P2 + cmd: respond_to_support + args: + response: accept_bond +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SUPPORT + target: P3 +- actor: P2 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: P3 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P3 + cmd: respond_to_support + args: + response: accept_bond +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +expect: + events: [] + state: {} + rejects: [] + state_hash: b15834bdf1b30ef0e3d84bdba10395d0fa4efd16086d24b59a6080d08b1804f2