diff --git a/games/ground/src/edition.rs b/games/ground/src/edition.rs index e8f7ce9..296ba16 100644 --- a/games/ground/src/edition.rs +++ b/games/ground/src/edition.rs @@ -81,6 +81,53 @@ const TOKENS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Tokens. pub struct Table { cols: Vec, rows: Vec>, + /// 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>> { + static R: OnceLock>>> = 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 { + 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 { + #[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, 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 { + #[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 = 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 = 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 = 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; diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md index 7d4fc2f..0656253 100644 --- a/specs/FindingRegister.md +++ b/specs/FindingRegister.md @@ -45,7 +45,8 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by | F15 | underdetermined | note | — | — | 2026-08-05 | clay-borg | | F16 | inconsistent | withdrawn | games/ground/examples/difficulty.rs | counterexample | 2026-08-05 | clay-borg | | F17 | degenerate | raised | games/ground/examples/attack-value.rs | counterexample | 2026-08-06 | ground-game | -| F18 | inert | raised | — | — | 2026-08-06 | clay-borg | +| F18 | inert | raised | `games/ground/src/edition.rs::card_text_tests::the_engine_reads_only_part_of_what_it_vendored` | counterexample | 2026-08-06 | clay-borg | +| F24 | inert | raised | `games/ground/src/edition.rs::card_text_tests::the_hardcoded_deck_still_matches_the_edition` | default | 2026-08-07 | clay-borg | | F19 | degenerate | applied | crates/cb-render-html/src/lib.rs::overhead_table | counterexample | 2026-08-06 | clay-borg | | F20 | inert | applied | crates/cb-render-html/src/lib.rs::ending_page | counterexample | 2026-08-06 | clay-borg | | F21 | degenerate | note | — | — | 2026-08-06 | clay-borg | @@ -201,6 +202,17 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by sees `Clarify` where the card reads *"Ask What Happened — Invite a concrete account before judging."* **`inert`**: the data exists and cannot fire, because nothing reads it. **Ours, and CB-WP-0028 fixes it.** +- **F24 — the draw pile is a Rust literal.** `solution_deck()` builds six + of each suit from an array and never opens `Solutions.csv`, whose `suit` + and `quantity` columns say the same thing. **They agree today** — 24 + rows, six per suit — so nothing is wrong now, and that is the point: + the engine is right by maintenance coincidence rather than by reading. + **`inert`**: the data exists and cannot fire. Role `default`, not + `counterexample` — the reproduction is green *because* the two agree, + which is the state GameDesign §1.3 says to expect from a documented + provisional choice, and it turns red the moment either side moves. + **Ours.** CB-WP-0037 T02 deletes the literal. + - **F15 — the rules define one game, not a series.** `OutcomeView` gives `personal` (per seat), `group_success` (per table) and `winners`. Summing the first and counting the third answer different questions, and GROUND diff --git a/workplans/CB-WP-0037-the-fourteen-unread-files.md b/workplans/CB-WP-0037-the-fourteen-unread-files.md new file mode 100644 index 0000000..1125e27 --- /dev/null +++ b/workplans/CB-WP-0037-the-fourteen-unread-files.md @@ -0,0 +1,149 @@ +--- +id: CB-WP-0037 +kind: product +title: "The fourteen unread files" +status: active +--- + +# Purpose + +``` +structural tier M (imports more of an external dataset under AM-4's + budgets, and gives an open register row its first + reproduction) +chaos d8 = 1 → no override +declared tier M +``` + +**Declaration 9 of chaos window 3.** + +## Why this, and why now + +**F18 is the only open finding clay-borg owns**, the only row in the +register lacking a reproduction, and the only metric off target +(`open, lacking a reproduction: 1, target 0`). F17 is with `ground-game`. + +**And F18 is understated.** Its prose describes display data — *"the cards +cannot say what they do"* — and calls itself fixed by CB-WP-0028. Measured +now: **5 of 19 files vendored**, and among the 14 unread are + +| file | what is in it | +|---|---| +| `DARVO.csv` | `mandatory_effect`, `target_memory`, `advance` — **the DARVO sequence, as rules** | +| `Relations.csv` | `formation`, `breaking`, `rules_text` — **how Bonds and Rivalries form and break** | +| `Scenarios.csv` | four scenarios with their setups — **the engine hardcodes one** | +| `Rules_Text.csv` | 21 sections of the rules themselves | +| `Player_Mats.csv` | includes `choice_rule` and `stress_track` | + +**The engine implements DARVO and relation behaviour without ever having +read the edition's statement of them.** That is not inert display data; it +is rules taken from a secondary source and never checked against the +primary one. + +A spot check is reassuring — REVERSE's engine path flips Focus to Blame, +gives the target +1 Stress, takes a Protection and reduces the owner's +Stress by 2, exactly as `DARVO.csv` states. **Reassuring is not checked**, +and the whole point of this repo is the difference. + +## Task: give F18 a reproduction + +```task +id: CB-WP-0037-T01 +status: done +priority: high +``` + +An artifact that measures the gap instead of asserting it: **per edition +file, columns present against columns the engine reads.** + +**Controls:** +- **it can go red and green** — improving coverage must move it, or it is + not measuring coverage (ADR-0006 D3); +- it reports **per file**, never a single ratio: *"5 of 19"* is the sum + shape GameDesign §1.2 exists to refuse; +- **an absent upstream is reported absent, never as a pass** — the shape + `edition-check` already uses. + +**Done 2026-08-07.** `the_engine_reads_only_part_of_what_it_vendored`. +**F18 has a reproduction**, and the register's one off-target metric — +`open, lacking a reproduction: 1` — is now **0**. + +**Recorded at the accessor, not counted from the source.** A list of +column names beside the code would be a second copy of a fact the `get` +calls already carry; grepping the source would over-count, because six +column names are shared between vendored files. + +**And the first version was wrong in this repo's signature way.** It +watched `Table::at` only, so it reported `visibility`, `required_solution` +and `point_value` as unread when the engine reads all three — +`problems_of` predates `Table` and resolves its own indices. 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 → 10/13 +once the manual reader was recorded too. + +Measured, per file, never as a ratio: + +| file | read | unread | +|---|---|---| +| Problems.csv | 10/13 | `problem_id`, `symbol_id`, `back_design_id` | +| Actions.csv | 4/9 | `symbol_id`, **`resolution_order`**, **`target`**, **`stress_restriction`**, `designer_note` | +| Solutions.csv | 4/8 | **`suit`**, `symbol_id`, **`quantity`**, `back_design_id` | +| Modes.csv | 5/7 | `mode_type`, `back_design_id` | +| Tokens.csv | 6/9 | `shape`, `size`, `symbol_id` | + +**`resolution_order`, `target` and `stress_restriction` are rules**, and +the engine implements all three from `GroundRules.md` without reading the +edition's statement of them. That is T02's work. + +**F24 raised**: `solution_deck()` is a Rust literal — six of each suit, +never opening `Solutions.csv`. **It agrees today** (24 rows, six per +suit), which is why it is `inert` with role `default` rather than a +counterexample: right by maintenance coincidence, not by reading. +`the_hardcoded_deck_still_matches_the_edition` is the guard until T02 +deletes the literal, and it turns red the moment either side moves. + +## Task: vendor what carries rules, and check it against the engine + +```task +id: CB-WP-0037-T02 +status: todo +priority: high +``` + +`DARVO.csv`, `Relations.csv`, `Scenarios.csv` first — they carry +mechanism. + +**Controls:** +- **every divergence between the edition's text and the engine is a + finding**, raised, not quietly fixed: the edition is `ground-game`'s to + rule on; +- **agreement is recorded too.** A survey that finds nothing and leaves no + trace cannot be told apart from one never run (CB-EV-0027 §3); +- AM-4's dependency and size budgets hold, or the pass says what it cost. + +## Task: classify the rest + +```task +id: CB-WP-0037-T03 +status: todo +priority: medium +``` + +`Symbols`, `Design_Tokens`, `Back_Designs`, `BOM`, `Print_Manifest` are +production and visual identity. + +**Control:** each becomes either a vendored file or an **ornament +declaration with a falsifier** ([`OrnamentRegister.md`](../specs/OrnamentRegister.md)). +**O4 already says nothing may be declared about `Player_Mats` or +`Glossary` until they are read** — so read them or leave O4 alone. + +## Task: evidence + +```task +id: CB-WP-0037-T04 +status: todo +priority: medium +``` + +`evidence/CB-EV-*.md`. **Did the engine's rules match the edition's?** +Say so either way, per rule, not as a count.