Files
Hankan/test/domain/gate-audit.test.ts
MechaCat02 467d9d1d7c fix(tutor): what live gpt-oss-20b lessons showed — marks lost, feedback swallowed, answers given away
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>
2026-09-16 22:50:33 +02:00

113 lines
4.3 KiB
TypeScript

/* audit-gate.mjs, reproduced through the PORT's own code.
run-checks.sh measures lib/ on its own. This measures what the app
actually does: the allowed set built from its database, the resolver
that falls back to its dictionary, parse() as the tutor tab sees it. If
the two ever disagree, the port is not running the gate that was measured.
Numbers from audit-gate.mjs, and why they are what they are, are in that
file: 7 of 41 earlier messages, 2 of 13 from the live 1.10 review. */
import { describe, it, expect, beforeAll, afterAll, 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 { editCard } from "@app/db/writes.js";
import type { Db } from "@app/db/types.js";
import { parseMessage } from "@app/domain/gloss.js";
import { proseIsKorean, scanTask, taskMaterial } from "@lib/gate.js";
import { markKnown } from "@lib/srs.js";
import { allowedSet, courseScaffold, gateFor, metWords, UNITS, unitOf } from "@app/domain/gate.js";
import { headsFor } from "@app/domain/resolver.js";
const read = (f: string) => JSON.parse(readFileSync(new URL(`../../${f}`, import.meta.url), "utf8"));
const DICT = new URL("../../app/public/dict/", import.meta.url);
interface Snapshot {
road: { done: Record<string, number>; unit: string };
deckWordsMet: string[];
allowedWordsExpected: string[];
}
const snapshot = read("fixtures/progress-snapshot.json") as Snapshot;
let db: Db;
beforeAll(async () => {
vi.stubGlobal("fetch", async (input: string) => {
const name = String(input).split("/dict/")[1] ?? "";
return new Response(readFileSync(new URL(name, DICT)));
});
db = await SqliteWasmDb.open({ memory: true });
await migrate(db);
const { ensureBands } = await import("@app/domain/dictionary.js");
await ensureBands(db, 6);
// The student's state in the snapshot: the deck words he has met.
for (const w of snapshot.deckWordsMet) {
const lemma = await db.get<{ id: number }>(
`SELECT id FROM lemma WHERE headword = ?
ORDER BY unit_id IS NULL, source <> 'curated', id LIMIT 1`,
[w],
);
if (lemma) await editCard(db, lemma.id, markKnown(20_000));
}
}, 60_000);
afterAll(async () => {
vi.unstubAllGlobals();
await db.close();
});
const progress = () => ({
current: snapshot.road.unit,
done: Object.fromEntries(Object.keys(snapshot.road.done).map((k) => [k, true])),
confidence: {},
});
describe("the allowed set", () => {
it("is the one the shipped app computed from this state — 149 words", async () => {
const met = await metWords(db);
const done = new Set(Object.keys(snapshot.road.done));
const base = new Set([...UNITS.filter((u) => done.has(u.id)).flatMap((u) => u.words ?? []), ...met]);
const expected = new Set(snapshot.allowedWordsExpected);
expect([...expected].filter((w) => !base.has(w)), "missing here").toEqual([]);
expect([...base].filter((w) => !expected.has(w)), "extra here").toEqual([]);
});
});
async function audit(file: string): Promise<string[]> {
const gate = gateFor({ progress: progress(), met: await metWords(db) });
const allowed = allowedSet(gate);
const scaffold = courseScaffold();
const { messages } = read(file) as { messages: string[] };
const flagged: string[] = [];
for (const [i, m] of messages.entries()) {
const parsed = parseMessage(m);
const tokens = taskMaterial(parsed.task).join(" ").match(/[가-힣]+/g) ?? [];
const heads = await headsFor(db, tokens);
const found = scanTask(parsed, { allowed, scaffold, heads, unitOf }).map((f) => f.word);
if (proseIsKorean(m)) found.push("«KO»");
if (found.length) flagged.push(`#${i} ${found.join(" ")}`);
}
return flagged;
}
describe("the audit, through the port", () => {
it("flags 7 of the 41 earlier messages — the shipped gate's count", async () => {
expect(await audit("fixtures/tutor-messages.json")).toEqual([
"#10 마셔",
"#14 «KO»",
"#18 마셔",
"#20 마셔",
"#27 마셔",
"#30 살 이야",
"#32 살 이야",
]);
});
it("flags 2 of the 13 from the live 1.10 review — both 아파, from unit 3.2", async () => {
expect(await audit("fixtures/tutor-messages-live.json")).toEqual(["#1 아파", "#3 아파"]);
});
});