Files
RingBRP/tools/check-cited.mjs
T
slaguru666andClaude Opus 5 a5c4378521 R-294: EXPOSURE's tables measured into an artifact, and 58.7% was 74.9%
Desk pass 9 found the scenario's most consequential table held by nothing:
~30 sampled figures, no baseline, catchable only by re-running the exact
command. lethality-baseline.json does not cover them — it measures creatures
SOLO against a frozen four-agent party that is not this cast.

Building the measurer found the figures were also wrong.

simulate.mjs's CLI calls runFight with no options, so every published figure
was measured at the default 40-round ceiling — and runFight does not report
truncation, it scores whoever is standing when the loop stops. Measured at
400:

  line.column6      14.8% -> 15.2%    34/2000 truncated
  line.hollow6col2   7.8% ->  8.3%   129
  line.hollow6col3  28.7% -> 30.1%   264
  cut.hollow6       58.7% -> 74.9%   578   <- 29% of runs never finished

Fourteen points on the single most alarming number in the case, and the one
v0.16 added specifically to warn four-player tables. fight-tail learned this
in R-270 and carries an assertUncensored; EXPOSURE's own tables never got
one. The longest fight at cap 400 is 120 rounds and 400 vs 2000 are
identical, so the cap is comfortable rather than merely sufficient.

tools/pack-tables.mjs measures all 17 configs against the DERIVED cast via
castAndCut, using measure()'s exact discipline — one rng threaded through
every run, not a reseed per run, because reseeding is a different stream and
would not reproduce the published table. It refuses to report or record a
truncated sweep.

tools/check-packs.mjs holds the baseline against the game, so the pair is not
a loop: check-cited holds the prose against the record, this holds the record
against the harness. Hard claims read `now` and never `base`: nothing
truncated, the party equals the declared cast, config floor, and more of the
same creature may not make the party safer. Figures compare exactly, since
the runs are deterministic.

Verified by breaking it: a drifted baseline, CAP lowered to 40, and a removed
config each turn it red, and --update refuses outright rather than recording
a truncated sweep. The removed-config test first passed for a bad reason —
MIN_CONFIGS is a floor and 16 clears it — so a dropped-config check was added
and re-tested on a row in no monotonic chain. All files restored
byte-identical after each probe.

EXPOSURE's three tables and the nine prose figures around them are rebuilt
from the artifact with citation markers; the multi-seed stability claims were
re-measured too (the cut is 74.9/72.7/75.0/73.5 across four seeds, not
58.7/60.5/60.3/59.8). CLEAN GROUND v0.20.

npm run check: 20 guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:32:35 +01:00

221 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A figure a scenario prints must still be the figure its artifact holds.
*
* check-firstblood and check-attackers both compare a baseline against a fresh run, so
* they catch the GAME changing. Neither of them reads the document. Re-record after a
* re-cast and the artifact updates, the guard goes green, and the paragraph goes on
* printing the old number with a citation under it saying where the new one lives. The
* citation makes it worse, not better: it tells a GM the figure is checked.
*
* So the citations are machine-readable. A figure copied out of an artifact is written
*
* a swing of **40**<!-- cite: first-blood cut.swing -->
*
* and this guard resolves every one of them against the named JSON. Copy a number wrong,
* or re-record and leave the prose alone, and the build stops with both values named.
*
* This is the check-bestiary arrangement one size down. That document is GENERATED from
* the game, so it cannot drift; a scenario is written by hand and cannot be, but the
* numbers inside it can be held to the same standard.
*
* node tools/check-cited.mjs check every citation in every scenario
* node tools/check-cited.mjs --list print what each one currently resolves to
*/
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
import { scenarioFiles } from "./check-scenarios.mjs";
import { step5Split } from "./step5-split.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");
/* Artifact name as written in a citation -> where the figure comes from. Adding an entry
here is what makes a figure citeable; nothing else in the document needs to know.
TWO KINDS OF SOURCE, because there are two kinds of figure and they fail differently.
A STRING is a recorded baseline: a sampled measurement with a seed, a run count and noise
around it. Re-running is expensive, so the number is stored and `--update` re-records it.
The failure such a citation catches is "somebody re-recorded and left the prose behind".
A `{ derive }` is computed on this build. It is for a figure that is a pure function of
rules.mjs — enumerated, exact, no seed and no runs. Storing one would be a cache of the
rule, and a cache can go stale; worse, it would arrive with an `--update` able to silence
a real disagreement between the document and the game. So nothing is stored, there is
nothing to update, and the failure it catches is the one that actually happened three
times to the same number: the prose is wrong about the rule.
The mechanism is identical either way, which is the point. The document writes
`**48.1%**<!-- cite: step5 refused.held -->` and does not care which kind it is. */
const ARTIFACTS = {
"first-blood": "tools/first-blood-baseline.json",
"attackers": "tools/attackers-baseline.json",
"fight-tail": "tools/fight-tail-baseline.json",
"lethality": "tools/lethality-baseline.json",
"outcomes": "tools/outcome-coverage-baseline.json",
"packs": "tools/pack-tables-baseline.json",
"step5": { derive: step5Split, label: "tools/step5-split.mjs, enumerated on this build" }
};
const isDerived = name => typeof ARTIFACTS[name] === "object";
const sourceOf = name => isDerived(name) ? ARTIFACTS[name].label : ARTIFACTS[name];
/* Fields that exist in an artifact but must never appear in prose. A sample maximum is
the clearest case: same party, same seeds, same runs, and renaming a config moved
fight-tail's `longest` from 71 to 90 rounds, because the stream is derived from the id.
It reads like a bound on the encounter and is a property of the label. Citing it would
be the n=1 mistake one level up, so the citation itself is refused. */
const UNCITEABLE = {
longest: "a sample maximum, not a bound — it moves by a third of its value on a re-seed. "
+ "Cite p99 instead: 'one fight in a hundred runs past N rounds' is stable to about three rounds."
};
const loaded = new Map();
function artifact(name) {
if (loaded.has(name)) return loaded.get(name);
const entry = ARTIFACTS[name];
if (!entry) return null;
/* Derived once per run, not once per citation: a dozen citations against one enumeration
should cost one enumeration. */
if (isDerived(name)) {
const value = entry.derive();
loaded.set(name, value);
return value;
}
const file = path.join(ROOT, entry);
const json = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
loaded.set(name, json);
return json;
}
/** "configs.cut.swing" or "cut.swing" — the leading container is optional. */
function resolve(json, dotted) {
const direct = dotted.split(".").reduce((o, k) => (o == null ? o : o[k]), json);
if (direct !== undefined) return direct;
for (const container of ["configs", "agents"]) {
const v = dotted.split(".").reduce((o, k) => (o == null ? o : o[k]), json?.[container]);
if (v !== undefined) return v;
}
return undefined;
}
/* The number immediately before the marker, allowing for markup and a trailing unit:
"**40**", "25.5%", "1.4x". Anchored to the end so it is the nearest one.
[ \t] rather than \s throughout, so a citation cannot span a newline. The failure text
below has always promised the comment must follow its figure "immediately, with nothing
but markup between"; \s* quietly allowed a marker on the following line, so the code was
looser than its own documentation. Making them agree also keeps the offender report
honest — see the count check. */
const CITE = /([\d]+(?:\.[\d]+)?)[ \t]*(?:%|x)?\**[ \t]*(?:—|-|–)?[ \t]*<!--[ \t]*cite[ \t]*:[ \t]*([\w-]+)[ \t]+([\w.]+)[ \t]*-->/gi;
/* ANY marker a person might have meant as a citation, however spelled or placed. The two
patterns are deliberately ASYMMETRIC: the reader accepts only what the format specifies,
the counter recognises anything citation-shaped, and the difference is reported.
Both were once the same literal, and that made the count check blind in exactly the way
it existed to prevent — neither carried `i`, so `<!-- Cite: ... -->`, `<!-- CITE: ... -->`
and `<!-- cite : ... -->` were invisible to the reader AND to the counter watching for
invisible markers. A citation printing 41 against an artifact holding 40 passed under all
three, reporting OK with a count identical to a clean tree. Capitalising the first word of
a comment is not an exotic mistake; it is what a person does without thinking.
CITE now tolerates case and a space around the colon, so those spellings simply work.
ANY_CITE stays looser still, so a spelling neither of us anticipated is counted and
named rather than skipped. */
const ANY_CITE = /<!--[ \t]*cite[ \t]*:[^>]*-->/gi;
let checked = 0;
const problems = [];
const rows = [];
/* Deliberately NOT scenarioText(): that strips HTML comments, which is where the
citations live. The raw file is the thing being checked. */
for (const [rel, abs] of scenarioFiles()) {
const text = readFileSync(abs, "utf8");
/* A MARKER THIS GUARD CANNOT READ IS WORSE THAN NO MARKER AT ALL, because whoever wrote
it believes they added a check. CITE requires the comment to sit immediately after its
number; put one word between them and the citation is silently skipped while the run
still reports OK with the same count as a clean tree. Found by a peer session writing
a bad citation by accident. So every occurrence is counted and the totals must agree. */
const markers = [...text.matchAll(ANY_CITE)];
const spans = [...text.matchAll(CITE)].map(m => [m.index, m.index + m[0].length]);
/* Located by position, not per line: a valid citation is a marker some CITE match covers.
The first version of this filtered line by line, which named any VALID citation that
happened to share a line with nothing — reporting a working marker as broken whenever
another one was genuinely unread. The count was right and the explanation was wrong,
which is the defect this guard exists to prevent, two levels down. */
const unread = markers.filter(m => !spans.some(([a, b]) => m.index >= a && m.index < b));
if (unread.length) {
const lineOf = i => text.slice(0, i).split("\n").length;
problems.push(` ${rel}: ${unread.length} citation marker(s) this guard cannot read, so `
+ `nothing checks them — a marker that looks like a citation and is never read is the `
+ `defect this guard exists for. Either it is misplaced (the comment must follow its `
+ `figure immediately, \`**40**<!-- cite: artifact path -->\`, on the same line with `
+ `nothing but markup between) or it is misspelled (case and spaces around the colon `
+ `are fine; the shape \`cite: <artifact> <dotted.path>\` is not optional).`);
unread.forEach(m => {
const n = lineOf(m.index);
problems.push(` line ${n}: ${text.split("\n")[n - 1].trim().slice(0, 110)}`);
});
}
for (const m of text.matchAll(CITE)) {
const [, printed, name, dotted] = m;
checked++;
const json = artifact(name);
if (!json) {
problems.push(` ${rel}: cites "${name}", which is not a known artifact `
+ `(${Object.keys(ARTIFACTS).join(", ")})`);
continue;
}
const held = resolve(json, dotted);
if (held === undefined) {
problems.push(` ${rel}: cites ${name} ${dotted}, which that artifact does not hold`);
continue;
}
const leaf = dotted.split(".").pop();
if (UNCITEABLE[leaf]) {
problems.push(` ${rel}: cites ${name} ${dotted}, which must not be quoted — ${UNCITEABLE[leaf]}`);
continue;
}
rows.push([rel, `${name} ${dotted}`, printed, String(held)]);
if (Number(printed) !== Number(held)) {
problems.push(isDerived(name)
? ` ${rel}: prints ${printed} but the game gives ${held} (${dotted}, from `
+ `${sourceOf(name)}). Nothing was re-recorded and nothing can be — this figure is `
+ `enumerated from rules.mjs on every build, so the document is wrong about the `
+ `rule. Fix the sentence, or change the rule and watch the number follow.`
: ` ${rel}: prints ${printed} but ${sourceOf(name)} holds ${held} `
+ `(${dotted}). Re-recording updated the artifact and left the prose behind — fix the sentence.`);
}
}
}
if (LIST) {
for (const [f, src, printed, held] of rows) {
console.log(` ${f.padEnd(32)} ${src.padEnd(28)} prints ${printed.padStart(6)} holds ${held}`);
}
process.exit(0);
}
/* A guard that passes because it found nothing to check is the defect this suite has now
been caught by four times, so it is not allowed here: the citations exist, and a run
that cannot see them means the markers were reformatted away, not that the prose is
clean. */
if (!checked) {
console.error("check-cited: FAILED — no cited figures found at all. The scenarios carry "
+ "`<!-- cite: artifact path -->` markers next to every measured number; finding none "
+ "means they have been stripped or reformatted, not that there is nothing to check.");
process.exit(1);
}
if (problems.length) {
console.error("check-cited: FAILED — a scenario's cited figures do not match their artifacts");
problems.forEach(p => console.error(p));
process.exit(1);
}
const artifacts = new Set(rows.map(r => r[1].split(" ")[0]));
console.log(`check-cited: OK — ${checked} cited figures across ${scenarioFiles().length} scenario files `
+ `resolve against ${artifacts.size} baselines`);