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>
113 lines
4.3 KiB
TypeScript
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 아파"]);
|
|
});
|
|
});
|