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>
113 lines
5.6 KiB
JavaScript
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);
|