/* ══════════════════════════════════════════════════════════════════════ 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; }