Files
RingBRP/tools/check-outcomes.mjs
T
slaguru666andClaude Opus 5 9c2ce9d905 R-290: check-outcomes — a beat that names a roll must say what it does
Desk pass 7's finding, turned into the nineteenth guard. check-rollable asks
whether a skill is reachable at 25% by the declared cast; pass 5 found that
is rollability, not competence; pass 7 found it is also not coverage. A beat
could be reachable, well-rated and silent about every band but the one the GM
improvises, and the whole suite stayed green.

tools/outcome-coverage.mjs measures. Beats are located structurally — the
bullet that owns the tag and its children, ending at the next bullet of the
same or shallower indent or the next heading, because R-287 is what an
unbounded scope does. Band widths are counted by grading all 100 results
through gradeRoll, never by arithmetic on fumbleStart, which is off by one
and was written wrongly twice this session before being caught. Ratings come
from the declared cast via R-286's reader, best-in-cast per skill, because
that is the die a table actually rolls.

tools/check-outcomes.mjs holds it, in two kinds:

  RATCHET — stated failure/fumble/special counts may improve and may not
  regress; unwritten-band exposure may fall and may not rise. --update
  re-records these, because freezing them would make every improvement fail.

  HARD CLAIMS — read from what is measured NOW, never from the baseline, so
  --update cannot silence them: a floor of 18 beats (check-cited once passed
  with zero citations), no beat bare of every band, every scope structurally
  bounded and none over 5% of the file, no unparseable tag, no beat naming a
  skill the cast has no rating for.

Verified by breaking it: a stripped beat, a broken tag regex, an unparseable
tag and a removed failure case each turn it red; the file and baseline were
restored byte-identical after each; and --update with a bare beat present
re-records the baseline and still fails.

Two bare beats found and filled while building it — the six at the back, and
the Insight that is deliberately indistinguishable on a success and a miss,
which now says so rather than saying nothing. Coverage 16/21 failure, 5
fumble, 4 special at the start; 18/21, 6 and 6 now.

The three figures are cited against the artifact rather than typed, which was
the peer session's condition and the right one: pass 7 hand-counted 22 beats
where there are 21 (Act Three's warning QUOTES a beat, and a hand count reads
the quotation as one — R-286 in the other direction), and its other three
counts were stale within one commit.

Its first catch was its author: the STATUS line announcing this guard
contained a beat-shaped tag and was counted as a beat. The reader was not
changed — prose shaped like a beat is what this file is for — and the error
message now names the fenced block as the place to write an example. Minutes
later check-cited refused a citation-shaped comment in the post-pass about
citations. Three readers, three authors describing their own format inside
it, each caught by a guard built for a different pass.

CLEAN GROUND v0.18. npm run check: 19 guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:47:25 +01:00

151 lines
8.6 KiB
JavaScript

/**
* A beat that names a roll must say what the roll DOES — and this holds the claim.
*
* check-rollable asks whether a skill is reachable at 25% by somebody in the declared cast.
* Desk pass 5 found that is rollability and not competence. Desk pass 7 found it is also not
* COVERAGE: a beat can be reachable, well-rated, and silent about every outcome but the one
* the GM improvises, and every guard in the suite stays green. Sixteen of twenty-one beats
* stated a failure case and only three a special, while a special is 13% at a rating of 63
* and the case asks for twenty-one rolls.
*
* node tools/check-outcomes.mjs hold the claim
* node tools/check-outcomes.mjs --list print what it found
* node tools/outcome-coverage.mjs --update re-record after deliberately changing it
*
* TWO KINDS OF CHECK, AND THE DIFFERENCE IS THE POINT.
*
* RATCHET — coverage may improve and may not regress; `--update` re-records it. This is
* check-focus's reliability ratchet in another place: the figures move as the document is
* written, and freezing them exactly would make every improvement a failure.
*
* HARD CLAIMS — read from what is measured NOW and never from the baseline, so `--update`
* cannot silence them. R-270 put two claim checks in check-fight-tail for this reason and
* they are the only part of that guard that cannot be argued with. A guard whose every
* assertion can be re-recorded is a record of what happened, not a check.
*
* ITS FIRST CATCH WAS ITS AUTHOR. The STATUS line written in the same commit said this
* guard "reads every [CUS: ...] beat" — inside backticks, which is where real beats live —
* and the reader counted the sentence about beats as a beat. The `...` is not a skill anybody
* in the cast has a rating for, so the unrated claim below fired as well and named it twice.
* Nothing was changed in the reader: prose that is shaped like a beat IS the thing this file
* is for, and a fenced block is the place to write one where it will not be read.
*
* THE VACUITY FLOOR IS NOT DECORATION. check-cited shipped passing with zero citations
* found: the corpus regex was wrong, nothing matched, and "all figures resolve" was true of
* an empty set. If the beat regex ever stops matching, this file must go red rather than
* report full coverage of nothing.
*/
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
import { measure, beatsIn, bestRatings, BASELINE } from "./outcome-coverage.mjs";
import { SCENARIO } from "./declared-cast.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const LIST = process.argv.slice(2).includes("--list");
/* The document has had twenty-one beats since Act Two was recut and has never had fewer.
A real re-cut that drops below this should fail once, loudly, and be re-recorded on
purpose — which is the whole difference between a floor and a baseline. */
const MIN_BEATS = 18;
/* R-287: a scope that loses its end becomes the file. Nothing here should read a tenth of
the document to decide what one beat says. */
const MAX_SCOPE_SHARE = 0.05;
if (!existsSync(BASELINE)) {
console.error(`check-outcomes: FAILED — no baseline at ${path.relative(ROOT, BASELINE)}. `
+ `Record one with: node tools/outcome-coverage.mjs --update`);
process.exit(1);
}
const base = JSON.parse(readFileSync(BASELINE, "utf8"));
const now = measure("check-outcomes");
const fileChars = readFileSync(SCENARIO, "utf8").length;
const problems = [];
const notes = [];
/* ── HARD CLAIMS — every one of these reads `now`, never `base` ─────────────────────── */
if (now.beats < MIN_BEATS) {
problems.push(` only ${now.beats} beats found, and this document has at least ${MIN_BEATS}. `
+ `Either the acts lost a third of their rolls, or the \`[CUS: ...]\` reader stopped matching `
+ `and this guard is about to report full coverage of nothing — which is exactly how `
+ `check-cited once passed with no citations at all.`);
} else {
notes.push(`${now.beats} beats found, ${now.quotedSkipped} blockquote quotation skipped`);
}
if (now.bare.length) {
problems.push(` ${now.bare.length} beat(s) name a roll and state NO outcome at all — `
+ `${now.bare.join(", ")}. A GM who reaches one has a die in their hand and nothing on the `
+ `page. Say what a miss gives, even when the honest answer is "the same nothing". `
+ `IF ONE OF THESE IS AN EXAMPLE RATHER THAN A BEAT, put it in a fenced block: beats live `
+ `inside backticks, so backticks cannot be the safe harbour here the way they are for a `
+ `cast declaration, and a fenced block is where this reader stops looking.`);
}
const unbounded = beatsIn(readFileSync(SCENARIO, "utf8")).filter(b => !b.quoted && !b.bounded);
if (unbounded.length) {
problems.push(` ${unbounded.length} beat scope(s) ran to the end of the file without finding `
+ `a structural end (${unbounded.map(b => `${b.skill}:${b.line}`).join(", ")}). R-287: a scope `
+ `that cannot find its end does not shrink or fail, it becomes the document, and then this `
+ `guard reads every outcome in the case as belonging to one beat.`);
}
if (now.widestScope > fileChars * MAX_SCOPE_SHARE) {
problems.push(` the widest beat scope is ${now.widestScope} characters, over ${Math.round(MAX_SCOPE_SHARE * 100)}% `
+ `of a ${fileChars}-character document. One beat should not need a twentieth of the case to `
+ `say what it does; the block boundaries have probably stopped working.`);
} else {
notes.push(`widest scope ${now.widestScope} chars of ${fileChars} — blocks end where they should`);
}
if (now.unparsed) {
problems.push(` ${now.unparsed} tag(s) look like \`[CUS: ...]\` and this reader cannot parse them. `
+ `A mechanic tag nothing reads is worse than none, because whoever wrote it believes the `
+ `beat is tagged. Spell them \`[CUS: Skill — what it gets]\`.`);
}
if (now.unrated.length) {
problems.push(` ${now.unrated.length} beat(s) name a skill nobody in the declared cast has a `
+ `rating for — ${now.unrated.join(", ")}. Those beats are silently dropped from every `
+ `probability below, so the coverage figures would be measured over a smaller case than `
+ `the one being run. check-rollable owns whether the skill is reachable; this owns whether `
+ `it was counted.`);
}
/* ── RATCHET — coverage may improve, never regress ──────────────────────────────────── */
for (const band of ["failure", "fumble", "special"]) {
if (now.stated[band] < base.stated[band]) {
problems.push(` beats stating a ${band} fell from ${base.stated[band]} to ${now.stated[band]}. `
+ `Coverage is a ratchet here: it may be improved and re-recorded, and it may not quietly `
+ `go backwards. If a beat was deliberately removed, re-record with --update and say so `
+ `in the commit.`);
}
}
for (const [key, label] of [["unwrittenFumble", "fumble"], ["unwrittenSpecial", "special"]]) {
if (now.session[key] > base.session[key] + 0.05) {
problems.push(` the chance a session hits an unwritten ${label} rose from ${base.session[key]}% `
+ `to ${now.session[key]}%. Either a beat lost its ${label} case or the acts gained a roll `
+ `without one.`);
}
}
notes.push(`stated: failure ${now.stated.failure}/${now.beats}, fumble ${now.stated.fumble}, special ${now.stated.special}`);
notes.push(`per session: ${now.session.anyFumble}% fumble (${now.session.unwrittenFumble}% unwritten), `
+ `${now.session.anySpecial}% special (${now.session.unwrittenSpecial}% unwritten)`);
if (LIST) {
const rating = bestRatings("check-outcomes");
for (const b of beatsIn(readFileSync(SCENARIO, "utf8")).filter(x => !x.quoted))
console.log(` ${String(b.line).padStart(5)} ${b.failure ? "fail" : " "} ${b.fumble ? "fum" : " "} `
+ `${b.special ? "spec" : " "} ${String(rating(b.skill) ?? "—").padStart(4)} ${b.skill}`);
notes.forEach(n => console.log(` ${n}`));
problems.forEach(p => console.log(p));
process.exit(0);
}
if (problems.length) {
console.error("check-outcomes: FAILED — a beat names a roll and does not say what it does");
problems.forEach(p => console.error(p));
process.exit(1);
}
console.log(`check-outcomes: OK — ${now.beats} beats, ${now.stated.failure} state a failure, `
+ `${now.stated.fumble} a fumble, ${now.stated.special} a special; ${now.session.unwrittenSpecial}% of `
+ `sessions hit a special with nothing written, down from ${base.session.unwrittenSpecial}% recorded`);