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>
This commit is contained in:
MechaCat02
2026-09-08 19:11:40 +02:00
commit b80deb2f6f
22 changed files with 7984 additions and 0 deletions

86
lib/blocks.js Normal file
View File

@@ -0,0 +1,86 @@
/* The block protocol — how 선생님 drives the UI.
The tutor writes prose plus fenced blocks; the client renders them
as real interface and sends structured answers back. */
const RE = {
task: /::task\s+(translate|match|build|choice)\s*\n([\s\S]*?)(?:\n::|$)/,
words: /::words\s*\n([\s\S]*?)(?:\n::|$)/,
gloss: /::gloss\s*\n([\s\S]*?)(?:\n::|$)/,
progress: /::progress\s+(\d{1,3})\s*(?:\|\s*([^\n]*))?/,
};
const rows = s => s.split("\n").map(l => l.trim()).filter(l => l && !/^::/.test(l));
const cols = l => l.split("|").map(x => x.trim());
export function parse(text) {
const w = text.match(RE.words), t = text.match(RE.task);
const g = text.match(RE.gloss), p = text.match(RE.progress);
const words = w ? rows(w[1]).map(l => { const c = cols(l);
return { ko: c[0], gloss: c[1] || "", note: c[2] || "" }; }) : null;
let task = null;
if (t) {
const r = rows(t[2]);
if (t[1] === "translate") task = { type: "translate", items: r.map(q => ({ q })) };
if (t[1] === "match") task = { type: "match", pairs: r.map(l => { const c = cols(l);
return { ko: c[0], gloss: c[1] }; }).filter(x => x.ko && x.gloss) };
if (t[1] === "build") task = { type: "build", items: r.map(l => { const c = cols(l);
return { en: c[0], chips: c.slice(1).filter(Boolean) }; }).filter(x => x.en && x.chips.length) };
if (t[1] === "choice") task = { type: "choice", items: r.map(l => { const c = cols(l);
return { q: c[0], options: c.slice(1).filter(Boolean) }; }).filter(x => x.q && x.options.length > 1) };
}
let gloss = null;
if (g) {
const blocks = []; let cur = null;
g[1].split("\n").forEach(line => {
const l = line.trim();
if (!l || /^::/.test(l)) return;
if (l.startsWith("=")) { if (cur) cur.en = l.slice(1).trim(); return; }
const c = cols(l);
if (!cur) { cur = { parts: [], en: "" }; blocks.push(cur); }
cur.parts.push({ ko: c[0], role: (c[1] || "N").toUpperCase()[0], gloss: c[2] || "", highlight: c[3] || "" });
});
const keep = blocks.filter(b => b.parts.length);
if (keep.length) gloss = keep;
}
let body = text;
if (p) body = body.replace(p[0], "");
if (g) body = body.replace(g[0], "");
if (w) body = body.slice(0, body.indexOf("::words") >= 0 ? body.indexOf("::words") : body.length);
if (t) body = body.replace(t[0], "");
return {
body: body.replace(/\n{3,}/g, "\n\n").trim(),
words, task, gloss,
progress: p ? { score: Math.max(0, Math.min(100, +p[1])), note: (p[2] || "").trim() } : null,
};
}
/** Roles a gloss part can carry, and what the UI should do with each. */
export const ROLES = {
S: "주어 subject", T: "주제 topic", O: "목적어 object",
V: "서술어 predicate", P: "자리·때 place/time", C: "이음 connective",
Q: "인용 quotation", M: "수식 modifier", N: "",
};
/** Turn a completed task back into the message the student sends. */
export function answerText(task, state, lookups = []) {
let body;
if (task.type === "translate")
body = "My answers:\n" + task.items.map((it, i) =>
`${it.q}${(state[i] || "").trim() || "(not sure)"}`).join("\n");
else if (task.type === "match")
body = "My pairings:\n" + (state.pairs.map(p => `${p.ko} = ${p.gloss}`).join("\n") || "(none)");
else if (task.type === "build")
body = "My sentences:\n" + task.items.map((it, i) =>
`${it.en}${(state[i] || []).join(" ") || "(not sure)"}`).join("\n");
else
body = "My choices:\n" + task.items.map((it, i) =>
`${it.q}${state[i] == null ? "(not sure)" : it.options[state[i]]}`).join("\n");
return body + (lookups.length
? `\n\n(I had to look up: ${lookups.join(", ")})`
: "\n\n(No lookups.)");
}

99
lib/conjugation.js Normal file
View File

@@ -0,0 +1,99 @@
/* 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;
}

72
lib/gate.js Normal file
View File

@@ -0,0 +1,72 @@
/* The gate — what the tutor is allowed to know, say and use, right now.
Generated from the curriculum plus progress. This is the mechanism that
stops material being taught out of order; port it before improving it. */
export function flatten(curriculum) {
const units = [];
curriculum.phases.forEach(p => p.units.forEach(u => units.push({ ...u, phase: p.phase, phaseKo: p.ko, phaseName: p.name })));
return units;
}
/**
* @param curriculum curriculum.json
* @param progress { current: "1.4", done: {"1.1":true,...}, confidence: {"1.4":62} }
* @param opts { vocabQuery } — with a dictionary, replaces the hand-listed words
*/
export function buildGate(curriculum, progress, opts = {}) {
const units = flatten(curriculum);
const at = id => units.findIndex(u => u.id === id);
const i = Math.max(0, at(progress.current));
const unit = units[i];
const done = units.filter(u => progress.done[u.id]);
const taught = done.flatMap(u => u.teaches);
// everything a later unit teaches is, by construction, forbidden now
const future = units.filter((u, k) => k > i || (!progress.done[u.id] && k !== i));
const near = [...new Set(future.flatMap(u => u.teaches))].slice(0, 18);
const tail = future.length ? future[future.length - 1] : null;
const vocabulary = opts.vocabQuery
? opts.vocabQuery(unit, done) // e.g. freq_rank BETWEEN …
: [...new Set(done.flatMap(u => u.words))];
// spiral targets: words first met in an earlier unit that this unit should
// deliberately bring back. Only those whose home unit is actually finished.
const doneIds = new Set(done.map(u => u.id));
const revisits = (unit.revisits || []).filter(r => doneIds.has(r.from)).map(r => r.word);
return {
unit, phase: { n: unit.phase, ko: unit.phaseKo, name: unit.phaseName },
taught, forbidden: { near, tailUnit: tail, count: future.length },
vocabulary, newWords: unit.words, revisits,
confidence: progress.confidence?.[unit.id] ?? null,
next: units[i + 1] || null,
finished: done.map(u => u.id),
};
}
/** Render the gate into the system prompt section. Keep the headings — the
model keys off them, and the pre-flight check refers to them by name. */
export function renderGate(g) {
const L = [];
L.push(`He is on PHASE ${g.phase.n} · ${g.phase.ko} (${g.phase.name}), UNIT ${g.unit.id} · ${g.unit.ko} (${g.unit.name})${g.unit.vocabUnit ? " — a VOCABULARY unit" : ""}.`);
L.push(`THIS UNIT'S GOAL: ${g.unit.goal}`, "");
L.push("════ WHAT HE KNOWS — the complete list ════");
L.push(g.taught.length ? g.taught.map(t => "• " + t).join("\n") : "• nothing yet — this is the very first unit", "");
L.push("════ THIS UNIT ADDS ════", g.unit.teaches.map(t => "• " + t).join("\n"));
if (g.unit.avoid?.length)
L.push("\nAND EXPLICITLY EXCLUDES, even if it seems natural:\n" + g.unit.avoid.map(t => "✗ " + t).join("\n"));
L.push("", "════ NOT TAUGHT YET — MUST NOT APPEAR ════");
L.push("Every one of these belongs to a later unit. Using any of them, even in passing, even to be helpful, breaks the sequence:");
L.push(g.forbidden.near.length ? g.forbidden.near.map(t => "✗ " + t).join("\n") : "— nothing; this is the last unit");
if (g.forbidden.tailUnit)
L.push(`…and everything else on the roadmap through ${g.forbidden.tailUnit.id} ${g.forbidden.tailUnit.ko}. If a thing is not on the KNOWS list above, it is not taught. That is the whole test — you do not need to recognise it on this list to exclude it.`);
L.push("", "════ VOCABULARY YOU MAY USE ════", g.vocabulary.join(" · ") || "(none yet)");
L.push("NEW WORDS THIS UNIT MAY INTRODUCE — and no others:",
g.newWords.length ? g.newWords.join(" · ")
: "(none — this unit adds no new vocabulary on purpose. It is a contrast/synthesis unit: work it entirely with words he already has.)");
if (g.revisits.length)
L.push("BRING BACK ON PURPOSE — he met these earlier and they are due for reuse here:", g.revisits.join(" · "));
return L.join("\n");
}

125
lib/hangul.js Normal file
View File

@@ -0,0 +1,125 @@
/* 한글 — 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: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"},
};

46
lib/srs.js Normal file
View File

@@ -0,0 +1,46 @@
/* SM-2 lite. Four grades, day-granularity intervals. */
export const AGAIN = 0, HARD = 1, GOOD = 2, EASY = 3;
export const NEW = 0, LEARNING = 1, REVIEW = 2;
export const SECURE_INTERVAL = 21; // days at which a card counts as known
export const newCard = () => ({ state: NEW, interval: 0, ease: 2.5, due: 0, reps: 0, lapses: 0 });
export function grade(card, g, today) {
const c = { ...card };
if (c.state === NEW || c.state === LEARNING) {
if (g <= HARD) { c.state = LEARNING; c.interval = 0; c.due = today; }
else if (g === GOOD){ c.state = REVIEW; c.interval = 1; c.due = today + 1; }
else { c.state = REVIEW; c.interval = 4; c.due = today + 4; }
} else {
if (g === AGAIN) { c.ease = Math.max(1.3, c.ease - 0.2); c.lapses++; c.state = LEARNING; c.interval = 0; c.due = today; }
else if (g === HARD) { c.ease = Math.max(1.3, c.ease - 0.15); c.interval = Math.max(1, Math.round(c.interval * 1.2)); c.due = today + c.interval; }
else if (g === GOOD) { c.interval = Math.max(1, Math.round(c.interval * c.ease)); c.due = today + c.interval; }
else { c.ease = Math.min(3, c.ease + 0.15); c.interval = Math.max(2, Math.round(c.interval * c.ease * 1.3)); c.due = today + c.interval; }
c.interval = Math.min(c.interval, 365);
c.due = Math.min(c.due, today + 365);
}
c.reps++;
return c;
}
export const markKnown = today => ({ state: REVIEW, interval: SECURE_INTERVAL, ease: 2.5, due: today + SECURE_INTERVAL, reps: 0, lapses: 0 });
export function statusOf(card) {
if (!card || card.state === NEW) return "new";
if (card.state === LEARNING) return "learning";
return card.interval >= SECURE_INTERVAL ? "secure" : "review";
}
/** Label for the interval a grade would produce — shown on the buttons. */
export function preview(card, g, today) {
const c = grade(card || newCard(), g, today);
if (c.interval === 0) return "again now";
if (c.interval === 1) return "1 day";
if (c.interval < 30) return `${c.interval} days`;
const mo = Math.round(c.interval / 30);
return `${mo} month${mo === 1 ? "" : "s"}`;
}
/** Local day number, DST-safe. */
export const dayNumber = (d = new Date()) =>
Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 864e5);