CB-WP-0037 T01: F18 gets a reproduction, and F24 falls out of it
Some checks failed
ci / check (push) Failing after 4s

F18 was the only open finding clay-borg owns, the only register row
lacking a reproduction, and the only off-target metric. It is also
understated: it reads as display data, but among the 14 unvendored files
are DARVO.csv (mandatory_effect, advance), Relations.csv (formation,
breaking) and Scenarios.csv — rules the engine already implements from a
secondary source and has never checked against the primary one.

The reproduction records column reads AT THE ACCESSOR rather than counting
them from the source: a list beside the code would be a second copy of a
fact the get calls already carry, and grepping would over-count because
six column names are shared between vendored files.

The first version was wrong in this repo's signature way — it watched
Table::at only, so it called visibility, required_solution and point_value
unread when the engine reads all three through problems_of's own index
lookups. Correct about the accessor, wrong about the engine: the ADR-0018
family, committed inside the artifact built to measure it. Problems.csv
went 7/13 to 10/13 once the manual reader was recorded too.

F24 raised: solution_deck() is a Rust literal that never opens
Solutions.csv. It agrees today, which is the point — the engine is right
by maintenance coincidence rather than by reading. Role `default`, with a
test that goes red the moment either side moves.

open, lacking a reproduction: 1 -> 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-07 23:11:17 +02:00
parent bd9e168af5
commit e5b805c185
3 changed files with 328 additions and 2 deletions

View file

@ -81,6 +81,53 @@ const TOKENS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Tokens.
pub struct Table {
cols: Vec<String>,
rows: Vec<Vec<String>>,
/// Which file this is, for the coverage probe (CB-WP-0037 T01).
/// Test-only, like the probe it feeds: the shipped path has no use
/// for it and `-D warnings` is right to say so.
#[cfg(test)]
what: String,
}
/// Which columns of which file the engine actually asks for.
///
/// **Recorded at the accessor, not counted from the source** (F18's
/// reproduction). A list of column names written beside the code would be
/// a second copy of a fact the `get` calls already carry, and this repo
/// has 62 untagged literals as evidence of where that goes. Grepping the
/// source instead would over-count, because six column names are shared
/// between vendored files -- and over-counting coverage understates the
/// gap, which is the wrong direction for a measurement whose whole job is
/// to size it.
///
/// Test-only: it exists to measure, and a global mutable has no business
/// in the shipped path.
#[cfg(test)]
pub mod probe {
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Mutex, OnceLock};
fn reads() -> &'static Mutex<BTreeMap<String, BTreeSet<String>>> {
static R: OnceLock<Mutex<BTreeMap<String, BTreeSet<String>>>> = OnceLock::new();
R.get_or_init(|| Mutex::new(BTreeMap::new()))
}
pub fn record(file: &str, column: &str) {
reads()
.lock()
.expect("probe")
.entry(file.to_string())
.or_default()
.insert(column.to_string());
}
pub fn columns_read(file: &str) -> BTreeSet<String> {
reads()
.lock()
.expect("probe")
.get(file)
.cloned()
.unwrap_or_default()
}
}
impl Table {
@ -103,10 +150,17 @@ impl Table {
}
rows.push(f);
}
Ok(Self { cols, rows })
Ok(Self {
cols,
rows,
#[cfg(test)]
what: what.to_string(),
})
}
fn at(&self, name: &str) -> Result<usize, String> {
#[cfg(test)]
crate::edition::probe::record(&self.what, name);
self.cols
.iter()
.position(|c| c == name)
@ -275,7 +329,15 @@ pub fn problems_of(scenario_id: &str) -> Result<Vec<EditionProblem>, String> {
.into_iter()
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
.collect();
// CB-WP-0037 T01: recorded here too. `problems_of` predates `Table`
// and resolves its own indices, so a probe that only watched
// `Table::at` reported `visibility`, `required_solution` and
// `point_value` as unread when the engine reads all three. Correct
// about the accessor, wrong about the engine -- the family ADR-0018
// is named for, committed inside the artifact built to measure it.
let at = |name: &str| -> Result<usize, String> {
#[cfg(test)]
probe::record("Problems.csv", name);
cols.iter()
.position(|c| c == name)
.ok_or_else(|| format!("edition data has no column {name:?}"))
@ -458,6 +520,109 @@ mod card_text_tests {
/// ADR-0015 D5 made this visible rather than leaving it a surprise:
/// the edition ships four scenarios and the engine deals one.
/// **F18's reproduction** (CB-WP-0037 T01).
///
/// The finding says the engine cannot show what the cards do because
/// it does not read the data. That was asserted from a hand count and
/// never measured, so the row sat open with no artifact — the only
/// one in the register lacking one.
///
/// This measures it: **per vendored file, columns present against
/// columns the engine asks for**, recorded at the accessor so the
/// number cannot drift from the code.
///
/// **Per file, never a ratio.** *"20 of 47"* is the sum shape
/// GameDesign §1.2 exists to refuse; which columns, in which file, is
/// what a reader can act on.
///
/// **It can go red in both directions.** Read a new column and the
/// unread list shrinks; vendor a file and a new row appears. A
/// coverage check that only ever prints the same number is not
/// measuring coverage (ADR-0006 D3).
#[test]
fn the_engine_reads_only_part_of_what_it_vendored() {
// Touch every public reader, so the probe sees a real pass.
let _ = problem_texts("SCN_01");
let _ = problems_of("SCN_01");
let _ = actions();
let _ = solutions();
let _ = modes();
let _ = tokens();
let files = [
("Problems.csv", CSV),
("Actions.csv", ACTIONS_CSV),
("Solutions.csv", SOLUTIONS_CSV),
("Modes.csv", MODES_CSV),
("Tokens.csv", TOKENS_CSV),
];
let mut report = String::new();
let mut total_unread = 0usize;
for (name, csv) in files {
let header: Vec<String> = fields(csv.lines().next().expect("header"))
.into_iter()
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
.collect();
let read = probe::columns_read(name);
let unread: Vec<&String> = header.iter().filter(|c| !read.contains(*c)).collect();
total_unread += unread.len();
report.push_str(&format!(
"{name}: {} of {} columns read; unread: {unread:?}\n",
header.len() - unread.len(),
header.len(),
));
assert!(
!read.is_empty(),
"{name} is vendored and no column of it is read at all:\n{report}"
);
}
// The probe must actually have seen something, or every count
// above is zero for a reason that has nothing to do with the gap.
assert!(
total_unread > 0,
"every vendored column is read — F18's premise no longer holds, \
so the finding needs closing rather than this test passing:\n{report}"
);
println!("{report}");
}
/// **F24** (CB-WP-0037 T01). The draw pile is a Rust literal.
///
/// `solution_deck()` builds 6 of each suit from an array and never
/// opens `Solutions.csv`, whose `suit` and `quantity` columns say the
/// same thing. **It currently agrees** — 24 rows, 6 per suit — so
/// nothing is wrong today, and that is exactly the problem: the
/// engine is right by maintenance coincidence, not by reading, and
/// the day `ground-game` changes a quantity nothing here notices.
///
/// This test is the guard until the literal is deleted (T02): it goes
/// red if either side moves.
#[test]
fn the_hardcoded_deck_still_matches_the_edition() {
use std::collections::BTreeMap;
let t = Table::parse(SOLUTIONS_CSV, "Solutions.csv").expect("solutions");
let mut from_file: BTreeMap<String, u32> = BTreeMap::new();
for row in &t.rows {
let suit = t.get(row, "suit").expect("suit").to_string();
let n: u32 = t
.get(row, "quantity")
.expect("quantity")
.parse()
.expect("quantity is a number");
*from_file.entry(suit).or_default() += n;
}
let mut from_code: BTreeMap<String, u32> = BTreeMap::new();
for c in solution_deck() {
*from_code.entry(format!("{:?}", c.suit)).or_default() += 1;
}
assert_eq!(
from_file, from_code,
"the hardcoded solution deck and the edition disagree — the \
literal is the copy, so the edition is right and F24 has \
become a live defect rather than a latent one"
);
}
#[test]
fn the_edition_ships_more_scenarios_than_the_engine_deals() {
let mut found = 0;