CB-WP-0042 T01-T04: H2's scoped stress, with the named defects caught
Some checks failed
ci / check (push) Failing after 4s
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:
parent
05d1a9aada
commit
04e26b3077
9 changed files with 900 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue