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>
This commit is contained in:
tegwick 2026-08-04 00:47:56 +02:00
parent f2281fa86c
commit 2da19a49b7
16 changed files with 593 additions and 167 deletions

View file

@ -1077,42 +1077,12 @@ mod tests {
}
}
/// **A recorded finding, not a desired property.** With the standard
/// preset's placeholder Problem values (value = priority), the total
/// a game can possibly reach is below GR-E01's threshold at 2, 3 and
/// 4 players — group success is unreachable regardless of play. Only
/// 56p can clear its 9.
///
/// This test pins the arithmetic so the gap cannot close silently.
/// **It is expected to fail** when Problem values become scenario
/// data (GR-S01 calls the current fixture a stand-in); the failure is
/// the signal to delete it, not to re-tune it.
#[test]
fn the_standard_preset_cannot_reach_the_threshold_below_five_seats() {
let mut report = Vec::new();
for players in 2..=6u8 {
let initial = setup(players, 42);
let best: u32 = initial.problems.values().map(|p| u32::from(p.value)).sum();
let threshold = play(initial, &mut policies("greedy", players, 42))
.expect("game")
.state
.outcome
.expect("outcome")
.threshold;
report.push(format!("{players}p best {best} vs threshold {threshold}"));
if players < 5 {
assert!(
best < threshold,
"{players}p: best {best} now reaches threshold {threshold} — \
the fixture changed, delete this test"
);
} else {
assert!(
best >= threshold,
"{players}p: best {best} cannot reach threshold {threshold}"
);
}
}
println!("GR-E01 reachability: {}", report.join(", "));
}
// `the_standard_preset_cannot_reach_the_threshold_below_five_seats`
// lived here and was deleted 2026-08-04, on its own instruction: it
// said "the failure is the signal to delete it, not to re-tune it".
// ground-game ruled GR-S01's deal and the game became winnable.
//
// The record did not go with it. `gd0001_group_success_is_reachable_at
// _every_seat_count` in lib.rs is the same arithmetic, inverted rather
// than removed, and carries why the numbers changed.
}

150
games/ground/src/edition.rs Normal file
View file

@ -0,0 +1,150 @@
//! The edition dataset, vendored and read by hand (ADR-0011).
//!
//! `Problems.csv` is authoritative (ground-game, GROUND-WP-0002 T01). The
//! engine used to invent Problem values and suits; it reads them now.
//!
//! **Why not the `csv` crate.** It fits — 17,651 marginal lines against
//! AM-4b's 19,742 of headroom — and is refused anyway, because that is
//! 89% of everything the budget has left to read 20 rows, and the next
//! dependency would have 2,091 lines to live in. If this data ever grows
//! nested quoting, embedded newlines, or multiple dialects, that decision
//! is wrong and `csv` is the answer (ADR-0011 D1).
use crate::{SolutionCard, Suit};
/// One Problem as the edition prints it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditionProblem {
/// `hidden_priority`: 0 is the Surface Problem.
pub priority: u8,
pub value: u8,
pub suit: Suit,
/// `visibility == "Surface"` — dealt face up (GR-S01).
pub surface: bool,
}
const CSV: &str = include_str!("../../../editions/ground-darvo-r0/Problems.csv");
/// Split one CSV record, honouring `"…"` quoting.
///
/// `problem_text` contains commas, which is the only reason this is not a
/// `split(',')`. Doubled quotes inside a quoted field are not handled and
/// do not occur; if they ever do, this returns the wrong field count and
/// `problems_of` fails loudly rather than mis-parsing.
fn fields(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quoted = false;
for c in line.chars() {
match c {
'"' => quoted = !quoted,
',' if !quoted => out.push(std::mem::take(&mut cur)),
c => cur.push(c),
}
}
out.push(cur);
out
}
fn suit_of(s: &str) -> Option<Suit> {
match s.trim() {
"Clarify" => Some(Suit::Clarify),
"Repair" => Some(Suit::Repair),
"Boundary" => Some(Suit::Boundary),
"Change" => Some(Suit::Change),
_ => None,
}
}
/// Every Problem of one scenario, ordered by `hidden_priority`.
///
/// Returns `Err` rather than an empty list when the data does not parse:
/// a loader that silently reads nothing would hand `setup` a game with no
/// Problems and look like a rules bug.
pub fn problems_of(scenario_id: &str) -> Result<Vec<EditionProblem>, String> {
let mut lines = CSV.lines();
let header = lines.next().ok_or("edition data is empty")?;
let cols: Vec<String> = fields(header)
.into_iter()
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
.collect();
let at = |name: &str| -> Result<usize, String> {
cols.iter()
.position(|c| c == name)
.ok_or_else(|| format!("edition data has no column {name:?}"))
};
let (c_scn, c_pri, c_val, c_sol, c_vis) = (
at("scenario_id")?,
at("hidden_priority")?,
at("point_value")?,
at("required_solution")?,
at("visibility")?,
);
let mut out = Vec::new();
for line in lines.filter(|l| !l.trim().is_empty()) {
let f = fields(line);
if f.len() != cols.len() {
return Err(format!(
"edition row has {} fields, header has {}: {line}",
f.len(),
cols.len()
));
}
if f[c_scn].trim() != scenario_id {
continue;
}
out.push(EditionProblem {
priority: f[c_pri]
.trim()
.parse()
.map_err(|_| format!("hidden_priority {:?} is not a number", f[c_pri]))?,
value: f[c_val]
.trim()
.parse()
.map_err(|_| format!("point_value {:?} is not a number", f[c_val]))?,
suit: suit_of(&f[c_sol])
.ok_or_else(|| format!("required_solution {:?} is not a suit", f[c_sol]))?,
surface: f[c_vis].trim() == "Surface",
});
}
if out.is_empty() {
return Err(format!("edition data has no Problems for {scenario_id}"));
}
out.sort_by_key(|p| p.priority);
Ok(out)
}
/// GR-S01 as ruled by ground-game 2026-08-04: **Surface always, plus
/// hidden priorities `1..=k`**, with k by seat band. Surface is never one
/// of the hidden slots.
///
/// Available points are therefore 6 / 9 / 12 with this edition's values —
/// the numbers ground-game ruled the thresholds 5 / 7 / 9 against.
pub fn hidden_depth(players: u8) -> Result<u8, String> {
match players {
2 => Ok(2),
3..=4 => Ok(3),
5..=6 => Ok(4),
other => Err(format!("GR-S01: unsupported player count {other}")),
}
}
/// The Problems dealt at `players` seats, Surface first.
pub fn deal(scenario_id: &str, players: u8) -> Result<Vec<EditionProblem>, String> {
let k = hidden_depth(players)?;
let all = problems_of(scenario_id)?;
let dealt: Vec<EditionProblem> = all
.into_iter()
.filter(|p| p.surface || (p.priority >= 1 && p.priority <= k))
.collect();
Ok(dealt)
}
/// The core Solution deck, 6 per suit in canonical order (GR-S04).
pub fn solution_deck() -> Vec<SolutionCard> {
[Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]
.into_iter()
.flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6))
.collect()
}

View file

@ -7,6 +7,8 @@
/// aggregate. That its tests do need `scenarios` is a real seam — setup
/// presets currently live behind that feature (see `bot.rs`).
pub mod bot;
#[cfg(feature = "scenarios")]
pub mod edition;
/// K13's per-player projection (CB-WP-0008 T02) — the trait's first
/// implementor. Needs the runtime's `Project`, which the game already
@ -1788,26 +1790,12 @@ impl GroundState {
}
}
/// GR-S01: hidden-Problem priorities admitted per player count.
#[cfg(feature = "scenarios")]
fn problem_priorities(players: u8) -> Result<u8, String> {
match players {
2 => Ok(2),
3..=4 => Ok(3),
5..=6 => Ok(4),
other => Err(format!("GR-S01: unsupported player count {other}")),
}
}
// 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.
/// GR-S04: the 24 core Solution cards, 6 per suit, in canonical order
/// before the seeded shuffle.
#[cfg(feature = "scenarios")]
fn core_solution_deck() -> Vec<SolutionCard> {
[Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]
.into_iter()
.flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6))
.collect()
}
// GR-S04's deck now lives in `edition::solution_deck` (ADR-0011),
// beside the Problem data it is dealt against.
#[cfg(feature = "scenarios")]
impl ScenarioGame for GroundState {
@ -1824,11 +1812,14 @@ impl ScenarioGame for GroundState {
setup.preset
));
}
let priorities = problem_priorities(seats)?;
// 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)?;
let mut rng = ChaChaRng::from_seed(Seed(seed));
// GR-S04: shuffle first, then deal, so the deal is seed-derived.
let mut deck = core_solution_deck();
let mut deck = crate::edition::solution_deck();
rng.shuffle(&mut deck);
// GR-S02: Stress 2, Freedom READY, DARVO OFF, two Solution cards.
@ -1849,17 +1840,18 @@ impl ScenarioGame for GroundState {
);
}
// GR-S01: priority 1 is the Surface Problem, face up; the rest
// start face down.
let suits = [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change];
let problems = (1..=u32::from(priorities))
.map(|priority| {
// 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)| {
(
priority,
(i + 1) as u32,
ProblemState {
suit: suits[(priority as usize - 1) % suits.len()],
value: priority as u8,
face_up: priority == 1,
suit: p.suit,
value: p.value,
face_up: p.surface,
denied: false,
claimed_by: None,
protected_this_round: false,
@ -2041,10 +2033,26 @@ mod tests {
assert_eq!(player.hand.len(), 2);
}
assert_eq!(state.solution_deck.len(), 24 - 6);
// GR-S01: 3 players → priorities 13, priority 1 face up.
assert_eq!(state.problems.len(), 3);
assert!(state.problems[&1].face_up);
assert!(!state.problems[&2].face_up);
// 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");
}
/// GR-S03/S04: the same seed reproduces setup exactly; a different
@ -2476,25 +2484,28 @@ mod replay_probe {
}
}
/// **GD-0001: group success is arithmetically unreachable below 5
/// seats**, and this is the reproduction rather than an argument.
/// **GD-0001, INVERTED 2026-08-04.** Group success is reachable at
/// every seat count.
///
/// The maintainer played several 3-player games on 2026-08-03 and
/// could not win any of them. This says why: claim *every* Problem
/// the deal puts in play, concede nothing, and the total still falls
/// short of GR-E01's threshold at 2, 3 and 4 seats.
/// 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.
///
/// It reads both numbers out of the engine — `problem_priorities`
/// (GR-S01's deal) and `threshold` (GR-E01) — so it cannot drift from
/// the rules it is testing, and it holds for **either dataset**: the
/// stand-in gives 3/6/10 and `Problems.csv` gives 4/6/9, against the
/// same 5/7/9.
/// 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.
///
/// **This test asserts the defect.** It is expected to keep passing
/// until ground-game rules, and to be inverted when it does — either
/// the deal count rises or the thresholds fall.
/// It reads both numbers out of the engine — the deal and
/// `threshold` — so it cannot drift from the rules it tests.
///
/// **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.
#[test]
fn gd0001_group_success_is_unreachable_below_five_seats() {
fn gd0001_group_success_is_reachable_at_every_seat_count() {
let mut verdicts = Vec::new();
for seats in 2..=6u8 {
let state = fresh_n(seats);
@ -2516,18 +2527,24 @@ mod replay_probe {
.filter(|(_, _, _, _, ok)| !ok)
.map(|(s, _, _, _, _)| *s)
.collect();
// Positive control: a run where everything is reachable would
// report a clean sheet and prove nothing.
assert!(
!verdicts.is_empty() && verdicts.iter().any(|(_, _, _, _, ok)| *ok),
"no seat count was reachable; the harness measured nothing useful"
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"
);
// 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();
assert_eq!(
unreachable,
vec![2, 3, 4],
"GD-0001 has changed: ground-game may have ruled. Re-read the \
finding before editing this test."
available,
vec![6, 9, 9, 12, 12],
"available points are not the 6/9/12 ground-game ruled against"
);
// 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");
}
/// AM-7 scaling floor from GameKernel §5: fold throughput at 100k