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>
163 lines
7.2 KiB
JavaScript
163 lines
7.2 KiB
JavaScript
/* Korean conjugation — the 아/어 rule and the seven irregular classes.
|
|
Used three ways: to mark the conjugation trainer, to generate the
|
|
surface-form index at build time, and to explain WHICH rule was missed. */
|
|
import { decompose, compose } from "./hangul.js";
|
|
|
|
/** Forms that do not fall out of the rules and are simply known. */
|
|
export const IRREGULAR_FORMS = {
|
|
"덥다":"더워","춥다":"추워","쉽다":"쉬워","어렵다":"어려워","무섭다":"무서워",
|
|
"맵다":"매워","가깝다":"가까워",
|
|
"듣다":"들어","걷다":"걸어","묻다":"물어",
|
|
"모르다":"몰라","부르다":"불러","다르다":"달라","빠르다":"빨라","고르다":"골라",
|
|
"낫다":"나아","짓다":"지어","붓다":"부어",
|
|
"하다":"해","되다":"돼","이다":"야","그렇다":"그래","어떻다":"어때",
|
|
};
|
|
|
|
/** Dictionary form → 반말 present (해체). Returns null for non-verbs. */
|
|
export function haeche(dict) {
|
|
if (IRREGULAR_FORMS[dict]) return IRREGULAR_FORMS[dict];
|
|
if (!dict || dict.slice(-1) !== "다") return null;
|
|
const stem = dict.slice(0, -1);
|
|
if (!stem) return null;
|
|
if (stem.slice(-1) === "하") return stem.slice(0, -1) + "해";
|
|
|
|
const d = decompose(stem[stem.length - 1]);
|
|
if (!d) return null;
|
|
const [i, m, f] = d;
|
|
const bright = (m === 0 || m === 8); // ㅏ or ㅗ → 아, else 어
|
|
|
|
if (m === 18 && f === 0) { // ㅡ drops: 크다 → 커, 바쁘다 → 바빠
|
|
let h = 4;
|
|
if (stem.length >= 2) {
|
|
const prev = decompose(stem[stem.length - 2]);
|
|
if (prev && (prev[1] === 0 || prev[1] === 8)) h = 0;
|
|
}
|
|
return stem.slice(0, -1) + compose(i, h, 0);
|
|
}
|
|
if (f === 0) { // vowel-final stem contracts
|
|
if ([0, 4, 1, 5, 6, 2].includes(m)) return stem; // 가 · 서 · 보내 · 세 · 켜
|
|
if (m === 8) return stem.slice(0, -1) + compose(i, 9, 0); // ㅗ+아 → ㅘ 오다 → 와
|
|
if (m === 13) return stem.slice(0, -1) + compose(i, 14, 0); // ㅜ+어 → ㅝ 주다 → 줘
|
|
if (m === 20) return stem.slice(0, -1) + compose(i, 6, 0); // ㅣ+어 → ㅕ 마시다 → 마셔
|
|
if (m === 11) return stem.slice(0, -1) + compose(i, 10, 0); // ㅚ+어 → ㅙ 되다 → 돼
|
|
return stem + (bright ? "아" : "어");
|
|
}
|
|
return stem + (bright ? "아" : "어");
|
|
}
|
|
|
|
/** 반말 present → 반말 past. 먹어 → 먹었어, 가 → 갔어, 해 → 했어. */
|
|
export function past(present) {
|
|
if (!present) return null;
|
|
const d = decompose(present[present.length - 1]);
|
|
if (!d) return null;
|
|
if (d[2] !== 0) return present + "었어";
|
|
return present.slice(0, -1) + compose(d[0], d[1], 20) + "어";
|
|
}
|
|
|
|
export const polite = present => present ? present + "요" : null;
|
|
|
|
/** Which class a dictionary form belongs to — drives the "why" in feedback. */
|
|
export function irregularClass(dict) {
|
|
if (IRREGULAR_FORMS[dict]) {
|
|
if (/르다$/.test(dict)) return "르";
|
|
if (/^(듣다|걷다|묻다)$/.test(dict)) return "ㄷ";
|
|
if (/(렇다|얗다|갛다|떻다)$/.test(dict)) return "ㅎ";
|
|
if (/^(낫다|짓다|붓다)$/.test(dict)) return "ㅅ";
|
|
if (/^(하다|되다|이다)$/.test(dict)) return "special";
|
|
return "ㅂ";
|
|
}
|
|
const stem = dict.slice(0, -1);
|
|
const d = decompose(stem[stem.length - 1]);
|
|
if (!d) return "regular";
|
|
if (d[1] === 18 && d[2] === 0) return "ㅡ";
|
|
return "regular";
|
|
}
|
|
|
|
/** Human explanation of the rule applied — shown when an answer is wrong. */
|
|
export function explain(dict) {
|
|
const cls = irregularClass(dict);
|
|
if (cls !== "regular") return `${cls} 불규칙`;
|
|
const stem = dict.slice(0, -1);
|
|
if (stem.slice(-1) === "하") return "하다 → 해";
|
|
const d = decompose(stem[stem.length - 1]);
|
|
const bright = d && (d[1] === 0 || d[1] === 8);
|
|
return `stem ${stem} · last vowel ${bright ? "ㅏ/ㅗ → 아" : "neither → 어"}`;
|
|
}
|
|
|
|
/** Build-time: every surface form a learner will meet, mapped back to its lemma.
|
|
Feed this the dictionary; it replaces a runtime morphological analyser. */
|
|
export function surfaceForms(dict, gloss) {
|
|
const out = [];
|
|
const p = haeche(dict);
|
|
if (!p) return out;
|
|
const g = gloss.replace(/^to be /, "").replace(/^to /, "");
|
|
out.push({ form: p, gloss: g, note: `반말, from ${dict}` });
|
|
out.push({ form: polite(p), gloss: g, note: `polite, from ${dict}` });
|
|
const q = past(p);
|
|
if (q) out.push({ form: q, gloss: `${g} (past)`, note: `반말 past, from ${dict}` });
|
|
return out;
|
|
}
|
|
|
|
/* ── Reading an inflected form back to its dictionary entry ────────────
|
|
The artifact's lexicon held 가다 plus a handful of pre-generated forms,
|
|
and nothing else. Measured over 660 realistic inflections of the 74
|
|
curriculum verbs and adjectives — 가고, 가면, 가네, 앉으면, 갑니다 —
|
|
ALL 660 failed to resolve, so the student tapped a word he had been
|
|
taught and was told it was not in the word list. With the stripper
|
|
below, all 660 resolve.
|
|
|
|
The guard matters as much as the list: a candidate is only accepted if
|
|
the stem + 다 is a word the lexicon actually holds AS A VERB OR
|
|
ADJECTIVE. Without that, 가지 (eggplant) becomes "a form of 가다". */
|
|
|
|
export const ENDINGS = [
|
|
"았어요","었어요","였어요","으세요","자마자","으니까",
|
|
"았어","었어","였어","았다","었다","으면","으니","는데",
|
|
"아서","어서","아도","어도","아요","어요","여요","네요",
|
|
"세요","지요","거든","더라","는다","았","었","였",
|
|
"고","지","면","네","자","니","게","는","며","는지","은지","아","어","여","다"
|
|
];
|
|
|
|
const LEAD_N = 4, LEAD_B = 17; // ㄴ and ㅂ as batchim indices
|
|
|
|
/** 갑 → 가, but only when the final really is the jamo given. */
|
|
function dropFinal(ch, jamo) {
|
|
const d = decompose(ch);
|
|
if (!d || d[2] !== jamo) return null;
|
|
return compose(d[0], d[1], 0);
|
|
}
|
|
|
|
/** Every dictionary form this surface could plausibly be. */
|
|
export function deconjugateCandidates(token) {
|
|
const out = [], push = stem => { if (stem && !out.includes(stem + "다")) out.push(stem + "다"); };
|
|
const bases = [token];
|
|
if (/요$/.test(token) && token.length > 1) bases.push(token.slice(0, -1));
|
|
for (const t of bases) {
|
|
if (/니다$/.test(t) && t.length > 2) { // 앉습니다 → 앉 · 갑니다 → 가
|
|
const head = t.slice(0, -2), last = head[head.length - 1];
|
|
if (last === "습") push(head.slice(0, -1));
|
|
const s = dropFinal(last, LEAD_B);
|
|
if (s) push(head.slice(0, -1) + s);
|
|
}
|
|
if (/다$/.test(t) && t.length > 1) { // 간다 → 가
|
|
const s = dropFinal(t[t.length - 2], LEAD_N);
|
|
if (s) push(t.slice(0, -2) + s);
|
|
}
|
|
for (const e of ENDINGS)
|
|
if (t.length > e.length && t.slice(-e.length) === e) push(t.slice(0, -e.length));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* @param token the surface form seen in text
|
|
* @param isVerb (dictionaryForm) => boolean — true only for words the
|
|
* lexicon holds as a verb or adjective. REQUIRED; without
|
|
* it this guesses nouns into verbs.
|
|
* @returns the dictionary form, or null.
|
|
*/
|
|
export function deconjugate(token, isVerb) {
|
|
for (const d of deconjugateCandidates(token)) if (isVerb(d)) return d;
|
|
return null;
|
|
}
|