check-unmarked: the figures a vocabulary cannot name, and a repair to HEAD
eead663 committed a document citing `step5 unsettledFactor` while the module
that would export it was still uncommitted in another session's working tree,
so `check-cited` fails on a fresh checkout of HEAD. step5-split.mjs is included
here to repair that; the key derives Act Four's 1.29 from the raw proportions,
which is the figure R-294 exists about.
check-figures (R-299) reports OK on the "24" it was written to catch. Its own
entry names why: the eight shapes were read off figures that are already cited,
so the vocabulary is learned from the marked figures and cannot contain the
phrasing of the one nobody marked. Line 1272 is a table cell whose measurement
status lives in the header two rows above it, and figuresIn reads one line.
check-unmarked reads structure instead of vocabulary: a bold percentage or
decimal in an opted-in document, and a bold number in a table whose header row
names a measurement. Twelve unmarked figures in CLEAN GROUND; eleven correct
and unheld, one the stale 24 that line 1142 had been citing correctly as 25 for
130 lines. Waivers carry a reason, an empty one is fatal, and every waiver
prints on a green build.
Two guards now cover one question, which is one too many. The right end state
is the structural classes folded in beside check-figures' SHAPES, keeping its
opt-in rule and ratchet. That fold is offered to its author rather than taken.
Twelve string tests on unmarkedIn (88 -> 100), both halves mutation-checked.
Twenty-two guards green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
eead6633c8
commit
960fb6be27
@@ -7216,3 +7216,90 @@ by its third run, and the fix is in the file next to the rule it fixes.
|
||||
|
||||
**State:** CLEAN GROUND v0.23, **21 guards**, 162 cited figures. Still the only untested thing
|
||||
is a run with human beings.
|
||||
|
||||
---
|
||||
|
||||
## R-300 — the vocabulary cannot contain the word nobody wrote
|
||||
|
||||
**R-299 built `check-figures` to catch the figure no marker holds, and it reports OK on the
|
||||
figure it was built for.** Not an argument — a run, against the document as it stood at
|
||||
`9897d55`, after its own commit had added markers to that very file:
|
||||
|
||||
```
|
||||
$ sed -n 1272p docs/scenarios/CLEAN_GROUND.md
|
||||
| Six hollow men + three of the column | **24** |
|
||||
$ node tools/check-figures.mjs
|
||||
check-figures: OK — 25 measurement-shaped figures across 5 scenarios, 7 marked …
|
||||
exit=0
|
||||
```
|
||||
|
||||
**R-299 states the mechanism itself, one sentence before the blind spot it causes.** The
|
||||
eight shapes are *"the phrasings the documents actually use when quoting the simulator, each
|
||||
one read off a figure that is already cited somewhere — so the guard learns its vocabulary
|
||||
from the marked figures and applies it to the unmarked."* **A vocabulary learned from the
|
||||
marked figures cannot contain the phrasing of the figure nobody marked.** The defect sits
|
||||
outside the training set by construction, and the narrowing that took the first draft from
|
||||
282 false positives down to 25 is the same narrowing that drops it. This is not a bug in the
|
||||
shapes. It is a property of deriving them that way, and it is the sharpest form yet of the
|
||||
thing this log keeps recording: **a check that passes for a reason unrelated to what it
|
||||
claims.** R-299's reasoning is sound, its ratchet and opt-in rule are both right, and it is
|
||||
still green on the one number it was written to find.
|
||||
|
||||
**Why that phrasing escaped.** Line 1272 is a table cell. Its measurement status lives in the
|
||||
header row two lines above — `| | Median rounds |` — and `figuresIn` reads one line at a
|
||||
time, so `median\s+N` and `N\s*rounds` both miss it. Nothing is adjacent to the number except
|
||||
a pipe.
|
||||
|
||||
**And the document was already contradicting itself.** Line 1142's measured table prints
|
||||
**25** and cites `packs line.hollow6col3.median`. Line 1272 printed **24** for the same
|
||||
configuration with no marker at all. One hundred and thirty lines apart, the correct side
|
||||
checked on every build and the wrong side invisible to every guard in the repo, through
|
||||
eleven desk passes. The self-contradiction is the part worth keeping: **the document already
|
||||
held the right answer, in the regime, and that bought nothing**, because a guard cannot
|
||||
compare a figure it cannot see against one it can.
|
||||
|
||||
**`tools/check-unmarked.mjs` reads structure instead of vocabulary**, which is why it does
|
||||
not inherit the gap:
|
||||
|
||||
- a **bold percentage or decimal** anywhere in an opted-in document. The corpus bolds what it
|
||||
publishes and leaves ratings plain — `Brawl 55%` is a fact about a sheet, `**8.3%**` is a
|
||||
claim about a run — so the document's own typography is the declaration, and it needs no
|
||||
vocabulary at all.
|
||||
- a **bold number in a table whose header row names a measurement**, scoped by walking the
|
||||
`|---|` separator to find which header owns which rows.
|
||||
|
||||
Twelve unmarked figures in CLEAN GROUND. **Eleven were correct and unheld; one was the 24.**
|
||||
|
||||
**What was done with the twelve.** Nine now carry citations — the four median-rounds rows
|
||||
against `packs`, the accepting 48.7% against `step5 accepted.taken`, the main table's 0%
|
||||
against `packs line.hollow6.wiped`. Six carry `uncited:` waivers naming why no artifact
|
||||
exists: three special/fumble bands out of `resolveBands`, the Spot 40 fumble, the 100-against-60
|
||||
tie-break (a pair no countdown row uses), and the column's Dodge 68, which is a creature
|
||||
rating rather than a measurement. **A waiver must carry a reason and an empty one is fatal**
|
||||
— the call made for the empty cast marker in R-288 — and every waiver prints on every green
|
||||
build, so a decision to stop checking something stays visible instead of becoming the
|
||||
default.
|
||||
|
||||
**One figure was promoted rather than waived.** Act Four's **1.29** was loose arithmetic over
|
||||
two step5 rows, and `step5Split` now derives `unsettledFactor` so check-cited holds it. Worth
|
||||
recording why it survives inspection: **1.29 is right, and right for the reason R-294 was
|
||||
written about** — the raw proportions give 1.2889, the rounded rows give 1.2832, and the
|
||||
document has the raw answer.
|
||||
|
||||
**Two guards now cover one question and that is one too many.** `check-figures` catches
|
||||
phrase-shaped figures anywhere, including in documents that cite nothing, which
|
||||
`check-unmarked` does not attempt; `check-unmarked` catches published and tabulated figures,
|
||||
which no vocabulary reaches. Both are green, both are in `npm run check`, and the right end
|
||||
state is one guard holding both readers — the structural classes folded in beside `SHAPES`,
|
||||
keeping R-299's opt-in rule and ratchet, which are the better half of the design. **That fold
|
||||
is not done here**, because `check-figures` belongs to another session and was committed
|
||||
minutes before this work; it was offered to its author rather than taken. Until then the
|
||||
build runs both, which is worse to maintain and strictly better to trust.
|
||||
|
||||
**Verification.** Twelve string tests on `unmarkedIn` in `check-behaviour` (88 → 100), each
|
||||
naming the shape it classifies rather than editing the corpus — R-292's rule, because a
|
||||
reader tested by editing the document goes quiet the day somebody rewords the sentence.
|
||||
Both halves mutation-checked: disabling the table-header class fails *"a bold figure under a
|
||||
measurement header is fatal"* and nothing else; accepting an empty waiver fails *"an EMPTY
|
||||
waiver is fatal"* and nothing else. End to end, restoring the **24** stops the build with
|
||||
both values named. Twenty-two guards green.
|
||||
|
||||
+1
-1
@@ -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-figures.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-unmarked.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"
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE }
|
||||
import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, STARTER_TEAM }
|
||||
from "./scenario-starter.mjs";
|
||||
import { castMarkersIn, castLikeIn } from "./declared-cast.mjs";
|
||||
import { unmarkedIn } from "./check-unmarked.mjs";
|
||||
import { tagsIn, classifiedFiles, playableFiles } from "./check-scenarios.mjs";
|
||||
import { beatsIn, beatLikeIn } from "./outcome-coverage.mjs";
|
||||
import { step5Split, split as step5Of, THING_RATING } from "./step5-split.mjs";
|
||||
@@ -918,6 +919,59 @@ test("the blend uses raw proportions, not the rounded rows", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ----------------- check-unmarked, read on strings ----------------- */
|
||||
/* On strings and not on the corpus, for the reason R-292 gives: a reader tested by editing
|
||||
the document proves the document, and goes quiet the day somebody rewords the sentence it
|
||||
was anchored to. Every case below is a shape the reader must classify, stated outright. */
|
||||
|
||||
const um = md => unmarkedIn("x.md", md);
|
||||
|
||||
test("an unmarked bold percentage is fatal", () =>
|
||||
assert.equal(um("The party wipes **8.3%** of the time.").strict.length, 1));
|
||||
|
||||
test("the same figure carrying a cite is not this guard's business", () =>
|
||||
assert.equal(um("wipes **8.3%**<!-- cite: packs line.x.wiped --> of the time").strict.length, 0));
|
||||
|
||||
test("a waiver clears the figure and goes on the record with its reason", () => {
|
||||
const r = um("Dodge is **68%**<!-- uncited: a rating, not a measurement -->.");
|
||||
assert.equal(r.strict.length, 0);
|
||||
assert.equal(r.waived.length, 1);
|
||||
assert.equal(r.waived[0].reason, "a rating, not a measurement");
|
||||
});
|
||||
|
||||
test("an EMPTY waiver is fatal — the R-288 call, again", () =>
|
||||
assert.equal(um("Dodge is **68%**<!-- uncited: -->.").strict.length, 1));
|
||||
|
||||
test("a bold figure under a measurement header is fatal — the 24 itself", () =>
|
||||
assert.equal(um("| | Median rounds |\n|---|---|\n| six a side | **24** |").strict.length, 1));
|
||||
|
||||
test("the same figure under a header that names no measurement is ignored", () =>
|
||||
assert.equal(um("| | Roll |\n|---|---|\n| it watches | **3** |").strict.length, 0));
|
||||
|
||||
test("a fenced figure is the artifact, not a claim about it", () =>
|
||||
assert.equal(um("```\n wiped **8.3%**\n```").strict.length, 0));
|
||||
|
||||
test("an inline-code figure is ignored too", () =>
|
||||
assert.equal(um("see `wiped **8.3%**` above").strict.length, 0));
|
||||
|
||||
test("a bare figure in a measured sentence is loose, never fatal", () => {
|
||||
const r = um("The median across runs was 21 and it wiped 8.3% of parties.");
|
||||
assert.equal(r.strict.length, 0);
|
||||
assert.ok(r.loose.length >= 1, "a bare figure in a measured sentence must still be reported");
|
||||
});
|
||||
|
||||
test("a skill rating in ordinary prose is neither strict nor loose", () => {
|
||||
const r = um("Nia Mercer carries Pistol 55% and a bad temper.");
|
||||
assert.equal(r.strict.length, 0);
|
||||
assert.equal(r.loose.length, 0, "ratings are facts about a sheet, not measurements");
|
||||
});
|
||||
|
||||
test("a marker after the closing pipe of a table cell still counts", () =>
|
||||
assert.equal(um("| | Median rounds |\n|---|---|\n| six | **24**<!-- cite: packs a.b --> |").strict.length, 0));
|
||||
|
||||
test("the reader reports the line the figure is actually on", () =>
|
||||
assert.equal(um("one\ntwo\nwipes **8.3%** here").strict[0].where, "x.md:3"));
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
await runAll();
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* A figure with no citation marker is a figure no guard can see.
|
||||
*
|
||||
* check-cited resolves every `<!-- cite: ... -->` in the corpus against the artifact it
|
||||
* names, and it is exact. But it can only resolve the markers that EXIST. A number written
|
||||
* without one is not checked, not reported, and not distinguishable from prose — and the
|
||||
* guard's green line says "160 cited figures resolve" either way, which reads like coverage.
|
||||
*
|
||||
* That is how "a median 24 rounds" survived eleven desk passes in three places. Nothing was
|
||||
* wrong with check-cited. The figure simply never entered its field of view.
|
||||
*
|
||||
* So this is the other half, and it is deliberately ASYMMETRIC — the shape this project
|
||||
* arrived at for cast markers, beat tags and `POWER:`. The strict reader above says whether
|
||||
* what is declared is right. This one says what was never declared at all:
|
||||
*
|
||||
* STRICT a published measurement — a bold percentage or decimal, or a bold number in a
|
||||
* table whose own header calls the column a measurement — must carry `cite:`,
|
||||
* or an `uncited:` waiver that says why. Anything else stops the build.
|
||||
*
|
||||
* LOOSE a bare percentage or decimal sitting in a sentence about medians, wipes or
|
||||
* runs is COUNTED and named, never fatal. It is the class this guard knows it
|
||||
* cannot judge: most are ratings and prose, some are the next stale 24.
|
||||
*
|
||||
* The waiver is not a silencer. It must carry a reason, an empty one is fatal (the call made
|
||||
* for the empty cast marker in R-288), and every waiver is printed on every green build so
|
||||
* that a decision to stop checking something stays visible rather than becoming the default.
|
||||
*
|
||||
* Skill ratings are not measurements. "Brawl 55%" is a fact about a sheet, not a figure
|
||||
* derived from running the game, and bolding is how this corpus distinguishes them: the
|
||||
* documents bold what they publish and leave ratings plain. That convention is the whole
|
||||
* basis of the strict class, and it is why the loose class exists to catch its exceptions.
|
||||
*
|
||||
* node tools/check-unmarked.mjs check the corpus
|
||||
* node tools/check-unmarked.mjs --loose also print every loose hit, not just the count
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { playableFiles } from "./check-scenarios.mjs";
|
||||
|
||||
const LOOSE_LIST = process.argv.slice(2).includes("--loose");
|
||||
|
||||
/* Code fences hold printed baselines and harness output — figures that are already the
|
||||
artifact rather than a claim about it. Blanked rather than removed so every index still
|
||||
points at the right line. */
|
||||
const CODE = /```[\s\S]*?```|`[^`\n]*`/g;
|
||||
const scannable = text => text.replace(CODE, m => " ".repeat(m.length));
|
||||
|
||||
const PERCENT_OR_DECIMAL = String.raw`\d[\d,]*(?:\.\d+)?\s*%|\d[\d,]*\.\d+`;
|
||||
const BOLD_MEASURED = new RegExp(String.raw`\*\*\s*(?:[~<>≈]\s*)?(${PERCENT_OR_DECIMAL})\s*\*\*`, "g");
|
||||
const BOLD_NUMBER = /\*\*\s*(?:[~<>≈]\s*)?(\d[\d,]*(?:\.\d+)?\s*%?)\s*\*\*/g;
|
||||
const BARE_FIGURE = new RegExp(String.raw`(?<![\*\d.])(${PERCENT_OR_DECIMAL})`, "g");
|
||||
|
||||
/* A marker may follow the figure directly or after the closing pipe of a table cell. */
|
||||
const MARKER_AFTER = /^[\s|]*<!--\s*(cite|uncited)\s*:\s*([^>]*?)\s*-->/;
|
||||
|
||||
/* What makes a COLUMN a measurement. Note `%` is absent on purpose: a column of percentages
|
||||
may be skill ratings, and the first draft of this guard called all 135 of them figures. */
|
||||
const MEASURE_WORD = /\b(median|mean|average|wiped?|deaths?|dead|rounds?|runs?|rate|odds|chance|share|percentile)\b/i;
|
||||
/* The same test for a sentence, plus the phrasings prose uses that a column heading does not. */
|
||||
const MEASURED_SENTENCE = new RegExp(MEASURE_WORD.source + String.raw`|\bof the time\b|\bin \d+ (?:runs|sessions|seeds)\b`, "i");
|
||||
|
||||
/** Header of the markdown table a line belongs to, or null when it is not in one. */
|
||||
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;
|
||||
const header = lines[i - 1];
|
||||
for (let j = i + 1; j < lines.length && /^\s*\|/.test(lines[j]); j++) owner[j] = header;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
const lineIndexOf = (text, at) => text.slice(0, at).split("\n").length - 1;
|
||||
|
||||
export function unmarkedIn(label, raw) {
|
||||
const text = scannable(raw);
|
||||
const lines = raw.split("\n");
|
||||
const owner = tableHeaders(lines);
|
||||
const strict = [], loose = [], waived = [];
|
||||
const claimed = new Set();
|
||||
|
||||
const consider = (m, why) => {
|
||||
const end = m.index + m[0].length;
|
||||
const after = MARKER_AFTER.exec(raw.slice(end, end + 120));
|
||||
const li = lineIndexOf(raw, m.index);
|
||||
const where = `${label}:${li + 1}`;
|
||||
if (after) {
|
||||
if (after[1] === "uncited") {
|
||||
if (!after[2]) strict.push({ where, figure: m[1], why:
|
||||
`carries an empty \`uncited:\` waiver. A waiver with no reason is a decision nobody `
|
||||
+ `recorded — say why this figure is not citable, or cite it.` });
|
||||
else waived.push({ where, figure: m[1], reason: after[2] });
|
||||
}
|
||||
claimed.add(`${li}:${m.index}`);
|
||||
return; // a cite: is check-cited's business, not ours
|
||||
}
|
||||
strict.push({ where, figure: m[1], why, line: lines[li].trim() });
|
||||
};
|
||||
|
||||
for (const m of text.matchAll(BOLD_MEASURED))
|
||||
consider(m, "a published percentage or decimal carrying no marker");
|
||||
|
||||
for (const m of text.matchAll(BOLD_NUMBER)) {
|
||||
const li = lineIndexOf(raw, m.index);
|
||||
const head = owner[li];
|
||||
if (!head || !MEASURE_WORD.test(head)) continue;
|
||||
if (PERCENT_OR_DECIMAL && new RegExp(`^(?:${PERCENT_OR_DECIMAL})$`).test(m[1].trim())) continue;
|
||||
consider(m, `a bold figure in a table whose header reads "${head.replace(/\|/g, "").trim()}"`);
|
||||
}
|
||||
|
||||
for (const m of text.matchAll(BARE_FIGURE)) {
|
||||
const li = lineIndexOf(raw, m.index);
|
||||
if (claimed.has(`${li}:${m.index}`)) continue;
|
||||
if (!MEASURED_SENTENCE.test(lines[li])) continue;
|
||||
const end = m.index + m[0].length;
|
||||
if (MARKER_AFTER.test(raw.slice(end, end + 120))) continue;
|
||||
loose.push({ where: `${label}:${li + 1}`, figure: m[1], line: lines[li].trim() });
|
||||
}
|
||||
return { strict, loose, waived };
|
||||
}
|
||||
|
||||
if (import.meta.main || process.argv[1]?.endsWith("check-unmarked.mjs")) {
|
||||
const strict = [], loose = [], waived = [];
|
||||
for (const [label, abs] of playableFiles()) {
|
||||
const r = unmarkedIn(label, readFileSync(abs, "utf8"));
|
||||
strict.push(...r.strict); loose.push(...r.loose); waived.push(...r.waived);
|
||||
}
|
||||
|
||||
if (strict.length) {
|
||||
console.error(`check-unmarked: FAILED — ${strict.length} published figure(s) carry no citation marker\n`);
|
||||
for (const s of strict) {
|
||||
console.error(` ${s.where} ${s.figure}`);
|
||||
console.error(` ${s.why}`);
|
||||
if (s.line) console.error(` ${s.line.slice(0, 96)}`);
|
||||
}
|
||||
console.error(`\n A figure without a marker is not checked by anything. Cite it against its`);
|
||||
console.error(` artifact, or waive it with <!-- uncited: why this figure has no artifact -->.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (waived.length) {
|
||||
console.log(`check-unmarked: ${waived.length} figure(s) waived, each on the record:`);
|
||||
for (const w of waived) console.log(` ${w.where} ${w.figure} — ${w.reason}`);
|
||||
}
|
||||
if (LOOSE_LIST) for (const l of loose) console.log(` loose ${l.where} ${l.figure} | ${l.line.slice(0, 80)}`);
|
||||
|
||||
console.log(`check-unmarked: OK — every published figure in ${playableFiles().length} playable files `
|
||||
+ `carries a marker, ${waived.length} waived, ${loose.length} unbolded figure(s) in measured `
|
||||
+ `sentences left unjudged (--loose to list them)`);
|
||||
}
|
||||
@@ -138,8 +138,15 @@ export function step5Split() {
|
||||
const mix = k => Math.round((w * rows.refused.raw[k] + (1 - w) * rows.accepted.raw[k]) * 1000) / 10;
|
||||
const blended = { taken: mix("taken"), held: mix("held"), unsettled: mix("unsettled") };
|
||||
|
||||
return { ...rows, blended, refusedShare: insight, acceptedShare: 100 - insight,
|
||||
thing: THING_RATING };
|
||||
/* The factor Act Four quotes for what accepting costs in *unsettled* outcomes. Derived
|
||||
here rather than left as arithmetic in the prose, because it is the figure that was
|
||||
wrong for five versions — and because it must come off the RAW proportions: the rounded
|
||||
rows give 1.28 and the answer is 1.29. Exposing it means check-cited holds the document
|
||||
to it instead of nobody holding it to anything. */
|
||||
const unsettledFactor = Math.round(rows.accepted.raw.unsettled / rows.refused.raw.unsettled * 100) / 100;
|
||||
|
||||
return { ...rows, blended, unsettledFactor, refusedShare: insight,
|
||||
acceptedShare: 100 - insight, thing: THING_RATING };
|
||||
}
|
||||
|
||||
if (import.meta.main || process.argv[1]?.endsWith("step5-split.mjs")) {
|
||||
|
||||
Reference in New Issue
Block a user