feat(tutor): the turn enforced — retries, evidence, the 다지기 checklist, earned progress

"The client enforces; the prompt only explains." Every rule the artifact's
tutor was merely asked to follow, it broke: it certified words on one
correct answer, scored a unit before anything was answered, used a word
from three phases ahead, answered in Korean, and invented spelling
diagnoses. The reworked app fixed each by making the client refuse. This
ports those refusals; domain/turn.ts holds the turn, testable without React.

The gate. A reply is scanned before he sees it — the side of the exercise
he must decode, through the one resolver, and its prose for Korean. A
refused draft is never stored, shown or applied: the tutor is asked again
and told exactly why. After two retries the reply is shown with its words
flagged, and the next turn names them. (The artifact's follow-up told the
tutor it could declare such a word in ::words; that contradicts the gate
and is left out.)

Marking. ::result feeds recall evidence per word. lib/srs.js is looser
than PORT.md, so the call site tightens it: one outcome per word per round,
and "learned" also needs five rounds between the first and last CORRECT
answer — lib alone counted a wrong answer as the start of the span. A
lookup is never recall. What he mistook a word for is kept. The schedule
takes at most one good grade a day from marking; in the artifact five good
rounds in one afternoon made a word "secure" by interval alone.

Phase reviews. The client holds the 다지기 checklist — each unit's rule and
every word the phase introduced, 132 items for Phase 1 — worked in batches
of ten. ::confirmed ticks a rule on the tutor's word but a word only on
evidence; "-item" puts one back; anything off the list is ignored.

Progress is earned: ignored until the unit has an answer, +25 at most per
message, a fall honoured in full, and the next unit only at 85% with three
answers — plus, in a review, nothing open. advanceUnit() enforces it too,
not only the banner.

The prompt gains a per-round tail after the shipped prompt — the practice
set (scored on the evidence, round-robin by word class, each word with the
words one letter away), the checklist, retry notes — sent as a second,
uncached system block so the stable prefix still caches.

Also: recall answers carry the letter-level jamo comparison (kept out of
his own bubble, since it is written to the model); match chips are keyed by
pair index, the bug PORT.md names; and the stand-in tutor exercises every
path offline — recall, ::result, ::confirmed, progress only after answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 21:09:51 +02:00
parent 089f303ff9
commit a1c86d9550
24 changed files with 1941 additions and 183 deletions

View File

@@ -85,8 +85,12 @@ describe("the new roadmap model", () => {
expect(p.done["1.1"]).toBe(true);
});
it("finishes the current unit and moves on in one step", async () => {
const { advanceUnit } = await import("@app/domain/progress.js");
it("finishes the current unit and moves on in one step — once it is earned", async () => {
const { advanceUnit, noteAnswer, applyProgressReport } = await import("@app/domain/progress.js");
expect(await advanceUnit(db, await readProgress(db)), "40% and no answers").toBeNull();
for (let i = 0; i < 3; i++) await noteAnswer(db, await readProgress(db), `answer ${i}`);
for (const n of [65, 90]) await applyProgressReport(db, await readProgress(db), n, "");
const next = await advanceUnit(db, await readProgress(db));
const p = await readProgress(db);
expect(next).toBe("1.3");

235
test/domain/learner.test.ts Normal file
View File

@@ -0,0 +1,235 @@
/* The learner model — what the client enforces instead of trusting the tutor.
Earned progress, recall evidence, the phase-review checklist, the practice
set and the letter-level check, each against a real database with the
shipped dictionary loaded. */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { Db } from "@app/db/types.js";
import { editCard, editEvidence } from "@app/db/writes.js";
import { UNITS } from "@app/domain/gate.js";
import {
CONF_STEP,
advanceUnit,
applyProgressReport,
isExerciseAnswer,
isReady,
noteAnswer,
readProgress,
reviewCoverage,
} from "@app/domain/progress.js";
import { applyResults, cardLemmaFor, currentRound, emptyEvidence, isLearned, readEvidence } from "@app/domain/evidence.js";
import { applyConfirmed, checklist, coverage } from "@app/domain/ledger.js";
import { confusable, practicePool, practiceSet, statLine } from "@app/domain/practice.js";
import { expectedFor, recallLetterBlock } from "@app/domain/letters.js";
import { editCurrentUnit } from "@app/db/writes.js";
import { SECURE_INTERVAL, newCard } from "@lib/srs.js";
import { dictionaryDb } from "../helpers/dict-db.js";
const TODAY = 20_000;
let db: Db;
beforeEach(async () => {
db = await dictionaryDb(1);
});
afterEach(async () => {
vi.unstubAllGlobals();
await db.close();
});
const unit = (id: string) => UNITS.find((u) => u.id === id)!;
const ok = (item: string) => ({ item, ok: true, mistakenFor: "" });
const wrong = (item: string, mistakenFor = "") => ({ item, ok: false, mistakenFor });
describe("earned progress", () => {
it("ignores a report on a unit nothing has been answered in", async () => {
const r = await applyProgressReport(db, await readProgress(db), 85, "looks solid");
expect(r).toEqual({ stored: 0, ignored: true, clamped: false });
});
it("caps a rise at +25 per message, and honours a fall in full", async () => {
await noteAnswer(db, await readProgress(db), "My answers:\n나 → I");
expect((await applyProgressReport(db, await readProgress(db), 95, "")).stored).toBe(CONF_STEP);
expect((await applyProgressReport(db, await readProgress(db), 90, "")).stored).toBe(2 * CONF_STEP);
expect((await applyProgressReport(db, await readProgress(db), 10, "slipping")).stored).toBe(10);
expect((await readProgress(db)).notes["1.1"]).toBe("slipping");
});
it("offers the next unit only at 85% with three answers behind it", async () => {
await noteAnswer(db, await readProgress(db), "My answers:\n나 → I");
for (const n of [25, 50, 75, 90]) await applyProgressReport(db, await readProgress(db), n, "");
let p = await readProgress(db);
expect(p.confidence["1.1"]).toBe(90);
expect(isReady(p)).toBe(false); // one answer
expect(await advanceUnit(db, p)).toBeNull();
await noteAnswer(db, p, "나무");
await noteAnswer(db, p, "I think it is 나무");
p = await readProgress(db);
expect(isReady(p)).toBe(true);
expect(await advanceUnit(db, p)).toBe("1.2");
});
it("does not count a request for an exercise as an answer", () => {
expect(isExerciseAnswer("My written answers:\nthe sea → 바다")).toBe(true);
expect(isExerciseAnswer("is 바다 the sea?")).toBe(true);
expect(isExerciseAnswer("Let's start unit 1.1 자음과 모음 (Consonants & vowels).")).toBe(false);
expect(isExerciseAnswer("Give me a matching exercise")).toBe(false);
});
it("keeps a 다지기 review closed while its checklist is open, whatever the score", async () => {
await editCurrentUnit(db, "1.10");
const p0 = await readProgress(db);
for (let i = 0; i < 3; i++) await noteAnswer(db, p0, `answer ${i}`);
for (const n of [25, 50, 75, 90]) await applyProgressReport(db, await readProgress(db), n, "");
const p = await readProgress(db);
const cover = await reviewCoverage(db, p);
expect(cover?.total).toBe(132);
expect(isReady(p, cover)).toBe(false);
expect(await advanceUnit(db, p)).toBeNull();
});
});
describe("recall evidence", () => {
it("counts a round per marked message, and one outcome per word in it", async () => {
await applyResults(db, [ok("나무"), wrong("나무")], [], TODAY);
expect(await currentRound(db)).toBe(1);
expect((await readEvidence(db, ["나무"])).get("나무")).toMatchObject({ ok: 1, wrong: 0, rounds: 1 });
});
it("never credits a correct answer given after a lookup", async () => {
await applyResults(db, [ok("바다")], ["바다"], TODAY);
expect((await readEvidence(db, ["바다"])).get("바다")).toMatchObject({ ok: 0, lookups: 1, streak: 0 });
});
it("drops a marked item that names no studiable word", async () => {
const out = await applyResults(db, [ok("없는낱말"), ok("나무")], [], TODAY);
expect(out?.ignored).toEqual(["없는낱말"]);
expect(out?.recorded).toEqual(["나무"]);
});
it("keeps what he mistook a word for", async () => {
await applyResults(db, [wrong("바다", "바지")], [], TODAY);
expect(await db.get("SELECT mistook FROM confusion WHERE word = '바다'")).toEqual({ mistook: "바지" });
});
/* lib/srs.js alone would call this learned: its span runs from the first
outcome of ANY kind. PORT.md measures from the first CORRECT answer. */
it("measures the five-round span from the first correct answer, not the first outcome", async () => {
const other = async () => applyResults(db, [ok("소리")], [], TODAY + 100);
await applyResults(db, [wrong("나무")], [], TODAY); // round 1
await other(); // 2
await other(); // 3
for (let r = 4; r <= 6; r++) await applyResults(db, [ok("나무")], [], TODAY + r);
let e = (await readEvidence(db, ["나무"])).get("나무")!;
expect(e).toMatchObject({ ok: 3, first_round: 1, first_ok_round: 4, last_ok_round: 6 });
expect(isLearned(e)).toBe(false);
for (let r = 7; r <= 9; r++) await applyResults(db, [ok("나무")], [], TODAY + r);
e = (await readEvidence(db, ["나무"])).get("나무")!;
expect(isLearned(e)).toBe(true);
});
it("lets the schedule take a good grade at most once a day", async () => {
const id = (await cardLemmaFor(db, "나무"))!;
await applyResults(db, [ok("나무")], [], TODAY);
await applyResults(db, [ok("나무")], [], TODAY);
await applyResults(db, [ok("나무")], [], TODAY);
expect(await db.get("SELECT reps FROM card WHERE lemma_id = ?", [id])).toEqual({ reps: 1 });
});
});
describe("the phase-review checklist", () => {
it("lists Phase 1 as its nine units and 123 words — 132 items", () => {
const list = checklist(1);
expect(list.rules).toHaveLength(9);
expect(list.words).toHaveLength(123);
});
it("ignores ::confirmed outside a 다지기 unit", async () => {
const out = await applyConfirmed(db, unit("1.4"), ["받침"]);
expect(out.ticked).toEqual([]);
});
it("ticks a rule on the tutor's word, but a word only on evidence", async () => {
const review = unit("1.10");
let out = await applyConfirmed(db, review, ["받침", "나무", "없는것"]);
expect(out).toEqual({ ticked: ["받침"], reopened: [], refused: ["나무"] });
await editEvidence(db, {
...emptyEvidence("나무"),
ok: 3,
streak: 3,
rounds: 3,
first_round: 1,
last_round: 7,
last_seen: 7,
first_ok_round: 1,
last_ok_round: 7,
});
out = await applyConfirmed(db, review, ["나무", "-받침"]);
expect(out).toEqual({ ticked: ["나무"], reopened: ["받침"], refused: [] });
const c = await coverage(db, 1);
expect(c.openRules).toContain("받침");
expect(c.openWords).not.toContain("나무");
expect(c.done).toBe(1);
});
it("counts a word secure in review as already confirmed", async () => {
const id = (await cardLemmaFor(db, "바다"))!;
await editCard(db, id, { ...newCard(), state: 2, interval: SECURE_INTERVAL, due: TODAY + 30, reps: 6 });
expect((await coverage(db, 1)).openWords).not.toContain("바다");
});
});
describe("the practice set", () => {
it("brings back a missed word once it has gone unseen, and mixes the word classes", async () => {
await applyResults(db, [wrong("바다")], [], TODAY);
// He is on 1.1, so the pool is 1.1's twenty words.
const pool = practicePool(unit("1.1"), await readProgress(db));
expect(pool).toContain("바다");
// Just missed, it ranks below a word never tested: 40 for the miss +
// 30 for no correct answer yet, against 90 for 20 rounds of "unseen".
let set = await practiceSet(db, pool, 10, await currentRound(db));
expect(set[0]!.ko).not.toBe("바다");
// Eight rounds later — marking words from another unit — it leads.
for (let r = 0; r < 8; r++) await applyResults(db, [ok("코")], [], TODAY + r);
set = await practiceSet(db, pool, 10, await currentRound(db));
expect(set[0]!.ko).toBe("바다");
expect(set).toHaveLength(10);
// Round-robin by class: the set is not one kind of word.
expect(new Set(set.map((w) => w.pos)).size).toBeGreaterThan(1);
});
it("describes each word by its evidence, and names the words one letter away", () => {
const line = statLine({ ko: "발", en: "foot", evidence: null }, 5);
expect(line).toMatch(/^발 \(foot\) \[never tested\]/);
expect(confusable("발").length).toBeGreaterThan(0);
expect(confusable("바쁘다")).toContain("나쁘다");
});
});
describe("the letter-level check", () => {
const words = [
{ ko: "짧다", gloss: "to be short", note: "" },
{ ko: "바다", gloss: "sea", note: "" },
];
it("finds the spelling a recall prompt wants in the message's ::words", () => {
expect(expectedFor("to be short (dictionary form)", words)).toBe("짧다");
expect(expectedFor("the sea", words)).toBe("바다");
expect(expectedFor("a mountain", words)).toBe("");
});
it("hands the tutor the jamo comparison for a wrong spelling, and nothing for a right one", () => {
const task = { type: "recall" as const, items: [{ q: "to be short", hint: "" }, { q: "the sea", hint: "" }] };
const block = recallLetterBlock(task, ["빫다", "바다"], words);
expect(block).toContain("LETTER-LEVEL CHECK");
expect(block).toContain("first consonant: wrote ㅃ, should be ㅉ");
expect(block).not.toContain("the sea");
expect(recallLetterBlock(task, ["짧다", "바다"], words)).toBe("");
});
});

142
test/domain/turn.test.ts Normal file
View File

@@ -0,0 +1,142 @@
/* One tutor turn, enforced — with a scripted tutor in place of a model.
The client refuses what the prompt only asks for: an exercise built from
a word he has not been given, an explanation written in Korean. A refused
draft is never returned; the tutor is asked again and told why; after
GATE_TRIES the reply is shown with its stray words named. */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { readFileSync } from "node:fs";
import type { Db } from "@app/db/types.js";
import { GATE_TRIES } from "@lib/gate.js";
import { gateFor, metWords } from "@app/domain/gate.js";
import { noteAnswer, readProgress } from "@app/domain/progress.js";
import { applyReply, readRecent, runTurn, trimHistory } from "@app/domain/turn.js";
import type { Sample, SampleRequest } from "@app/domain/stub-tutor.js";
import { dictionaryDb } from "../helpers/dict-db.js";
const TEMPLATE = readFileSync(new URL("../../prompt/tutor-system.md", import.meta.url), "utf8");
const TODAY = 20_000;
let db: Db;
beforeEach(async () => {
db = await dictionaryDb(1);
});
afterEach(async () => {
vi.unstubAllGlobals();
await db.close();
});
/** A tutor that replies from a script, and remembers what it was sent. */
function scripted(...replies: string[]) {
const seen: SampleRequest[] = [];
const sample: Sample = async (req) => {
seen.push(req);
return { text: replies[Math.min(seen.length - 1, replies.length - 1)]! };
};
return { sample, seen };
}
/* Unit 1.1 on a fresh install: 나무 is its own word; 마셔 belongs to 2.3. */
const CLEAN = "Read these.\n\n::task translate\n나무\n바다\n::";
const STRAY = "Read these.\n\n::task translate\n나무 마셔\n::";
const KOREAN = `${"한글은 소리를 적는 글자예요. ".repeat(4)}\n\n::task translate\n나무\n::`;
async function turn(sample: Sample, onRetry = vi.fn()) {
const progress = await readProgress(db);
const gate = gateFor({ progress, met: await metWords(db) });
const result = await runTurn({
db,
sample,
template: TEMPLATE,
gate,
progress,
focus: "auto",
recent: [],
history: [],
message: "Let's start unit 1.1.",
strays: [],
onRetry,
});
return { result, onRetry };
}
describe("the gate, enforced", () => {
it("passes a reply built from allowed words at the first attempt", async () => {
const { sample, seen } = scripted(CLEAN);
const { result } = await turn(sample);
expect(seen).toHaveLength(1);
expect(result).toMatchObject({ text: CLEAN, findings: [], korean: false, retries: 0 });
});
it("refuses a draft with an ungated word, never returns it, and tells the tutor why", async () => {
const { sample, seen } = scripted(STRAY, CLEAN);
const { result, onRetry } = await turn(sample);
expect(result.text).toBe(CLEAN);
expect(result.retries).toBe(1);
expect(onRetry).toHaveBeenCalledOnce();
expect(seen[0]!.systemTail).not.toContain("NOT DELIVERED");
expect(seen[1]!.systemTail).toContain("THAT MESSAGE WAS NOT DELIVERED");
expect(seen[1]!.systemTail).toContain("마셔 (belongs to unit 2.3)");
});
it("gives up after GATE_TRIES retries and shows the reply with its words named", async () => {
const { sample, seen } = scripted(STRAY);
const { result } = await turn(sample);
expect(seen).toHaveLength(GATE_TRIES + 1);
expect(result.retries).toBe(GATE_TRIES);
expect(result.findings.map((f) => f.word)).toEqual(["마셔"]);
});
it("sends back an explanation written in Korean", async () => {
const { sample, seen } = scripted(KOREAN, CLEAN);
const { result } = await turn(sample);
expect(result.text).toBe(CLEAN);
expect(seen[1]!.systemTail).toContain("You wrote your explanation in Korean");
});
it("keeps the shipped prompt stable and puts the round in the tail", async () => {
const { sample, seen } = scripted(STRAY, CLEAN);
await turn(sample);
expect(seen[0]!.system).toBe(seen[1]!.system);
expect(seen[0]!.system).toContain("WHAT HE KNOWS");
expect(seen[0]!.systemTail).toContain("VOCABULARY DUE — WORK THESE IN");
});
});
describe("the transcript sent", () => {
it("keeps the newest turns that fit, and opens with the learner", () => {
const turns = [
{ role: "assistant" as const, content: "opening" },
{ role: "user" as const, content: "a".repeat(30) },
{ role: "assistant" as const, content: "b".repeat(30) },
];
expect(trimHistory(turns, 1000)[0]).toEqual({ role: "user", content: "Let's continue the lesson." });
expect(trimHistory(turns, 65).map((t) => t.content)).toEqual(["a".repeat(30), "b".repeat(30)]);
});
});
describe("an accepted reply", () => {
it("applies marking, then earned progress, and remembers the exercise type", async () => {
await noteAnswer(db, await readProgress(db), "My answers:\n나무 → tree\n바다 → sea");
const reply = [
"Two for two.",
"::result",
"나무 | ok",
"바다 | wrong | 바지",
"::",
"::task recall",
"tree",
"::",
"::progress 95 | good start",
].join("\n");
const { parseMessage } = await import("@app/domain/gloss.js");
const applied = await applyReply(db, parseMessage(reply), { lookups: [], today: TODAY });
expect(applied.results?.recorded).toEqual(["나무", "바다"]);
expect(applied.progress).toMatchObject({ stored: 25, ignored: false, clamped: true });
expect(await readRecent(db)).toEqual(["recall"]);
expect((await readProgress(db)).notes["1.1"]).toBe("good start");
});
});

27
test/helpers/dict-db.ts Normal file
View File

@@ -0,0 +1,27 @@
/* A migrated in-memory database with the shipped dictionary loaded — the
same band files, through the same loader, as the app. */
import { vi } from "vitest";
import { readFileSync } from "node:fs";
import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js";
import { migrate } from "@app/db/migrate.js";
import type { Db } from "@app/db/types.js";
const DICT = new URL("../../app/public/dict/", import.meta.url);
/** Serve app/public/dict/ to the loader's fetch. Undo with vi.unstubAllGlobals(). */
export function serveDictionary(): void {
vi.stubGlobal("fetch", async (input: string) => {
const name = String(input).split("/dict/")[1] ?? "";
return new Response(readFileSync(new URL(name, DICT)));
});
}
export async function dictionaryDb(upToBand = 1): Promise<Db> {
serveDictionary();
const db = await SqliteWasmDb.open({ memory: true });
await migrate(db);
const { ensureBands } = await import("@app/domain/dictionary.js");
await ensureBands(db, upToBand);
return db;
}