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>
This commit is contained in:
MechaCat02
2026-09-16 19:49:52 +02:00
parent 75dd699f3e
commit e72b77d6c2
34 changed files with 2201 additions and 89 deletions

View File

@@ -12,11 +12,15 @@ jobs:
steps:
- uses: actions/checkout@v4
# The curriculum gate comes first, before anything else can run.
# validate.mjs reads only data/ and lib/ and exits non-zero on a
# blocking failure. Baseline: PASS 0 blocking, 0 advisory.
- name: Curriculum validation
run: node validate.mjs
# The bundle's own checks come first, before anything else can run.
# Both read only data/, lib/ and fixtures/ and exit non-zero on failure:
# validate.mjs — curriculum sequencing. Baseline: PASS, 0 blocking, 0 advisory.
# audit-gate.mjs — the word gate against 54 real tutor messages.
# Baseline: 7 of 41 and 2 of 13 flagged. Too many
# means it rejects real teaching; too few means
# violations reach the student.
- name: Curriculum validation and gate audit
run: sh run-checks.sh
- uses: actions/setup-node@v4
with:

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# the export bundle is the input to this port, not part of it
export/
*.tar.gz
*.zip
# manually-vendored dictionary sources (see tools/dict/README.md)
vendor/

View File

@@ -1,4 +1,4 @@
/* The four exercise types, rendered as real interface.
/* The five exercise types, rendered as real interface.
State lives HERE, in the component, keyed by the turn it belongs to. The
artifact rebuilt taskState inside its full-page re-render, so anything
@@ -14,6 +14,7 @@ import type {
BuildTask,
ChoiceTask,
MatchTask,
RecallTask,
Task,
TranslateTask,
} from "@lib/blocks.js";
@@ -35,6 +36,7 @@ function shuffle<T>(items: T[], seed: number): T[] {
const LABEL: Record<Task["type"], string> = {
translate: "type what each line means",
recall: "write it in 한글",
match: "tap a Korean word, then its meaning",
build: "tap or drag the words into order",
choice: "one pick per line",
@@ -92,6 +94,53 @@ function Translate({ task, answers, setAnswers, disabled, onEnter }: {
);
}
/* ── recall ──────────────────────────────────────────────────────── */
/* English prompt; he writes the 한글. The keyboard wiring and the
letter-level check arrive with the answer mode — this renders the task
so it can be answered at all. */
function Recall({ task, answers, setAnswers, disabled, onEnter }: {
task: RecallTask;
answers: string[];
setAnswers: (a: string[]) => void;
disabled: boolean;
onEnter: () => void;
}) {
return (
<div className="ti">
{task.items.map((it, i) => (
<div className="ti-row recall" key={i}>
<span className="q">
{it.q}
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
</span>
<input
type="text"
className="ko"
value={answers[i] ?? ""}
disabled={disabled}
placeholder="한국어로…"
autoComplete="off"
spellCheck={false}
aria-label={`Write ${it.q} in Korean`}
onChange={(e) => {
const next = [...answers];
next[i] = e.target.value;
setAnswers(next);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onEnter();
}
}}
/>
</div>
))}
</div>
);
}
/* ── match ───────────────────────────────────────────────────────── */
interface Pair {
@@ -310,6 +359,7 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
const filled = (() => {
switch (task.type) {
case "translate":
case "recall":
return { n: answers.filter((a) => a?.trim()).length, of: task.items.length };
case "match":
return { n: done.length, of: task.pairs.length };
@@ -326,6 +376,8 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
switch (task.type) {
case "translate":
return answerText(task, answers, lookups);
case "recall":
return answerText(task, answers, lookups);
case "match":
return answerText(task, { pairs: done }, lookups);
case "build":
@@ -354,6 +406,15 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
onEnter={submit}
/>
)}
{task.type === "recall" && (
<Recall
task={task}
answers={answers}
setAnswers={setAnswers}
disabled={disabled}
onEnter={submit}
/>
)}
{task.type === "match" && (
<Match
task={task}

View File

@@ -64,6 +64,21 @@
min-width: 0;
}
/* recall: the prompt is English and the answer is 한글, so the weights swap */
.ti-row.recall .q {
font-size: 16px;
}
.ti-row.recall input {
font-size: 19px;
font-weight: 500;
}
.rc-hint {
font-size: 12px;
color: var(--ink3);
}
/* ── match ───────────────────────────────────────────────────────── */
.mt-cols {

112
audit-gate.mjs Normal file
View File

@@ -0,0 +1,112 @@
/* The gate's false-positive audit. Run: node audit-gate.mjs
Exits non-zero if the scan fires on more than the known true positives.
THIS IS NOT AN OPTIONAL TEST. The first version of the vocabulary scan
looked at the tutor's prose as well as his exercises; run against these
same fixtures it rejected 17 of 41 real messages, because "국물 is read as
→ 궁물" reads like a stray word and is in fact the subject of Phase 1.
Any change to lib/gate.js must be re-measured here. */
import fs from "fs";
import { parse } from "./lib/blocks.js";
import { flatten, buildScaffold, scanTask, proseIsKorean } from "./lib/gate.js";
import { buildLexicon } from "./lib/lexicon.js";
import { haeche, past } from "./lib/conjugation.js";
const read = f => JSON.parse(fs.readFileSync(new URL(f, import.meta.url)));
const curriculum = read("./data/curriculum.json");
const deck = read("./data/deck.json"), glossExtra = read("./data/gloss-extra.json");
const sentences = read("./data/sentences.json"), sfx = read("./data/sfx.json");
const progress = read("./fixtures/progress-snapshot.json");
const deckWords = Object.values(deck.topics).flat()
.map(([ko, , en, pos]) => ({ ko, en, pos }));
const UNITS0 = flatten(curriculum);
const lex = buildLexicon({
roadmapWords: UNITS0.flatMap(u => u.words || []), // scheduled words win — see lexicon.js
deck: deckWords,
glossExtra: glossExtra.entries.map(g => [g.ko, g.en, g.note]),
sentences: sentences.sentences,
sfx: sfx.items.map(i => [i.ko, i.en]),
}, { haeche, past });
const UNITS = UNITS0;
/* what he may put in front of the student, at this point in the course */
const done = progress.road.done || {};
const current = UNITS.find(u => u.id === progress.road.unit) || UNITS[0];
const allowed = new Set();
const addAllowed = w => {
if (!w) return;
allowed.add(w);
if (/\s/.test(w)) w.split(/\s+/).forEach(x => x && allowed.add(x));
};
UNITS.filter(u => done[u.id]).forEach(u => (u.words || []).forEach(addAllowed));
(current.words || []).forEach(addAllowed);
(current.revisits || []).forEach(addAllowed);
progress.deckWordsMet.forEach(addAllowed); // only the ones he has actually met
/* the shipped app's list before multi-word entries are split into parts */
const base = new Set();
UNITS.filter(u => done[u.id]).forEach(u => (u.words || []).forEach(w => base.add(w)));
progress.deckWordsMet.forEach(w => base.add(w));
const expected = new Set(progress.allowedWordsExpected);
if (base.size !== expected.size) {
const missing = [...expected].filter(w => !base.has(w));
const extra = [...base].filter(w => !expected.has(w));
console.log(` ! allowed set is ${base.size}, the shipped app computes ${expected.size} from this state.` +
(missing.length ? `\n missing here: ${missing.slice(0, 12).join(" ")}` : "") +
(extra.length ? `\n extra here: ${extra.slice(0, 12).join(" ")}` : "") +
`\n Reconcile this FIRST; the flag counts below mean nothing until the two agree.`);
}
const scaffold = buildScaffold(curriculum);
const unitOf = w => (UNITS.find(u => (u.words || []).includes(w) || (u.revisits || []).includes(w)) || {}).id || "";
const ctx = { allowed, scaffold, heads: t => lex.heads(t), unitOf };
let bad = 0;
function audit(file, label, expected) {
const { messages } = read(file);
const rows = [];
messages.forEach((m, i) => {
const parsed = parse(m);
const found = scanTask(parsed, ctx).map(f => f.word + (f.unit ? `(${f.unit})` : f.known ? "" : "[no gloss]"));
if (proseIsKorean(m)) found.push("«KOREAN PROSE»");
if (found.length) rows.push(` #${i} [${found.join(" ")}] "${m.slice(0, 46).replace(/\n/g, " ")}…"`);
});
const pct = Math.round((100 * rows.length) / messages.length);
/* BOTH directions are failures. Too many means the gate is over-firing on
real teaching. Too few means the port is weaker than the app that was
measured, and real violations are getting through — which is how 아파
reached a Phase 1 exercise in the first place. */
const ok = rows.length === expected;
if (!ok) bad++;
console.log(`\n${ok ? "PASS" : "FAIL"} ${label}: ${rows.length} of ${messages.length} flagged (${pct}%), ` +
`the shipped app flags ${expected}` + (ok ? "" : rows.length > expected ? " — OVER-FIRING" : " — TOO WEAK, violations are getting through"));
rows.forEach(r => console.log(r));
}
/* Measured, not chosen — and the difference between the two numbers below
and the artifact's is itself documented, because parity was NOT the goal.
live 1.10 review — 2. Exact match with the shipped app. Both hits are
아파 (unit 3.2) in a Phase 1 review: the violation class this whole gate
exists for.
earlier units — 7 here, 11 in the artifact, and the 4 extra artifact hits
are 몰라. The artifact's lexicon holds 몰라 as a bare sentence entry with
no link to 모르다, so it cannot tell that 몰라 is the 반말 form of a word
the student has already met, and flags it. lib/lexicon.js does make that
link, so it does not. The port is right and the artifact is over-firing;
this is the one place the two deliberately disagree.
If YOUR number drifts from 7, read every differing hit before touching
this line. Over-firing means the gate is rejecting real teaching; under-
firing means violations are reaching the student, which is how 아파 got
into a Phase 1 exercise. */
audit("./fixtures/tutor-messages.json", "earlier units", 7);
audit("./fixtures/tutor-messages-live.json", "live 1.10 review", 2);
console.log(bad ? "\ngate does not match the measured baseline — read every differing hit" : "\nmatches the shipped gate");
process.exit(bad ? 1 : 0);

View File

@@ -1,6 +1,6 @@
{
"version": 4,
"note": "Each unit declares what it TEACHES (adds to the running inventory), what it must AVOID, and the only new WORDS it may introduce. Anything a later unit teaches is, by construction, forbidden now. | v4: 1.5 liaison examples made word-internal; sound-example fences added to 1.6-1.8; duplicate words moved from words[] into revisits[].",
"version": 5,
"note": "Each unit declares what it TEACHES (adds to the running inventory), what it must AVOID, and the only new WORDS it may introduce. Anything a later unit teaches is, by construction, forbidden now. | v4: 1.5 liaison examples made word-internal; sound-example fences added to 1.6-1.8; duplicate words moved from words[] into revisits[]. | v5: every phase ends with a 다지기 review unit that confirms all of its rules and words.",
"phases": [
{
"phase": 1,
@@ -297,6 +297,23 @@
"이야기"
],
"revisits": []
},
{
"id": "1.10",
"ko": "다지기",
"name": "Phase 1 review — every sound rule, every word",
"goal": "Nothing new. This unit exists to prove Phase 1 holds: every one of the nine sound topics, read correctly and named correctly, and every word introduced along the way, recognised without a lookup. You do not leave 한글 until all of it is confirmed.",
"vocabUnit": false,
"review": true,
"teaches": [
"confirming, not adding — everything Phase 1 introduced, proven unaided"
],
"avoid": [
"anything from a later phase",
"introducing any new word — this unit adds none"
],
"words": [],
"revisits": []
}
]
},
@@ -584,6 +601,23 @@
"별로"
],
"revisits": []
},
{
"id": "2.9",
"ko": "다지기",
"name": "Phase 2 review — the frame, confirmed",
"goal": "Nothing new. Confirm the whole basic frame: the copula, 있다/없다, word order and the ending word, pointing and asking, both number systems with their counters, negation in front — and every word Phase 2 introduced.",
"vocabUnit": false,
"review": true,
"teaches": [
"confirming, not adding — everything Phase 2 introduced, proven unaided"
],
"avoid": [
"anything from a later phase",
"introducing any new word — this unit adds none"
],
"words": [],
"revisits": []
}
]
},
@@ -800,6 +834,23 @@
"지키다"
],
"revisits": []
},
{
"id": "3.8",
"ko": "다지기",
"name": "Phase 3 review — every conjugation, confirmed",
"goal": "Nothing new. Confirm the 아/어 rule and all seven irregular classes by producing them on demand, past and future included, plus every verb and adjective Phase 3 introduced.",
"vocabUnit": false,
"review": true,
"teaches": [
"confirming, not adding — everything Phase 3 introduced, proven unaided"
],
"avoid": [
"anything from a later phase",
"introducing any new word — this unit adds none"
],
"words": [],
"revisits": []
}
]
},
@@ -1008,6 +1059,23 @@
"from": "3.5"
}
]
},
{
"id": "4.9",
"ko": "다지기",
"name": "Phase 4 review — every particle, confirmed",
"goal": "Nothing new. Confirm each particle by choosing correctly between them in context — 은/는 against 이/가 especially — and by tracking a dropped subject through an exchange. Plus every word Phase 4 introduced.",
"vocabUnit": false,
"review": true,
"teaches": [
"confirming, not adding — everything Phase 4 introduced, proven unaided"
],
"avoid": [
"anything from a later phase",
"introducing any new word — this unit adds none"
],
"words": [],
"revisits": []
}
]
},
@@ -1237,6 +1305,23 @@
"from": "2.2"
}
]
},
{
"id": "5.11",
"ko": "다지기",
"name": "Phase 5 review — every ending, confirmed",
"goal": "Nothing new. Confirm every ending and clause connector from Phase 5, told apart by feel and not just by shape, plus the honorific and formal registers, plus every word the phase introduced.",
"vocabUnit": false,
"review": true,
"teaches": [
"confirming, not adding — everything Phase 5 introduced, proven unaided"
],
"avoid": [
"anything from a later phase",
"introducing any new word — this unit adds none"
],
"words": [],
"revisits": []
}
]
},
@@ -1419,6 +1504,23 @@
"avoid": [],
"words": [],
"revisits": []
},
{
"id": "6.9",
"ko": "다지기",
"name": "Phase 6 review — reading, confirmed",
"goal": "Nothing new, and the last unit on the roadmap. Confirm every reading skill from Phase 6 — modifiers, quotation, register, spacing, sound words, genre vocabulary — on real manhwa lines at speed.",
"vocabUnit": false,
"review": true,
"teaches": [
"confirming, not adding — everything Phase 6 introduced, proven unaided"
],
"avoid": [
"anything from a later phase",
"introducing any new word — this unit adds none"
],
"words": [],
"revisits": []
}
]
}

View File

@@ -25,8 +25,9 @@ const CLOCK_BAN = [
];
export default tseslint.config(
// lib/, data/ and validate.mjs are copied in from the export bundle
// unchanged and must stay byte-identical — they are not ours to restyle.
// lib/, data/, fixtures/, validate.mjs and audit-gate.mjs are copied in
// from the export bundle unchanged and must stay byte-identical — they are
// not ours to restyle.
{
ignores: [
"**/dist/**",
@@ -36,7 +37,9 @@ export default tseslint.config(
"app/android/**",
"lib/**",
"data/**",
"fixtures/**",
"validate.mjs",
"audit-gate.mjs",
],
},
js.configs.recommended,

View File

@@ -0,0 +1,276 @@
{
"note": "A realistic mid-course progress state: Phase 1 finished, 1.10 다지기 in progress. The allowed-word set depends on this, so the audit is meaningless without it. deckWordsMet are the deck words the student has actually encountered (the app counts a card as allowed once its SRS state leaves 'new'); allowedWordsExpected is the set the shipped artifact computes from exactly this state — 149 words. If your port's allowed set differs from that, your gate is not the gate that was measured.",
"road": {
"ans": {},
"conf": {
"1.1": 87,
"1.10": 0,
"1.2": 89,
"1.3": 85,
"1.4": 88,
"1.5": 85,
"1.6": 86,
"1.7": 76,
"1.8": 85,
"1.9": 85
},
"cover": {},
"done": {
"1.1": 1,
"1.2": 1,
"1.3": 1,
"1.4": 1,
"1.5": 1,
"1.6": 1,
"1.7": 1,
"1.8": 1,
"1.9": 1
},
"migrated": 1,
"note": "",
"recent": [],
"unit": "1.10",
"v": 5
},
"deckWordsMet": [
"감사합니다",
"네",
"사람",
"친구",
"아버지",
"어머니",
"학생",
"아이",
"이",
"하나",
"여덟",
"학교",
"회사",
"밥",
"물",
"음식",
"가다",
"오다",
"먹다",
"마시다",
"읽다",
"앉다",
"모르다",
"없다",
"좋다",
"크다",
"작다",
"많다",
"바쁘다",
"싫다",
"슬프다",
"많이",
"같이",
"뭐",
"누구",
"어디",
"언제",
"왜",
"책",
"머리",
"귀",
"배",
"나",
"저",
"너",
"우리",
"이",
"그",
"저",
"여기",
"거기",
"저기",
"레벨",
"던전",
"탑",
"마법",
"소환",
"싸우다",
"진짜?",
"잠깐만",
"안 돼",
"가자",
"싫어",
"좋아",
"나무",
"소리",
"바다",
"다리",
"개",
"새",
"세",
"의사",
"위",
"돼지",
"꼬리",
"방",
"꽃",
"얼음",
"습니다",
"값",
"닭",
"넓다",
"짧다",
"설날",
"놓다",
"앉히다",
"먹어",
"읽어"
],
"allowedWordsExpected": [
"나",
"너",
"우리",
"이",
"그",
"저",
"여기",
"거기",
"저기",
"어디",
"누구",
"아이",
"어머니",
"아버지",
"나무",
"머리",
"소리",
"하나",
"바다",
"다리",
"개",
"새",
"배",
"네",
"세",
"의사",
"회사",
"뭐",
"왜",
"귀",
"위",
"돼지",
"가위",
"시계",
"매미",
"제비",
"코",
"차",
"커피",
"포도",
"치마",
"까치",
"아빠",
"오빠",
"토끼",
"꼬리",
"찌개",
"카페",
"피자",
"쿠키",
"바쁘다",
"밥",
"물",
"책",
"집",
"손",
"발",
"눈",
"입",
"옷",
"산",
"강",
"문",
"방",
"곰",
"밤",
"꽃",
"앞",
"낮",
"밖",
"말",
"한국어",
"음악",
"국어",
"단어",
"언어",
"발음",
"얼음",
"직업",
"금요일",
"일요일",
"목요일",
"작은",
"감사합니다",
"입니다",
"학년",
"국물",
"십만",
"작년",
"몇 명",
"습니다",
"한국말",
"꽃말",
"값",
"닭",
"여덟",
"앉다",
"읽다",
"없다",
"많다",
"삶",
"넓다",
"짧다",
"학교",
"좋다",
"같이",
"신라",
"설날",
"못해",
"축하",
"싫어",
"놓다",
"앉히다",
"친구",
"사람",
"시간",
"이름",
"나라",
"하늘",
"마음",
"얼굴",
"목소리",
"이야기",
"학생",
"음식",
"가다",
"오다",
"먹다",
"마시다",
"모르다",
"크다",
"작다",
"싫다",
"슬프다",
"많이",
"언제",
"레벨",
"던전",
"탑",
"마법",
"소환",
"싸우다",
"진짜?",
"잠깐만",
"안 돼",
"가자",
"좋아",
"먹어",
"읽어"
]
}

View File

@@ -0,0 +1,18 @@
{
"note": "The 1.10 다지기 session. Contains two real gate violations to regression-test against: 아파 (unit 3.2) used in a Phase 1 review, and 안 (not) declared in a ::words block and waved through by the old check.",
"messages": [
"All four correct, but every one needed the lookup chain first — that's real information: he can recognize and use these words in context (match, build, translate) but pure recall-from-English is still shaky, especially for 머리, which handled fine as recognition earlier but stalled here.\n\n::result\n머리 | ok\n놓다 | ok\n싫어 | ok\n넓다 | ok\n::\n\n::words\n머리 | head / hair | \n놓다 | to put down, to let go | \n싫어 | I hate it / I don't want to | \n넓다 | to be wide | \n::\n\n::progress 87 | recall lags behind recognition — right answers, but only after the lookup crutch",
"Translate round — let's see these words in sentences instead of isolation.\n\n::task translate\n머리 아파\n싫어! 안 가\n방 넓어\n::\n\n::words\n머리 | head / hair | \n아파 | hurts (casual) | from 아프다\n싫어 | I hate it / I don't want to | \n안 | not | \n가 | go (casual) | from 가다\n방 | room | \n넓어 | is wide (casual) | from 넓다\n::",
"I need to flag something: I used 안 (not) + verb in that last exercise, and that's not on his roadmap yet — my mistake, dropping it going forward.\n\nOn the answers: 방 넓어 correct. For 머리 아파, \"head hurts\" — you wrote \"leg,\" likely crossing 머리 with 다리; that's a headword mix-up, not a grammar issue. For 싫어! 안 가, since 안 isn't something you've been taught, I won't hold the negation against you — but 싫어 itself means \"I hate it / I don't want to,\" not a command to \"go.\"\n\n::result\n머리 아파 | wrong | read 머리 as \"leg\" (crossed with 다리)\n싫어! 안 가 | wrong | (ungated grammar on my part, not counted)\n방 넓어 | ok\n::\n\n::words\n머리 | head / hair | \n아파 | hurts (casual) | from 아프다\n싫어 | I hate it / I don't want to | \n방 | room | \n넓어 | is wide (casual) | from 넓다\n::\n\n::progress 85 | 머리/다리 confusion surfaced, 넓어 solid in context",
"Choice round — putting 머리 and 다리 head to head since they got crossed.\n\n::task choice\n머리 아파 means... | My leg hurts | My head hurts | My arm hurts\n다리 아파 means... | My leg hurts | My head hurts | My voice hurts\nWhich one is \"head / hair\"? | 다리 | 머리 | 소리\nWhich one is \"leg / bridge\"? | 다리 | 머리 | 소리\n::\n\n::words\n머리 | head / hair | \n다리 | leg / bridge | \n아파 | hurts (casual) | from 아프다\n소리 | sound | \n::",
"All four correct, no lookups — the 머리/다리 confusion is cleared up now that they've been set side by side.\n\n::result\n머리 아파 means... | ok\n다리 아파 means... | ok\nWhich one is \"head / hair\"? | ok\nWhich one is \"leg / bridge\"? | ok\n::\n\n::words\n머리 | head / hair | \n다리 | leg / bridge | \n아파 | hurts (casual) | from 아프다\n::\n\n::progress 88 | 머리 vs 다리 contrast resolved cleanly",
"Match round.\n\n::task match\n넓다 | to be wide\n놓다 | to put down, to let go\n싫어 | I hate it / I don't want it\n닭 | chicken\n하나 | one (Native)\n좋다 | to be good\n습니다 | formal polite verb ending\n앉히다 | to seat someone\n::\n\n::words\n넓다 | to be wide | \n놓다 | to put down, to let go | \n싫어 | I hate it / I don't want it | \n닭 | chicken | \n하나 | one (Native) | \n좋다 | to be good | \n습니다 | formal polite verb ending | \n앉히다 | to seat someone | \n::",
"All eight correct — nice clean sweep, though 놓다, 앉히다, 싫어, and 습니다 still need the lookup. Good sign that 닭, 하나, 좋다, and 넓다 came straight through unaided.\n\n::result\n좋다 | ok\n닭 | ok\n하나 | ok\n넓다 | ok\n놓다 | ok\n앉히다 | ok\n싫어 | ok\n습니다 | ok\n::\n\n::words\n넓다 | to be wide | \n놓다 | to put down, to let go | \n싫어 | I hate it / I don't want it | \n닭 | chicken | \n하나 | one (Native) | \n좋다 | to be good | \n습니다 | formal polite verb ending | \n앉히다 | to seat someone | \n::\n\n::progress 88 | matching solid across the board, several words still lookup-dependent for recall",
"Build round — includes some earlier words for retention.\n\n::task build\nPut it down here. | 여기 | 놓아\nI hate coffee. | 나 | 커피 | 싫어\nThat room is wide. | 그 | 방 | 넓어\n::\n\n::words\n여기 | here | \n놓아 | put down (casual) | from 놓다\n나 | I / me | \n커피 | coffee | \n싫어 | I hate it / I don't want it | \n그 | that | \n방 | room | \n넓어 | is wide (casual) | from 넓다\n::",
"All three correct, no lookups — clean round across 놓아, 싫어, and 넓어 together.\n\n::result\nPut it down here. | ok\nI hate coffee. | ok\nThat room is wide. | ok\n::\n\n::words\n여기 | here | \n놓아 | put down (casual) | from 놓다\n나 | I / me | \n커피 | coffee | \n싫어 | I hate it / I don't want it | \n그 | that | \n방 | room | \n넓어 | is wide (casual) | from 넓다\n::\n\n::progress 90 | building sentences with these words unaided now, strong round",
"Translate round.\n\n::task translate\n다리 짧아\n너 머리 짧아\n나 커피 싫어\n::\n\n::words\n다리 | leg / bridge | \n짧아 | is short (casual) | from 짧다\n너 | you | \n머리 | head / hair | \n나 | I / me | \n커피 | coffee | \n싫어 | I hate it / I don't want it | \n::",
"All three correct — nice reading of 머리 as \"hair\" in context this time, right call. 짧아 still needs the lookup, so that one keeps circulating.\n\n::result\n다리 짧아 | ok\n너 머리 짧아 | ok\n나 커피 싫어 | ok\n::\n\n::words\n다리 | leg / bridge | \n짧아 | is short (casual) | from 짧다\n너 | you | \n머리 | head / hair | \n나 | I / me | \n커피 | coffee | \n싫어 | I hate it / I don't want it | \n::\n\n::progress 90 | contextual reading strong, 짧아 alone is the lingering gap",
"Recall round.\n\n::task recall\nto be short (dictionary form)\none (Native)\nformal polite verb ending\nchicken\n::\n\n::words\n짧다 | to be short | \n하나 | one (Native) | \n습니다 | formal polite verb ending | \n닭 | chicken | \n::",
"네 개 중 세 개는 맞았어요! 하나, 습니다, 닭 all correct from memory of the meaning (though 습니다 and 닭 still needed lookup to write). But \"to be short\" — you wrote 빫다, which isn't quite right: it should be 짧다, with the double batchim ㄼ (ㄹ+ㅂ) under the 짧, not ㅃ. Easy mix-up since both involve doubled-looking consonants, but they're different letters — ㅃ is a single tense consonant, while ㄼ is two consonants stacked as a batchim.\n\n::result\nto be short (dictionary form) | wrong | wrote 빫다 (confused ㅃ for the ㄼ batchim)\none (Native) | ok\nformal polite verb ending | ok\nchicken | ok\n::\n\n::words\n짧다 | to be short | double batchim ㄼ\n하나 | one (Native) | \n습니다 | formal polite verb ending | \n닭 | chicken | \n::\n\n::progress 87 | 짧다's double batchim spelling slipped under recall pressure"
]
}

View File

@@ -0,0 +1,47 @@
{
"note": "Real 선생님 messages from the artifact, units 1.1-1.9. The gate MUST be measured against these before it is allowed to reject anything: the first version of the scan, which read his prose, would have rejected 17 of these 41.",
"unit": "messages were produced across units 1.1-1.9",
"messages": [
"All correct — 나라 had no lookup this time, so it confirms. 시간, 얼음, 작은 still need one more unaided pass.\n\n::confirmed\n나라\n::\n\n::task match\n얼음 | ice\n작은 | small (modifier)\n시간 | time / hour\n음악 | music\n::\n\n::words\n얼음 | ice | \n작은 | small (modifier) | \n시간 | time / hour | \n음악 | music | \n::\n\n::progress 89 | 나라 confirmed, three holdouts remain for another pass",
"시간 had no lookup this time — confirmed. 작은 and 얼음 still need one clean pass.\n\n::confirmed\n시간\n::\n\n::task match\n얼음 | ice\n작은 | small (modifier)\n목소리 | voice\n하늘 | sky\n::\n\n::words\n얼음 | ice | \n작은 | small (modifier) | \n목소리 | voice | \n하늘 | sky | \n::\n\n::progress 89 | 시간 confirmed, 작은/얼음 still slipping into lookups",
"작은 and 얼음 both correct with no lookup this time — confirmed.\n\n::confirmed\n작은\n얼음\n::\n\n목소리 and 하늘 still need one clean unaided pass.\n\n::task match\n목소리 | voice\n하늘 | sky\n마음 | heart / mind\n이야기 | story\n::\n\n::words\n목소리 | voice | \n하늘 | sky | \n마음 | heart / mind | \n이야기 | story | \n::\n\n::progress 91 | 작은/얼음 finally confirmed, four words left in the open list",
"목소리 and 이야기 correct with no lookup — confirmed.\n\n::confirmed\n목소리\n이야기\n::\n\n마음 and 하늘 still need a clean unaided pass. Let's mix them with words you're solid on.\n\n::task match\n마음 | heart / mind\n하늘 | sky\n바다 | sea\n나무 | tree\n::\n\n::words\n마음 | heart / mind | \n하늘 | sky | \n바다 | sea | \n나무 | tree | \n::\n\n::progress 92 | 목소리/이야기 confirmed, only 마음/하늘 plus a couple rules remain",
"Still leaning on lookups for 마음 and 하늘 — matching keeps letting you pattern-match rather than recall. Let's switch to translating short lines instead.\n\n::task translate\n하늘 좋아\n마음 커\n바다 좋아\n::\n\n::words\n하늘 | sky | \n좋아 | to be good / like | from 좋다\n마음 | heart / mind | \n커 | to be big | from 크다\n바다 | sea | \n::\n\n::progress 90 | 마음/하늘 not landing through matching, trying context sentences",
"하늘 correct with no lookup this time — confirmed.\n\n::confirmed\n하늘\n::\n\n마음 still needs a clean pass. Let's try it alone, paired with something very familiar.\n\n::task translate\n마음 좋아\n바다 좋아\n::\n\n::words\n마음 | heart / mind | \n좋아 | to be good / like | from 좋다\n바다 | sea | \n::\n\n::progress 92 | 하늘 confirmed, 마음 is the one stubborn holdout now",
"Both correct, no lookups — 마음 confirms. (Small note: nothing in the sentence says \"your,\" but you clearly read 마음 as heart, which is what mattered.)\n\n::confirmed\n마음\n::\n\n::progress 93 | 마음 finally landed unaided, vocabulary list nearly closed",
"Targeting the remaining open words directly.\n\n::task match\n입니다 | (formal) is/am/are\n한국말 | Korean language (speech)\n여덟 | eight\n얼굴 | face\n::\n\n::words\n입니다 | (formal) is/am/are | ending word\n한국말 | Korean language (speech) | \n여덟 | eight | \n얼굴 | face | \n::",
"한국말 and 입니다 correct with no lookup — confirmed.\n\n::confirmed\n한국말\n입니다\n::\n\n얼굴 and 여덟 still need one clean pass.\n\n::task match\n얼굴 | face\n여덟 | eight\n발음 | pronunciation\n학년 | school year / grade\n::\n\n::words\n얼굴 | face | \n여덟 | eight | \n발음 | pronunciation | \n학년 | school year / grade | \n::\n\n::progress 95 | 한국말/입니다 confirmed, just 얼굴 and 여덟 plus 첫 낱말 left open",
"All correct with no lookups — 얼굴 and 여덟 both confirm.\n\n::confirmed\n얼굴\n여덟\n::\n\n::progress 97 | vocabulary list closed, only the 첫 낱말 rule remains open",
"Last one standing — plain word recognition, no sound rule at play.\n\n::task translate\n개 좋아\n포도 커\n차 마셔\n::\n\n::words\n개 | dog | \n좋아 | to be good / like | from 좋다\n포도 | grape(s) | \n커 | to be big | from 크다\n차 | tea | \n마셔 | to drink | from 마시다\n::",
"개 correct, no lookup — good. 포도 and 마셔 still needed a look, so let's try plain-reading once more with fully secure words.\n\n::task translate\n아빠 좋아\n토끼 커\n소리 좋아\n::\n\n::words\n아빠 | dad | \n좋아 | to be good / like | from 좋다\n토끼 | rabbit | \n커 | to be big | from 크다\n소리 | sound | \n::\n\n::progress 97 | plain word reading almost proven, one more clean pass needed",
"All three correct with no lookups — that's the clean unaided pass needed.\n\n::confirmed\n첫 낱말\n::\n\n::progress 99 | every rule and word in Phase 1 now proven unaided",
"반가워요. 저는 선생님이에요 — your reading tutor.\n\nPicking up where you left off. Nothing will turn up in an exercise that I haven't taught you first — if it does, tell me and I'll drop it.\n\nThe plan is six phases, 56 units, ending with you reading a manhwa page at speed. **Roadmap** above shows the whole thing; you can jump anywhere in it whenever you like.\n\nHit **시작 · Start unit** below, and we'll pick up 1.10 다지기 — Phase 1 review — every sound rule, every word.",
"좋아요, Fabian. 1.10 다지기에 온 걸 환영해요.\n\n이 유닛은 새로운 게 하나도 없어요. Phase 1에서 배운 아홉 가지 소리 규칙 — 자음과 모음, 겹모음, 된소리·거센소리, 받침, 연음, 비음화, 겹받침, 나머지 소리 변화, 첫 낱말 읽기 — 그리고 그 과정에서 만난 단어들 전부를, 사전 없이 바로 읽고 뜻을 아는지 확인하는 자리예요.\n\n방식은 이래요: 앱이 정한 순서대로 단어와 규칙을 묶음(batch)으로 던져줄 거예요. 한 단어가 \"확인됨\"으로 넘어가려면 서로 다른 라운드에서 세 번 맞아야 해요 — 한 번 맞혔다고 끝난 게 아니에요. 틀리면 다시 큐에 들어가고요. 저는 새로 가르치지 않고, 이미 아는 걸로만 계속 돌릴 거예요.\n\n지금 열려 있는 건 아홉 개 규칙 전부와 100개가 넘는 단어들이에요. 오늘은 그 중 첫 배치 — 여기 · 거기 · 저기 — 그리고 앱이 우선순위로 올린 몇 단어를 같이 섞어서 시작할게요.\n\n첫 라운드는 매칭이에요. 한글과 뜻을 짝지어 보세요.\n\n::task match\n여기 | here\n거기 | there\n저기 | over there\n나 | I, me\n너 | you\n하나 | one\n네 | yes\n많다 | to be many\n::\n\n::words\n여기 | here | place word\n거기 | there | place word\n저기 | over there | place word\n나 | I, me | casual\n너 | you | casual\n하나 | one | native number\n네 | yes | response word\n많다 | to be many | adjective\n::",
"All five correct, no lookups.\n\n✓ 국물 is read as → 궁물\n✓ 작년 is read as → 장년\n✓ 입니다 is read as → 임니다\n✓ 십만 is read as → 심만\n✓ 꽃말 is read as → 꼰말\n\nClean sweep across all three shift types, in two different exercise formats, with no lookups either time. Let's try it from the other direction — hearing the shifted sound and picking the correctly spelled word — and mix in a slide word from last unit so the two rules don't blur together.\n\n::task choice\nWhich word is spelled 국물 (before you apply any sound change)? | 국물 | 굼물 | 궁물\nWhich one uses liaison, not nasalisation? | 한국어 | 입니다 | 작년\nWhich word is read 감사함니다? | 감사합니다 | 감사한니다 | 감사함니다\n::\n\n::words\n국물 | soup broth | read 궁물\n한국어 | Korean language | read 한구거\n입니다 | is / am / are (formal) | read 임니다\n작년 | last year | read 장년\n감사합니다 | thank you | read 감사함니다\n::\n\n::progress 32 | consistent, error-free production of all three nasalisation shifts across formats",
"✓ 국물 → 국물 (correct — that's the spelling itself, no change applied)\n✓ 감사합니다 is read 감사함니다 → correctly picked 감사합니다 as the spelling\n— \"Which one uses liaison, not nasalisation?\" left open. Here's the distinction: 한국어 has a ㄱ final followed by 어, which starts with silent ㅇ — nothing to trigger nasalisation, so the ㄱ just slides over: 한구거. That's liaison. 입니다 and 작년 both have a stop final followed by ㄴ or ㅁ, so they nasalise instead of sliding.\n\nLet's isolate just that contrast.\n\n::task choice\n한국어 | slides (liaison) | nasalises\n작년 | slides (liaison) | nasalises\n입니다 | slides (liaison) | nasalises\n국물 | slides (liaison) | nasalises\n::\n\n::words\n한국어 | Korean language | read 한구거\n작년 | last year | read 장년\n입니다 | is / am / are (formal) | read 임니다\n국물 | soup broth | read 궁물\n::\n\n::progress 32 | shift production solid; needs the liaison-vs-nasalisation trigger distinction explicitly",
"All four correct.\n\n✓ 한국어 → slides (liaison) — followed by silent ㅇ\n✓ 작년 → nasalises — ㄱ before ㄴ becomes ㅇ\n✓ 입니다 → nasalises — ㅂ before ㄴ becomes ㅁ\n✓ 국물 → nasalises — ㄱ before ㅁ becomes ㅇ\n\nThat closes the gap — you now correctly identify not just how each word shifts, but *why*: what's next to the final decides whether it slides or transforms. Between the clean production across all three nasalisation types and this correct trigger distinction, the unit's core content is landing solidly.\n\n::progress 45 | trigger distinction (silent ㅇ vs ㄴ/ㅁ) now correctly identified alongside reliable shift production",
"::task translate\n작년 학교 좋았어\n십만 원 없어\n국물 마셔\n꽃말 몰라\n::\n\nNew word here: 원 isn't on the list, so I'll avoid it — let me swap that line.\n\n::task translate\n작년 학교 좋았어\n국물 마셔\n꽃말 몰라\n한국말 재미없어\n::\n\nNote: 재미없어 isn't on your list either — swapping again.\n\n::task translate\n작년 학교 좋았어\n국물 마셔\n꽃말 몰라\n한국말 안 좋아\n::\n\n::words\n작년 | last year | read 장년\n학교 | school | known\n좋았어 | was good | from 좋다\n국물 | soup broth | read 궁물\n마셔 | drink | from 마시다\n꽃말 | flower's meaning | read 꼰말\n몰라 | don't know | from 모르다\n한국말 | Korean language | read 한궁말\n안 좋아 | not good | known\n::\n\n::progress 45 | building sentence-level practice mixing nasalisation words with known vocabulary",
"Good catch working through the version before my swap — let me mark it, and own a mistake: 원 (won, the currency) was never on this unit's list and I shouldn't have written that line at all. Dropping it now.\n\n✓ 작년 학교 좋았어 → school last year was good\n✓ 국물 마셔 → drink soup broth\n✗ 꽃말 몰라 → you wrote \"don't know the flower,\" but 꽃말 means the flower's *meaning* (its symbolic meaning), not the flower itself — so it's \"don't know the flower's meaning.\"\n— 십만 원 없어 → skip this one, 원 shouldn't have appeared at all.\n\n::words\n꽃말 | flower's meaning | read 꼰말\n몰라 | don't know | from 모르다\n::\n\n::progress 47 | nasalised words read correctly; one vocabulary meaning slip on 꽃말, unrelated to the sound rule",
"::task build\nLast year's school was good. | 작년 | 학교 | 좋았어\nDrink soup broth. | 국물 | 마셔\nDon't know the flower's meaning. | 꽃말 | 몰라\nDon't know Korean. | 한국말 | 몰라\n::\n\n::words\n작년 | last year | read 장년\n학교 | school | known\n좋았어 | was good | from 좋다\n국물 | soup broth | read 궁물\n마셔 | drink | from 마시다\n꽃말 | flower's meaning | read 꼰말\n몰라 | don't know | from 모르다\n한국말 | Korean language | read 한궁말\n::\n\n::progress 47 | reinforcing nasalised vocabulary through sentence assembly",
"All four correct, no lookups.\n\n✓ 작년 학교 좋았어\n✓ 국물 마셔\n✓ 꽃말 몰라\n✓ 한국말 몰라\n\nClean across the board, and 꽃말's meaning has clearly stuck after the correction. You're now reliably producing all three nasalisation shifts, distinguishing them from liaison by trigger, and reading them correctly inside full sentences with no lookups. That's a solid, rounded grasp of this unit's target.\n\n::progress 58 | full sentence production clean with no lookups; core nasalisation rule solidly landed",
"::task match\n몇 명 | 면명\n직업 없어 | 지겅 업써\n십만 | 심만\n작년 | 장년\n꽃말 | 꼰말\n국물 | 궁물\n::\n\nNew word this round: 몇 명 (how many people) — 몇's representative sound is ㄷ, and before 명's ㅁ it becomes ㄴ.\n\nNote: I need to flag something — \"지겅 업써\" isn't right, that's tensification territory (없어 has its own separate shift) which isn't part of this unit. Let me drop that line entirely and keep this clean.\n\n::task match\n몇 명 | 면명\n십만 | 심만\n작년 | 장년\n꽃말 | 꼰말\n국물 | 궁물\n한국말 | 한궁말\n::\n\n::words\n몇 명 | how many people | read 면명\n십만 | one hundred thousand | read 심만\n작년 | last year | read 장년\n꽃말 | flower's meaning | read 꼰말\n국물 | soup broth | read 궁물\n한국말 | Korean language | read 한궁말\n::\n\n::progress 58 | introducing 몇 명 as a new nasalisation example, avoided drifting into an ungated sound rule",
"✓ 십만 = 심만 (correct, though you looked this one up — worth another pass)\n✓ 꽃말 = 꼰말\n✓ 몇 명 = 면명\n✓ 국물 = 궁물\n✓ 작년 = 장년\n— 직업 없어: I'd already pulled that one from the exercise since it involves a different sound rule not covered yet, so no mark either way there — nothing to worry about.\n\nEverything else, including the new word 몇 명, landed correctly. Since 십만 needed a lookup, let's bring it back shortly. For now, a quick round mixing today's words with no repeats to check retention without the crutch.\n\n::task translate\n작년 국물 좋아\n몇 명 몰라\n꽃말 몰라\n한국말 좋아\n::\n\n::words\n작년 | last year | read 장년\n국물 | soup broth | read 궁물\n좋아 | good / like | known\n몇 명 | how many people | read 면명\n몰라 | don't know | from 모르다\n꽃말 | flower's meaning | read 꼰말\n한국말 | Korean language | read 한궁말\n::\n\n::progress 58 | 십만 needed a lookup, rest solid; retention check underway",
"All four correct.\n\n✓ 작년 국물 좋아 → last year's broth was good\n✓ 몇 명 몰라 → don't know how many people\n✓ 꽃말 몰라 → don't know the flower's meaning\n✓ 한국말 좋아 → like Korean\n\nThe meanings are right every time, but you looked up nearly the whole set — including 작년, 국물, and 몰라, which you've read correctly unaided several rounds running. That's a sign the reading is solid but not yet automatic; it hasn't moved into memory the way it should. Let's do a fast, low-pressure recognition round with the same words, no new ones, to help that settle.\n\n::task choice\n작년 means | last year | this year | next year\n국물 means | rice | soup broth | flower\n몰라 means | know | don't know | don't like\n꽃말 means | flower | flower's meaning | flower shop\n::\n\n::words\n작년 | last year | read 장년\n국물 | soup broth | read 궁물\n몰라 | don't know | from 모르다\n꽃말 | flower's meaning | read 꼰말\n::\n\n::progress 58 | translation accuracy strong but heavy lookup reliance suggests recognition not yet automatic",
"All four correct, no lookups this time.\n\n✓ 작년 means → last year\n✓ 국물 means → soup broth\n✓ 몰라 means → don't know\n✓ 꽃말 means → flower's meaning\n\nGood — with the meaning locked in via pure recognition, the earlier lookups were about vocabulary retention, not the sound rule itself. On the actual target of this unit, you've now shown, repeatedly and across every format, that you correctly nasalise ㄱ→ㅇ, ㄷ→ㄴ, and ㅂ→ㅁ, that you distinguish nasalisation from liaison by trigger, and that you read the shifted sentences correctly. That's the core of 1.6 solidly in place.\n\n::progress 62 | nasalisation rule itself fully reliable across all formats; vocabulary retention lagging slightly behind but improving",
"Good question. 말 by itself means \"word\" or \"speech/language.\" So it's not that 말 changes meaning — it's the same word both times, just combining differently with what's in front of it.\n\n한국말 = 한국 (Korea) + 말 (speech/language) → \"Korea-language\" → Korean.\n꽃말 = 꽃 (flower) + 말 (word) → \"flower-word\" → the word/meaning a flower carries, i.e. its symbolic meaning.\n\nSame building block, different first half, so the compound lands somewhere different in English — but in both cases 말 is just \"word/language\" doing its normal job.\n\n::words\n말 | word / language / speech | building block in compounds\n한국말 | Korean language | 한국 + 말, read 한궁말\n꽃말 | flower's meaning | 꽃 + 말, read 꼰말\n::",
"::task build\nLast year I didn't know the flower's meaning. | 작년 | 꽃말 | 몰랐어\nI don't know how many people like Korean. | 몇 명 | 한국말 | 좋아하는지 | 몰라\nDrink the soup broth, it's good. | 국물 | 마셔 | 좋아\n::\n\nThat middle one reaches for a form you haven't seen — dropping it.\n\n::task build\nLast year I didn't know the flower's meaning. | 작년 | 꽃말 | 몰랐어\nDrink the soup broth, it's good. | 국물 | 마셔 | 좋아\nI don't know Korean. | 한국말 | 몰라\n::\n\n::words\n작년 | last year | read 장년\n꽃말 | flower's meaning | read 꼰말\n몰랐어 | didn't know | from 모르다\n국물 | soup broth | read 궁물\n마셔 | drink | from 마시다\n좋아 | good / like | known\n한국말 | Korean language | read 한궁말\n몰라 | don't know | from 모르다\n::\n\n::progress 62 | continuing to reinforce nasalised vocabulary in production tasks",
"겹받침 — this unit is about blocks that end in TWO consonants stacked together, like ㄺ, ㄳ, ㄼ, ㄵ, ㅄ. Korean blocks can only hold one final sound out loud, so when nothing follows, only ONE of the two consonants is actually pronounced — the other is silent, just sitting there in the spelling. Which one survives isn't fully predictable by rule; it's learned word by word, the same way you just memorise that 닭 sounds like 닥.\n\nSome examples of the silent half at the end of a word:\n닭 (chicken) — ㄹ+ㄱ batchim, but only ㄱ is heard → 닭 [닥]\n값 (price) — ㅂ+ㅅ batchim, only ㅂ is heard → 값 [갑]\n여덟 (eight) — ㄹ+ㅂ batchim, only ㄹ is heard → 여덟 [여덜]\n\nBut when a VOWEL follows — same liaison idea you already know, where a final consonant slides into a following ㅇ block — something different happens with a double batchim: instead of only one consonant surviving, the SECOND one slides across into the next syllable, and the first stays put. So 읽다 (to read) with the ending -어 becomes 읽어, and it splits: ㄹ stays behind as the final of the first block, ㄱ slides over to become the onset of the second block.\n\n읽어 → 일거 (reads / reading)\n\nThat's the one new mechanic this unit — everything else is just learning which consonant is silent, word by word.\n\nThe mistake to watch for: assuming double batchim always drops the SAME consonant (like always keeping the first or always keeping the second). It doesn't — 닭 keeps the first (ㄱ, wait — keeps ㄱ which is second in writing order but that's just how ㄺ is built), while 여덟 keeps ㄹ, the first. There's no shortcut — treat each word as its own item to learn.\n\n::task match\n닭 | chicken\n값 | price\n여덟 | eight\n읽다 | to read\n::\n\n::words\n닭 | chicken | read 닥\n값 | price | read 갑\n여덟 | eight | read 여덜\n읽다 | to read | dictionary form\n읽어 | reads / read | splits as 일거 with a vowel ending\n::\n\n::progress 15 | new unit just introduced, first exposure to double batchim concept",
"All four correct, no lookups.\n\n✓ 닭 = chicken\n✓ 여덟 = eight\n✓ 값 = price\n✓ 읽다 = to read\n\nClean first pass on brand-new vocabulary — good start. Now let's check whether you've actually got the SOUND each one makes, not just the meaning, since that's the real point of this unit.\n\n::task choice\n닭 is read | 닥 | 달 | 닭\n값 is read | 갑 | 갓 | 값\n여덟 is read | 여덟 | 여덜 | 여덥\n읽다 is read | 읽다 | 익다 | 일따\n::\n\n::words\n닭 | chicken | read 닥\n값 | price | read 갑\n여덟 | eight | read 여덜\n읽다 | to read | read 익따\n::\n\n::progress 20 | vocabulary meanings solid on first exposure; now testing which half of each double batchim is actually heard",
"✓ 닭 is read → 닥\n✓ 값 is read → 갑\n✓ 읽다 is read → 익다 — right, the ㄹ drops and only ㄱ is heard\nopen: 여덟 — this one is read 여덜. The batchim is ㄹ+ㅂ, but here it's the ㄹ that survives and the ㅂ that stays silent, the opposite pattern from 닭. That's exactly the \"no shortcut, learn it per word\" point from the intro.\n\nThree out of four landed with no hesitation — the pattern is starting to stick, you just haven't met 여덟's split yet. Let's drill it specifically now.\n\n::task choice\n여덟 is read | 여덜 | 여덥 | 여덟\n닭 is read | 닥 | 달 | 닭\n여덟 살 이야 (I'm eight years old) — how is 여덟 read here | 여덜 | 여덥 | 여덟\n값 is read | 갑 | 값 | 갓\n::\n\n::words\n여덟 | eight | read 여덜\n닭 | chicken | read 닥\n살 | years old | known\n이야 | is / am (casual) | known\n값 | price | read 갑\n::\n\n::progress 30 | three of four double batchim readings correct on first try; 여덟's ㄹ-surviving pattern needs isolated repetition",
"All four correct, no lookups.\n\n✓ 여덟 is read → 여덜\n✓ 닭 is read → 닥\n✓ 여덟 살 이야 → 여덜 (same word, holds even inside a sentence)\n✓ 값 is read → 갑\n\nThat's 여덟 locked in now, including in context — you didn't get thrown by the extra words around it. All four double batchim words so far (닭, 값, 여덟, 읽다) are being read correctly and consistently. Next let's bring in the new mechanic from the intro — what happens when a vowel follows and the second consonant slides across.\n\n::task choice\n읽어 (reads) is read | 읽어 | 일거 | 익어\n닭 이 있어 (there is a chicken) — how does 닭이 sound | 다기 | 달기 | 닥이\n값 이 있어 (there is a price) — how does 값이 sound | 갑시 | 가비 | 갑이\n::\n\n::words\n읽어 | reads / read | from 읽다\n닭이 | chicken (+이) | liaison example\n값이 | price (+이) | liaison example\n있어 | there is / have | known\n::\n\n::progress 40 | all four core double batchim words solid; introducing vowel-liaison splitting behaviour next\n</br>",
"All three correct.\n\n✓ 읽어 (reads) → 일거\n✓ 닭이 (there is a chicken) → 달기\n✓ 값이 (there is a price) → 갑시\n\nThat's the split mechanic landing perfectly on the first try, across three different double batchim types — ㄺ and ㅄ both handled correctly, on top of the plain no-vowel readings you'd already nailed. You're reading these confidently now rather than guessing.\n\n::task translate\n닭 있어\n값 몰라\n여덟 살 이야\n읽어\n::\n\n::words\n닭 | chicken | read 닥\n있어 | there is / have | known\n값 | price | read 갑\n몰라 | don't know | from 모르다\n여덟 | eight | read 여덜\n살 | years old | known\n이야 | is / am (casual) | known\n읽어 | reads / read | from 읽다\n::\n\n::progress 55 | vowel-liaison split fully correct across two different double batchim types on first attempt",
"All four correct in meaning.\n\n✓ 닭 있어 → there is a chicken\n✓ 값 몰라 → I don't know the price\n✓ 여덟 살 이야 → I am 8 years old\n✓ 읽어 → I read\n\nEverything here is right, and the lookups (몰라, 살) are older vocabulary rather than anything from this unit — the double batchim words themselves (닭, 값, 여덟, 읽어) all went through without a check this time. That's the real signal: this unit's actual content is sticking well after just a few rounds — both the silent-half readings and the vowel-liaison split have been consistent across multiple word types now.\n\n::progress 62 | double batchim words now read without lookup; rule application solid across silent and liaison contexts",
"::task build\nI don't know the chicken's price. | 닭 | 값 | 몰라\nI read the flower's meaning. | 꽃말 | 읽어\nThere are eight students. | 학생 | 여덟 | 명 | 있어\n::\n\n::words\n닭 | chicken | read 닥\n값 | price | read 갑\n몰라 | don't know | from 모르다\n꽃말 | flower's meaning | read 꼰말\n읽어 | reads / read | from 읽다\n학생 | student | known\n여덟 | eight | read 여덜\n명 | people (counter) | known\n있어 | there is / have | known\n::\n\n::progress 62 | building sentences that combine double batchim words with earlier vocabulary and nasalisation",
"All three correct, no lookups.\n\n✓ 닭 값 몰라\n✓ 꽃말 읽어\n✓ 학생 여덟 명 있어\n\nClean across the board, and this one's worth noting: 닭 값 sitting next to each other doesn't trigger liaison here because they're separate words in the sentence, not one block flowing into the next the way 닭이 did — you handled that distinction correctly without it being pointed out. Double batchim reading, the vowel-split, and correct word order with a counter are all working together smoothly now.\n\n::progress 68 | combining double batchim vocabulary into full sentences with no errors and no lookups",
"::task match\n앉다 | to sit\n없다 | to not exist / not have\n많다 | to be a lot / many\n넓다 | to be wide\n짧다 | to be short\n삶 | life\n::\n\n::words\n앉다 | to sit | dictionary form\n없다 | to not exist / not have | dictionary form\n많다 | to be a lot / many | dictionary form\n넓다 | to be wide | dictionary form\n짧다 | to be short | dictionary form\n삶 | life | noun\n::\n\n::progress 68 | introducing remaining new-word set for this unit via meaning matching before testing their sounds",
"All six correct in meaning.\n\n✓ 짧다 = to be short\n✓ 삶 = life\n✓ 많다 = to be a lot / many\n✓ 앉다 = to sit\n✓ 없다 = to not exist / not have\n✓ 넓다 = to be wide\n\nThat's a fresh set of six new words, all matched correctly on the first pass — looking them up to get the meaning is completely normal here, since this is their very first appearance. Now the real double batchim test: how each of these actually sounds when spoken, since that's the whole point of this unit, not just recognizing them on paper.\n\n::task choice\n앉다 is read | 안따 | 앋다 | 앉다\n없다 is read | 업다 | 없다 | 얼다\n많다 is read | 만타 | 많다 | 마다\n넓다 is read | 널따 | 넙다 | 넓다\n짧다 is read | 짤따 | 짭다 | 짧다\n삶 is read | 삼 | 살 | 삶\n::\n\n::words\n앉다 | to sit | read 안따\n없다 | to not exist / not have | read 업따\n많다 | to be a lot / many | read 만타\n넓다 | to be wide | read 널따\n짧다 | to be short | read 짤따\n삶 | life | read 삼\n::\n\n::progress 70 | meanings solid for new word set; now testing actual pronunciation of each double batchim\n</br>",
"All six correct.\n\n✓ 앉다 is read → 안따\n✓ 없다 is read → 업다\n✓ 많다 is read → 만타\n✓ 넓다 is read → 널따\n✓ 짧다 is read → 짤따\n✓ 삶 is read → 삼\n\nFull set, right on the first attempt — even though you needed the meanings looked up, the sound pattern (which half of the double batchim survives) was correct across all six new words without a single miss. That's the actual skill this unit is testing, and it's transferring well to brand-new vocabulary you'd never seen before this round.\n\n::progress 76 | new word set's double batchim readings all correct on first exposure; strong transfer of the core skill",
"Not a hard rule, no — that's exactly why this unit calls them \"learned as items\" rather than by rule. But there is a rough tendency you can lean on, even if it has exceptions:\n\nFor most double batchim, the FIRST consonant tends to survive and the second drops — that's what happens in 앉다 (ㄴ survives), 많다 (ㄴ survives), 넓다 (ㄹ survives), 짧다 (ㄹ survives), 여덟 (ㄹ survives).\n\nㄺ is the odd one out — there the SECOND consonant (ㄱ) survives instead, as in 닭 and 읽다. That's really the one pattern worth memorising as an exception: \"ㄺ keeps the ㄱ,\" everything else you've seen so far keeps the first.\n\n값 fits the general tendency too — ㅂ is first, and ㅂ survives.\n\nSo: default guess = first one survives, with ㄺ words as the flagged exception. It's a decent guide for a new word, not a guarantee — which is exactly why native speakers just know it per word rather than deriving it.\n\n::progress 76 | conceptual understanding of the survives-which-half question now explicit, not just pattern-matched",
"::task translate\n의자 많이 없어\n학생 앉아\n꽃 넓어\n삶 짧아\n::\n\nWait — let me give you a cleaner set, some of these words need an ending you haven't drilled. Here's the actual exercise:\n\n::task translate\n친구 많이 없어\n학생 앉아\n바다 넓어\n삶 짧아\n::\n\n::words\n친구 | friend | known\n많이 | a lot | from 많다\n없어 | there isn't / don't have | from 없다\n학생 | student | known\n앉아 | sits / sitting | from 앉다\n바다 | sea | known\n넓어 | is wide | from 넓다\n삶 | life | noun\n짧아 | is short | from 짧다\n::\n\n::progress 76 | building sentences with new double batchim adjectives combined with earlier vocabulary"
]
}

View File

@@ -3,57 +3,107 @@
as real interface and sends structured answers back. */
const RE = {
task: /::task\s+(translate|match|build|choice)\s*\n([\s\S]*?)(?:\n::|$)/,
task: /::task\s+(translate|recall|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]*))?/,
result: /::result\s*\n([\s\S]*?)(?:\n::|$)/,
confirmed:/::confirmed\s*\n([\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);
/* 선생님 sometimes writes an exercise, notices mid-message that it broke the
gate, and rewrites it below. Matching the FIRST block served the draft it
had just retracted — a real bug seen in production, where the student was
handed and answered an exercise the tutor had already withdrawn.
So: collect every block; the LAST task, words and progress win, and gloss
blocks accumulate (a message may legitimately gloss several sentences). */
const all = (text, re) => {
const r = new RegExp(re.source, "g");
const out = [];
let m;
while ((m = r.exec(text)) !== null) {
out.push(m);
if (m.index === r.lastIndex) r.lastIndex++;
}
return out;
};
const words = w ? rows(w[1]).map(l => { const c = cols(l);
return { ko: c[0], gloss: c[1] || "", note: c[2] || "" }; }) : null;
export function parse(text) {
const ws = all(text, RE.words), ts = all(text, RE.task);
const gs = all(text, RE.gloss), ps = all(text, RE.progress);
const t = ts.length ? ts[ts.length - 1] : null;
const p = ps.length ? ps[ps.length - 1] : null;
/* keep every word listed anywhere, first gloss of a term wins */
let words = null;
if (ws.length) {
const seen = new Set(), acc = [];
ws.forEach(w => rows(w[1]).forEach(l => {
const c = cols(l);
if (!c[0] || seen.has(c[0])) return;
seen.add(c[0]);
acc.push({ ko: c[0], gloss: c[1] || "", note: c[2] || "" });
}));
if (acc.length) words = acc;
}
let task = null;
if (t) {
const r = rows(t[2]);
if (t[1] === "translate") task = { type: "translate", items: r.map(q => ({ q })) };
/* recall: English prompt, the student WRITES the 한글. The one task type
that proves memory rather than recognition — and the only one whose
answers need letter-level marking, see hangul.js letterCheck(). */
if (t[1] === "recall") task = { type: "recall", items: r.map(l => { const c = cols(l);
return { q: c[0], hint: c[1] || "" }; }).filter(x => x.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) };
if (task) { task.retracted = ts.length - 1; /* >0 means a draft was withdrawn */
task.rows = r; } /* raw rows, for the gate to read */
}
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] || "" });
if (gs.length) {
const blocks = [];
gs.forEach(g => {
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(); cur = null; } 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;
}
/* marking the tutor sends back: per-item outcomes, and words he certifies */
const rs = all(text, RE.result), cs = all(text, RE.confirmed);
const results = rs.length ? rows(rs[rs.length - 1][1]).map(l => {
const c = cols(l);
return { item: c[0], ok: /^ok$/i.test(c[1] || ""), mistakenFor: c[2] || "" };
}).filter(x => x.item) : null;
const confirmed = cs.length ? rows(cs[cs.length - 1][1]).map(l => cols(l)[0]).filter(Boolean) : null;
/* strip every block, not only the matched one */
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], "");
[RE.progress, RE.gloss, RE.task, RE.words, RE.result, RE.confirmed].forEach(re => {
body = body.replace(new RegExp(re.source, "g"), "");
});
body = body.replace(/^[ \t]*::[ \t]*$/gm, "");
return {
body: body.replace(/\n{3,}/g, "\n\n").trim(),
words, task, gloss,
words, task, gloss, results, confirmed,
progress: p ? { score: Math.max(0, Math.min(100, +p[1])), note: (p[2] || "").trim() } : null,
};
}
@@ -65,10 +115,20 @@ export const ROLES = {
Q: "인용 quotation", M: "수식 modifier", N: "",
};
/** Turn a completed task back into the message the student sends. */
export function answerText(task, state, lookups = []) {
/**
* Turn a completed task back into the message the student sends.
*
* `letterBlock` is the output of hangul.js letterCheck() for a recall task —
* pass it, always. The tutor cannot see inside a Hangul syllable and will
* invent a diagnosis if you leave him to it.
*/
export function answerText(task, state, lookups = [], letterBlock = "") {
let body;
if (task.type === "translate")
if (task.type === "recall")
body = "My written answers:\n" + task.items.map((it, i) =>
`${it.q}${(state[i] || "").trim() || "(not sure)"}`).join("\n")
+ (letterBlock ? "\n\n" + letterBlock : "");
else 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")

View File

@@ -97,3 +97,66 @@ export function surfaceForms(dict, gloss) {
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;
}

View File

@@ -70,3 +70,135 @@ export function renderGate(g) {
L.push("BRING BACK ON PURPOSE — he met these earlier and they are due for reuse here:", g.revisits.join(" · "));
return L.join("\n");
}
/* ══════════════════════════════════════════════════════════════════════
ENFORCING THE GATE
══════════════════════════════════════════════════════════════════════
Everything above tells the tutor what he may use. This half checks
whether he listened, because he does not reliably. Two real failures
from the artifact's transcript:
· 좁아 appeared in a review exercise. The word is in no unit, no deck
and nowhere in the prompt. The student tapped it and got a blank.
· 안 (not) appeared in a Phase 1 exercise, declared in a ::words
block. It is a real word belonging to a later unit, and the old
check only asked "does the app know this word" — never "has he been
taught it". The tutor himself admitted, one message later, that it
was not on the roadmap yet.
THE SCOPE IS THE WHOLE DESIGN, AND IT WAS MEASURED, NOT REASONED.
Checking his prose against the allowed vocabulary was run over all 41 of
his real messages: it would have rejected 17 of them, 41%. Nearly every
hit was a PRONUNCIATION — "국물 is read as → 궁물". 궁물 is not a word and
never will be; it is how a word sounds, and sound is the whole of Phase
1. Others were misreadings quoted on purpose (감사함니다) to contrast
with the right form. Nothing separates those from a stray word by
spelling alone, and a tutor who cannot write 궁물 cannot teach 비음화.
So the gate reads only the side of an exercise the student must decode.
Re-measured with that scope: 1 of 41, and that one is a true positive.
KEEP A CORPUS OF REAL TUTOR MESSAGES AS A FIXTURE and re-run the audit
whenever this file changes (see fixtures/ and audit-gate.mjs). A
vocabulary gate that has not been measured against real output will be
far too aggressive; 41% is not a near miss. */
const HANGUL_RUN = /[가-힣]+/g;
/** Words that belong in a teacher's prose without being taught vocabulary:
* unit and phase names, and the metalanguage of the course itself.
* Rejecting a message for saying 받침 is worse than the problem solved. */
export function buildScaffold(curriculum) {
const set = new Set();
const add = s => (String(s || "").match(HANGUL_RUN) || []).forEach(w => set.add(w));
curriculum.phases.forEach(p => { add(p.ko); add(p.name); p.units.forEach(u => { add(u.ko); add(u.name); }); });
(`한글 한국어 한국말 선생님 학생 반말 존댓말 높임말 말투
자음 모음 받침 겹받침 음절 글자 낱말 단어 문장 어절 띄어쓰기
연음 비음화 격음화 경음화 구개음화 유음화 탈락 된소리 거센소리 예사소리 소리
조사 어미 어간 동사 형용사 명사 대명사 부사 관형사 수사 감탄사 조동사
의성어 의태어 수업 복습 다지기 힌트 제출 정답 오답 문제 연습 예문 보기
주어 목적어 서술어 주제 자리 이름 뜻 읽기 쓰기 듣기 말하기 때 것 거 수
네 아니요 그래 맞아 처럼 같이`).split(/\s+/).forEach(w => w && set.add(w));
return set;
}
/** Only the side of an exercise the student has to decode. The answer side
* is where readings live, and it is deliberately not gated. */
export function taskMaterial(task) {
if (!task) return [];
return (task.rows || []).map(r => {
const c = String(r).split("|");
if (task.type === "translate") return r; // the line to read
if (task.type === "build") return c.slice(1).join(" "); // the chips
if (task.type === "match" || task.type === "choice") return c[0] || "";
return ""; // recall prompts are English
});
}
/**
* @param parsed a parsed tutor message (see blocks.js)
* @param ctx { allowed:Set<string>, scaffold:Set<string>,
* heads:(token)=>string[] every dictionary word this
* surface could be, best first,
* unitOf:(word)=>string the unit that introduces it }
* @returns [{ word, unit, known }] known=false means no gloss exists at all
*/
export function scanTask(parsed, ctx) {
const body = taskMaterial(parsed && parsed.task).join(" ")
.replace(/\[[^\]]*\]/g, "") // 학교 [학꾜] — a sound, not a word
.replace(/\([^)]*\)/g, "");
// Hangul in the MEANING or NOTE of a ::words entry is a reading he is
// quoting ("국물 | soup broth | read 궁물"), not a word he is using.
// The headword itself is NOT exempt: declaring a word does not license it.
const readings = new Set();
((parsed && parsed.words) || []).forEach(w =>
((`${w.note || ""} ${w.gloss || w.m || ""}`).match(HANGUL_RUN) || []).forEach(t => readings.add(t)));
const seen = new Set(), out = [];
(body.match(HANGUL_RUN) || []).forEach(tok => {
if (seen.has(tok)) return;
seen.add(tok);
if (ctx.scaffold.has(tok) || readings.has(tok)) return;
const heads = ctx.heads(tok);
if (!heads.length) { out.push({ word: tok, unit: "", known: false }); return; }
// allowed if ANY route to a dictionary word is allowed: 닭이 is 닭 with
// a particle, 넓어 is 넓다. Stopping at the first route misreported both.
if (heads.some(h => ctx.scaffold.has(h) || ctx.allowed.has(h))) return;
out.push({ word: tok, unit: heads.map(ctx.unitOf).find(Boolean) || "", known: true });
});
return out;
}
/** The second rule, and far easier to be sure about: he must EXPLAIN in
* English. Korean inside a block or in brackets is material; Korean in the
* prose around it is the failure. Fires on exactly the one message in the
* 41-message corpus that deserved it. */
export function proseIsKorean(text) {
const prose = String(text || "")
.replace(/::(task|words|gloss|result|confirmed|progress)[\s\S]*?(?:\n::|$)/g, " ")
.replace(/\[[^\]]*\]/g, " ");
const ko = (prose.match(/[가-힣]/g) || []).length;
const la = (prose.match(/[A-Za-z]/g) || []).length;
return ko >= 40 && ko / ((ko + la) || 1) >= 0.5;
}
/** What to tell the tutor when a message is sent back. The two severities
* need different fixes, so they are reported separately. */
export function rejectionNote(findings) {
const unknown = findings.filter(f => !f.known).map(f => f.word);
const early = findings.filter(f => f.known);
const L = [];
if (unknown.length)
L.push(`${unknown.join(" · ")} — the app has no gloss for ${unknown.length > 1 ? "these" : "this"} at all, ` +
`so he taps the word and gets nothing. Either a typo, or a word that exists nowhere in his course.`);
if (early.length)
L.push(`${early.map(f => f.word + (f.unit ? ` (belongs to unit ${f.unit})` : " (not on his list)")).join(" · ")}` +
`real ${early.length > 1 ? "words" : "word"}, but from a unit he has not reached. Declaring ` +
`${early.length > 1 ? "them" : "it"} in a ::words block does NOT make ${early.length > 1 ? "them" : "it"} ` +
`allowed; the roadmap decides that, not you.`);
return L.join("\n");
}
/** Bounded retry. A student stuck behind a tutor that cannot satisfy the
* checker is worse than a bad word, so after GATE_TRIES the message is
* shown anyway with the words flagged in the UI. */
export const GATE_TRIES = 2;

View File

@@ -123,3 +123,78 @@ export const KEYBOARD = {
],
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");
}

108
lib/lexicon.js Normal file
View File

@@ -0,0 +1,108 @@
/* ══════════════════════════════════════════════════════════════════════
THE LEXICON — every surface form the app can gloss
══════════════════════════════════════════════════════════════════════
Two jobs, and they must share one resolver or they drift apart:
· the student taps a word and gets its meaning
· the gate asks whether the tutor was allowed to use it
Both need the same question answered: which dictionary word is this
surface form? Get it wrong in the first and the student is told a word he
was taught is "not in the word list". Get it wrong in the second and 닭이
is reported as an unknown word when it is just 닭 with a particle. Both
happened.
An entry carries `base` when it is a surface form of something else, and
resolution returns EVERY route, best first — the caller decides which it
needs. A single "best" answer was the bug. */
import { deconjugateCandidates } from "./conjugation.js";
export const PARTICLES = ["이랑","에서","에게","한테","으로","부터","까지","보다","처럼","같이",
"까","은","는","이","가","을","를","도","만","에","와","과","랑","의","로"];
export class Lexicon {
constructor() { this.map = new Map(); this.verbs = new Set(); }
/** @param base the dictionary word this is a form of, if any */
add(ko, gloss, note = "", src = "deck", base = "") {
if (!ko || this.map.has(ko)) return;
this.map.set(ko, { ko, gloss, note, src, base });
}
addVerb(dictionaryForm) { if (/다$/.test(dictionaryForm)) this.verbs.add(dictionaryForm); }
get(ko) { return this.map.get(ko) || null; }
isVerb(ko) { return this.verbs.has(ko); }
/** Every dictionary word this surface form could be, best first. */
heads(token) {
const out = [], push = x => { if (x && !out.includes(x)) out.push(x); };
const e = this.get(token);
if (e) { push(token); push(e.base); }
for (const p of PARTICLES) {
if (token.length > p.length && token.slice(-p.length) === p) {
const b = token.slice(0, -p.length), eb = this.get(b);
if (eb) { push(b); push(eb.base); }
}
}
for (const d of deconjugateCandidates(token))
if (this.verbs.has(d) && this.get(d)) push(d);
return out;
}
/** What to show when the student taps a word. */
lookup(token) {
const direct = this.get(token);
if (direct) return direct;
for (const p of PARTICLES) {
if (token.length > p.length && token.slice(-p.length) === p) {
const e = this.get(token.slice(0, -p.length));
if (e) return { ...e, ko: token, note: (e.note ? e.note + " · " : "") + `with 조사 ${p}` };
}
}
for (const d of deconjugateCandidates(token)) {
if (!this.verbs.has(d)) continue;
const e = this.get(d);
if (e) return { ...e, ko: token, note: (e.note ? e.note + " · " : "") + `a form of ${d}` };
}
return null;
}
}
/** Build it from the shipped data. Expand verbs and adjectives into their
* surface forms at load: it is cheap and it is what makes 앉아 resolve. */
/**
* ORDER MATTERS, and it changes what the gate permits.
*
* A word the roadmap schedules for a specific unit must be entered FIRST,
* with no `base`. 마셔 belongs to unit 2.3. If the deck is expanded first,
* 마셔 is created as a surface form of 마시다 and inherits that stem's
* permission — so a unit-2.3 word silently becomes legal in Phase 1 because
* its dictionary form happens to be a card the student has met. That is a
* real difference: it is four messages' worth of violations in the fixture
* corpus, and it is invisible unless you run audit-gate.mjs.
*
* The roadmap decides when a form may appear. Enter it first; let the deck
* expansion fill in only what the roadmap has not already claimed.
*/
export function buildLexicon({ roadmapWords = [], deck = [], glossExtra = [], sentences = [], sfx = [] } = {},
{ haeche, past }) {
const lex = new Lexicon();
roadmapWords.forEach(w => { lex.add(w, w, "", "roadmap"); if (/다$/.test(w)) lex.addVerb(w); });
const addWord = (ko, gloss, pos) => {
lex.add(ko, gloss, "", "deck");
if (pos !== "verb" && pos !== "adj" || !/다$/.test(ko)) return;
lex.addVerb(ko);
const p = haeche(ko);
if (!p) return;
const g = String(gloss).replace(/^to be /, "").replace(/^to /, "");
lex.add(p, g, `반말, from ${ko}`, "form", ko);
lex.add(p + "요", g, `polite, from ${ko}`, "form", ko);
const q = past(p);
if (q) lex.add(q, g + " (past)", `반말 past, from ${ko}`, "form", ko);
};
deck.forEach(w => addWord(w.ko || w[0], w.en || w[2], w.pos || w[3]));
glossExtra.forEach(g => lex.add(g[0], g[1], g[2] || "", "gloss"));
sentences.forEach(s => (s.parts || []).forEach(p => lex.add(p[0], p[1], "seen in a sentence", "sentence")));
sfx.forEach(f => lex.add(f[0], f[1], "의성어 · 의태어", "sfx"));
return lex;
}

View File

@@ -44,3 +44,45 @@ export function preview(card, g, today) {
/** Local day number, DST-safe. */
export const dayNumber = (d = new Date()) =>
Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 864e5);
/* ── Recall evidence, kept separate from the schedule ──────────────────
The SRS above decides WHEN to show a card. This decides whether the
student actually knows the word, and the two must not be conflated.
The artifact's tutor kept certifying words on a single correct answer.
Getting a word right once proves nothing: it may be a guess, or it may
still be in the student's head from the line above. So a word counts as
learned only after three corrects, in three separate rounds, spread over
at least five rounds — which forces at least one genuine recall rather
than an echo. A lookup resets the streak: answering correctly after
looking the word up is not recall.
Enforce this in the CLIENT. The prompt asks the tutor to hold the line
and he does not; the artifact only stopped premature certification once
the app refused to store it. */
export const LEARNED_OK = 3, LEARNED_STREAK = 2, LEARNED_SPAN = 5;
export const newEvidence = () => ({
ok: 0, wrong: 0, lookups: 0, streak: 0,
firstRound: 0, lastRound: 0, lastSeen: 0, rounds: 0,
});
/** @param outcome "ok" | "wrong" @param round monotonic round counter */
export function noteOutcome(ev, outcome, round, lookedUp) {
const e = { ...ev };
if (e.firstRound === 0) e.firstRound = round;
if (e.lastSeen !== round) { e.rounds++; e.lastSeen = round; }
e.lastRound = round;
if (lookedUp) { e.lookups++; e.streak = 0; return e; } // not recall
if (outcome === "ok") { e.ok++; e.streak++; }
else { e.wrong++; e.streak = 0; }
return e;
}
export const isLearned = ev =>
ev.ok >= LEARNED_OK && ev.streak >= LEARNED_STREAK &&
ev.rounds >= LEARNED_OK && (ev.lastRound - ev.firstRound) >= LEARNED_SPAN;
/** Reject a tutor's ::confirmed for a word the evidence does not support. */
export const acceptConfirmation = ev => isLearned(ev);

115
lib/sync.js Normal file
View File

@@ -0,0 +1,115 @@
/* ══════════════════════════════════════════════════════════════════════
SYNCING BETWEEN DEVICES — three gates, each paid for in lost user data
══════════════════════════════════════════════════════════════════════
The artifact shipped without these twice and destroyed real work twice.
Last-write-wins is adequate for one user whose worst conflict is a
duplicated SRS grade — but ONLY with all three of the following.
1. HYDRATION. A client may not push a document until the server has told
it what it already holds. This is the one that caused the loss: a
laptop with a week-old local copy ran a boot-time migration, which
re-stamped that stale copy with the current time and pushed it. A
boot-time write always looks like the newest edit in the world. A
week of phone work — chat and roadmap both — was gone. Note that rule
2 would NOT have saved it: the data was real, just old.
2. A COUNTER, NOT A CLOCK. Phone and laptop clocks disagree by minutes.
Compare a monotonic per-document counter; fall back to the timestamp
only when one side has none (an older client).
3. NO SILENT SHRINKING. A copy holding strictly less than the local one —
fewer chat turns, fewer finished units, fewer cards — is never adopted
by accident: keep the local copy and push it back. Deliberate
deletions set a flag and are obeyed. This is what made the loss
recoverable: the phone still had the real chat, refused the truncated
server copy, and restored it.
And: MIGRATIONS RUN AFTER THE FIRST PULL, NEVER AT BOOT, and never
rewrite history. The trigger for the whole incident was a one-shot repair
that cleared the chat. It was correct for the device it was written for
and a loaded gun for every other one. */
export const DOCS = ["srs", "log", "meta", "chat"];
/** How much a copy holds. Shrinking is always deliberate, never a race. */
export function weigh(name, d) {
if (!d) return 0;
if (name === "chat") return (d.turns || []).length;
if (name === "meta") return d.road && d.road.done ? Object.keys(d.road.done).length : 0;
if (name === "srs") return d.cards ? Object.keys(d.cards).length : 0;
if (name === "log") return d.days ? Object.keys(d.days).length : 0;
return 0;
}
/** Is the copy that just arrived later than ours? */
export function isLater(remote, localVersion, localStamp) {
if (typeof remote.v === "number" && localVersion > 0) {
if (remote.v !== localVersion) return remote.v > localVersion;
return typeof remote.u === "number" && remote.u > localStamp;
}
return typeof remote.u === "number" && remote.u > localStamp;
}
/**
* Decide what to do with an incoming document.
* @returns "ignore" | "adopt" | "reassert"
* reassert = ours holds more and nothing said the shrink was
* deliberate, so keep ours, bump past theirs, and push it back.
*/
export function reconcile(name, remote, local) {
if (!remote || !remote.d) return "ignore";
if (!isLater(remote, local.version || 0, local.stamp || 0)) return "ignore";
if (weigh(name, local.data) > weigh(name, remote.d) && !remote.x) return "reassert";
return "adopt";
}
/**
* The write side. Hold every edit until the document has been hydrated;
* an edit made before then keeps its old stamp so a newer server copy
* still wins.
*/
export function makeWriter({ push, now = () => Date.now() }) {
const state = {}; // name -> {version,stamp,dirty,hydrated,intent}
const S = n => (state[n] = state[n] || { version: 0, stamp: 0, dirty: false, hydrated: false, intent: false });
return {
state,
/** a normal user edit */
touch(name, deliberateShrink = false) {
const s = S(name);
s.dirty = true;
if (deliberateShrink) s.intent = true;
if (s.hydrated) { s.stamp = now(); s.version += 1; } // otherwise: hold
return s;
},
/** call when the first snapshot for this document arrives (or is known absent) */
hydrate(name) {
const s = S(name);
if (s.hydrated) return s;
s.hydrated = true;
if (s.dirty) { s.stamp = now(); s.version += 1; } // the held edit is real after all
return s;
},
adopted(name, remote) {
const s = S(name);
s.stamp = typeof remote.u === "number" ? remote.u : now();
s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0);
s.dirty = false; s.intent = false;
return s;
},
reasserted(name, remote) {
const s = S(name);
s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0) + 1;
s.stamp = now(); s.dirty = true;
return s;
},
flush(name, data) {
const s = S(name);
if (!s.dirty || !s.hydrated) return null;
s.dirty = false;
const body = { u: s.stamp, v: s.version, d: data };
if (s.intent) { body.x = 1; s.intent = false; }
return push(name, body);
},
};
}

View File

@@ -13,6 +13,7 @@
},
"scripts": {
"validate": "node validate.mjs",
"audit": "node audit-gate.mjs",
"dict:fetch": "node tools/dict/fetch.mjs",
"dict:build": "node tools/dict/build.mjs",
"dict:assert": "node tools/dict/assert-roadmap.mjs",
@@ -24,7 +25,7 @@
"build": "npm run build -w app",
"preview": "npm run preview -w app",
"cap:sync": "npm run cap:sync -w app",
"check": "npm run validate && npm run typecheck && npm run test && npm run dict:assert"
"check": "npm run validate && npm run audit && npm run typecheck && npm run test && npm run dict:assert"
},
"devDependencies": {
"@eslint/js": "^9.17.0",

View File

@@ -13,16 +13,33 @@ You are 선생님, a Korean reading tutor built into the student's own study app
HIS GOAL: read Korean manhwa. Reading and decoding meaning ONLY. Never drill pronunciation production, handwriting or conversation.
WHAT HE BRINGS
- Romanization is fully retired. NEVER write romanization or IPA — not once, not as a hint. 한글 and English only. For a spoken form use 한글 in brackets: 학교 [학꾜].
- LANGUAGE — everything you SAY is in English. Explanations, marking, corrections, instructions, encouragement: English. Korean appears only as the MATERIAL — the words and lines of an exercise, a form you are quoting, a unit name. He is a beginner who cannot yet read a Korean sentence; an explanation written in Korean is not a harder lesson, it is no lesson at all. This holds no matter how much Korean is in the unit, in the word lists, or in this prompt.
Romanization is fully retired. NEVER write romanization or IPA — not once, not as a hint. 한글 and English only. For a spoken form use 한글 in brackets: 학교 [학꾜].
- Register: manhwa is written in 반말 — teach 먹어, 가, 좋아, 안 돼, not 해요/합니다, until the roadmap says otherwise.
{{GATE}}
════ PRE-FLIGHT CHECK — DO THIS BEFORE WRITING EVERY EXERCISE ════
Go through your exercise word by word and ask of each: does this trace to the KNOWS list, to this unit's own additions, or to this unit's new-word list? If not, remove it. Check the same way for phenomena — a final consonant sliding into the next block, a nasalised ending, a double batchim, an irregular verb, a particle, a tense marker. Do not include something because he has probably seen it; include it only if it is listed.
Compose the exercise in your head and check it BEFORE you type the ::task block, not after. Go through it word by word and ask of each: does this trace to the KNOWS list, to this unit's own additions, or to this unit's new-word list? If not, replace it. Check the same way for phenomena — a final consonant sliding into the next block, a nasalised ending, a double batchim, an irregular verb, a particle, a tense marker. Do not include something because he has probably seen it; include it only if it is listed.
NEVER write a ::task block and then retract it in the same message. The client shows him only the FINAL block, so he never sees the draft you withdrew — which means a line like "here is the actual exercise" refers to something he cannot see and just confuses him. Check first, then write once. If you do catch yourself mid-block, write the corrected block and say nothing about the draft.
NEVER open a message with a block either. Every message starts with prose — one line is enough: what this round is practising, or what you noticed in his last answer. A message that begins with ::task renders as an exercise with nothing above it.
That opening line is not optional when you correct yourself: keep the introduction you would have written anyway, and put the corrected exercise under it. A correction must never replace the lead-in.
If you notice mid-lesson that you have already used something ungated: say so plainly in one clause, drop it, and carry on. Never build a justification for why it was acceptable.
════ EVERY LINE MUST MEAN SOMETHING ════
An exercise sentence has to be something a person could actually say, or a line that could sit in a manhwa panel. Ask of each one: who would say this, and when? If there is no answer, rewrite it.
Nonsense is worse than easy. 곰 물 마셔 — "the bear drinks water" — is not practice, it is noise: he cannot use meaning to check his own reading, and a wrong answer teaches him nothing. The same goes for a bear drinking rice, a book eating, a door that is sad.
The gate is a floor, not a licence. When the allowed words will not combine into anything sensible, do NOT pad the line out with a random noun. Instead: use fewer words (two-word lines and bare predicates are perfectly good Korean — 몰라, 배고파, 학교 가), reuse a word from an earlier unit, or switch the exercise type to matching or choice, which need no sentence at all.
Check plausibility as well as grammar. Animals do not drink coffee, children do not go to the office, and a person is not "read". A sentence can be simple, odd, or funny — 토끼 커 is fine — but it must be a thing that could be true.
════ SCOPE DISCIPLINE ════
This unit's goal is narrow on purpose. Every exercise tests THAT and nothing wider. When he is answering correctly, do not widen the scope to keep it interesting — go faster, or go deeper inside the same rule, or use the same rule on less familiar words. Edge cases, exceptions and "what about…" variants belong to whichever unit owns them. Silently expanding scope is the failure mode to avoid.
@@ -40,7 +57,7 @@ The first message of a unit is a LESSON, not a warm-up, and it is the one place
- He leans on the word list too much and cannot yet read an unfamiliar sentence unaided. His answers tell you which words he looked up. Words he keeps looking up are the ones to build the next exercise from; words he never looks up can be used freely and should be.
════ EXERCISES — THE DEFAULT, NOT THE EXCEPTION ════
The app renders four kinds of exercise as real interactive UI. EVERY teaching message must end with exactly one task block. Only skip it when he asked a direct question that wants a plain answer. Never describe the mechanics and never ask him to type answers into the chat box when a task block would do.
The app renders five kinds of exercise as real interactive UI. EVERY teaching message must end with exactly one task block. Only skip it when he asked a direct question that wants a plain answer. Never describe the mechanics and never ask him to type answers into the chat box when a task block would do.
{{VARIETY}}
@@ -50,6 +67,12 @@ Typing — he types an English translation per line:
학교 작아
::
Recall — English prompt, and he WRITES the 한글 with the app's on-screen keyboard. The one type that proves memory rather than recognition: he cannot pick from a list. Use it regularly. Second field is an optional hint:
::task recall
chicken | double batchim
the sea
::
Matching — he pairs Korean with meanings, 68 pairs:
::task match
친구 | friend
@@ -68,6 +91,30 @@ Choice — one pick per line. Best for a contrast: which particle, which ending,
His answers come back as one message. Mark them the normal way.
════ NEVER WORK OUT A SPELLING MISTAKE YOURSELF ════
A Hangul syllable reaches you as ONE character. You cannot see the letters inside it, and when you try you get it backwards. This really happened: a student wrote 빫다 for 짧다 and was told the ㄼ batchim was wrong — the ㄼ was identical in both and the only slip was the first consonant, ㅃ where 짧다 has ㅉ. A whole explanation was built on the one part he had got right.
So do not do it. Whenever he writes 한글, the app computes the real jamo comparison and hands it to you under LETTER-LEVEL CHECK, spelling both words out:
• to be short (dictionary form)
wanted 짧다 [짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]
wrote 빫다 [빫=ㅃ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]
syllable 1 — WRONG: first consonant: wrote ㅃ, should be ㅉ — CORRECT, do not call these mistakes: vowel ㅏ, batchim ㄼ
Use that and only that. Name the slot it names, and NEVER call a letter wrong that it lists as correct. If an answer arrives without a LETTER-LEVEL CHECK, say the word was wrong and give the correct spelling — do not speculate about which letter caused it.
════ THE CLIENT ENFORCES THE GATE — YOUR MESSAGE CAN BE REFUSED ════
The vocabulary list above is not advice. Before the student sees a message, the app scans the side of your exercise he has to decode — the line to translate, the left column of a match, the chips of a build — and if it contains a word he is not allowed to meet, the message is DISCARDED and you are asked to write it again. He never sees the rejected version, so do not refer to it.
Two things get a message refused:
- A word with no gloss anywhere. He taps it and gets nothing.
- A real word from a unit he has not reached. DECLARING IT IN A ::words BLOCK DOES NOT LICENSE IT. The roadmap decides what he may meet, not you. This is the rule most often broken: 안 (not) was smuggled into a Phase 1 exercise inside a ::words block and the tutor admitted one message later that it was not on the roadmap yet.
- Writing your explanation in Korean. The prose around your blocks must be English.
A reading you are quoting stays fine — "국물 is read 궁물", or asking which word is read 감사함니다 — as long as the word it comes from is allowed and declared. Readings are material, not vocabulary.
If you think the roadmap genuinely needs a word earlier than it appears, say so in your prose. Then build the exercise without it.
════ COLOUR-CODED SENTENCE BREAKDOWN ════
Whenever you show a full sentence he has not seen worked through — always in a unit intro, always when correcting a sentence he misread, and whenever a new pattern first appears — add a gloss block. Roles: S subject, T topic, O object, V predicate, P place or time, C connective, Q quotation, M modifier. The fourth field is optional and highlights the meaningful piece INSIDE the word — the tense marker, the particle, the ending.
@@ -87,14 +134,32 @@ Any message containing Korean MUST end with a reference block:
::
List EVERY Korean form that appears in your message, exactly as written — every word of every exercise item, every word quoted in your prose, conjugated forms as they appear (먹어, not just 먹다), and words he already knows. One per line, in order of appearance. The app renders this as a panel beside the chat, so do NOT repeat the glosses in your prose.
════ MARKING — REQUIRED whenever he answered an exercise ════
After your prose marks, list every item with its outcome, one per line:
::result
닭 | ok
여덟 | wrong | 여덜
::
Second field is ok or wrong. Third field, on a wrong answer only, is what you think he mistook it for — the plausible near-miss, not just "incorrect". The app uses this to keep his recall record, schedule the word again, and choose what you practise next, so mark EVERY item including the ones he got right.
════ HOW TO TEST A WORD ════
Getting a word right once proves nothing: it may be a guess, or still in his head from the line above. Never re-test a word in the message you just showed it in, and never certify on a single pass. The app keeps the count and REFUSES a ::confirmed it does not have evidence for — three corrects, in three separate rounds, at least five rounds apart, none of them after a lookup.
MIX THE CLASSES. A round of six nouns is a weak round: put verbs, adjectives, nouns and function words in the same exercise so he cannot lean on one kind of answer.
PIT SIMILAR WORDS AGAINST EACH OTHER. The gate tells you which words are one jamo apart — 발 and 밤, 눈 and 분. Put the pair in the same round. If he can only tell them apart when separated, he is reading the shape, not the letters.
NEVER SWAP IN EASIER WORDS to produce a clean pass. If he keeps missing a word, that word is the exercise.
════ REPORTING PROGRESS — REQUIRED whenever he answered an exercise ════
::progress 0-100 | one short clause on what is or isn't landing
Your honest read of how well he has THIS UNIT. Move it gradually — a good round is a few points, not thirty. Under 60: keep drilling the basics. 6084: he mostly has it. 85+: ready to move on, and the app offers him the next unit — do not offer that in your prose.
DO NOT write a ::progress line on a message where he has not just answered an exercise. A unit introduction NEVER carries one: he has done nothing yet, so there is nothing to read. Carrying the previous unit's number into a new one is the specific mistake to avoid — each unit is scored from zero on its own evidence. The client clamps this anyway: it ignores progress until he has answered at least once in the unit, and caps the rise per message.
════ FORMAT ════
Plain text. **bold** is the only markup. No headings, tables, code fences or bullet characters.
BLOCK ORDER: gloss blocks inline where you refer to them; then at the END, the task block, then the words block, then the progress line. Nothing after them.
BLOCK ORDER: gloss blocks inline where you refer to them; then at the END, the task block, then the words block, then the progress line. Nothing after them. Exactly ONE task block per message.
He types Korean with an on-screen 한글 keyboard built into the app, so asking him to write a short Korean answer is fine when it tests reading.
{{FOCUS}}
LANGUAGE, ONCE MORE: you write to him in ENGLISH. Korean is the subject you are teaching, not the language you teach in. If a paragraph of yours could not be read by someone on unit 1.10, it is wrong.

5
run-checks.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
# Both checks. Non-zero exit on any failure — wire this into CI.
set -e
echo "── curriculum ──"; node validate.mjs
echo; echo "── word gate ──"; node audit-gate.mjs

View File

@@ -4,12 +4,12 @@
sentences appeared as prose above the exercise those same lines had
already been rendered into.
It is lib/blocks.js, not the model. parse() removes ::words by
truncating the body at its index, then removes ::task by substring
but RE.task's terminator (?:\n::|$) is part of the match, so t[0] ends
with the "\n::" of the following ::words that the truncation just cut
off. The substring no longer occurs, replace() is a no-op, and the whole
task block stays in the body.
It is lib/blocks.js, not the model. parse() deletes each block with a
regex whose terminator (?:\n::|$) is part of the match, so deleting one
block also deletes the "::" that opens the next. That block is then no
longer a block, and stays in the body as a bare word plus its rows. The
16 Sep lib changed which block survives — it now strips every kind in
turn instead of truncating at ::words — but not the defect.
Order is what decides it. The stub tutor emits ::words before ::task and
is therefore fine; a real model emitted ::task first. Nothing in the
@@ -33,15 +33,17 @@ function translateItems(task: ParsedMessage["task"]): { q: string }[] {
const CAPTURED = "::task translate\n\ub098 \uac00\ub2e4 \n\ub108 \uba39\ub2e4 \n\uc6b0\ub9ac \ub9c8\uc2dc\ub2e4 \n\uc774 \uc790\ub2e4 \n\uadf8 \ub098\ubb34 \uc11c\ub2e4 \n\n::words\n\ub098 | I, me (casual) | pron \n\uac00\ub2e4 | to go | verb (plain) \n\ub108 | you (casual) | pron \n\uba39\ub2e4 | to eat | verb (plain) \n\uc6b0\ub9ac | we, our | pron \n\ub9c8\uc2dc\ub2e4 | to drink | verb (plain) \n\uc774 | this | det \n\uc790\ub2e4 | to sleep | verb (plain) \n\uadf8 | that (near you) | det \n\ub098\ubb34 | tree | noun \n\uc11c\ub2e4 | to stand | verb (plain) \n\n::progress 20";
describe("lib/blocks.js — the leak, pinned as it is", () => {
it("leaves the whole ::task block in body when ::task precedes ::words", () => {
it("leaves the de-coloned ::words block in body when ::task precedes ::words", () => {
const r = parse(CAPTURED);
// The blocks themselves parse correctly...
expect(r.task?.type).toBe("translate");
expect(translateItems(r.task)).toHaveLength(5);
expect(r.words).toHaveLength(11);
// ...and the body still carries the markup that produced them.
expect(r.body).toContain("::task translate");
expect(r.body).toContain("나 가다");
// ...and the body carries the words block, its "::" eaten by the task's
// terminator: not even recognisable as markup any more.
expect(r.body).toMatch(/^words\n/);
expect(r.body).toContain("나 | I, me (casual) | pron");
expect(r.body).not.toContain("::");
});
it("is fine in the other order, which is why the stub never showed it", () => {

View File

@@ -15,22 +15,42 @@ describe("parse — ::words", () => {
]);
});
it("truncates the body at ::words — the block is contractually last", () => {
it("strips a closed block without truncating what follows it", () => {
// The old parse() cut the body at ::words; every block is now removed
// on its own, so prose after a properly closed block survives.
const p = parse("Read this.\n::words\n밥 | rice\n::\ntrailing junk");
expect(p.body).toBe("Read this.");
expect(p.body).toBe("Read this.\n\ntrailing junk");
});
});
describe("parse — the four task types", () => {
it("translate", () => {
describe("parse — the five task types", () => {
it("translate — and every task carries its raw rows and a retraction count", () => {
const p = parse("Try these.\n::task translate\n우리 밥 먹어\n학교 작아\n::");
expect(p.task).toEqual({ type: "translate", items: [{ q: "우리 밥 먹어" }, { q: "학교 작아" }] });
expect(p.task).toEqual({
type: "translate",
items: [{ q: "우리 밥 먹어" }, { q: "학교 작아" }],
retracted: 0,
rows: ["우리 밥 먹어", "학교 작아"],
});
expect(p.body).toBe("Try these.");
});
it("recall — English prompt, optional hint in the second field", () => {
const p = parse("::task recall\nchicken | double batchim\nthe sea\n::");
expect(p.task).toEqual({
type: "recall",
items: [
{ q: "chicken", hint: "double batchim" },
{ q: "the sea", hint: "" },
],
retracted: 0,
rows: ["chicken | double batchim", "the sea"],
});
});
it("match", () => {
const p = parse("::task match\n친구 | friend\n물 | water\n::");
expect(p.task).toEqual({
expect(p.task).toMatchObject({
type: "match",
pairs: [
{ ko: "친구", gloss: "friend" },
@@ -41,7 +61,7 @@ describe("parse — the four task types", () => {
it("build — first field is the English, the rest are chips in order", () => {
const p = parse("::task build\nWe eat rice. | 우리 | 밥 | 먹어\n::");
expect(p.task).toEqual({
expect(p.task).toMatchObject({
type: "build",
items: [{ en: "We eat rice.", chips: ["우리", "밥", "먹어"] }],
});
@@ -49,16 +69,22 @@ describe("parse — the four task types", () => {
it("choice", () => {
const p = parse("::task choice\n나 학교 ___ 가 | 에 | 에서 | 을\n::");
expect(p.task).toEqual({
expect(p.task).toMatchObject({
type: "choice",
items: [{ q: "나 학교 ___ 가", options: ["에", "에서", "을"] }],
});
});
it("drops malformed rows rather than emitting half a task", () => {
// A choice needs more than one option; a match needs both sides.
expect(parse("::task choice\nonly a question\n::").task).toEqual({ type: "choice", items: [] });
expect(parse("::task match\n친구\n::").task).toEqual({ type: "match", pairs: [] });
// A choice needs more than one option; a match needs both sides. The raw
// rows are kept regardless — the gate reads those, not the parsed items.
expect(parse("::task choice\nonly a question\n::").task).toEqual({
type: "choice",
items: [],
retracted: 0,
rows: ["only a question"],
});
expect(parse("::task match\n친구\n::").task).toMatchObject({ type: "match", pairs: [] });
});
it("is null when there is no task block", () => {
@@ -66,6 +92,51 @@ describe("parse — the four task types", () => {
});
});
/* The tutor sometimes writes an exercise, notices it broke the gate, and
writes a corrected one below. Matching the FIRST block handed the student
the draft that had just been withdrawn. */
describe("parse — several blocks of one kind", () => {
const reply = [
"Draft:",
"::task translate",
"원 없어",
"::",
"Sorry, 원 is not his yet. Here:",
"::task translate",
"물 없어",
"::",
"::words",
"물 | water",
"::",
"::words",
"물 | WATER",
"없어 | there is none",
"::",
"::progress 30 | a",
"::progress 40 | b",
].join("\n");
it("takes the LAST task and counts the drafts it replaced", () => {
const p = parse(reply);
expect(p.task).toMatchObject({ type: "translate", items: [{ q: "물 없어" }], retracted: 1 });
});
it("accumulates words, the first gloss of a term winning", () => {
expect(parse(reply).words).toEqual([
{ ko: "물", gloss: "water", note: "" },
{ ko: "없어", gloss: "there is none", note: "" },
]);
});
it("takes the last progress line", () => {
expect(parse(reply).progress).toEqual({ score: 40, note: "b" });
});
it("strips every block from the body, not only the one it used", () => {
expect(parse(reply).body).toBe("Draft:\n\nSorry, 원 is not his yet. Here:");
});
});
describe("parse — ::gloss", () => {
it("reads parts and the = line, defaulting the role to N", () => {
const p = parse(
@@ -83,23 +154,17 @@ describe("parse — ::gloss", () => {
]);
});
/* KNOWN LIMITATION, pinned deliberately.
The system prompt tells the tutor it may put several sentences in one
::gloss block, separated by their = lines. parse() sets `en` on the
current block when it meets "=", but never closes the block, so the
parts of every sentence pile into one run-on line and only the last
translation survives.
lib/ ships unchanged, so this is not fixed here. The app splits a
::gloss block on its = lines and calls parse() once per sentence —
see app/src/domain/gloss.ts. If lib/blocks.js is ever revised, the
one-line fix is `cur = null` after setting `en`, and this test and
that workaround both go away. */
it("does NOT close a block on the = line (see gloss.ts for the workaround)", () => {
it("closes a sentence at its = line, so one block can gloss several", () => {
const p = parse("::gloss\n나 | S | I\n가 | V | go\n= I go.\n밥 | O | rice\n= Rice.\n::");
expect(p.gloss).toHaveLength(1);
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가", "밥"]);
expect(p.gloss![0]!.en).toBe("Rice."); // "I go." is lost
expect(p.gloss).toHaveLength(2);
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가"]);
expect(p.gloss![0]!.en).toBe("I go.");
expect(p.gloss![1]!.en).toBe("Rice.");
});
it("accumulates separate gloss blocks", () => {
const p = parse("::gloss\n나 | S | I\n= I.\n::\nand\n::gloss\n밥 | O | rice\n= Rice.\n::");
expect(p.gloss!.map((g) => g.en)).toEqual(["I.", "Rice."]);
});
it("defaults a missing role to N and an absent highlight to empty", () => {
@@ -108,6 +173,41 @@ describe("parse — ::gloss", () => {
});
});
describe("parse — ::result", () => {
it("reads item | outcome | mistaken-for", () => {
expect(parse("::result\n닭 | ok\n여덟 | wrong | 여덜\n::").results).toEqual([
{ item: "닭", ok: true, mistakenFor: "" },
{ item: "여덟", ok: false, mistakenFor: "여덜" },
]);
});
it("counts only a literal ok as correct", () => {
// The prompt asks for "ok or wrong". Anything else is not a pass.
expect(parse("::result\n값 | right\n::").results![0]!.ok).toBe(false);
expect(parse("::result\n값 | OK\n::").results![0]!.ok).toBe(true);
});
it("takes the last block", () => {
expect(parse("::result\n닭 | wrong\n::\n::result\n닭 | ok\n::").results).toEqual([
{ item: "닭", ok: true, mistakenFor: "" },
]);
});
it("is null when there is no result block", () => {
expect(parse("Nice.").results).toBeNull();
});
});
describe("parse — ::confirmed", () => {
it("reads the first column, keeping a leading minus", () => {
expect(parse("::confirmed\n연음\n-닭\n값 | extra\n::").confirmed).toEqual(["연음", "-닭", "값"]);
});
it("takes the last block", () => {
expect(parse("::confirmed\n연음\n::\n::confirmed\n닭\n::").confirmed).toEqual(["닭"]);
});
});
describe("parse — ::progress", () => {
it("reads the score and the note", () => {
const p = parse("Nice work.\n::progress 72 | particles are landing");
@@ -142,6 +242,9 @@ describe("parse — a full reply", () => {
"밥 | rice",
"먹어 | eat | from 먹다",
"::",
"::result",
"밥 | ok",
"::",
"::progress 64 | word order is solid",
].join("\n");
@@ -151,6 +254,7 @@ describe("parse — a full reply", () => {
expect(p.task!.type).toBe("translate");
expect(p.words).toHaveLength(2);
expect(p.gloss).toHaveLength(1);
expect(p.results).toHaveLength(1);
expect(p.progress!.score).toBe(64);
});
});
@@ -173,6 +277,16 @@ describe("answerText — the message the student sends back", () => {
);
});
it("recall — the letter-level block rides between the answers and the lookups", () => {
const task = parse("::task recall\nchicken | double batchim\nthe sea\n::").task!;
expect(answerText(task, ["닭", ""], ["닭"], "BLOCK")).toBe(
"My written answers:\nchicken → 닭\nthe sea → (not sure)\n\nBLOCK\n\n(I had to look up: 닭)",
);
expect(answerText(task, ["닭", "바다"], [])).toBe(
"My written answers:\nchicken → 닭\nthe sea → 바다\n\n(No lookups.)",
);
});
it("match", () => {
const task = parse("::task match\n친구 | friend\n물 | water\n::").task!;
const out = answerText(task, { pairs: [{ ko: "친구", gloss: "friend" }] }, []);
@@ -201,6 +315,7 @@ describe("answerText — the message the student sends back", () => {
it("round-trips: every task type parses and answers without throwing", () => {
const blocks: [string, TaskState][] = [
["::task translate\n밥\n::", ["rice"]],
["::task recall\nrice\n::", ["밥"]],
["::task match\n밥 | rice\n::", { pairs: [] }],
["::task build\nRice. | 밥\n::", [["밥"]]],
["::task choice\n___ | 밥 | 물\n::", [1]],

View File

@@ -12,6 +12,8 @@ import {
explain,
surfaceForms,
IRREGULAR_FORMS,
deconjugate,
deconjugateCandidates,
} from "@lib/conjugation.js";
describe("haeche — the 아/어 rule", () => {
@@ -142,3 +144,35 @@ describe("surfaceForms — the build-time index generator", () => {
expect(surfaceForms("학교", "school")).toEqual([]);
});
});
/* Reading an inflected form back to its dictionary entry. Without it every
one of 660 realistic inflections of the curriculum's verbs failed to
resolve, and the student was told a taught word was not in the list. */
describe("deconjugate", () => {
const verbs = new Set(["가다", "앉다", "먹다", "마시다", "좋다"]);
const isVerb = (d: string) => verbs.has(d);
it.each([
["갑니다", "가다"],
["먹습니다", "먹다"],
["앉으면", "앉다"],
["가고", "가다"],
["가면", "가다"],
["가네", "가다"],
["간다", "가다"],
["먹었어요", "먹다"],
["좋아서", "좋다"],
])("%s → %s", (form, dict) => {
expect(deconjugate(form, isVerb)).toBe(dict);
});
it("proposes every plausible dictionary form, unguarded", () => {
expect(deconjugateCandidates("갑니다")).toEqual(["가다", "갑니다"]);
expect(deconjugateCandidates("앉으면")).toEqual(["앉다", "앉으다"]);
});
it("accepts nothing the lexicon does not hold as a verb — 가지 is an eggplant", () => {
expect(deconjugateCandidates("가지")).toContain("가다");
expect(deconjugate("가지", () => false)).toBeNull();
});
});

View File

@@ -3,7 +3,19 @@
The nine cases named in the export README are all here. */
import { describe, it, expect } from "vitest";
import { Composer, compose, decompose, isJamo, CHO, JUNG, JONG, KEYBOARD } from "@lib/hangul.js";
import {
Composer,
compose,
decompose,
isJamo,
CHO,
JUNG,
JONG,
KEYBOARD,
spellOut,
letterDiff,
letterCheck,
} from "@lib/hangul.js";
/** Type a sequence of jamo into a fresh composer, returning the final value. */
function type(jamo: string[]): string {
@@ -132,3 +144,55 @@ describe("KEYBOARD", () => {
for (const base of Object.keys(KEYBOARD.shift)) expect(flatKeys.has(base), base).toBe(true);
});
});
/* A model cannot see the letters inside a syllable. Asked about 빫다 for
짧다 the artifact's tutor blamed the ㄼ — identical in both — when the slip
was the initial ㅉ→ㅃ. These are the strings the tutor is handed instead. */
describe("letter-level marking", () => {
it("spells a word out jamo by jamo, unpacking a double batchim", () => {
expect(spellOut("짧다")).toBe("짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ");
});
it("names the wrong slot AND the right ones — the prompt's own example", () => {
expect(letterDiff("짧다", "빫다")).toBe(
"syllable 1 (빫 for 짧) — WRONG: first consonant: wrote ㅃ, should be ㅉ — " +
"CORRECT, do not call these mistakes: vowel ㅏ, batchim ㄼ (ㄹ+ㅂ)",
);
});
it("catches a dropped half of a double batchim", () => {
expect(letterDiff("닭", "닥")).toBe(
"syllable 1 (닥 for 닭) — WRONG: batchim: wrote ㄱ, should be ㄺ (ㄹ+ㄱ) — " +
"CORRECT, do not call these mistakes: first consonant ㄷ, vowel ㅏ",
);
});
it("reports identity, a length mismatch, and says nothing about an empty side", () => {
expect(letterDiff("학교", "학교")).toBe("identical");
expect(letterDiff("바다", "바")).toBe("length differs: 2 syllables expected, 1 written");
expect(letterDiff("", "x")).toBe("");
});
it("builds the block a marking message carries, skipping correct answers", () => {
const block = letterCheck([
{ prompt: "to be short (dictionary form)", expected: "짧다", written: "빫다" },
{ prompt: "the sea", expected: "바다", written: "바다" },
]);
expect(block).toBe(
[
"════ LETTER-LEVEL CHECK — computed by the app ════",
"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.",
"• to be short (dictionary form)",
" wanted 짧다 [짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]",
" wrote 빫다 [빫=ㅃ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]",
" syllable 1 (빫 for 짧) — WRONG: first consonant: wrote ㅃ, should be ㅉ — " +
"CORRECT, do not call these mistakes: vowel ㅏ, batchim ㄼ (ㄹ+ㅂ)",
].join("\n"),
);
});
it("is empty when nothing differs, so no block is sent at all", () => {
expect(letterCheck([{ prompt: "the sea", expected: "바다", written: "바다" }])).toBe("");
});
});

123
test/lib/lexicon.test.ts Normal file
View File

@@ -0,0 +1,123 @@
/* Golden tests pinning lib/lexicon.js — the one resolver shared by word
lookup and the gate. It ships unchanged. */
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { buildLexicon, Lexicon, PARTICLES } from "@lib/lexicon.js";
import { haeche, past } from "@lib/conjugation.js";
import { flatten } from "@lib/gate.js";
import type { Curriculum } from "@lib/gate.js";
const conj = { haeche, past };
describe("ORDER MATTERS — roadmap words are entered first", () => {
/* 마셔 belongs to unit 2.3. Expanded from the deck first, it becomes a form
of 마시다 and inherits that stem's permission, so a unit-2.3 word turns
legal in Phase 1. In the fixture corpus that is four messages of
violations that stop being reported. */
it("keeps a scheduled form its own head", () => {
const lex = buildLexicon(
{ roadmapWords: ["마셔"], deck: [{ ko: "마시다", en: "to drink", pos: "verb" }] },
conj,
);
expect(lex.heads("마셔")).toEqual(["마셔"]);
expect(lex.get("마셔")).toMatchObject({ src: "roadmap", base: "" });
});
it("links it to the stem when the deck got there first", () => {
const lex = buildLexicon({ deck: [{ ko: "마시다", en: "to drink", pos: "verb" }] }, conj);
expect(lex.heads("마셔")).toEqual(["마셔", "마시다"]);
});
});
describe("heads — every route, best first", () => {
const lex = buildLexicon(
{
roadmapWords: ["닭"],
deck: [
{ ko: "마시다", en: "to drink", pos: "verb" },
{ ko: "친구", en: "friend", pos: "noun" },
],
glossExtra: [["이야", "it is"]],
sfx: [["쿵", "thud"]],
},
conj,
);
it("strips a particle", () => {
expect(lex.heads("닭이")).toEqual(["닭"]);
expect(lex.heads("친구가")).toEqual(["친구"]);
});
it("follows a generated form back to its stem", () => {
expect(lex.heads("마셨어")).toEqual(["마셨어", "마시다"]);
});
it("deconjugates a form nobody generated, but only onto a known verb", () => {
expect(lex.heads("마시면")).toEqual(["마시다"]);
});
it("returns nothing for a word it cannot place", () => {
expect(lex.heads("없는말")).toEqual([]);
});
});
describe("lookup — what the student sees on a tap", () => {
const lex = buildLexicon(
{ deck: [{ ko: "마시다", en: "to drink", pos: "verb" }, { ko: "친구", en: "friend", pos: "noun" }] },
conj,
);
it("notes the particle", () => {
expect(lex.lookup("친구가")).toMatchObject({ ko: "친구가", gloss: "friend", note: "with 조사 가" });
});
it("notes the dictionary form", () => {
expect(lex.lookup("마시면")).toMatchObject({ gloss: "to drink", note: "a form of 마시다" });
});
it("is null for an unknown word", () => {
expect(lex.lookup("없는말")).toBeNull();
});
});
describe("Lexicon", () => {
it("never replaces an existing entry — the first writer wins", () => {
const lex = new Lexicon();
lex.add("밥", "rice");
lex.add("밥", "meal");
expect(lex.get("밥")!.gloss).toBe("rice");
});
it("strips the longer particle forms", () => {
expect(PARTICLES).toEqual(expect.arrayContaining(["에서", "한테", "까지", "처럼"]));
});
});
describe("the shipped data", () => {
const read = (f: string) => JSON.parse(readFileSync(new URL(`../../data/${f}`, import.meta.url), "utf8"));
const curriculum = read("curriculum.json") as Curriculum;
const deck = read("deck.json") as { topics: Record<string, [string, string, string, string][]> };
const glossExtra = read("gloss-extra.json") as { entries: { ko: string; en: string; note?: string }[] };
const sentences = read("sentences.json") as { sentences: { parts: [string, string][] }[] };
const sfx = read("sfx.json") as { items: { ko: string; en: string }[] };
const units = flatten(curriculum);
const lex = buildLexicon(
{
roadmapWords: units.flatMap((u) => u.words ?? []),
deck: Object.values(deck.topics)
.flat()
.map(([ko, , en, pos]) => ({ ko, en, pos })),
glossExtra: glossExtra.entries.map((g) => [g.ko, g.en, g.note] as const),
sentences: sentences.sentences,
sfx: sfx.items.map((i) => [i.ko, i.en] as const),
},
conj,
);
it("resolves every roadmap word", () => {
const missing = units.flatMap((u) => u.words ?? []).filter((w) => !lex.lookup(w));
expect(missing).toEqual([]);
});
});

View File

@@ -19,6 +19,13 @@ import {
statusOf,
preview,
dayNumber,
LEARNED_OK,
LEARNED_STREAK,
LEARNED_SPAN,
newEvidence,
noteOutcome,
isLearned,
acceptConfirmation,
} from "@lib/srs.js";
const TODAY = 20_000;
@@ -168,3 +175,74 @@ describe("dayNumber", () => {
expect(c - b).toBe(1);
});
});
/* Recall evidence decides whether a word is KNOWN; the schedule above only
decides when to show it. The client refuses a ::confirmed it does not
support, because the tutor certified words on a single correct answer. */
describe("recall evidence", () => {
const run = (steps: [outcome: "ok" | "wrong", round: number, lookedUp?: boolean][]) =>
steps.reduce((e, [o, r, l]) => noteOutcome(e, o, r, l), newEvidence());
it("starts empty", () => {
expect(newEvidence()).toEqual({
ok: 0,
wrong: 0,
lookups: 0,
streak: 0,
firstRound: 0,
lastRound: 0,
lastSeen: 0,
rounds: 0,
});
expect([LEARNED_OK, LEARNED_STREAK, LEARNED_SPAN]).toEqual([3, 2, 5]);
});
it("is pure — the record passed in is not modified", () => {
const e = newEvidence();
noteOutcome(e, "ok", 1);
expect(e.ok).toBe(0);
});
it("never counts a lookup as recall, and a lookup resets the streak", () => {
const e = run([
["ok", 1],
["ok", 2],
["ok", 3, true],
]);
expect(e).toMatchObject({ ok: 2, lookups: 1, streak: 0, rounds: 3 });
});
it("learns three corrects in three rounds spanning five", () => {
const e = run([
["ok", 1],
["ok", 4],
["ok", 7],
]);
expect(isLearned(e)).toBe(true);
expect(acceptConfirmation(e)).toBe(true);
});
it("refuses three corrects crammed into consecutive rounds", () => {
expect(isLearned(run([["ok", 1], ["ok", 2], ["ok", 3]]))).toBe(false);
});
/* PINNED AS IT IS — lib is looser than PORT.md in two ways, and the app
enforces PORT.md's version at the call site (domain/turn.ts):
· several corrects in ONE round each count;
· the span runs from the first outcome of any kind, not the first
correct, so a wrong answer in round 1 lengthens it. */
it("counts every correct within a round", () => {
expect(run([["ok", 1], ["ok", 1]])).toMatchObject({ ok: 2, streak: 2, rounds: 1 });
});
it("measures the span from the first outcome, correct or not", () => {
const e = run([
["wrong", 1],
["ok", 4],
["ok", 5],
["ok", 6],
]);
expect(e).toMatchObject({ firstRound: 1, lastRound: 6 });
expect(isLearned(e)).toBe(true);
});
});

96
test/lib/sync.test.ts Normal file
View File

@@ -0,0 +1,96 @@
/* Golden tests pinning lib/sync.js — the three gates, as the artifact
applies them to four whole documents. The port syncs rows instead and
applies the same rules row by row; these pin the reference behaviour. */
import { describe, it, expect } from "vitest";
import { weigh, isLater, reconcile, makeWriter } from "@lib/sync.js";
import type { RemoteDoc } from "@lib/sync.js";
describe("weigh — how much a copy holds", () => {
it("counts turns, finished units, cards and days", () => {
expect(weigh("chat", { turns: [1, 2, 3] })).toBe(3);
expect(weigh("meta", { road: { done: { "1.1": 1 } } })).toBe(1);
expect(weigh("srs", { cards: { a: 1, b: 2 } })).toBe(2);
expect(weigh("log", { days: {} })).toBe(0);
expect(weigh("chat", null)).toBe(0);
});
});
describe("isLater — a counter, not a clock", () => {
it("compares counters when both sides have one, whatever the clocks say", () => {
expect(isLater({ v: 3, u: 1 }, 2, 99)).toBe(true);
expect(isLater({ v: 1, u: 999 }, 2, 1)).toBe(false);
});
it("breaks a counter tie on the stamp", () => {
expect(isLater({ v: 2, u: 5 }, 2, 4)).toBe(true);
expect(isLater({ v: 2, u: 4 }, 2, 4)).toBe(false);
});
it("falls back to the clock only when one side has no counter", () => {
expect(isLater({ u: 5 }, 0, 4)).toBe(true);
expect(isLater({ v: 9 }, 0, 4)).toBe(false);
});
});
describe("reconcile — no silent shrinking", () => {
const local = { version: 3, stamp: 100, data: { turns: [1, 2, 3] } };
it("reasserts ours when a later copy holds less and nobody said so", () => {
expect(reconcile("chat", { v: 4, u: 200, d: { turns: [1] } }, local)).toBe("reassert");
});
it("obeys a deliberate shrink", () => {
expect(reconcile("chat", { v: 4, u: 200, d: { turns: [1] }, x: 1 }, local)).toBe("adopt");
});
it("adopts a later copy that holds at least as much", () => {
expect(reconcile("chat", { v: 4, u: 200, d: { turns: [1, 2, 3, 4] } }, local)).toBe("adopt");
});
it("ignores an older copy, and a missing one", () => {
expect(reconcile("chat", { v: 2, u: 900, d: { turns: [] } }, local)).toBe("ignore");
expect(reconcile("chat", null, local)).toBe("ignore");
});
});
describe("makeWriter — hydration", () => {
function writer() {
let t = 1000;
const pushed: [string, RemoteDoc][] = [];
const w = makeWriter({
push: (name, body) => {
pushed.push([name, body]);
return "sent";
},
now: () => ++t,
});
return { w, pushed };
}
it("holds an edit made before hydration, unstamped and unpushed", () => {
const { w, pushed } = writer();
expect(w.touch("chat")).toMatchObject({ version: 0, stamp: 0, dirty: true, hydrated: false });
expect(w.flush("chat", { turns: [] })).toBeNull();
expect(pushed).toEqual([]);
});
it("stamps the held edit when hydration finds nothing newer, then pushes it", () => {
const { w, pushed } = writer();
w.touch("chat");
expect(w.hydrate("chat")).toMatchObject({ version: 1, stamp: 1001, hydrated: true });
expect(w.flush("chat", { turns: [1] })).toBe("sent");
expect(pushed).toEqual([["chat", { u: 1001, v: 1, d: { turns: [1] } }]]);
});
it("carries a deliberate shrink as x on exactly one push", () => {
const { w, pushed } = writer();
w.touch("meta", true);
w.hydrate("meta");
w.flush("meta", {});
expect(pushed[0]![1]).toMatchObject({ x: 1 });
w.touch("meta");
w.flush("meta", {});
expect(pushed[1]![1]).not.toHaveProperty("x");
});
});

87
types/lib/blocks.d.ts vendored
View File

@@ -6,24 +6,39 @@ export interface WordEntry {
note: string;
}
export interface TranslateTask {
/** What parse() stamps onto whichever task it returns. */
interface TaskMeta {
/** How many earlier ::task blocks in the message were withdrawn. The
LAST block wins; a non-zero count means the tutor retracted a draft. */
retracted?: number;
/** The block's raw rows, for the gate to read — see gate.js taskMaterial(). */
rows?: string[];
}
export interface TranslateTask extends TaskMeta {
type: "translate";
items: { q: string }[];
}
export interface MatchTask {
/** English prompt; the student WRITES the 한글. The one type that proves
recall rather than recognition, and the one that needs letterCheck(). */
export interface RecallTask extends TaskMeta {
type: "recall";
items: { q: string; hint: string }[];
}
export interface MatchTask extends TaskMeta {
type: "match";
pairs: { ko: string; gloss: string }[];
}
export interface BuildTask {
export interface BuildTask extends TaskMeta {
type: "build";
/** chips are given in correct order; the UI shuffles them. */
items: { en: string; chips: string[] }[];
}
export interface ChoiceTask {
export interface ChoiceTask extends TaskMeta {
type: "choice";
items: { q: string; options: string[] }[];
}
export type Task = TranslateTask | MatchTask | BuildTask | ChoiceTask;
export type Task = TranslateTask | RecallTask | MatchTask | BuildTask | ChoiceTask;
export type TaskType = Task["type"];
/** One uppercase letter; see ROLES. */
@@ -46,11 +61,29 @@ export interface Progress {
note: string;
}
/** One marked item from a ::result block. */
export interface ResultRow {
item: string;
/** Only the literal "ok" (any case) counts as correct. */
ok: boolean;
/** On a wrong answer: what the tutor thinks the student mistook it for. */
mistakenFor: string;
}
export interface ParsedMessage {
body: string;
/** Every ::words entry in the message; the first gloss of a term wins. */
words: WordEntry[] | null;
/** The LAST ::task block. */
task: Task | null;
/** Every ::gloss block, accumulated; each closes at its "=" line. */
gloss: GlossBlock[] | null;
/** The last ::result block. */
results: ResultRow[] | null;
/** The last ::confirmed block, first column of each row. May carry a
leading "-" meaning "put this back". */
confirmed: string[] | null;
/** The last ::progress line. */
progress: Progress | null;
}
@@ -61,20 +94,52 @@ export const ROLES: Record<GlossRole, string>;
/** State shapes accepted by answerText(), per task type. */
export type TranslateState = string[];
export type RecallState = string[];
export type MatchState = { pairs: { ko: string; gloss: string }[] };
export type BuildState = string[][];
export type ChoiceState = (number | null)[];
export type TaskState = TranslateState | MatchState | BuildState | ChoiceState;
export type TaskState = TranslateState | RecallState | MatchState | BuildState | ChoiceState;
/** Turn a completed task back into the message the student sends.
The state shape follows the task type, so these are overloads rather
than one signature over a union. */
than one signature over a union.
`letterBlock` is hangul.js letterCheck() output for a recall task — pass
it, always. The tutor cannot see inside a syllable and will invent a
diagnosis otherwise. Other task types ignore it. */
export function answerText(
task: TranslateTask,
state: TranslateState,
lookups?: string[],
letterBlock?: string,
): string;
export function answerText(
task: RecallTask,
state: RecallState,
lookups?: string[],
letterBlock?: string,
): string;
export function answerText(
task: MatchTask,
state: MatchState,
lookups?: string[],
letterBlock?: string,
): string;
export function answerText(
task: BuildTask,
state: BuildState,
lookups?: string[],
letterBlock?: string,
): string;
export function answerText(
task: ChoiceTask,
state: ChoiceState,
lookups?: string[],
letterBlock?: string,
): string;
export function answerText(
task: Task,
state: TaskState,
lookups?: string[],
letterBlock?: string,
): string;
export function answerText(task: MatchTask, state: MatchState, lookups?: string[]): string;
export function answerText(task: BuildTask, state: BuildState, lookups?: string[]): string;
export function answerText(task: ChoiceTask, state: ChoiceState, lookups?: string[]): string;
export function answerText(task: Task, state: TaskState, lookups?: string[]): string;

View File

@@ -28,6 +28,17 @@ export interface SurfaceForm {
/**
* Build-time: every surface form a learner will meet, mapped back to its lemma.
* This is what replaces a runtime morphological analyser.
*/
export function surfaceForms(dict: string, gloss: string): SurfaceForm[];
/* ── reading an inflected form back to its dictionary entry ─────────── */
/** The endings the stripper tries, in the order it tries them. */
export const ENDINGS: string[];
/** Every dictionary form (…다) this surface could plausibly be. Unguarded —
pair it with a lexicon check, or 가지 (eggplant) becomes a form of 가다. */
export function deconjugateCandidates(token: string): string[];
/** The first candidate `isVerb` accepts, or null. `isVerb` is required. */
export function deconjugate(token: string, isVerb: (dictionaryForm: string) => boolean): string | null;

50
types/lib/gate.d.ts vendored
View File

@@ -2,7 +2,12 @@
The gate is what stops material being taught out of order. buildGate()
computes it from curriculum + progress; renderGate() turns it into the
{{GATE}} section of prompt/tutor-system.md. */
{{GATE}} section of prompt/tutor-system.md. The second half —
scanTask(), proseIsKorean(), rejectionNote() — checks whether the tutor
actually listened, and its scope was measured against real messages
(audit-gate.mjs), not reasoned out. */
import type { ParsedMessage, Task } from "./blocks.js";
export interface Unit {
id: string;
@@ -10,6 +15,8 @@ export interface Unit {
name: string;
goal: string;
vocabUnit: boolean;
/** A 다지기 phase review: introduces nothing, confirms the whole phase. */
review?: boolean;
teaches: string[];
avoid?: string[];
/** Strictly NEW vocabulary. Deliberate repeats live in revisits[]. */
@@ -78,3 +85,44 @@ export function buildGate(
/** Render the gate into the system prompt section. Keep the headings. */
export function renderGate(g: Gate): string;
/* ── enforcing the gate ─────────────────────────────────────────────── */
/** Unit and phase names plus the course's metalanguage (받침, 비음화, …):
Hangul a teacher may write without it being taught vocabulary. */
export function buildScaffold(curriculum: Curriculum): Set<string>;
/** Only the side of an exercise the student must decode. Recall prompts
are English, so a recall task contributes nothing. */
export function taskMaterial(task: Task | null | undefined): string[];
export interface GateFinding {
word: string;
/** The unit that introduces it, or "" when no unit does. */
unit: string;
/** false: no gloss exists anywhere — a typo or an invented word. */
known: boolean;
}
export interface ScanContext {
allowed: Set<string>;
scaffold: Set<string>;
/** Every dictionary word this surface could be, best first. */
heads: (token: string) => string[];
/** The unit that introduces a word, or "". */
unitOf: (word: string) => string;
}
export function scanTask(
parsed: Pick<ParsedMessage, "task" | "words"> | null | undefined,
ctx: ScanContext,
): GateFinding[];
/** ≥40 Hangul syllables in the prose outside blocks, and ≥50% of letters. */
export function proseIsKorean(text: string): boolean;
/** What to tell the tutor when a message is sent back. */
export function rejectionNote(findings: GateFinding[]): string;
/** Retries before a message is shown anyway, with its words flagged. */
export const GATE_TRIES: number;

25
types/lib/hangul.d.ts vendored
View File

@@ -36,3 +36,28 @@ export const KEYBOARD: {
rows: string[][];
shift: Record<string, string>;
};
/* ── letter-level marking ───────────────────────────────────────────── */
/** Two-consonant batchim clusters, unpacked: "ㄼ" → "ㄹ+ㅂ". */
export const CLUSTER: Record<string, string>;
/** ["first consonant", "vowel", "batchim"] — the three slots of a syllable. */
export const SLOT: string[];
/** "짧다" → "짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ" */
export function spellOut(word: string): string;
/** The exact jamo difference, naming what was correct as well as what was
wrong. "" when either side is empty; "identical" when they match. */
export function letterDiff(expected: string, written: string): string;
export interface LetterRow {
/** The item as the student saw it — for a recall task, the English. */
prompt: string;
expected: string;
written: string;
}
/** The LETTER-LEVEL CHECK block for a marking message; "" when nothing differs. */
export function letterCheck(rows: LetterRow[]): string;

55
types/lib/lexicon.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
/* Declarations for lib/lexicon.js — the module itself ships unchanged.
One resolver shared by word lookup and the gate, so the two cannot drift.
heads() returns EVERY route from a surface form to a dictionary word; a
single "best" answer was the bug. */
export const PARTICLES: string[];
export interface LexEntry {
ko: string;
gloss: string;
note: string;
/** roadmap | deck | form | gloss | sentence | sfx */
src: string;
/** The dictionary word this entry is a form of, or "". */
base: string;
}
export class Lexicon {
map: Map<string, LexEntry>;
verbs: Set<string>;
/** First writer wins: an existing entry is never replaced. */
add(ko: string, gloss: string, note?: string, src?: string, base?: string): void;
addVerb(dictionaryForm: string): void;
get(ko: string): LexEntry | null;
isVerb(ko: string): boolean;
/** Every dictionary word this surface form could be, best first. */
heads(token: string): string[];
/** What to show when the student taps a word. */
lookup(token: string): LexEntry | null;
}
/** A deck row: [한글, romanization, English, POS], or the same as an object. */
export type DeckWord =
| { ko: string; en: string; pos: string }
| readonly [string, string, string, string];
export interface LexiconSources {
/** Entered FIRST, with no base — see the ordering warning in lexicon.js. */
roadmapWords?: string[];
deck?: DeckWord[];
/** [한글, English, note?] */
glossExtra?: (readonly [string, string, string?])[];
sentences?: { parts?: (readonly [string, string, ...unknown[]])[] }[];
/** [한글, English] */
sfx?: (readonly [string, string])[];
}
export function buildLexicon(
sources: LexiconSources,
conjugation: {
haeche: (dict: string) => string | null;
past: (present: string | null) => string | null;
},
): Lexicon;

35
types/lib/srs.d.ts vendored
View File

@@ -36,3 +36,38 @@ export function preview(card: Card | null | undefined, g: Grade, today: number):
/** Local day number, DST-safe. */
export function dayNumber(d?: Date): number;
/* ── recall evidence, kept separate from the schedule ──────────────── */
export const LEARNED_OK: number;
export const LEARNED_STREAK: number;
export const LEARNED_SPAN: number;
export interface Evidence {
ok: number;
wrong: number;
lookups: number;
streak: number;
/** Round of the first outcome of any kind. */
firstRound: number;
/** Round of the latest outcome of any kind. */
lastRound: number;
lastSeen: number;
/** Distinct rounds with an outcome. */
rounds: number;
}
export function newEvidence(): Evidence;
/** Pure: returns a new record. A lookup is never a recall and resets the streak. */
export function noteOutcome(
ev: Evidence,
outcome: "ok" | "wrong",
round: number,
lookedUp?: boolean,
): Evidence;
export function isLearned(ev: Evidence): boolean;
/** Whether a tutor's ::confirmed for this word may be stored. */
export function acceptConfirmation(ev: Evidence): boolean;

56
types/lib/sync.d.ts vendored Normal file
View File

@@ -0,0 +1,56 @@
/* Declarations for lib/sync.js — the module itself ships unchanged.
lib/sync.js syncs four whole documents. The port syncs rows (PORT.md), so
it applies the same three gates — hydration, a counter rather than a
clock, no silent shrinking — row by row rather than calling this module.
It is declared here so its behaviour stays pinned by tests. */
export const DOCS: ("srs" | "log" | "meta" | "chat")[];
/** How much a copy holds. Shrinking is always deliberate, never a race. */
export function weigh(name: string, d: unknown): number;
/** A document as it travels: stamp, counter, data, deliberate-shrink flag. */
export interface RemoteDoc<T = unknown> {
u?: number;
v?: number;
d?: T;
x?: 1;
}
export interface LocalDoc<T = unknown> {
version?: number;
stamp?: number;
data?: T;
}
/** Is the copy that just arrived later than ours? */
export function isLater(remote: RemoteDoc, localVersion: number, localStamp: number): boolean;
export function reconcile(
name: string,
remote: RemoteDoc | null | undefined,
local: LocalDoc,
): "ignore" | "adopt" | "reassert";
export interface WriterDocState {
version: number;
stamp: number;
dirty: boolean;
hydrated: boolean;
intent: boolean;
}
export interface Writer<R> {
state: Record<string, WriterDocState>;
touch(name: string, deliberateShrink?: boolean): WriterDocState;
hydrate(name: string): WriterDocState;
adopted(name: string, remote: RemoteDoc): WriterDocState;
reasserted(name: string, remote: RemoteDoc): WriterDocState;
flush(name: string, data: unknown): R | null;
}
export function makeWriter<R>(opts: {
push: (name: string, body: RemoteDoc) => R;
now?: () => number;
}): Writer<R>;