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>
109 lines
5.1 KiB
JavaScript
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;
|
|
}
|