diff --git a/editions/ground-darvo-r0/PROVENANCE.md b/editions/ground-darvo-r0/PROVENANCE.md index f3ff8f0..71204d6 100644 --- a/editions/ground-darvo-r0/PROVENANCE.md +++ b/editions/ground-darvo-r0/PROVENANCE.md @@ -62,6 +62,8 @@ sha256 eb21fa3237637790fef601fe6668a190a549715e9a78b7dfe47b40cd069b648e ../cat sha256 f58e81f84ea2b0d16e39932261eb3f3d9890345cdf37ad6f0b3abc00636840be ../experiments/h1-problem-stress/rules_delta.yaml sha256 7b1cc0149122b855e827bc930576ed165bf7dd8d62707e845a9e514ce3521f8e ../experiments/h1-problem-stress/Actions.csv sha256 62785f5e7e245c60171624d15de2f40187a44fec54f93c7d9705cf52584b1078 ../experiments/h1-problem-stress/Rules_Text.csv +sha256 002e3adf2d038579f2803975d8c6344e2b77ee9da9eaaa1b52c8bd1666bb5201 ../experiments/h1-problem-stress/VARIANT.md +sha256 443199db94601cc889557e5e86823f374dbfdf875962fc84f95c01865605101c ../experiments/h1-problem-stress/metadata.json ``` **`rules_delta.yaml` is the load-bearing one**: it is the executable diff --git a/evidence/CB-EV-0031-a-seat-that-does-not-regulate.md b/evidence/CB-EV-0031-a-seat-that-does-not-regulate.md index 62e5c76..3d401f4 100644 --- a/evidence/CB-EV-0031-a-seat-that-does-not-regulate.md +++ b/evidence/CB-EV-0031-a-seat-that-does-not-regulate.md @@ -143,12 +143,18 @@ takes the **clamp** at 5 to collapse a gap and change who wins. **Its impact was claimed and never measured, and the measurement is zero** (round 2, #5). This file said the pressure was invisible to *"the -two modes CB-EV-0030 reports on"* without checking. The reviewer ran -~72,000 games across all three modes with a divergence detector and found -**no divergence at all**; `attack-value`'s output is byte-identical with -the fix and without it. **The defect is real in principle and its only -witness is a hand-constructed board.** It is worth fixing and it changed -nothing that has been reported. +two modes CB-EV-0030 reports on"* without checking. Reverting the fix +leaves `attack-value`'s output **byte-identical across all 24 cells and +all three modes**, including the two whose `won` column reads through the +tiebreak. **The defect is real in principle and its only witness is a +hand-constructed board.** + +**The sample size quoted here was "~72,000 games" and was not +derivable from anything in this repo** (round 3, #9). `make panels` runs +**17,600**: `attack-value` is 2 variants × 3 modes × 3 policies × 4 seat +counts × 200 = 14,400, and `regulation` is 2 × 4 × 2 × 200 = 3,200. The +figure was adopted from a reviewer's message and never re-derived — in +the file whose whole correction history is about exactly that. **Inert arms, and a second wrong-subject error caught on the way.** A DARVO arm at the End of Round 5 can never advance a stage — `GameEnded` @@ -162,19 +168,32 @@ follows immediately — and ground-game's criterion 1 is about DARVO | 4p | 800 | 0 | | 6p | 1200 | 0 | +**Two kinds of number are in that table and this file did not distinguish +them** (round 3, #10). `600 / 800 / 1200` is **exactly `seats × games`** +and holds on every sample tried. `363` and `29` are **sample-specific**: +across four disjoint windows the 2p arms run 354–366 and the inert share +runs **7.1%–11.5%**. So "92% of arms at 2p are live" is a fact about seeds +`0..200`, not about the game — it reads 88.5% elsewhere. + +The same applies to the baseline win counts quoted throughout +(`132/165/190/200`): the *identity* of greedy and reactive under baseline +holds on every sample, the **digits do not**. + **The first version of that metric was wrong and equalled `darvo` in every cell**, because it tested `g.rounds >= 5` — a property of the *game*, not of the *event*. Every arm in every completed game was marked inert, which briefly looked like "criterion 1 is not met after all". An arm is inert when no `RoundEnded` follows it. -**29-of-363 was independently confirmed twice**, and the citation for it -was wrong the first time (round 2, #6): this file credited -`CB-REV-0001`, which does not contain the figure. Round 1's reviewer *did* -report it — 29 of 363 at 2p, 8% — but only in its message, and it was -never transcribed into the review file. **The record is now corrected -there.** Round 2 re-derived it independently, by a different definition -(no later DARVO event names that player), and agrees in every cell. +**The 29-of-363 citation has now been wrong twice and is stated plainly +here.** Round 2 found it credited to `CB-REV-0001`, which did not contain +the figure; round 1's reviewer *had* reported it, in its message only. +Round 3 found the remaining problem (#10, #11): both "independent +confirmations" used **seeds 0..200**, so what was confirmed twice is the +**definition**, not the figure — and whether round 1's `StrictReactive` +was the corrected one-arm policy or the five-difference one **cannot be +determined from anything in this repo**, because that reviewer's code was +never kept. Treat 29/363 as one sample of a quantity that varies. **Criterion 1 stands as met**: 92% of arms at 2p and all of them above are live. diff --git a/games/ground/examples/attack-value.rs b/games/ground/examples/attack-value.rs index a1afcc6..47e7d1b 100644 --- a/games/ground/examples/attack-value.rs +++ b/games/ground/examples/attack-value.rs @@ -24,6 +24,10 @@ use cb_kernel::PlayerId; use games_ground::bot::{play, Choice, GreedyPolicy, Policy}; use games_ground::{Action, GroundCommand, GroundState, ScoringMode}; +/// Games per cell. Named once so the footer and the assertion cannot +/// disagree — the footer said "200 games per cell" over 195-game columns. +const GAMES: u32 = 200; + /// Greedy's ordering with ATTACK's rank as a parameter. /// /// A copy of the ranking rather than a call into it: `GreedyPolicy::rank` @@ -98,9 +102,10 @@ fn sweep( mk: &dyn Fn(u8) -> Vec>, ) -> (u32, u32, u32, u32) { let (mut games, mut won, mut atk, mut darvo) = (0, 0, 0, 0); + let mut played = 0u32; let mut errs: Vec = Vec::new(); let mut setup_fails = 0u32; - for seed in 0..200u64 { + for seed in 0..GAMES as u64 { let Ok(mut st) = GroundState::setup( &Setup { players, @@ -130,6 +135,9 @@ fn sweep( } }; games += 1; + if g.state.outcome.is_some() && g.rounds == 5 { + played += 1; + } // In the co-op mode the table wins together. In the other two the // question is whether SEAT 0 is among the winners — because that @@ -161,20 +169,30 @@ fn sweep( } } } - if setup_fails > 0 { - eprintln!(" !! {setup_fails} of 200 SETUPS failed ({players}p)"); - } - if games != 200 { - eprintln!(" !! only {games} of 200 games ran ({players}p, {mode:?})"); - } - if !errs.is_empty() { - eprintln!( - " !! {} of 200 games did not run ({}p): first = {}", - errs.len(), - players, - errs[0] - ); - } + // **Asserted, not printed** (CB-REV-0003 #1). This harness produced + // every number in CB-EV-0030's DARVO table, and it still only warned + // on a short cell — the very defect fixed in `regulation.rs` and left + // here, while the gate registered to close it claimed the property + // for both. + assert_eq!( + games, + GAMES, + "{players}p {mode:?} {variant:?}: only {games} of {GAMES} games ran \ + ({setup_fails} setups refused, {} play errors) — the cell is short, so \ + every number in it is over a sample nobody chose", + errs.len() + ); + // **And that a game RAN is not that it was PLAYED** (CB-REV-0003 #2). + // Stopping the engine after one round gave 200 games, all-zero + // columns and exit 0 — byte for byte the signature CB-EV-0030 §3 + // claims to distinguish from a real result. A counted game must have + // reached an outcome. + assert_eq!( + played, GAMES, + "{players}p {mode:?} {variant:?}: {played} of {games} games reached an \ + outcome — the rest stopped early, and all-zero columns would read as \ + a result rather than as nothing having happened" + ); (games, won, atk, darvo) } diff --git a/games/ground/examples/regulation.rs b/games/ground/examples/regulation.rs index 59ce515..c015448 100644 --- a/games/ground/examples/regulation.rs +++ b/games/ground/examples/regulation.rs @@ -71,9 +71,15 @@ impl Policy for Reactive { } } -/// `Scenarios.csv`: "All players start at Stress 2." Named rather than -/// inlined so the peak metric cannot silently disagree with setup. -const START_STRESS: u8 = 2; +/// The starting Stress, **read off the dealt state** rather than declared. +/// +/// It was a `const 2` "named so the peak metric cannot silently disagree +/// with setup" — and nothing compared it to setup, so raising it to 7 +/// printed `peak 7` in every cell with no warning (CB-REV-0003 #6). +/// Naming a constant is not checking it. +fn start_stress(state: &GroundState) -> u8 { + state.players.values().map(|p| p.stress).max().unwrap_or(0) +} /// Games per cell. Named once so the assertion and the banner cannot /// disagree — the banner said "200 games per cell" over 196-game columns. @@ -94,6 +100,13 @@ struct Cell { /// arm is real and can do nothing. Reported separately, because /// ground-game's criterion 1 is asking about DARVO *mattering*. inert_arms: u32, + /// Games that reached an outcome over the full five rounds. + /// + /// **`games` counts `play` returning `Ok`, which is not the claim.** + /// Stopping the engine after one round produced 200 games, all-zero + /// columns and exit 0 — the exact signature CB-EV-0030 §3 says the + /// instrumentation distinguishes from a real result. + played: u32, } fn sweep(mode: ScoringMode, variant: Variant, players: u8, reactive: bool) -> Cell { @@ -105,6 +118,7 @@ fn sweep(mode: ScoringMode, variant: Variant, players: u8, reactive: bool) -> Ce peak_stress: 0, setup_fails: 0, inert_arms: 0, + played: 0, }; for seed in 0..GAMES as u64 { let Ok(mut st) = GroundState::setup( @@ -120,6 +134,9 @@ fn sweep(mode: ScoringMode, variant: Variant, players: u8, reactive: bool) -> Ce }; st.mode = mode; st.variant = variant; + // The dealt state, kept so the peak metric can read the real + // starting Stress rather than trust a constant. + let started = st.clone(); let mut ps: Vec> = (0..players) .map(|_| { if reactive { @@ -138,6 +155,10 @@ fn sweep(mode: ScoringMode, variant: Variant, players: u8, reactive: bool) -> Ce } }; c.games += 1; + // A game that RAN is not a game that was PLAYED (CB-REV-0003 #2). + if g.state.outcome.is_some() && g.rounds == 5 { + c.played += 1; + } if g.state.outcome.as_ref().is_some_and(|o| o.group_success) { c.won += 1; } @@ -150,15 +171,19 @@ fn sweep(mode: ScoringMode, variant: Variant, players: u8, reactive: bool) -> Ce c.atk += 1; } } - // Peak Stress **held**, not peak Stress *assigned*. + // Peak Stress **held**. // - // The first version took a maximum over `StressSet` PAYLOADS. - // Starting Stress is 2 and is written by `setup`, never by an - // event, so a table that sat at 2 all game reported **1**, and a - // table with no `StressSet` at all would report 0. CB-EV-0031 §2 - // built its headline claim on that number. Wrong subject: the - // metric answered "highest value ever assigned", the prose said - // "highest Stress reached". + // The original took a maximum over `StressSet` PAYLOADS, and the + // starting value is written by `setup` and never by an event — so + // a table that sat at 2 all game reported 1. + // + // **The first correction did not change the number it computes** + // (CB-REV-0003 #5): `held`'s values are exactly + // `{start} ∪ {payloads}`, so with the floor applied + // `peak_held ≡ max(start, peak_payload)` for every possible input, + // and the comment claiming a change of subject was false of the + // new code too. The real fix is that `start` is now read off the + // dealt state instead of asserted by a constant. // An arm is inert when NO `RoundEnded` follows it: the game // ended in the same End step, so the sequence never advances a // stage. `g.rounds >= 5` is a property of the GAME, not of the @@ -169,9 +194,10 @@ fn sweep(mode: ScoringMode, variant: Variant, players: u8, reactive: bool) -> Ce .events .iter() .rposition(|e| matches!(e, games_ground::GroundEvent::RoundEnded { .. })); + let start = start_stress(&started); let mut held: std::collections::BTreeMap = - g.state.players.keys().map(|s| (*s, START_STRESS)).collect(); - c.peak_stress = c.peak_stress.max(START_STRESS); + g.state.players.keys().map(|s| (*s, start)).collect(); + c.peak_stress = c.peak_stress.max(start); for (i, e) in g.events.iter().enumerate() { if matches!(e, games_ground::GroundEvent::DarvoTriggered { .. }) { c.darvo += 1; @@ -190,7 +216,7 @@ fn sweep(mode: ScoringMode, variant: Variant, players: u8, reactive: bool) -> Ce // START_STRESS floor made the two agree. c.peak_stress = c .peak_stress - .max(held.values().copied().max().unwrap_or(START_STRESS)); + .max(held.values().copied().max().unwrap_or(start)); } } // **`games`, not `games + setup_fails`** (CB-REV-0002 #1). @@ -207,6 +233,11 @@ fn sweep(mode: ScoringMode, variant: Variant, players: u8, reactive: bool) -> Ce the cell is short, so every number in it is over a sample nobody chose", c.games, c.setup_fails ); + assert_eq!( + c.played, GAMES, + "{players}p {variant:?}: {} of {} games reached an outcome over five rounds", + c.played, c.games + ); if c.setup_fails > 0 { eprintln!( " !! {players}p {variant:?}: {} setups refused", diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 0ec93fd..3370a15 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -2427,6 +2427,108 @@ mod tests { ); } + /// **Every tiebreak comparator, both modes** (CB-REV-0003 #4). + /// + /// The strengthened oracle covered **1 of 6**: GR-E03's Stress + /// key. Reversing GR-E03's *Bond* key, or **either** of GR-E04's + /// two keys, left all 179 tests and all 26 scenarios green — + /// GR-E04's tiebreak had no coverage at all, because the only + /// GR-E04 scenario sets `group_success: false`, so the winners + /// branch returns empty and the comparator never runs. + /// + /// Each case is built so exactly one key decides, and the + /// expected winner is named — asserting "the set changed" is what + /// let a reversed comparator pass in the first place. + #[test] + fn every_tiebreak_key_decides_the_way_the_rules_say() { + let board = |mode: ScoringMode| { + let mut s = setup(4, Variant::Baseline, 5); + s.mode = mode; + let seats: Vec = s.players.keys().copied().collect(); + let ids: Vec = s.problems.keys().copied().collect(); + for id in &ids { + s.problems.remove(id); + } + (s, seats) + }; + let mk = |value: u8, claimed_by: Option| ProblemState { + suit: Suit::Repair, + value, + face_up: true, + denied: false, + claimed_by, + protected_this_round: false, + }; + + // GR-E03 key 2, Stress: equal claims, lower Stress wins. + let (mut s, seats) = board(ScoringMode::CommonProblem); + s.problems.insert(1, mk(4, Some(seats[0]))); + s.problems.insert(2, mk(4, Some(seats[1]))); + s.players.get_mut(&seats[0]).expect("a").stress = 5; + s.players.get_mut(&seats[1]).expect("b").stress = 1; + assert_eq!( + s.score().winners, + vec![seats[1]], + "GR-E03: lower Stress must win the tiebreak" + ); + + // GR-E03 key 3, Bonds: equal claims AND equal Stress, more + // Bonds wins. + s.players.get_mut(&seats[0]).expect("a").stress = 1; + s.relations + .insert(Pair::new(seats[0], seats[2]), Relation::Bond); + assert_eq!( + s.score().winners, + vec![seats[0]], + "GR-E03: with claims and Stress level, more Bonds must win" + ); + + // GR-E04 key 2, combined Stress: two coalitions of equal + // score, the calmer one wins. + let (mut s, seats) = board(ScoringMode::BondedCoalitions); + s.problems.insert(1, mk(4, Some(seats[0]))); + s.problems.insert(2, mk(4, Some(seats[2]))); + s.relations + .insert(Pair::new(seats[0], seats[1]), Relation::Bond); + s.relations + .insert(Pair::new(seats[2], seats[3]), Relation::Bond); + for seat in &seats { + s.players.get_mut(seat).expect("p").stress = 1; + } + s.players.get_mut(&seats[0]).expect("p").stress = 5; + let won = s.score().winners; + assert!( + won.contains(&seats[2]) && !won.contains(&seats[0]), + "GR-E04: the coalition with lower combined Stress must win, got {won:?}" + ); + + // GR-E04 key 3, Blame — and reaching it takes care, which is + // the point. A coalition's score is `sum(claimed - blame)`, so + // a Blame token lowers the score too and key 1 decides first. + // The key is only reachable when the claims COMPENSATE: 5 + // claimed with one Blame ties 4 claimed with none. + s.players.get_mut(&seats[0]).expect("p").stress = 1; + s.problems.insert(1, mk(5, Some(seats[0]))); + s.problems.insert(2, mk(4, Some(seats[2]))); + s.players.get_mut(&seats[0]).expect("p").blame_from = vec![seats[3]]; + let scored = s.score(); + assert_eq!( + scored.coalitions.len(), + 2, + "the fixture needs two coalitions to compare" + ); + assert_eq!( + scored.coalitions[0].score, scored.coalitions[1].score, + "the Blame key is unreachable unless the scores tie: {:?}", + scored.coalitions + ); + let won = scored.winners; + assert!( + won.contains(&seats[2]) && !won.contains(&seats[0]), + "GR-E04: with score and Stress level, fewer Blame must win, got {won:?}" + ); + } + /// **`rules_delta.yaml`'s `unchanged:` list is ground-game's claim /// about their own experiment, and it is checkable.** /// diff --git a/gates.toml b/gates.toml index 931c6b1..93116d7 100644 --- a/gates.toml +++ b/gates.toml @@ -137,7 +137,7 @@ retire_if = "two passes run with no finding while artifacts keep growing — tha id = "CHAOS" name = "the chaos roll" target = "" -cadence = "none" +cadence = "manual" checks = "d8 on each tier declaration, 12-declaration calibration window (window 2, opened 2026-08-03; window 1 ran at d4)" added = "2026-07-30" review_by = "2026-11-30" @@ -178,7 +178,7 @@ id = "CB-REV-0002/7" name = "variant panels" target = "panels" cadence = "all" -checks = "the H1 measurement harnesses actually run, and a short cell fails rather than printing a number a reader must notice" +checks = "both H1 measurement harnesses run; a short cell fails, and so does a cell whose games did not reach an outcome" notes = """ Registered because the second adversarial review asked what the harness would report if the work silently stopped, and the answer was "green, and @@ -186,4 +186,12 @@ nothing else": `attack-value` and `regulation` were wired into no target, so every figure in CB-EV-0030 and CB-EV-0031 came from a manual run of an ungated binary -- including the assertion added to catch short cells, which was unreachable from `make`. + +CB-REV-0003 #1 then found this `checks` line claimed a property that held +for only one of the two harnesses: `attack-value`, which produced every +number in CB-EV-0030's table, still only warned. And #2 found that +counting games proves they STARTED: stopping the engine after one round +gave 200 games, all-zero columns and exit 0 -- the signature CB-EV-0030 +says the instrumentation distinguishes from a result. Both now assert an +outcome was reached. """ diff --git a/reviews/CB-REV-0003-h1.md b/reviews/CB-REV-0003-h1.md new file mode 100644 index 0000000..9f92ec4 --- /dev/null +++ b/reviews/CB-REV-0003-h1.md @@ -0,0 +1,127 @@ +# CB-REV-0003 — round 3 + +Fresh agent again. Run 2026-08-08 against the corrections made after +[`CB-REV-0002`](CB-REV-0002-h1.md). + +> **Verdict: not approvable. Four FATAL, five SERIOUS, two MINOR — and +> three of the four FATAL were created or left by round 2's corrections.** +> +> **The pattern is now established over three rounds** and is the most +> useful thing this review has produced. + +| round | challenges | FATAL | of which introduced by the previous round's fix | +|---|---:|---:|---:| +| 1 | 13 | 5 | — | +| 2 | 8 | 3 | 2 | +| 3 | 11 | 4 | 3 | + +--- + +## The four fatal ones + +### 1. The fix for round 2's #1 was applied to one of the two harnesses + +`regulation.rs` got `assert_eq!(games, GAMES)`. **`attack-value.rs` — which +produced every number in CB-EV-0030's DARVO table — kept `if games != 200 +{ eprintln!(..) }`.** Injected setup refusals gave exit 0 and a full table +over 195-game columns. + +**And the gate registered to close the finding asserted the property for +both.** Its `checks` line was false of half of what it gates. + +**Conceded.** Both harnesses assert now; the `checks` line says what is +actually checked. + +### 2. Counting games proves they STARTED, not that they were PLAYED + +Stopping the engine after one round (`round >= 5` → `>= 1`) gives **200 +games in every cell, every value zero, exit 0, `make panels` green** — +byte for byte the signature CB-EV-0030 §3 claims the instrumentation +distinguishes from a real result. + +**Conceded, and it is the sharpest finding of the three rounds.** The +counter answered *"did `play` return `Ok`"* while the claim made of it was +*"the zeros are real"*. Both harnesses now require every counted game to +have reached an outcome over five rounds; the one-round mutation fails +with the right message. + +### 3. Round 2's `.csv` filter disabled the checks it was added beside + +The filter was applied to **all three** loops — digest comparison, the CSV +parser check, and upstream freshness. So `catalog.yaml` and +`rules_delta.yaml`, whose missing digests were round 1's finding, were +recorded and then **never compared**, and never checked against upstream +at all. Tampering with both produced `[ok] × 12, exit 0`. + +**Conceded.** Only the parser loop filters; tampering with either YAML now +fails on digest *and* freshness. + +### 4. Five of six tiebreak comparators had no coverage + +The strengthened oracle covered GR-E03's Stress key. **GR-E03's Bond key +and both of GR-E04's keys could be reversed with 179 tests and 26 +scenarios green** — GR-E04's tiebreak never executes in any scenario, +because the only GR-E04 scenario sets `group_success: false`. + +**Conceded.** All four are now covered and each was verified red under +mutation. **The Blame key took care to reach**: a coalition's score is +`sum(claimed − blame)`, so a Blame token lowers the score and key 1 +decides first — the key is only reachable when claims compensate, and the +test asserts the scores tie before relying on it. + +## The serious ones + +| # | challenge | outcome | +|---|---|---| +| 5 | "peak Stress **held**, not assigned" — `peak_held ≡ max(start, peak_payload)` for every possible input, so the correction computed the same number | **conceded.** The fix was cosmetic and the comment was false of the new code too. The real coupling was missing, which is #6 | +| 6 | `START_STRESS` was an unchecked constant — setting it to 7 printed `peak 7` everywhere | **conceded.** Now read off the dealt state. Verified: changing setup's Stress to 3 moves the reported peak to 3 | +| 7 | `cadence = "none"` was a pure loophole — a real target that runs nowhere and passes | **conceded, removed.** It had one user, the empty-target CHAOS entry, which the loop already skips. `manual` remains self-declared and unchecked, and that is now stated rather than implied | +| 8 | sibling discovery swapped a hand-written list for three hand-written globs; `metadata.json` and `VARIANT.md` were invisible, and both are named in the package's own `changed_files` | **conceded.** Walked, not globbed. **It found both immediately**, plus nested files the globs could never reach | +| 9 | "~72,000 games" is not producible; `make panels` runs **17,600** | **conceded.** Adopted from a reviewer's message and never re-derived — in the file whose correction history is about exactly that. The conclusion (zero divergence) reproduces | + +## The minor ones + +- **10 — two kinds of number were presented alike.** `600/800/1200` is + exactly `seats × games` on every sample; **`363` and `29` vary** — + 7.1%–11.5% inert across four windows. "92% of arms are live" is a fact + about seeds `0..200`. Now separated. +- **11 — the 29/363 citation, wrong twice, is now stated as one sample** + of a varying quantity. Both "independent confirmations" used the same + seeds, so what was confirmed was the *definition*. Whether round 1's + `StrictReactive` was the corrected policy cannot be determined: **that + reviewer's code was never kept**, which is itself worth fixing. + +## What held, after being attacked + +**Every one of the 24 cells in CB-EV-0030's DARVO table reproduces +exactly** — the first of three attempts at that table to produce no wrong +number. `regulation`'s `assert_eq!(c.games, GAMES)` catches both the setup +and the `play` path. The #13 fix is genuinely controlled. H1-B's mutation +coverage is real. `make all` runs `panels` and fails on it. The +1-arm-per-seat-per-game invariant holds on every sample — **and is still +unexplained.** + +## What could not be checked + +Upstream freshness (`../ground-game` not checked out, so that half of +`edition-check` has never run here); whether `metadata.json` and +`VARIANT.md` are load-bearing to anything; round 1's `StrictReactive`; +felt-play; cost. + +--- + +## The finding that outlasts H1 + +Three rounds, each correcting the last, each introducing defects of the +same class. **The corrections are not getting safer.** + +> A correction is written under the belief that the error is now +> understood. That belief is the condition under which this class of +> error is produced — so the correction inherits it, and the next round +> finds the same shape one level in. + +**Round 4 is owed by the same argument.** The honest conclusion is not +that the work is nearly right; it is that **author-made corrections to +measurement work should be assumed defective until a fresh reader has +attacked them**, and this project should stop treating "corrected" as a +state closer to done than "found wrong". diff --git a/tools/edition-check.py b/tools/edition-check.py index c31034b..c2a3f92 100755 --- a/tools/edition-check.py +++ b/tools/edition-check.py @@ -66,18 +66,27 @@ SIBLINGS = "../" # raised FileNotFoundError from `digest` instead of failing with the # designed message, and "the sibling packages are covered" counted lines # in a Markdown file -- it passed with all three files deleted. -SIBLING_GLOBS = ("catalog.yaml", "experiments/*/rules_delta.yaml", "experiments/*/*.csv") +# Everything under `editions/` that is not the edition directory itself. +# **Walked, not globbed** (CB-REV-0003 #8): the first version listed three +# hand-written glob patterns, which is the same self-certifying shape as +# reading the list out of PROVENANCE -- one hand-written list swapped for +# another. It missed `metadata.json` and `VARIANT.md`, both named in the +# package's OWN `changed_files` manifest, and anything a directory deeper. +SIBLING_SKIP = {".DS_Store"} def sibling_files(): - """Sibling packages that are on disk, as `../`-relative paths.""" - import glob as _glob - - root = os.path.join(ROOT, EDITION, SIBLINGS) + """Every file beside the edition, as `../`-relative paths.""" + base = os.path.join(ROOT, "editions") + edition_dir = os.path.join(ROOT, EDITION) out = [] - for pattern in SIBLING_GLOBS: - for hit in _glob.glob(os.path.join(root, pattern)): - rel = os.path.relpath(hit, os.path.join(ROOT, EDITION)) + for dirpath, _dirs, files in os.walk(base): + if os.path.abspath(dirpath).startswith(os.path.abspath(edition_dir)): + continue + for name in files: + if name in SIBLING_SKIP: + continue + rel = os.path.relpath(os.path.join(dirpath, name), edition_dir) out.append(rel.replace(os.sep, "/")) return sorted(out) @@ -98,7 +107,7 @@ def check(): print(f" [FAIL] a digest is recorded for a file that is not here: {', '.join(missing)}") rc = 1 - for name in [f for f in present if f.endswith(".csv")]: + for name in present: if name not in want: continue path = os.path.join(ROOT, EDITION, name) @@ -118,6 +127,17 @@ def check(): # ADR-0015 D3's falsifier, checked rather than asserted: the hand # reader handles commas inside quotes and NOTHING ELSE. A doubled # quote or an embedded newline means `csv` is the answer after all. + # ONLY this loop filters: it is ADR-0015 D3's falsifier about the + # hand-rolled CSV reader, and running it over YAML made a valid + # `catalog.yaml` line with an odd quote count fail with a message + # about a parser that never reads it (CB-REV-0002 #9). + # + # **The digest and freshness loops must NOT filter** — CB-REV-0003 #3: + # the round-2 correction applied this filter to all three, so the two + # sibling YAMLs whose missing digests were round 1's finding were + # recorded and then never compared, and never checked against + # upstream at all. `make edition-check` answered its own headline + # question with [ok] when the answer was no. for name in [f for f in present if f.endswith(".csv")]: raw = open(os.path.join(ROOT, EDITION, name), encoding="utf-8-sig").read() if '""' in raw: @@ -138,7 +158,7 @@ def check(): print(" [----] upstream not checked out — freshness UNVERIFIED") print(f" expected {UPSTREAM_DIR}") return rc - for name in [f for f in present if f.endswith(".csv")]: + for name in present: up = os.path.join(UPSTREAM_DIR, name) if not os.path.exists(up): print(f" [FAIL] {name} is not in upstream — where did it come from?") diff --git a/tools/loop-lint.py b/tools/loop-lint.py index 6f7e6dd..66363a0 100644 --- a/tools/loop-lint.py +++ b/tools/loop-lint.py @@ -316,11 +316,13 @@ def check_gate_registry(root=REPO): if not target: continue cadence = g.get("cadence") - if cadence not in ("all", "manual", "none"): + if cadence not in ("all", "manual"): out.append(Finding( "gates", "gates.toml", - f"{target!r} declares no cadence — say whether `make all` runs " - f"it, or a gate can exist without ever running")) + f"{target!r} declares no cadence — say `all` or `manual`. " + f"`none` was a pure loophole: a real target that runs " + f"nowhere and passes, which is the condition this rule " + f"exists to prevent (CB-REV-0003 #7)")) elif cadence == "all" and target not in deps: out.append(Finding( "gates", "gates.toml",