diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 4b1a3a6..e676268 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -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. diff --git a/package.json b/package.json index 9e6de08..3c65c49 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/tools/check-behaviour.mjs b/tools/check-behaviour.mjs index cbcca00..db2ad5d 100644 --- a/tools/check-behaviour.mjs +++ b/tools/check-behaviour.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%** 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%**."); + 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%**.").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** |").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(); diff --git a/tools/check-unmarked.mjs b/tools/check-unmarked.mjs new file mode 100644 index 0000000..d21771b --- /dev/null +++ b/tools/check-unmarked.mjs @@ -0,0 +1,150 @@ +/** + * A figure with no citation marker is a figure no guard can see. + * + * check-cited resolves every `` 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`(?]*?)\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 .`); + 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)`); +} diff --git a/tools/step5-split.mjs b/tools/step5-split.mjs index 575d04a..424d81e 100644 --- a/tools/step5-split.mjs +++ b/tools/step5-split.mjs @@ -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")) {