check-figures: the twenty-first guard — figures with nothing holding them

check-cited resolves every citation marker against the artifact it names, and
has one structural blind spot: 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 again.

Desk pass 11 proved it. "A median 24 rounds" sat in the Pacing Note and "24 if
the column joins" in EXPOSURE, against a baseline holding 21 at two of the
column joining and 25 at three, through every pass that checked citations.

This reads the figures instead of the markers. Narrow by design: an earlier
draft matched any number within 45 characters of a measurement word and found
282 candidates, nearly all prose ("down 140 steps", "an engineer on his
rounds", "01:06"). The shapes here are the phrasings the documents actually use
when quoting the simulator, each read off a figure that is cited somewhere.

Opt-in rule: a document that uses citations must mark every measurement figure.
One that cites nothing is held by a ratchet instead — turning three unguarded
scenarios red is how a guard gets switched off on the day it is written — and
joins the strict regime the moment it gains its first marker.

It found three things in CLEAN GROUND before it was wired in:

- 74.9% printed bare twice while cited correctly four times, and that is the
  figure that was published at 58.7% until the truncated sweep was found.
- "the longest fight is 120 rounds" — 120 is exactly packs cut.hollow6.longest,
  the 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
  left the least stable figure in the suite as the only one nothing held. The
  sentence now leans on the guarantee that is actually strong: check-packs
  refuses to record a sweep in which anything reached the cap.

Proved by breaking it, four ways, with byte-identical restores: a stripped
marker fails, a planted "median 24 rounds" fails at its own line, a new bare
figure in an uncited document trips the ratchet, and a threshold column header
("Past 15 rounds") does not fire — that last one was a real false positive in
the first draft, which tested the matched text rather than its context.

v0.23. 21 guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 19:44:47 +01:00
co-authored by Claude Opus 5
parent a1f9e0dca2
commit 91a104e682
4 changed files with 255 additions and 7 deletions
+8 -6
View File
@@ -9,10 +9,10 @@
| | |
|---|---|
| **Version** | **v0.22**, 13 September 2026 |
| **Version** | **v0.23**, 13 September 2026 |
| **Can it be run?** | **Yes, from this document alone.** A GM with this file and the compendium can run it end to end. |
| **Is it convention-ready?** | **Nearly.** The print pack is built (`CLEAN_GROUND_HANDOUTS.html`, held by `check-handouts`). What is missing is a run with human beings, and a slot. |
| **Playtesting** | **Eleven desk passes, no human runs.** `_1.md` GM chair (seed 4271) · `_2.md` Act Four stress (9314) · `_3.md` player chair, contrarian (5580) · `_4.md` **the clock, against v0.13's recut timings** (6142) · `_5.md` **the four-player cut, played** (8815) · `_6.md` **the four untested branches, forced** (3319) · `_7.md` **an honest session, nothing forced** (7742) · `_8.md` **the cold read, plus an honest session** (5108) · `_9.md` **the regression pass, every fix from 4–8 re-checked** · `_10.md` **the Close, and a twelve-seed census** (4417) · `_11.md` **the fight the players choose, and what it leaves** (7311). Sixty-five fix-list items and nine post-passes, and pass 7's finding is now a guard: **`check-outcomes` reads every bracketed mechanic tag in the acts and holds what its beat states.** Pass 7 ran ~3:35 against 3:40 at six players with no cuts taken — the first honest pass inside the clock unaided. **Pass 11 closed the last desk-reachable branch; the only untested thing left is a run with human beings.** |
| **Playtesting** | **Eleven desk passes, no human runs.** `_1.md` GM chair (seed 4271) · `_2.md` Act Four stress (9314) · `_3.md` player chair, contrarian (5580) · `_4.md` **the clock, against v0.13's recut timings** (6142) · `_5.md` **the four-player cut, played** (8815) · `_6.md` **the four untested branches, forced** (3319) · `_7.md` **an honest session, nothing forced** (7742) · `_8.md` **the cold read, plus an honest session** (5108) · `_9.md` **the regression pass, every fix from 4–8 re-checked** · `_10.md` **the Close, and a twelve-seed census** (4417) · `_11.md` **the fight the players choose, and what it leaves** (7311). Sixty-five fix-list items and nine post-passes, and two passes' findings are now guards: **`check-outcomes` reads every bracketed mechanic tag in the acts and holds what its beat states**, and **`check-figures` reads the figures rather than the markers, so a measured number printed with nothing holding it stops the build.** Pass 7 ran ~3:35 against 3:40 at six players with no cuts taken — the first honest pass inside the clock unaided. **Pass 11 closed the last desk-reachable branch; the only untested thing left is a run with human beings.** |
| **What it is** | A **convention one-shot**, decided 13 September 2026. It ends in the slot; there is no second session and the text must not promise one. |
| **Where it runs** | **Contingency 2027 — Sunday 31 January 2027, afternoon** (slot 9), Searles Leisure Resort, Hunstanton. |
| **Runtime** | **3h30 of play plus a 10-minute break — 3h40 wall clock**, which is the house budget. Act Two was cut from 65 minutes to 45 in v0.13 to get there. The **Pacing Note** carries a further 65 minutes of cuts; take them as the default. |
@@ -1066,7 +1066,7 @@ whoever it is copying, always.
> punch that dies in two rounds — do not buff it. Six of the column kill one party in
> seven; ten kill nine in ten. **The six at the back are never fought alone**: two of the
> column joining in takes that fight from nobody dying to one wipe in twelve, and **at
> four players the six at the back wipe 74.9% on their own.**
> four players the six at the back wipe **74.9%**<!-- cite: packs cut.hollow6.wiped --> on their own.**
>
> **A fight also costs the session** — a median 13<!-- cite: packs line.hollow6.median --> rounds,
> **21**<!-- cite: packs line.hollow6col2.median --> if two of the column join, which is most of an act. **So there is no version of a fight here that is good for anybody**, and
@@ -1084,10 +1084,12 @@ encounter in the case that is incapable of being dangerous.
> measured through `simulate.mjs`'s CLI, which calls `runFight` with no options — so it ran
> at the default **40-round ceiling**, and `runFight` does not report truncation: it scores
> whoever is standing when the loop stops. **Six hollow men against the four-player cut hit
> that ceiling in 578 runs of 2000, and the party's real wipe rate is 74.9%, not the 58.7%
> that ceiling in 578 runs of 2000, and the party's real wipe rate is **74.9%**<!-- cite: packs cut.hollow6.wiped -->, not the 58.7%
> this document printed for three versions.** Fourteen points, on the most alarming number
> in the case. Everything here is now measured at a 400-round cap where nothing truncates
> (the longest fight is 120 rounds), recorded in `tools/pack-tables-baseline.json`, and held
> in the case. Everything here is now measured at a 400-round cap, and **`check-packs` refuses
> to record a sweep in which anything reached it** — a stronger guarantee than quoting a
> longest fight, because a sample maximum moves by a third of its value on a re-seed.
> Recorded in `tools/pack-tables-baseline.json`, and held
> by **`check-packs`** against the game and by **`check-cited`** against this prose. The
> figures carry citation markers; they are no longer typed.
+1 -1
View File
@@ -11,7 +11,7 @@
"play": "node tools/playthrough.mjs",
"mj": "node tools/mj-queue.mjs",
"simulate": "node tools/simulate.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/check-cited.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs",
"test": "bun run check",
"readme": "bun tools/update-readme.mjs"
},
+234
View File
@@ -0,0 +1,234 @@
/**
* 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.
*
* 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")), []);
}
/* 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);
let unmarked = 0;
lines.forEach((ln, i) => {
if (fenced.has(i)) return;
for (const f of figuresIn(ln)) {
rows.push({ label, line: i + 1, ...f, optedIn });
if (f.marked) return;
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`);
+12
View File
@@ -0,0 +1,12 @@
{
"note": "Unmarked measurement figures in scenarios that use no citations. May fall, never rise. Generated by tools/check-figures.mjs --update.",
"uncited": {
"tools/scenario-lastadmission.mjs": 1,
"tools/scenario-openday.mjs": 0,
"tools/scenario-starter.mjs": 0,
"tools/scenario-throughtrain.mjs": 8,
"docs/scenarios/LAST_ADMISSION.md": 1,
"docs/scenarios/OPEN_DAY.md": 0,
"docs/scenarios/THROUGH_TRAIN.md": 8
}
}