Files
RingBRP/tools/check-figures.mjs
T
slaguru666andClaude Opus 5 e9f7d458f0 check-figures: a cell inherits its column's header
R-300 was right, and it was right with a live defect rather than an argument:
a bare **24** in a table whose own header row says "Median rounds", reported
OK by this guard, against a baseline holding 25. The scan read one line at a
time, so measurement status living two lines above was invisible.

Fixed by reading the header. A cell now 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, and it is deliberately
a different mechanism from the shapes: the shapes guess at phrasing, the header
does not have to.

The general point in R-300 still stands and the file now says so where the
shapes are defined: a vocabulary learned from the marked figures cannot contain
the phrasing of the figure nobody marked. check-unmarked attacks that from the
other end, treating bold as the corpus's own mark of a published figure.

Two bugs found while testing, both mine, both caught before commit:

- 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 reported roughly 130 correctly-cited rows as unheld — the
  best-marked tables in the corpus. Comments are now blanked rather than
  removed, so every offset still points at the right character.
- "3.48 of 6" — the 6 is the party size the mean is out of, not a measurement.

Coverage goes from 25 figures, 7 marked, to 120 figures, 102 marked.

Proved again by breaking it, five ways, every restore byte-identical: the
historical bare **24** fails at its own line, a stripped prose marker fails, a
planted "median 24 rounds" fails, a new bare figure in an uncited document
trips the ratchet, and neither a threshold column header nor any of the 102
cited cells fires.

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

323 lines
16 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. */
const SHAPES = [
["median", new RegExp(String.raw`median(?:\s+of)?\s+` + N, "gi")],
["rounds", new RegExp(N + String.raw`\s*\*{0,2}\s*rounds\b`, "gi")],
["wiped", new RegExp(N + String.raw`\s*%?\s*\*{0,2}\s*(?:wiped|wipes)\b`, "gi")],
["wipes-pct", new RegExp(String.raw`wipe[sd]?\s+` + N + String.raw`\s*%`, "gi")],
["wipe-rate", new RegExp(String.raw`wipe rate[^.]{0,24}?` + N + String.raw`\s*%`, "gi")],
["of-N-hurt", new RegExp(N + String.raw`\s*\*{0,2}\s*of\s+\d+\s+(?:hurt|down)\b`, "gi")],
["deaths", new RegExp(N + String.raw`\s*\*{0,2}\s*deaths?\b`, "gi")],
["pct-of-runs", new RegExp(N + String.raw`\s*%[^.]{0,18}\bruns?\b`, "gi")]
];
/* 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|deaths?|swing|rounds?)\b/i;
/** 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));
/** 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;
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;
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,
marked: MARKED_AFTER.test(line.slice(numEnd))
});
}
}
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;
/** 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))) {
const numEnd = line.indexOf(m[1], m.index) + m[1].length;
/* 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(), marked: MARKED_AFTER.test(line.slice(numEnd)) });
}
}
return out.sort((a, b) => a.text.localeCompare(b.text));
}
const strict = [];
const loose = {};
const rows = [];
for (const [label, abs] of playableFiles()) {
const src = readFileSync(abs, "utf8");
/* 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;
const lines = src.split("\n");
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 headers = tableHeaders(lines);
let unmarked = 0;
lines.forEach((ln, i) => {
if (fenced.has(i)) return;
/* Shape pass and header pass can find the same number — "median 21" inside a cell of a
column headed Median. Counted once, at the position it occupies. */
const seen = new Set();
const found = [...figuresIn(ln), ...cellFiguresIn(ln, headers[i])]
.filter(f => (f.at === undefined ? true : !seen.has(f.at) && seen.add(f.at)));
for (const f of found) {
rows.push({ label, line: i + 1, ...f, optedIn });
if (f.marked) continue;
unmarked++;
if (optedIn) {
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 ${(src.match(/<!--[ \t]*cite[ \t]*:/gi) ?? []).length} other figures. `
+ `Either mark it, or say in words why this number is not a measurement.`);
}
}
});
if (!optedIn) loose[label] = 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);
}
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; ${looseTotal} bare in ${Object.keys(loose).length} document(s) that cite nothing, held at the ratchet`);