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 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 15:50:05 +01:00
co-authored by Claude Opus 5
parent ae8179bd9b
commit ed851b76e3
2 changed files with 58 additions and 0 deletions
+26
View File
@@ -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.
+32
View File
@@ -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("`<!-- cast = x -->`"), [], "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();