CB-WP-0042 T01-T04: H2's scoped stress, with the named defects caught
Some checks failed
ci / check (push) Failing after 4s

H2 is ground-game's answer to our H1 reading — that a flat +1 to every
seat is a solve-rate tax scaling with the number of Problems. Unclaimed
Problems now tick only the seats in scope: global (all), personal (the
owner), bond (the owner's Bond network over Bond edges only, degree 0
falling back to personal), assigned by hidden priority so 2p never has the
bond card in play.

T01: the package is vendored with digests, and H2's Problems.csv is r0's
with one column added and NOTHING else changed — checked, not assumed,
because the delta claims deal_and_thresholds unchanged and a silent
difference would make every H2-vs-baseline comparison a comparison of two
boards as well as two rule sets. Scopes are read from the column, not
derived from the priority in Rust: F25 exists because we hardcoded numbers
the edition already carried.

T02: owner and scope are new ProblemState fields, both Option and both
skipped when None, so a baseline state serialises without them and every
recorded scenario's hash is untouched — asserted on the JSON, not assumed.
with_variant() replaces the bare field write, because state.variant = v
would leave owners unassigned: a silently wrong game rather than a failing
one.

T03: every named defect is mutation-proven — traversing Rivalry edges,
applying stacking once, a degree-0 owner ticking everyone, personal
hitting everyone. The degree-0 mutation MISSED first: the fallback lives
inside bond_network and the mutation broke the None-owner arm instead, a
different branch. It stayed green until aimed at the path the test
exercises. A mutation that misses is not evidence the test works.

T04: ownership is not a permission. Filtering SOLVE to the owner turns it
red, which is the regression this task exists for — the engine had no
owner concept before T02 added one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 16:06:21 +02:00
parent 05d1a9aada
commit 04e26b3077
9 changed files with 900 additions and 7 deletions

View file

@ -11,6 +11,8 @@
//! is wrong and `csv` is the answer (ADR-0011 D1).
use crate::{SolutionCard, Suit};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// One Problem as the edition prints it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -77,6 +79,9 @@ const RELATIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Rela
const SCENARIOS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Scenarios.csv");
const PLAYER_MATS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Player_Mats.csv");
const GLOSSARY_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Glossary.csv");
/// H2's Problems table (CB-WP-0042). r0's with one column added.
const H2_PROBLEMS_CSV: &str =
include_str!("../../../editions/experiments/h2-scoped-problem-stress/Problems.csv");
/// A vendored CSV, parsed into rows addressable by column name.
///
@ -504,6 +509,59 @@ pub fn scenarios() -> Result<Vec<ScenarioText>, String> {
Ok(out)
}
/// Who an unclaimed Problem's End-of-Round Stress falls on (H2-SCOPE).
///
/// **Read from the edition, never derived from the priority.** The delta
/// states a priority→scope mapping and `Problems.csv` carries the
/// column; F25 exists because we hardcoded numbers the edition already
/// held, and this is the same shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StressScope {
/// All seats.
Global,
/// The Problem's owner alone.
Personal,
/// The owner's Bond network — Bond edges only, never Rivalry.
Bond,
}
impl std::str::FromStr for StressScope {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s.trim() {
"global" => Ok(StressScope::Global),
"personal" => Ok(StressScope::Personal),
"bond" => Ok(StressScope::Bond),
other => Err(format!(
"unknown stress_scope {other:?} (global, personal, bond)"
)),
}
}
}
/// H2's scope per hidden priority, for one scenario.
///
/// Keyed by `hidden_priority` because that is what survives the deal —
/// `EditionProblem` carries the priority and not the `problem_id`.
pub fn stress_scopes(scenario_id: &str) -> Result<BTreeMap<u8, StressScope>, String> {
let t = Table::parse(H2_PROBLEMS_CSV, "h2/Problems.csv")?;
let mut out = BTreeMap::new();
for row in &t.rows {
if t.get(row, "scenario_id")? != scenario_id {
continue;
}
let priority: u8 = t
.get(row, "hidden_priority")?
.parse()
.map_err(|_| "hidden_priority is not a number".to_string())?;
out.insert(priority, t.get(row, "stress_scope")?.parse()?);
}
if out.is_empty() {
return Err(format!("h2/Problems.csv has no rows for {scenario_id}"));
}
Ok(out)
}
/// The stress gate, printed on every player mat (CB-WP-0037 T03).
///
/// **A mat is mostly ornamentation with one rule on it.** Symbol, colour
@ -750,6 +808,80 @@ mod card_text_tests {
println!("{report}");
}
/// **H2 changes only the column it says it changes** (CB-WP-0042 T01).
///
/// `rules_delta.yaml`'s `unchanged:` list claims
/// `deal_and_thresholds`, and H2 ships its own `Problems.csv`. If a
/// value, suit, visibility or priority moved in it, **every baseline
/// comparison in every H2 measurement would be against a different
/// board** — and the variant would be testing two things at once.
#[test]
fn h2_adds_a_column_and_alters_nothing_else() {
let base = Table::parse(CSV, "Problems.csv").expect("r0");
let h2 = Table::parse(H2_PROBLEMS_CSV, "h2/Problems.csv").expect("h2");
let key = |t: &Table, r: &Vec<String>| {
(
t.get(r, "problem_id").expect("id").to_string(),
t.get(r, "scenario_id").expect("scn").to_string(),
t.get(r, "visibility").expect("vis").to_string(),
t.get(r, "hidden_priority").expect("pri").to_string(),
t.get(r, "point_value").expect("val").to_string(),
t.get(r, "required_solution").expect("sol").to_string(),
)
};
let a: Vec<_> = base.rows.iter().map(|r| key(&base, r)).collect();
let b: Vec<_> = h2.rows.iter().map(|r| key(&h2, r)).collect();
assert_eq!(
a, b,
"H2's Problems.csv differs from r0 beyond `stress_scope` — the deal \
moved, so H2 would be testing a rules change and a board change at once"
);
// And it really does add the column, or there is nothing to read.
assert!(
h2.cols.iter().any(|c| c == "stress_scope"),
"H2's Problems.csv has no stress_scope column"
);
assert!(
!base.cols.iter().any(|c| c == "stress_scope"),
"r0 already carries stress_scope — H2 is not the variant that adds it"
);
}
/// **The scopes come from the edition, not from the priority**
/// (CB-WP-0042 T01).
///
/// The delta states the mapping — 0 global, 1 personal, 2 personal,
/// 3 bond, 4 personal — and the file carries it. **Deriving it from
/// the priority in Rust would be F25 again**: a number hardcoded that
/// the edition already holds.
#[test]
fn the_stress_scopes_are_read_from_the_edition() {
let scopes = stress_scopes("SCN_01").expect("SCN_01 scopes");
assert_eq!(
scopes.get(&0),
Some(&StressScope::Global),
"Surface is global"
);
assert_eq!(scopes.get(&1), Some(&StressScope::Personal));
assert_eq!(scopes.get(&2), Some(&StressScope::Personal));
assert_eq!(
scopes.get(&3),
Some(&StressScope::Bond),
"priority 3 is the bond card — the seat-band dial, absent at 2p"
);
assert_eq!(scopes.get(&4), Some(&StressScope::Personal));
// Every scenario the edition ships, not just the one we deal.
for id in ["SCN_01", "SCN_02", "SCN_03", "SCN_04"] {
assert!(
stress_scopes(id).is_ok(),
"{id} has no scopes, so a later pass that deals it would have none"
);
}
}
/// **The stress gate is printed on the mats** (CB-WP-0037 T03).
///
/// `Player_Mats.csv` looked like pure ornamentation — a symbol, a