Files
Hankan/lib/conjugation.js
MechaCat02 b80deb2f6f chore: scaffold, CI, and the export bundle verbatim
The export bundle is the input to this port, not a sketch: the curriculum,
the tutor prompt and the five logic modules are finished and tested. They
land here byte-identical and stay that way.

  diff -r export/data data && diff -r export/lib lib
  diff -r export/prompt prompt && diff export/validate.mjs validate.mjs

data/, lib/, prompt/ and validate.mjs sit at the repo root so validate.mjs
runs verbatim with no path edits. All four are excluded from lint and
formatting — they are not ours to restyle. Types for lib/ live alongside in
types/ rather than as sibling .d.ts files, so the verbatim check stays a
plain directory diff.

CI runs the curriculum gate first, before anything else can pass:

  node validate.mjs   PASS — 0 blocking, 0 advisory

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:11:40 +02:00

100 lines
4.4 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;
}