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

201 lines
8.6 KiB
JavaScript

/* 한글 — decomposition, composition, and a 두벌식 input method.
No dependencies. Lifted from the artifact; the IME is unit-tested
against 먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace. */
export const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
export const JUNG = "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ";
export const JONG = " ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ";
const VJOIN = {"ㅗㅏ":"ㅘ","ㅗㅐ":"ㅙ","ㅗㅣ":"ㅚ","ㅜㅓ":"ㅝ","ㅜㅔ":"ㅞ","ㅜㅣ":"ㅟ","ㅡㅣ":"ㅢ"};
const FJOIN = {"ㄱㅅ":"ㄳ","ㄴㅈ":"ㄵ","ㄴㅎ":"ㄶ","ㄹㄱ":"ㄺ","ㄹㅁ":"ㄻ","ㄹㅂ":"ㄼ",
"ㄹㅅ":"ㄽ","ㄹㅌ":"ㄾ","ㄹㅍ":"ㄿ","ㄹㅎ":"ㅀ","ㅂㅅ":"ㅄ"};
const FSPLIT = Object.fromEntries(Object.entries(FJOIN).map(([k,v]) => [v, [k[0], k[1]]]));
/** [initialIndex, medialIndex, finalIndex] or null if not a syllable block. */
export function decompose(ch) {
const c = ch.charCodeAt(0) - 0xAC00;
if (c < 0 || c > 11171) return null;
return [Math.floor(c / 588), Math.floor((c % 588) / 28), c % 28];
}
export function compose(i, m, f = 0) {
return String.fromCharCode(0xAC00 + (i * 21 + m) * 28 + f);
}
export const isJamo = {
initial: c => CHO.includes(c),
medial: c => JUNG.includes(c),
final: c => c && JONG.indexOf(c) > 0,
};
/* ── 두벌식 IME ───────────────────────────────────────────
Hold one composing buffer per input. Feed jamo with key(),
punctuation and spaces with text(), and Backspace with back().
Each call returns the full new value. */
export class Composer {
constructor() { this.reset(); }
reset() { this.cho = null; this.jung = null; this.jong = null; }
get empty() { return !this.cho && !this.jung && !this.jong; }
/** The syllable currently being assembled, as text. */
render() {
if (this.cho && this.jung) {
const i = CHO.indexOf(this.cho), m = JUNG.indexOf(this.jung);
const f = this.jong ? JONG.indexOf(this.jong) : 0;
if (i >= 0 && m >= 0 && f >= 0) return compose(i, m, f);
}
return (this.cho || "") + (this.jung || "") + (this.jong || "");
}
/** Strip the composing tail off a value so it can be rebuilt. */
_base(value) {
const cur = this.render();
return cur && value.endsWith(cur) ? value.slice(0, -cur.length) : value;
}
key(value, j) {
let base = this._base(value);
if (isJamo.medial(j)) {
if (this.jong) { // final splits off to start a new block
const parts = FSPLIT[this.jong];
let moved;
if (parts) { this.jong = parts[0]; moved = parts[1]; }
else { moved = this.jong; this.jong = null; }
base += this.render();
this.cho = moved; this.jung = j; this.jong = null;
} else if (this.jung) {
const join = VJOIN[this.jung + j];
if (join) this.jung = join;
else { base += this.render(); this.reset(); this.jung = j; }
} else this.jung = j;
} else {
if (this.cho && this.jung) {
if (this.jong) {
const join = FJOIN[this.jong + j];
if (join) this.jong = join;
else { base += this.render(); this.reset(); this.cho = j; }
} else if (isJamo.final(j)) this.jong = j;
else { base += this.render(); this.reset(); this.cho = j; }
} else {
if (!this.empty) base += this.render();
this.reset(); this.cho = j;
}
}
return base + this.render();
}
back(value) {
let base = this._base(value);
if (this.jong) {
const parts = FSPLIT[this.jong];
this.jong = parts ? parts[0] : null;
} else if (this.jung) {
let peeled = null;
for (const [k, v] of Object.entries(VJOIN)) if (v === this.jung) peeled = k[0];
this.jung = peeled;
} else if (this.cho) {
this.cho = null;
} else { // pull a finished block back in
const last = base.slice(-1);
base = base.slice(0, -1);
const d = last ? decompose(last) : null;
if (d) {
this.cho = CHO[d[0]]; this.jung = JUNG[d[1]];
this.jong = d[2] ? JONG[d[2]] : null;
if (this.jong) { const p = FSPLIT[this.jong]; this.jong = p ? p[0] : null; }
else this.jung = null;
}
}
return base + this.render();
}
/** Commit the buffer and append literal text (space, punctuation). */
text(value, t) {
const base = this._base(value) + this.render();
this.reset();
return base + t;
}
}
/** Standard 두벌식 layout, top row first. Shift gives the tense pairs. */
export const KEYBOARD = {
rows: [
["ㅂ","ㅈ","ㄷ","ㄱ","ㅅ","ㅛ","ㅕ","ㅑ","ㅐ","ㅔ"],
["ㅁ","ㄴ","ㅇ","ㄹ","ㅎ","ㅗ","ㅓ","ㅏ","ㅣ"],
["ㅋ","ㅌ","ㅊ","ㅍ","ㅠ","ㅜ","ㅡ"],
],
shift: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"},
};
/* ── Letter-level marking ──────────────────────────────────────────────
A Hangul syllable is a single character, so a language model cannot see
the letters inside it. Asked which letter a student got wrong it will
reconstruct a plausible answer, and plausible is not the same as right:
asked about 빫다 for 짧다 the artifact's tutor blamed the ㄼ batchim —
which was identical in both — when the slip was the initial ㅉ→ㅃ.
So the client computes the comparison and the prompt forbids the tutor
from inferring one. Port this before anything else in the marking path;
it is the difference between a tutor that teaches spelling and one that
invents explanations. */
/** Two-consonant batchim clusters, unpacked for explanation. */
export const CLUSTER = {
"ㄳ":"ㄱ+ㅅ", "ㄵ":"ㄴ+ㅈ", "ㄶ":"ㄴ+ㅎ", "ㄺ":"ㄹ+ㄱ", "ㄻ":"ㄹ+ㅁ", "ㄼ":"ㄹ+ㅂ",
"ㄽ":"ㄹ+ㅅ", "ㄾ":"ㄹ+ㅌ", "ㄿ":"ㄹ+ㅍ", "ㅀ":"ㄹ+ㅎ", "ㅄ":"ㅂ+ㅅ"
};
export const SLOT = ["first consonant", "vowel", "batchim"];
const finalOf = d => { const f = JONG[d[2]] === " " ? "" : JONG[d[2]]; return f; };
const slotValue = (d, slot) =>
slot === 0 ? CHO[d[0]]
: slot === 1 ? JUNG[d[1]]
: (finalOf(d) ? finalOf(d) + (CLUSTER[finalOf(d)] ? ` (${CLUSTER[finalOf(d)]})` : "") : "none");
/** "짧다" → "짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ" */
export function spellOut(word) {
return String(word || "").split("").map(ch => {
const d = decompose(ch);
if (!d) return ch;
const f = finalOf(d);
return `${ch}=${CHO[d[0]]}+${JUNG[d[1]]}${f ? "+" + f + (CLUSTER[f] ? `(${CLUSTER[f]})` : "") : ""}`;
}).join(" · ");
}
/**
* The exact jamo difference between the word wanted and the word written.
* Always names what was CORRECT as well — the tutor's failure mode is
* calling a right letter wrong, so it must be told which ones to leave alone.
*/
export function letterDiff(expected, written) {
const exp = String(expected || ""), got = String(written || "");
if (!exp || !got) return "";
if (exp === got) return "identical";
const out = [];
if (exp.length !== got.length)
out.push(`length differs: ${exp.length} syllables expected, ${got.length} written`);
const n = Math.min(exp.length, got.length);
for (let i = 0; i < n; i++) {
if (exp[i] === got[i]) continue;
const a = decompose(exp[i]), b = decompose(got[i]);
if (!a || !b) { out.push(`syllable ${i + 1}: wrote ${got[i]} for ${exp[i]}`); continue; }
const wrong = [], right = [];
for (let s = 0; s < 3; s++) {
if (a[s] === b[s]) right.push(`${SLOT[s]} ${slotValue(a, s)}`);
else wrong.push(`${SLOT[s]}: wrote ${slotValue(b, s)}, should be ${slotValue(a, s)}`);
}
out.push(`syllable ${i + 1} (${got[i]} for ${exp[i]}) — WRONG: ${wrong.join("; ")}` +
(right.length ? ` — CORRECT, do not call these mistakes: ${right.join(", ")}` : ""));
}
return out.join(" | ");
}
/** The block a marking message should carry. Empty when nothing differs. */
export function letterCheck(rows) {
const an = rows.filter(r => r.expected && r.written && r.expected !== r.written)
.map(r => `${r.prompt}\n wanted ${r.expected} [${spellOut(r.expected)}]\n` +
` wrote ${r.written} [${spellOut(r.written)}]\n ${letterDiff(r.expected, r.written)}`);
if (!an.length) return "";
return "════ LETTER-LEVEL CHECK — computed by the app ════\n" +
"This is the actual jamo comparison. Use it exactly. Do NOT work out for yourself " +
"which letter was wrong, and never call a letter wrong that is listed as correct.\n" +
an.join("\n");
}