Files
RingBRP/tools/check-figures.mjs
T
slaguru666andClaude Opus 5 d0db7954bc check-figures: the guard's body belongs behind the entry-point check
The fold exported scanDocument for the string tests, and importing it also ran
the guard. check-behaviour imports that module, so a figure defect called
process.exit(1) inside check-behaviour: it reported check-figures' failure under
its own name having run zero of its 108 tests.

The build went red, which is why this was survivable, but it went red in the
wrong place and every behavioural test was silently not running while appearing
to. A guard that stops another guard from running, and cannot say so, is the
worst version of the fault this file exists to catch.

Body now sits behind import.meta.main, the idiom step5-split.mjs already uses.
Importing yields the four readers and nothing else. Tested by spawning a fresh
process, because the property is "importing has no effect" and a source
assertion would pass on a file that grew a second side effect elsewhere.

Reverting the check fails exactly one test. With a defect planted,
check-behaviour runs 108 green and check-figures fails in its own slot.

Mine, introduced by R-307. R-309.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:39:46 +01:00

462 lines
26 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 nothing holds is a figure that drifts, and the guard that would catch it cannot
* see it.
*
* `check-cited` resolves every citation marker in the corpus against the artifact it names.
* It is a good reader and it has exactly one blind spot, which is structural rather than a
* bug: it can only resolve the markers that exist. A measured figure written into prose with
* NO marker beside it is not a failed citation — it is not a citation at all, and nothing
* looks at it ever again.
*
* That is not hypothetical. Desk pass 11 found "a median 24 rounds" in the Pacing Note and
* "24 if the column joins" in EXPOSURE. The baseline holds 21 at two of the column joining
* and 25 at three. 24 is neither, it had been wrong through every pass that checked
* citations, and it survived because it carried no marker to check. The same document
* printed its four-player wipe rate uncited twice while citing it correctly four times —
* and that figure is the one that was published at 58.7% until a truncated sweep was found.
*
* ⚠ THE SHAPES HAVE A BLIND SPOT AND IT IS STRUCTURAL. R-299 said this guard "learns its
* vocabulary from the marked figures and applies it to the unmarked" — and a vocabulary
* learned that way cannot contain the phrasing of the figure nobody marked. The concurrent
* session proved it against a live defect: a THIRD stale 24, in a table cell, reported OK by
* this file. Its measurement status lived in the header row two lines above —
*
* | | Median rounds |
* |---|---|
* | Six hollow men + three of the column | **24** | <- the baseline says 25
*
* — and the scan read one line at a time, so a bare number in a column the document itself
* calls "Median rounds" was invisible. Fixed below by reading the header: a cell inherits
* the measurement status of its column, which is a claim the DOCUMENT makes rather than one
* this file's vocabulary has to anticipate. That is the narrow repair. The general point
* stands and is worth keeping in view — see `check-unmarked`, which attacks the same problem
* from the other end by treating bold as the corpus's own mark of a published figure.
*
* So this is the asymmetric counter for the other direction, and the fifth in this repo:
*
* check-cited reads markers -> is the figure beside this marker right?
* check-figures reads FIGURES -> does this figure have a marker at all?
*
* THE OPT-IN RULE. A document that uses citations has opted into the regime, and every
* measurement-shaped figure in it must carry a marker. A document with no markers anywhere
* has not, and its figures are counted and held by a ratchet rather than failing the build —
* because turning three unguarded scenarios red is how a guard gets switched off on the day
* it is written. The ratchet still means they cannot get worse, and the moment such a
* document gains its first citation it joins the strict regime in full.
*
* node tools/check-figures.mjs check the corpus
* node tools/check-figures.mjs --list print every figure found, marked or not
* node tools/check-figures.mjs --update re-record the ratchet for opted-out documents
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import path from "node:path";
import { playableFiles } from "./check-scenarios.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const BASELINE = path.join(ROOT, "tools", "figures-baseline.json");
const argv = process.argv.slice(2);
const LIST = argv.includes("--list");
const UPDATE = argv.includes("--update");
/* A number, with the document's own bolding allowed around it. */
const N = String.raw`\*{0,2}(\d+(?:\.\d+)?)\*{0,2}`;
/* THE SHAPES A HARNESS FIGURE TAKES IN THIS CORPUS.
Deliberately narrow. An earlier draft matched any number within forty-five characters of
a measurement word and reported 282 candidates, nearly all of them prose — "down 140
steps", "an engineer on his rounds", "01:06". A guard that cries wolf 282 times is a
guard nobody runs. These are the phrasings the documents actually use when quoting the
simulator, and each one was read off a figure that IS cited somewhere. */
/* `d` (hasIndices) so each match reports where its capture actually sits — see numEnd. */
const SHAPES = [
["median", new RegExp(String.raw`median(?:\s+of)?\s+` + N, "gid")],
["rounds", new RegExp(N + String.raw`\s*\*{0,2}\s*rounds\b`, "gid")],
["wiped", new RegExp(N + String.raw`\s*%?\s*\*{0,2}\s*(?:wiped|wipes)\b`, "gid")],
["wipes-pct", new RegExp(String.raw`wipe[sd]?\s+` + N + String.raw`\s*%`, "gid")],
["wipe-rate", new RegExp(String.raw`wipe rate[^.]{0,24}?` + N + String.raw`\s*%`, "gid")],
["of-N-hurt", new RegExp(N + String.raw`\s*\*{0,2}\s*of\s+\d+\s+(?:hurt|down)\b`, "gid")],
["deaths", new RegExp(N + String.raw`\s*\*{0,2}\s*deaths?\b`, "gid")],
["pct-of-runs", new RegExp(N + String.raw`\s*%[^.]{0,18}\bruns?\b`, "gid")]
];
/* WHAT IS NOT A MEASUREMENT, each with the case that put it here.
A DENOMINATOR. "one party in 300 wiped" is a rate written longhand; 300 is the bottom of
a fraction, not a sample statistic, and THROUGH_TRAIN writes rates that way throughout.
A THRESHOLD. "Past 15 rounds" is a column header in fight-tail's table — it names the
bucket the figures below are counted into, and it comes from the tool's own definition
rather than from a run. Same for "1 in 100 runs past".
BOTH ARE TESTED AGAINST THE CONTEXT, NOT THE MATCH. The first draft tested the matched
text alone, which cannot work: the `rounds` shape captures "15 rounds" and the word that
excuses it — "Past" — is outside the match. It reported a column header as an unheld
figure, which is the false positive that makes a guard get switched off. */
const NOT_A_MEASUREMENT = [
[/\b(?:in|of)\s+\*{0,2}\d+(?:\.\d+)?\*{0,2}\s*%?\s*\*{0,2}\s*(?:wiped|wipes)\b/i, "a denominator — 'one party in N wiped' is a rate written longhand"],
[/\b(?:past|beyond|over|under|at least|fewer than)\s+\*{0,2}\d+(?:\.\d+)?\*{0,2}\s*(?:%|\s*rounds?)?\s*$/i, "a threshold, not a sample — it names a bucket, not a result"]
];
/* FIELDS check-cited REFUSES TO LET ANYBODY CITE, and their values.
`longest` is a sample maximum: same party, same seeds, same runs, and renaming a config
moved one from 71 to 90 rounds, because the stream is derived from the id. check-cited
refuses a citation against it for that reason — but refusing the CITATION while the
document prints the NUMBER leaves the least stable figure in the suite as the only one
nothing holds. So a bare figure whose value matches an uncitable field is reported as its
own kind of problem, with the field named. */
const UNCITEABLE_FIELDS = ["longest"];
const uncitableValues = new Map();
for (const rel of ["tools/pack-tables-baseline.json", "tools/fight-tail-baseline.json"]) {
const abs = path.join(ROOT, rel);
if (!existsSync(abs)) continue;
const walk = (o, trail) => {
if (o && typeof o === "object") {
for (const [k, v] of Object.entries(o)) {
if (UNCITEABLE_FIELDS.includes(k) && typeof v === "number") {
uncitableValues.set(String(v), `${rel} ${[...trail, k].join(".")}`);
}
walk(v, [...trail, k]);
}
}
};
walk(JSON.parse(readFileSync(abs, "utf8")), []);
}
/* WHAT MAKES A COLUMN A MEASUREMENT, and therefore every number in it a figure.
Drawn from the baselines' own field names, exactly as the shapes above are. A bare `%` is
deliberately NOT here: a column of percentages may be skill ratings, and this corpus
prints plenty of those. */
const MEASURE_HEADER = /\b(median|mean|average|wiped?|wipes|hurt|down|dead|deaths?|swing|rounds?|runs?|rate|odds|chance|share|percentile)\b/i;
/* THE UNION OF TWO WORD LISTS, and both halves were load-bearing. This file could not see
*wipes* (`wiped?` does not match it) and missed the "Then wipes" column; check-unmarked
could not see *runs* and missed "1 in 100 runs past", which is the p99 column R-269 added
because the median is not a plan. Each list had a real gap the other covered, which is a
better argument for the union than any of the three rates we computed arguing about it.
NO THRESHOLD EXCLUSION APPLIES TO A HEADER. It was proposed and it is wrong: "Past 15
rounds" names a bucket and its cells are still measured percentages of runs, so excluding
the column would drop two correctly-cited figures. The threshold rule belongs where it
already is, on the cell. */
/** A cell holding prose rather than a value: a sentence break, or simply too many words. */
const isProse = t => /[.!?]\s+\S/.test(t) || t.trim().split(/\s+/).filter(Boolean).length > 8;
/** Split a markdown table row into cells, keeping each cell's offset in the line. */
function cells(line) {
const out = [];
let at = line.indexOf("|");
if (at < 0) return out;
at += 1;
for (;;) {
const end = line.indexOf("|", at);
if (end < 0) break;
out.push({ text: line.slice(at, end), start: at });
at = end + 1;
}
return out;
}
/** For every line, the header row of the table it is a body row of — or null. */
function tableHeaders(lines) {
const owner = new Array(lines.length).fill(null);
for (let i = 1; i < lines.length; i++) {
if (!/^\s*\|[\s:|-]+\|\s*$/.test(lines[i])) continue; /* the |---|---| separator */
if (!/^\s*\|/.test(lines[i - 1])) continue;
for (let j = i + 1; j < lines.length && /^\s*\|/.test(lines[j]); j++) owner[j] = lines[i - 1];
}
return owner;
}
/* A CITATION MARKER IS FULL OF DIGITS AND NONE OF THEM ARE FIGURES. `packs line.hollow6.hurt`
holds a 6; `fight-tail cut.over15` holds a 15. Scanning raw cell text made the guard
report every correctly-cited row in the document as unheld — 130 false positives, on the
tables that are the best-marked thing in the corpus. Comments are BLANKED rather than
removed so every offset still points at the right character of the real line. */
const maskComments = line => line.replace(/<!--[\s\S]*?-->/g, m => " ".repeat(m.length));
/* Inline code is quoted material, not the document making a claim — `wiped **8.3%**` in a
sentence about how a marker is written must not become a finding. Blanked, not stripped,
for the same reason the comments are: every offset has to keep pointing at the right
character of the real line, because the marker lookup runs against the unmasked text. */
const maskCode = line => line.replace(/`[^`\n]*`/g, m => " ".repeat(m.length));
/** Figures in a table row, by the header of their own column rather than by phrasing. */
export function cellFiguresIn(line, header) {
if (!header) return [];
const heads = cells(header);
const masked = maskComments(line);
const out = [];
for (const [i, cell] of cells(masked).entries()) {
if (!MEASURE_HEADER.test(heads[i]?.text ?? "")) continue;
/* A CELL THAT IS A SENTENCE IS NOT A VALUE. THROUGH_TRAIN's "Measured over 300 runs"
column holds prose — *"2 of 4 hurt, half a party member down, no wipes. The intended
shape."* — where the bold wraps a clause rather than a figure. The test is on the CELL
and not on the header's words, because "1 in 100 runs past" and "Measured over 300
runs" both contain *runs* and only one of them is prose. No edit to a word list can
separate those two; looking at the cell can. */
if (isProse(cell.text)) continue;
const re = /(\d+(?:\.\d+)?)/g;
let m;
while ((m = re.exec(cell.text))) {
/* "3.48 of 6" — the 6 is the party size the mean is out of, not a measurement. */
if (/\bof\s+$/i.test(cell.text.slice(0, m.index))) continue;
/* A DURATION IS NOT A HARNESS FIGURE. The Act Two beat table has a column headed
"Runs" holding `~10 min` — *how long the beat runs*, a different sense of the word
from the p99 column's "1 in 100 runs past". The suite measures ROUNDS and never
minutes; this document says so itself, calling three-to-four minutes a round "an
estimate, not a measurement". So the unit settles it, and the test is on the cell
because both headers contain *runs* and no word list can tell them apart. */
if (/^\s*(?:min|mins|minutes?|hrs?|hours?|secs?|seconds?)\b/i.test(cell.text.slice(m.index + m[1].length))) continue;
const numEnd = cell.start + m.index + m[1].length;
out.push({
kind: `column:${(heads[i].text.replace(/[*|]/g, "").trim() || "?").toLowerCase()}`,
value: m[1],
text: cell.text.replace(/\s+/g, " ").trim() || m[1],
at: numEnd,
numAt: numEnd - m[1].length,
marked: MARKED_AFTER.test(line.slice(numEnd))
});
}
}
return out;
}
/* BOLD IS THE CORPUS'S OWN MARK OF A PUBLISHED FIGURE — IN PROSE, AND ONLY THERE.
The documents bold what they publish and leave ratings plain: `Brawl 55%` is a fact about
a sheet, `**8.3%**` is a claim about a run. That is a signal from the corpus's habits
rather than from a guard author's imagination, so it catches phrasings no shape list
anticipates — the two figures that survived every reader here were `**1.29**` and
`**48.7%**`, neither in a table and neither matching a shape.
⚠ AND IT IS WORTHLESS INSIDE A TABLE, which is the finding that sets this boundary. Nine
measurement columns in the corpus mix bold with plain, every cell correctly cited. Four
consecutive rows of the mixed-force table read `0%`, `**8.3%**`, `**30.1%**`, `93.8%` —
and the two bolded rows are exactly the two the prose beneath singles out. A table bolds
what it wants READ; prose bolds what it PUBLISHES. Same mark, two conventions, so inside a
table this pass stays silent and the column header rules alone. */
const BOLD_PUBLISHED = /\*\*\s*(?:[~<>≈]\s*)?(\d[\d,]*(?:\.\d+)?\s*%|\d[\d,]*\.\d+)\s*\*\*/g;
/** Bold figures in a line of prose. Returns nothing for a table row, on purpose. */
export function boldFiguresIn(line) {
if (/^\s*\|/.test(line)) return [];
const masked = maskComments(line);
const out = [];
for (const m of masked.matchAll(BOLD_PUBLISHED)) {
const at = m.index + m[0].length;
out.push({ kind: "bold", value: m[1].replace(/[\s,%]/g, ""), text: m[0].trim(), at,
numAt: m.index + m[0].indexOf(m[1]),
marked: MARKED_AFTER.test(line.slice(at)) });
}
return out;
}
/* The marker check, deliberately looser than check-cited's CITE. That reader is strict on
purpose; here, ANY citation-shaped comment beside the figure means somebody marked it and
check-cited owns the question of whether they marked it correctly. Two guards disagreeing
about what counts as marked would leave a figure that neither of them holds. */
const MARKED_AFTER = /^[ \t]*(?:%|x)?\**[ \t]*(?:—|-|–)?[ \t]*<!--[ \t]*cite[ \t]*:/i;
/* THE WAIVER, and why it is not a silencer. Some figures have no artifact to resolve
against — three special/fumble bands out of `resolveBands`, a creature's Dodge rating, an
opposed pair no countdown row uses. Refusing to mark them leaves the guard red forever and
a red guard gets switched off, so they carry `<!-- uncited: why -->` instead. It must give
a reason: an empty waiver is a decision nobody recorded, and it is fatal — the call made
for the empty cast marker in R-288. Every waiver prints on every green build, so choosing
to stop checking something stays visible instead of becoming the default. */
const WAIVED_AFTER = /^[ \t]*(?:%|x)?\**[ \t]*(?:—|-|–)?[ \t]*<!--[ \t]*uncited[ \t]*:[ \t]*([^>]*?)[ \t]*-->/i;
const waiverAt = rest => { const m = WAIVED_AFTER.exec(rest); return m ? { reason: m[1] } : null; };
/** Every measurement-shaped figure on one line, with whether it carries a marker. */
export function figuresIn(line) {
const out = [];
const seen = new Set();
for (const [kind, re] of SHAPES) {
re.lastIndex = 0;
let m;
while ((m = re.exec(line))) {
/* THE CAPTURE'S OWN OFFSET, NOT A SEARCH FOR ITS TEXT. `line.indexOf(m[1], m.index)`
finds the FIRST copy of the digits at or after the match start, which is not
necessarily the one that was captured: "the wipe rate of 74 in ten is 74%" matches
with m[1] = "74", and the search lands on the earlier 74. numEnd then points into
the middle of the sentence, the marker test reads " in ten is 74%…" and a correctly
cited figure is reported as bare. Found by a stranger's read of the fold; it needs
an integer duplicate inside the one shape with a wide gap before its capture, so it
is contrived — but its failure is the bad direction, a FALSE POSITIVE on good prose,
which is the class that teaches a reader to stop reading the output.
The `d` flag gives the capture's real position and there is nothing to search for. */
const numEnd = m.indices[1][1];
/* Context, not the match: the word that excuses a figure sits outside it. */
const before = line.slice(Math.max(0, m.index - 24), numEnd);
if (NOT_A_MEASUREMENT.some(([re2]) => re2.test(before) || re2.test(m[0]))) continue;
/* One figure can match two shapes ("2 rounds" inside "median 2 rounds"); the figure is
the number, so it is counted once at the position it occupies. */
if (seen.has(numEnd)) continue;
seen.add(numEnd);
out.push({ kind, value: m[1], text: m[0].trim(), at: numEnd, numAt: numEnd - m[1].length,
marked: MARKED_AFTER.test(line.slice(numEnd)) });
}
}
return out.sort((a, b) => a.text.localeCompare(b.text));
}
/**
* Every figure in one document, by all three readers. Exported and taking a STRING because
* a reader tested by editing the corpus proves the corpus, and goes quiet the day somebody
* rewords the sentence it was anchored to (R-292).
*/
export function scanDocument(label, src) {
const lines = src.split("\n");
/* Fenced blocks are the safe harbour, exactly as they are for check-outcomes: a code
sample or a recorded harness dump is not the document making a claim. */
const fenced = new Set();
let inFence = false;
lines.forEach((ln, i) => {
if (/^\s*```/.test(ln)) { inFence = !inFence; fenced.add(i); return; }
if (inFence) fenced.add(i);
});
const optedIn = /<!--[ \t]*cite[ \t]*:/i.test(src);
const cites = (src.match(/<!--[ \t]*cite[ \t]*:/gi) ?? []).length;
const headers = tableHeaders(lines);
const rows = [], strict = [], waived = [];
let unmarked = 0;
lines.forEach((ln, i) => {
if (fenced.has(i)) return;
/* Three readers can find the same number — `**8.3%**` is a bold published figure AND a
`wipes N%` shape — and they stop at different characters, one after the digits and one
after the closing asterisks. So the identity of a figure is WHERE ITS NUMBER STARTS,
not where its marker would go, or the same figure is reported twice. */
const seen = new Set();
/* THE PROSE-CELL RULE BINDS THE COLUMN PASS AND NOT THE SHAPE PASS, and the difference is
what each reader claims. The column pass takes every number in a measurement column on
the header's authority, so in a cell of narrative it would claim words; the shape pass
matches a named phrasing — "2 of 4 hurt" is a measured figure whether it sits in a
sentence or not. Blanking prose cells for both read well and quietly dropped twelve
real figures out of THROUGH_TRAIN's ratchet, which is coverage this guard already had.
The exclusion lives in cellFiguresIn, where the claim it guards is made. */
const scan = maskCode(ln);
const found = [...figuresIn(scan), ...cellFiguresIn(scan, headers[i] && maskCode(headers[i])),
...boldFiguresIn(scan)]
.filter(f => !seen.has(f.numAt) && seen.add(f.numAt));
for (const f of found) {
rows.push({ label, line: i + 1, ...f, optedIn });
if (f.marked) continue;
const w = waiverAt(ln.slice(f.at));
if (w) {
if (!w.reason) strict.push(` ${label}:${i + 1} — "${f.text}" carries an EMPTY `
+ `\`uncited:\` waiver. A waiver with no reason is a decision nobody recorded. Say why `
+ `this figure has no artifact, or cite it.`);
else waived.push({ label, line: i + 1, text: f.text, reason: w.reason });
continue;
}
unmarked++;
if (!optedIn) continue;
const uncitable = uncitableValues.get(f.value);
strict.push(uncitable
? ` ${label}:${i + 1} — "${f.text}" is bare, and ${f.value} is the value of `
+ `${uncitable}, a field check-cited REFUSES to let anybody cite because a sample `
+ `maximum moves by a third on a re-seed. Refusing the citation while printing the `
+ `number leaves the least stable figure in the suite as the only one nothing holds. `
+ `Do not cite it — stop printing it, and lean on the guarantee instead.`
: ` ${label}:${i + 1} — "${f.text}" carries no citation marker, in a document `
+ `that cites ${cites} other figures. `
+ `Either mark it, or say in words why this number is not a measurement.`);
}
});
return { rows, strict, waived, unmarked, optedIn };
}
/* ── running the guard ─────────────────────────────────────────────────────────────────
EVERYTHING BELOW RUNS ONLY WHEN THIS FILE IS THE ENTRY POINT, and the reason is not tidiness.
`scanDocument` is exported so check-behaviour can test the readers on strings (R-292), and
for one commit importing it ALSO ran the whole guard: a figure defect called `process.exit(1)`
inside check-behaviour, which then reported check-figures' failure under its own name having
run ZERO of its 107 tests. A guard that stops another guard from running while appearing to
participate is the worst version of the fault this file exists to catch. Same idiom as
step5-split.mjs. */
if (import.meta.main || process.argv[1]?.endsWith("check-figures.mjs")) {
const strict = [];
const loose = {};
const rows = [];
const waived = [];
for (const [label, abs] of playableFiles()) {
const r = scanDocument(label, readFileSync(abs, "utf8"));
rows.push(...r.rows); strict.push(...r.strict); waived.push(...r.waived);
if (!r.optedIn) loose[label] = r.unmarked;
}
/* ── the ratchet, for documents outside the citation regime ────────────────────────────── */
const base = existsSync(BASELINE) ? JSON.parse(readFileSync(BASELINE, "utf8")) : null;
if (UPDATE) {
writeFileSync(BASELINE, JSON.stringify({
note: "Unmarked measurement figures in scenarios that use no citations. May fall, never rise. "
+ "Generated by tools/check-figures.mjs --update.",
uncited: loose
}, null, 1) + "\n");
console.log(`check-figures: recorded ${Object.keys(loose).length} document(s) outside the citation regime`);
process.exit(0);
}
const problems = [...strict];
if (!base) {
problems.push(` no ratchet at tools/figures-baseline.json — run: node tools/check-figures.mjs --update`);
} else {
for (const [label, n] of Object.entries(loose)) {
const was = base.uncited?.[label];
if (was === undefined) {
problems.push(` ${label} uses no citations and is not in the ratchet. Re-record with --update, `
+ `or give it its first citation marker and it joins the strict regime.`);
} else if (n > was) {
problems.push(` ${label}: unmarked measurement figures went ${was} -> ${n}. This document cites `
+ `nothing, so the ratchet is all that holds it: the count may fall, never rise.`);
}
}
/* A document that LEAVES the loose set has gained its first citation, which is good news
and must not be reported as a missing row. A document that vanishes entirely has been
deleted or reclassified, and that is worth saying out loud. */
for (const label of Object.keys(base.uncited ?? {})) {
if (!(label in loose) && !rows.some(r => r.label === label)) {
problems.push(` ${label} is in the ratchet but no longer in the corpus. If it was renamed or `
+ `deleted, re-record with --update.`);
}
}
}
if (LIST) {
for (const r of rows) {
console.log(` ${r.marked ? "cited " : "BARE "} ${r.label}:${r.line} [${r.kind}] ${r.text}`);
}
problems.forEach(p => console.log(p));
process.exit(0);
}
if (problems.length) {
console.error("check-figures: FAILED — measured figures are printed with nothing holding them");
problems.forEach(p => console.error(p));
console.error(` check-cited resolves the markers that exist; it cannot see a figure that has none. `
+ `That is how "a median 24 rounds" survived ten passes against a baseline holding 21.`);
process.exit(1);
}
/* WAIVERS ARE PRINTED ON EVERY GREEN BUILD. A figure nobody checks is the defect this guard
exists for, and a waived figure is one we have decided to stop checking — so the decision
stays on screen rather than becoming the default. Six of them is a short list; sixty would
be a finding. */
if (waived.length) {
console.log(`check-figures: ${waived.length} figure(s) waived, each on the record:`);
for (const w of waived) console.log(` ${w.label}:${w.line} ${w.text} — ${w.reason}`);
}
const marked = rows.filter(r => r.marked).length;
const looseTotal = Object.values(loose).reduce((a, b) => a + b, 0);
console.log(`check-figures: OK — ${rows.length} measurement-shaped figures across ${new Set(rows.map(r => r.label)).size} `
+ `scenarios, ${marked} marked and ${waived.length} waived; ${looseTotal} bare in `
+ `${Object.keys(loose).length} document(s) that cite nothing, held at the ratchet`);
}