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>
201 lines
8.6 KiB
JavaScript
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");
|
|
}
|