Files
Hankan/audit-gate.mjs
MechaCat02 e72b77d6c2 chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.

Copied byte-identical from the bundle:
  lib/        lexicon.js and sync.js are new; gate.js gains enforcement,
              hangul.js letter-level marking, srs.js recall evidence,
              conjugation.js deconjugate(); blocks.js now takes the last
              block, closes gloss at "=", and parses recall, ::result and
              ::confirmed
  data/       curriculum.json v5 — six 다지기 phase reviews; the 371
              roadmap words are unchanged and no band moves
  prompt/     English-only rule, recall, LETTER-LEVEL CHECK, marking
  audit-gate.mjs, run-checks.sh, fixtures/  — the word gate measured
              against 54 real tutor messages

CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.

types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.

TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:49:52 +02:00

113 lines
5.6 KiB
JavaScript

/* The gate's false-positive audit. Run: node audit-gate.mjs
Exits non-zero if the scan fires on more than the known true positives.
THIS IS NOT AN OPTIONAL TEST. The first version of the vocabulary scan
looked at the tutor's prose as well as his exercises; run against these
same fixtures it rejected 17 of 41 real messages, because "국물 is read as
→ 궁물" reads like a stray word and is in fact the subject of Phase 1.
Any change to lib/gate.js must be re-measured here. */
import fs from "fs";
import { parse } from "./lib/blocks.js";
import { flatten, buildScaffold, scanTask, proseIsKorean } from "./lib/gate.js";
import { buildLexicon } from "./lib/lexicon.js";
import { haeche, past } from "./lib/conjugation.js";
const read = f => JSON.parse(fs.readFileSync(new URL(f, import.meta.url)));
const curriculum = read("./data/curriculum.json");
const deck = read("./data/deck.json"), glossExtra = read("./data/gloss-extra.json");
const sentences = read("./data/sentences.json"), sfx = read("./data/sfx.json");
const progress = read("./fixtures/progress-snapshot.json");
const deckWords = Object.values(deck.topics).flat()
.map(([ko, , en, pos]) => ({ ko, en, pos }));
const UNITS0 = flatten(curriculum);
const lex = buildLexicon({
roadmapWords: UNITS0.flatMap(u => u.words || []), // scheduled words win — see lexicon.js
deck: deckWords,
glossExtra: glossExtra.entries.map(g => [g.ko, g.en, g.note]),
sentences: sentences.sentences,
sfx: sfx.items.map(i => [i.ko, i.en]),
}, { haeche, past });
const UNITS = UNITS0;
/* what he may put in front of the student, at this point in the course */
const done = progress.road.done || {};
const current = UNITS.find(u => u.id === progress.road.unit) || UNITS[0];
const allowed = new Set();
const addAllowed = w => {
if (!w) return;
allowed.add(w);
if (/\s/.test(w)) w.split(/\s+/).forEach(x => x && allowed.add(x));
};
UNITS.filter(u => done[u.id]).forEach(u => (u.words || []).forEach(addAllowed));
(current.words || []).forEach(addAllowed);
(current.revisits || []).forEach(addAllowed);
progress.deckWordsMet.forEach(addAllowed); // only the ones he has actually met
/* the shipped app's list before multi-word entries are split into parts */
const base = new Set();
UNITS.filter(u => done[u.id]).forEach(u => (u.words || []).forEach(w => base.add(w)));
progress.deckWordsMet.forEach(w => base.add(w));
const expected = new Set(progress.allowedWordsExpected);
if (base.size !== expected.size) {
const missing = [...expected].filter(w => !base.has(w));
const extra = [...base].filter(w => !expected.has(w));
console.log(` ! allowed set is ${base.size}, the shipped app computes ${expected.size} from this state.` +
(missing.length ? `\n missing here: ${missing.slice(0, 12).join(" ")}` : "") +
(extra.length ? `\n extra here: ${extra.slice(0, 12).join(" ")}` : "") +
`\n Reconcile this FIRST; the flag counts below mean nothing until the two agree.`);
}
const scaffold = buildScaffold(curriculum);
const unitOf = w => (UNITS.find(u => (u.words || []).includes(w) || (u.revisits || []).includes(w)) || {}).id || "";
const ctx = { allowed, scaffold, heads: t => lex.heads(t), unitOf };
let bad = 0;
function audit(file, label, expected) {
const { messages } = read(file);
const rows = [];
messages.forEach((m, i) => {
const parsed = parse(m);
const found = scanTask(parsed, ctx).map(f => f.word + (f.unit ? `(${f.unit})` : f.known ? "" : "[no gloss]"));
if (proseIsKorean(m)) found.push("«KOREAN PROSE»");
if (found.length) rows.push(` #${i} [${found.join(" ")}] "${m.slice(0, 46).replace(/\n/g, " ")}…"`);
});
const pct = Math.round((100 * rows.length) / messages.length);
/* BOTH directions are failures. Too many means the gate is over-firing on
real teaching. Too few means the port is weaker than the app that was
measured, and real violations are getting through — which is how 아파
reached a Phase 1 exercise in the first place. */
const ok = rows.length === expected;
if (!ok) bad++;
console.log(`\n${ok ? "PASS" : "FAIL"} ${label}: ${rows.length} of ${messages.length} flagged (${pct}%), ` +
`the shipped app flags ${expected}` + (ok ? "" : rows.length > expected ? " — OVER-FIRING" : " — TOO WEAK, violations are getting through"));
rows.forEach(r => console.log(r));
}
/* Measured, not chosen — and the difference between the two numbers below
and the artifact's is itself documented, because parity was NOT the goal.
live 1.10 review — 2. Exact match with the shipped app. Both hits are
아파 (unit 3.2) in a Phase 1 review: the violation class this whole gate
exists for.
earlier units — 7 here, 11 in the artifact, and the 4 extra artifact hits
are 몰라. The artifact's lexicon holds 몰라 as a bare sentence entry with
no link to 모르다, so it cannot tell that 몰라 is the 반말 form of a word
the student has already met, and flags it. lib/lexicon.js does make that
link, so it does not. The port is right and the artifact is over-firing;
this is the one place the two deliberately disagree.
If YOUR number drifts from 7, read every differing hit before touching
this line. Over-firing means the gate is rejecting real teaching; under-
firing means violations are reaching the student, which is how 아파 got
into a Phase 1 exercise. */
audit("./fixtures/tutor-messages.json", "earlier units", 7);
audit("./fixtures/tutor-messages-live.json", "live 1.10 review", 2);
console.log(bad ? "\ngate does not match the measured baseline — read every differing hit" : "\nmatches the shipped gate");
process.exit(bad ? 1 : 0);