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>
This commit is contained in:
slaguru666
2026-09-13 20:04:45 +01:00
co-authored by Claude Opus 5
parent 960fb6be27
commit e9f7d458f0
+90 -2
View File
@@ -15,6 +15,23 @@
* 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?
@@ -104,6 +121,71 @@ for (const rel of ["tools/pack-tables-baseline.json", "tools/fight-tail-baseline
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
@@ -149,13 +231,19 @@ for (const [label, abs] of playableFiles()) {
});
const optedIn = /<!--[ \t]*cite[ \t]*:/i.test(src);
const headers = tableHeaders(lines);
let unmarked = 0;
lines.forEach((ln, i) => {
if (fenced.has(i)) return;
for (const f of figuresIn(ln)) {
/* 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) return;
if (f.marked) continue;
unmarked++;
if (optedIn) {
const uncitable = uncitableValues.get(f.value);