Files
Hankan/lib/lexicon.js
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

109 lines
5.1 KiB
JavaScript

/* ══════════════════════════════════════════════════════════════════════
THE LEXICON — every surface form the app can gloss
══════════════════════════════════════════════════════════════════════
Two jobs, and they must share one resolver or they drift apart:
· the student taps a word and gets its meaning
· the gate asks whether the tutor was allowed to use it
Both need the same question answered: which dictionary word is this
surface form? Get it wrong in the first and the student is told a word he
was taught is "not in the word list". Get it wrong in the second and 닭이
is reported as an unknown word when it is just 닭 with a particle. Both
happened.
An entry carries `base` when it is a surface form of something else, and
resolution returns EVERY route, best first — the caller decides which it
needs. A single "best" answer was the bug. */
import { deconjugateCandidates } from "./conjugation.js";
export const PARTICLES = ["이랑","에서","에게","한테","으로","부터","까지","보다","처럼","같이",
"까","은","는","이","가","을","를","도","만","에","와","과","랑","의","로"];
export class Lexicon {
constructor() { this.map = new Map(); this.verbs = new Set(); }
/** @param base the dictionary word this is a form of, if any */
add(ko, gloss, note = "", src = "deck", base = "") {
if (!ko || this.map.has(ko)) return;
this.map.set(ko, { ko, gloss, note, src, base });
}
addVerb(dictionaryForm) { if (/다$/.test(dictionaryForm)) this.verbs.add(dictionaryForm); }
get(ko) { return this.map.get(ko) || null; }
isVerb(ko) { return this.verbs.has(ko); }
/** Every dictionary word this surface form could be, best first. */
heads(token) {
const out = [], push = x => { if (x && !out.includes(x)) out.push(x); };
const e = this.get(token);
if (e) { push(token); push(e.base); }
for (const p of PARTICLES) {
if (token.length > p.length && token.slice(-p.length) === p) {
const b = token.slice(0, -p.length), eb = this.get(b);
if (eb) { push(b); push(eb.base); }
}
}
for (const d of deconjugateCandidates(token))
if (this.verbs.has(d) && this.get(d)) push(d);
return out;
}
/** What to show when the student taps a word. */
lookup(token) {
const direct = this.get(token);
if (direct) return direct;
for (const p of PARTICLES) {
if (token.length > p.length && token.slice(-p.length) === p) {
const e = this.get(token.slice(0, -p.length));
if (e) return { ...e, ko: token, note: (e.note ? e.note + " · " : "") + `with 조사 ${p}` };
}
}
for (const d of deconjugateCandidates(token)) {
if (!this.verbs.has(d)) continue;
const e = this.get(d);
if (e) return { ...e, ko: token, note: (e.note ? e.note + " · " : "") + `a form of ${d}` };
}
return null;
}
}
/** Build it from the shipped data. Expand verbs and adjectives into their
* surface forms at load: it is cheap and it is what makes 앉아 resolve. */
/**
* ORDER MATTERS, and it changes what the gate permits.
*
* A word the roadmap schedules for a specific unit must be entered FIRST,
* with no `base`. 마셔 belongs to unit 2.3. If the deck is expanded first,
* 마셔 is created as a surface form of 마시다 and inherits that stem's
* permission — so a unit-2.3 word silently becomes legal in Phase 1 because
* its dictionary form happens to be a card the student has met. That is a
* real difference: it is four messages' worth of violations in the fixture
* corpus, and it is invisible unless you run audit-gate.mjs.
*
* The roadmap decides when a form may appear. Enter it first; let the deck
* expansion fill in only what the roadmap has not already claimed.
*/
export function buildLexicon({ roadmapWords = [], deck = [], glossExtra = [], sentences = [], sfx = [] } = {},
{ haeche, past }) {
const lex = new Lexicon();
roadmapWords.forEach(w => { lex.add(w, w, "", "roadmap"); if (/다$/.test(w)) lex.addVerb(w); });
const addWord = (ko, gloss, pos) => {
lex.add(ko, gloss, "", "deck");
if (pos !== "verb" && pos !== "adj" || !/다$/.test(ko)) return;
lex.addVerb(ko);
const p = haeche(ko);
if (!p) return;
const g = String(gloss).replace(/^to be /, "").replace(/^to /, "");
lex.add(p, g, `반말, from ${ko}`, "form", ko);
lex.add(p + "요", g, `polite, from ${ko}`, "form", ko);
const q = past(p);
if (q) lex.add(q, g + " (past)", `반말 past, from ${ko}`, "form", ko);
};
deck.forEach(w => addWord(w.ko || w[0], w.en || w[2], w.pos || w[3]));
glossExtra.forEach(g => lex.add(g[0], g[1], g[2] || "", "gloss"));
sentences.forEach(s => (s.parts || []).forEach(p => lex.add(p[0], p[1], "seen in a sentence", "sentence")));
sfx.forEach(f => lex.add(f[0], f[1], "의성어 · 의태어", "sfx"));
return lex;
}