/* 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);