Three unit-1.1 lessons with gpt-oss-20b through LM Studio and the server —
the first real model on the reworked turn. The letter-level check went out
right, and the +25 clamp held: a reported 80 on the first answer was stored
as 25. What failed was how the model wrote its blocks, a different way each
session. All three transcripts are in test/fixtures/, verbatim, and each
failure below is a test against them.
Marks lost. The prompt asks for `여덟 | wrong | 여덜`. The first session wrote
`we | wrong | 우라 → 우리`, English prompt first; the second wrote no ::result
at all and marked only in prose, `✗ 나 | I (humble) → 저`. evidence.ts keys on
the first field of a ::result row, so nothing was ever recorded — no
evidence, no schedule, no confusions, and a 다지기 review that could never
close. The artifact would have lost them the same way. domain/marking.ts
attaches each mark to its word only where that is unambiguous: one Korean
word first, or through a prompt of the exercise he answered, read via that
exercise's ::words as the letter check reads it. With no ::result block the
✓/✗ lines are read on the same terms, so a mark can never name a word the
exercise did not ask for; a mark on a whole sentence is still dropped. What
he mistook a word for is taken from what he actually wrote whenever the mark
itself gives no other word — the third session put the right answer there.
Its third session, marked through all of this: 20 evidence rows, 20 cards.
Progress on requests. The prompt allows marks, ::confirmed and ::progress
only in reply to an answer. The model wrote ::progress on every message, and
three requests for a new exercise took the unit from 50% to 80% with nothing
answered. A reply to anything but an answer now changes none of them.
Feedback swallowed. The model closed no blocks, so lib read what followed
each one as rows: "your score is about 5%" became a result row the student
never saw, and a "---" became a recall item he was asked to write in 한글.
Another session fenced every block in ```. gloss.ts now decides every
block's extent from the raw text — at "::", the next block, a rule or fence
line, a blank line with no row after it, or for the piped blocks the first
line without a "|" — and hands lib the blocks properly closed. The gate
audit, now run through the parser the lesson uses, still flags 7 and 2.
Answers given away. Translate rows came with their meanings ("나 | I") and
recall hints were the answers ("two | 이"). A translate row keeps only its
Korean line, and a recall hint that is the expected word, or any word the
message declares, is dropped.
Also: the spelling a recall prompt expects now keeps its qualifiers. With 나
"I, me (casual)" and 저 "I, me (humble)" in one list, "I (humble)" matched
나: no letter check was sent, and the mark for 저 was filed under 나.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
/* 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("tells a qualified meaning from the bare one", () => {
|
|
const pronouns = [
|
|
{ ko: "나", gloss: "I", note: "" },
|
|
{ ko: "저", gloss: "I (humble)", note: "" },
|
|
];
|
|
expect(expectedFor("I (humble)", pronouns)).toBe("저");
|
|
expect(expectedFor("I", pronouns)).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("");
|
|
});
|
|
});
|