CB-WP-0038: variant selection, H1 implemented, and H1 measured
Some checks failed
ci / check (push) Failing after 3s

ground-game packages hypotheses as selectable rules variants — a catalog,
a rules_delta.yaml, and prose — and their note is explicit that CSV text
alone is not executable here. So the kernel gains a Variant in game state:
in the state, therefore in the hash, therefore in the recording, because a
scenario replayed under a different variant would diverge silently.

Baseline is bit-for-bit what it was, asserted across seat counts and
seeds. A variant system that perturbs the baseline invalidates every
measurement this repo has.

H1-A and H1-B implemented from rules_delta.yaml and mutation-proven on
their own defects: "unclaimed" misread as face-up-and-unsolved, and the
attacker's Stress read after the attack's effects. Their `unchanged:` list
is asserted rather than trusted — that list is their claim about their own
experiment.

Measured, and three of their four criteria fail. DARVO arm rate is still
0 under greedy; ATTACK selection does not rise and falls for the rank-75
policy; group success collapses from 165/190/200 to 0 at 3/4/6 seats.
The mechanism is not the assumed one: greedy answers the pressure by
regulating, Stress plateaus at 3, so it never reaches the gate at 4 or the
arm at 5 — H1-A acts as a solve-rate tax and H1-B is unreachable under
competent play.

A harness defect was caught before the claim: sweep discarded refused
games silently and never reported its count, so "nobody won" and "nothing
played" printed identically. Reporting H1 as unwinnable on that basis
would have been the ADR-0018 family aimed at another repo's design. All
200 games ran in every cell; the zeros are real.

Chaos d8 = 8 — the window's first override, redrew L against a structural
L, so it changed nothing. Window 3 recorded in ChaosRollHistory.

NOT REVIEWED: tier L owes a separate-agent adversarial review, and no H1
result may reach ground-game until it has run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 00:50:08 +02:00
parent 627992bc85
commit a928b5925c
15 changed files with 928 additions and 28 deletions

View file

@ -173,6 +173,16 @@ pub struct GroundState {
pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>,
/// GR-E02..E04: which scoring mode this game uses.
pub mode: ScoringMode,
/// Which selectable rules package the kernel is playing
/// (CB-WP-0038, ground-game `editions/catalog.yaml`).
///
/// **In the state, therefore in the hash, therefore in the
/// recording.** A scenario replayed under a different variant would
/// diverge silently, and the recording is what every other artifact
/// rests on. `#[serde(default)]` so every scenario written before
/// variants existed still loads, as baseline — which is what it was.
#[serde(default)]
pub variant: Variant,
/// GR-R09: set once the game has ended and scoring has run.
pub outcome: Option<Outcome>,
/// GR-S04/U4: retained so a deck reshuffle stays a pure function of
@ -191,6 +201,48 @@ pub enum ScoringMode {
BondedCoalitions,
}
/// A selectable rules package (ground-game `editions/catalog.yaml`).
///
/// **Not a difficulty setting and not a preference.** A variant changes
/// what the rules *are*, so unlike `Pace` it legitimately changes the
/// outcome, the state hash and the recording — and must therefore be
/// recorded with the game rather than chosen at render time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Variant {
/// `ground-darvo-r0` — the printed baseline, and the default.
#[default]
Baseline,
/// `h1-problem-stress` — ground-game's hypothesis H1.
///
/// **Experimental.** Two deltas only: unclaimed Problems raise
/// everyone's Stress at Round End, and a high-Stress attacker gets a
/// small self-relief.
H1ProblemStress,
}
impl Variant {
/// The catalog's `variant_id`, which is how ground-game names these.
pub fn id(self) -> &'static str {
match self {
Variant::Baseline => "ground-darvo-r0",
Variant::H1ProblemStress => "h1-problem-stress",
}
}
}
impl std::str::FromStr for Variant {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"ground-darvo-r0" | "baseline" | "r0" => Ok(Variant::Baseline),
"h1-problem-stress" | "h1" => Ok(Variant::H1ProblemStress),
other => Err(format!(
"unknown variant {other:?} (ground-darvo-r0, h1-problem-stress)"
)),
}
}
}
/// GR-E01..E04: the final scoring result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Outcome {
@ -1374,6 +1426,13 @@ impl GroundState {
ou_cancels: &std::collections::BTreeSet<(PlayerId, PlayerId)>,
events: &mut Vec<GroundEvent>,
) {
// H1-B (CB-WP-0038): read BEFORE anything resolves. The delta
// says "the attacker's Stress was >= 4 **before this Attack's
// effects**", and the attacker's own Stress can move during
// resolution -- so capturing it afterwards would answer a
// different question.
let attacker_stress_before = self.players.get(&attacker).map_or(0, |p| p.stress);
// 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.
@ -1423,6 +1482,23 @@ impl GroundState {
events.push(event);
self.fold(events.last().expect("just pushed"));
}
// H1-B: the self-soothe, **after target and relation effects**
// (`order: after_target_and_relation_effects`) and only on an
// Attack that actually resolved -- both cancel paths above have
// already returned.
//
// The DARVO extra Attack comes through here too, which the delta
// requires: "DARVO-stage extra Attack uses the same Attack
// resolution (so it can self-soothe too if Stress >= 4)".
if self.variant == Variant::H1ProblemStress && attacker_stress_before >= 4 {
let stress = self.stress_after(attacker, -1);
events.push(GroundEvent::StressSet {
player: attacker,
stress,
});
self.fold(events.last().expect("just pushed"));
}
}
/// GR-A01: draw one Solution, reshuffling the discard first if the
@ -1452,10 +1528,38 @@ impl GroundState {
fn end_round_events(&self) -> Vec<GroundEvent> {
let mut events = Vec::new();
// H1-A (CB-WP-0038): problem pressure, and it lands BEFORE the
// DARVO arm check below, because ground-game's delta orders it
// "+1 Stress, then clamp 0-5, then DARVO arm check as today".
// Applying it after would make the pressure unable to arm
// anything for a round -- the opposite of the hypothesis.
//
// **"Unclaimed" includes Denied and still-hidden Problems**, and
// that is the clause a careless reading drops: it is `claimed_by
// .is_none()`, not "face-up and unsolved".
//
// The trigger loop reads `work`, so it sees the new Stress.
let mut work = self.clone();
if self.variant == Variant::H1ProblemStress
&& self.problems.values().any(|p| p.claimed_by.is_none())
{
for seat in self.seat_order() {
// `stress_after` clamps 0..=5, which is the delta's
// "then clamp".
let stress = work.stress_after(seat, 1);
let e = GroundEvent::StressSet {
player: seat,
stress,
};
work.fold(&e);
events.push(e);
}
}
// 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];
for seat in work.seat_order() {
let player = &work.players[&seat];
if player.stress == 5 && player.darvo == DarvoStage::Off {
events.push(GroundEvent::DarvoTriggered { player: seat });
}
@ -1891,6 +1995,11 @@ impl ScenarioGame for GroundState {
support_responses: BTreeMap::new(),
darvo_targets: BTreeMap::new(),
mode: ScoringMode::SharedGround,
// Baseline. The driver overwrites this after setup and
// before the hash is taken, which is the route `mode` uses
// (`table.rs`) — so a recorded session replays under the
// variant it was played under.
variant: Variant::default(),
outcome: None,
seed,
})
@ -1963,6 +2072,198 @@ impl ScenarioGame for GroundState {
#[cfg(test)]
mod tests {
/// CB-WP-0038 T02 — the H1 deltas, and ground-game's own claim about
/// what they leave alone.
mod h1 {
use super::super::*;
use cb_game_runtime::{ScenarioGame, Setup};
fn setup(players: u8, variant: Variant, seed: u64) -> GroundState {
let mut s = GroundState::setup(
&Setup {
players,
preset: format!("standard-{players}p"),
patch: Default::default(),
},
seed,
)
.expect("setup");
s.variant = variant;
s
}
/// **The load-bearing control.** A variant system that perturbs
/// the baseline invalidates every measurement this repo has.
#[test]
fn baseline_is_bit_for_bit_what_it_was() {
for players in [2u8, 3, 6] {
for seed in 0..8u64 {
let base = setup(players, Variant::Baseline, seed);
let mut default_built = GroundState::setup(
&Setup {
players,
preset: format!("standard-{players}p"),
patch: Default::default(),
},
seed,
)
.expect("setup");
// Untouched: whatever `setup` produces IS baseline.
assert_eq!(default_built.variant, Variant::Baseline);
default_built.variant = Variant::Baseline;
assert_eq!(
cb_events::state_hash_hex(&base),
cb_events::state_hash_hex(&default_built),
"{players}p seed {seed}: selecting the baseline changed it"
);
}
}
}
/// **H1-A.** Unclaimed Problems raise everyone's Stress at Round
/// End — and "unclaimed" includes Denied and still-hidden, which
/// is the clause a careless reading drops.
#[test]
fn h1a_pressure_applies_while_any_problem_is_unclaimed() {
let mut s = setup(3, Variant::H1ProblemStress, 7);
// A fresh deal has unclaimed Problems by construction.
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
// One hidden, one Denied: neither is "face-up unsolved", and
// both must still count.
let ids: Vec<u32> = s.problems.keys().copied().collect();
s.problems.get_mut(&ids[0]).expect("p").face_up = false;
s.problems.get_mut(&ids[1]).expect("p").denied = true;
let before: Vec<u8> = s.players.values().map(|p| p.stress).collect();
let events = s.end_round_events();
let bumped: Vec<&GroundEvent> = events
.iter()
.filter(|e| matches!(e, GroundEvent::StressSet { .. }))
.collect();
assert_eq!(
bumped.len(),
s.players.len(),
"every player takes the pressure, not just some"
);
for e in bumped {
if let GroundEvent::StressSet { player, stress } = e {
let was = s.players[player].stress;
assert_eq!(*stress, (was + 1).min(5), "clamped 0..=5");
}
}
let _ = before;
}
/// No unclaimed Problem, no pressure — the `when` clause is a
/// condition, not decoration.
#[test]
fn h1a_is_silent_once_every_problem_is_claimed() {
let mut s = setup(3, Variant::H1ProblemStress, 7);
let me = *s.players.keys().next().expect("seat");
for p in s.problems.values_mut() {
p.claimed_by = Some(me);
}
assert!(
!s.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
"pressure applied with nothing left unclaimed"
);
}
/// **The baseline must not feel H1-A at all.**
#[test]
fn h1a_does_not_touch_the_baseline() {
let s = setup(3, Variant::Baseline, 7);
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
assert!(
!s.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
"the baseline gained problem pressure"
);
}
fn attack(variant: Variant, attacker_stress: u8, protect_target: bool) -> Vec<GroundEvent> {
let mut s = setup(3, variant, 3);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let (a, t) = (seats[0], seats[1]);
s.players.get_mut(&a).expect("a").stress = attacker_stress;
s.players.get_mut(&t).expect("t").protection = u8::from(protect_target);
let mut events = Vec::new();
s.resolve_attack(a, t, &Default::default(), &mut events);
events
.into_iter()
.filter(|e| matches!(e, GroundEvent::StressSet { player, .. } if *player == a))
.collect()
}
/// **H1-B.** A high-Stress attacker who actually lands an Attack
/// gets a small self-relief.
#[test]
fn h1b_soothes_only_a_landed_attack_from_high_stress() {
// Stress 4, uncancelled: soothed.
let soothed = attack(Variant::H1ProblemStress, 4, false);
assert_eq!(soothed.len(), 1, "no self-soothe at Stress 4");
if let GroundEvent::StressSet { stress, .. } = soothed[0] {
assert_eq!(stress, 3, "the delta is -1");
}
// Below the threshold: nothing.
assert!(
attack(Variant::H1ProblemStress, 3, false).is_empty(),
"soothed below Stress 4"
);
// Cancelled by Protection: nothing. "Not cancelled" is a
// condition of the delta, and Protection is a cancel path.
assert!(
attack(Variant::H1ProblemStress, 4, true).is_empty(),
"a cancelled Attack still soothed the attacker"
);
// And the baseline never soothes.
assert!(
attack(Variant::Baseline, 4, false).is_empty(),
"the baseline gained the self-soothe"
);
}
/// **`rules_delta.yaml`'s `unchanged:` list is ground-game's claim
/// about their own experiment, and it is checkable.**
///
/// Trusting it would be taking a rules statement on faith, which
/// is the habit CB-WP-0037 was written to end.
#[test]
fn h1_changes_nothing_it_said_it_would_not() {
for players in [2u8, 3, 4, 5, 6] {
let base = setup(players, Variant::Baseline, 11);
let h1 = setup(players, Variant::H1ProblemStress, 11);
// deal_and_thresholds
assert_eq!(base.problems, h1.problems, "{players}p: the deal moved");
assert_eq!(
base.threshold(),
h1.threshold(),
"{players}p: the threshold moved"
);
// start_stress: 2
for (seat, p) in &h1.players {
assert_eq!(p.stress, 2, "{players}p {seat}: starting Stress moved");
}
// relation_slots: 2 — asserted through the engine's own
// capacity check rather than a constant beside it.
let seats: Vec<PlayerId> = h1.players.keys().copied().collect();
assert!(h1.has_free_slot(seats[0]), "a fresh seat has slots");
// ground_modes / darvo_stage_table / support: the tables
// are shared code, so equality of the starting state plus
// the deltas' scope is what carries them.
assert_eq!(base.mode, h1.mode, "{players}p: scoring mode moved");
assert_eq!(
base.solution_deck, h1.solution_deck,
"{players}p: deck moved"
);
}
}
}
use super::*;
use cb_events::state_hash_hex;
@ -1994,6 +2295,7 @@ mod tests {
support_responses: BTreeMap::new(),
darvo_targets: BTreeMap::new(),
mode: ScoringMode::SharedGround,
variant: Variant::Baseline,
outcome: None,
seed: 0,
}