From ed851b76e306355c7b9546bc59867b622b02cb98 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:50:05 +0100 Subject: [PATCH] R-291: assert that a tag one reader accepts stays visible to the other c0 asked whether R-290's widening of tagsIn reaches past outcome-coverage's loose counter, which would zero its unparsed count for the wrong reason and retire a canary. It does not -- 22 beats from each reader on CLEAN_GROUND.md, nothing that tagsIn accepts invisible to the other guard -- but "does not today" decays quietly, so it is now a test importing both real functions rather than a copy of either pattern. Mutation-checked: widening tagsIn to accept [CUS Spot] turns it red. Measuring it found something the question did not ask about, recorded in the log: for [cus: ...], [CUS : ...] and [ CUS: ...] the two guards disagree -- tagsIn reads the tag, beatLikeIn calls it unparseable -- because beatsIn's strict half kept the case-sensitive literal. The build stops, which is the safe direction, but names the wrong thing. outcome-coverage.mjs is c0's file and the call is theirs. Co-Authored-By: Claude Opus 5 --- docs/REVIEW_LOG.md | 26 ++++++++++++++++++++++++++ tools/check-behaviour.mjs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index d1ef9c5..2ea6ea4 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -6840,3 +6840,29 @@ starts from a clean corpus rather than from an exception list. pattern**: widen the reader for the spellings you thought of, and add a looser counter that names the one you did not. A marker nothing reads is worse than no marker, because whoever wrote it believes the thing is declared. + +## R-291 — the canary c0 asked about, asserted rather than remembered + +R-290 widened `tagsIn`. c0 asked the right question about it: `outcome-coverage`'s +`beatLikeIn` names the tags its own reader cannot parse, and if the widened `tagsIn` reaches +past that loose pattern, the unparsed count drops to zero for the wrong reason and stops +being a canary. + +**It does not.** Measured on both real readers rather than by copying either pattern: +CLEAN_GROUND.md gives 22 beats from `tagsIn`, 22 from `beatsIn`, 0 unparsed. Across spellings, +everything `tagsIn` accepts is seen by the other guard — parsed, or named. `[CUS Spot]` and +`[CUS= Spot]` are refused by both strict readers and still named by the counter. + +"Does not today" is exactly the kind of claim that decays without anyone noticing, so it is a +test: **a tag one reader accepts is never invisible to the other.** It imports both real +functions, so neither pattern is duplicated and the test cannot agree with a stale copy. +Mutation-checked — widening `tagsIn` to accept `[CUS Spot]`, past the counter, turns it red. + +**What the measurement found that the question did not ask about.** For `[cus: …]`, +`[CUS : …]` and `[ CUS: …]` the two guards now *disagree*: `tagsIn` reads the tag and checks +it for reachability, while `beatLikeIn` reports it unparseable and fails the build, because +`beatsIn`'s own strict half kept the case-sensitive literal R-290 removed from `tagsIn`. Not +silence — the build stops, which is the safe direction — but the message names the wrong +thing, and the fix is the one check-rollable already made for the cast: read through the other +guard's function rather than a second regex. That file is c0's and the call is theirs; it is +recorded here so it is a decision rather than something nobody wrote down. diff --git a/tools/check-behaviour.mjs b/tools/check-behaviour.mjs index a7ef3d5..4ee341f 100644 --- a/tools/check-behaviour.mjs +++ b/tools/check-behaviour.mjs @@ -32,6 +32,8 @@ import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE } import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, STARTER_TEAM } from "./scenario-starter.mjs"; import { castMarkersIn, castLikeIn } from "./declared-cast.mjs"; +import { tagsIn } from "./check-scenarios.mjs"; +import { beatsIn, beatLikeIn } from "./outcome-coverage.mjs"; let passed = 0, failed = 0; // Async tests are awaited in order rather than fired off — a failure that lands after @@ -819,6 +821,36 @@ test("a cast-shaped marker the reader cannot parse is named, not skipped", () => assert.deepEqual(castLikeIn("``"), [], "prose in backticks is not an oddity"); }); +/* ── R-290: two readers of the same tag must not lose one between them ─────────────── + check-scenarios' `tagsIn` decides which beats are checked for skills and reachability; + outcome-coverage's `beatsIn` decides which are checked for stated outcomes, with + `beatLikeIn` naming what it could not parse. Two strict readers of one format, written a + week apart by two sessions. + + c0 asked the right question when R-290 widened `tagsIn`: if the widening reaches past its + loose counter, that counter drops to zero for the wrong reason and stops being a canary. + It does not — the widened pattern is a subset of the loose one — but "does not today" is + the kind of claim that decays silently, so it is asserted rather than remembered. The + invariant is the weakest one that keeps the canary alive: a tag `tagsIn` accepts must be + visible to the other guard, either parsed or named. Never to neither. */ + +test("a tag one reader accepts is never invisible to the other", () => { + const seen = t => beatsIn(t).length + beatLikeIn(t).length; + for (const tag of ["[CUS: Spot — what it gets]", "[cus: Spot — what it gets]", + "[CUS : Spot — x]", "[ CUS: Spot — x]", "[CUS:Spot]"]) { + assert.equal(tagsIn(tag).length, 1, `tagsIn no longer reads ${tag}`); + assert.ok(seen(tag) >= 1, + `${tag} is read by tagsIn and invisible to the outcome guard — widening one reader past ` + + `the other's counter is how a tag gets checked for reachability and never for whether ` + + `the beat says what it does`); + } + // and the counter still has teeth: shapes the strict readers refuse are still named + for (const tag of ["[CUS Spot]", "[CUS= Spot]"]) { + assert.equal(tagsIn(tag).length, 0); + assert.equal(beatLikeIn(tag).length, 1, `${tag} is refused by both readers and named by neither`); + } +}); + /* ---------------------------------------------------------------- */ await runAll();