diff --git a/app/src/domain/gate.ts b/app/src/domain/gate.ts index 4ce3f7a..02949d5 100644 --- a/app/src/domain/gate.ts +++ b/app/src/domain/gate.ts @@ -26,8 +26,9 @@ prompt/tutor-system.md ships unchanged. {{GATE}} is the only structural substitution; {{VARIETY}} and {{FOCUS}} are the one-liners it expects. */ -import { buildGate, flatten, renderGate } from "@lib/gate.js"; +import { buildGate, buildScaffold, flatten, renderGate } from "@lib/gate.js"; import type { Curriculum, FlatUnit, Gate, ProgressState } from "@lib/gate.js"; +import type { Db } from "../db/types.js"; import { featureLevel, isReadableAt, LADDER_COMPLETE } from "@shared/phonology.mjs"; import { bandForUnit, REFERENCE_BAND, ceilingForBand } from "@shared/bands.mjs"; import curriculumJson from "@data/curriculum.json"; @@ -62,10 +63,28 @@ export interface VocabRow { export type BandQuery = (band: number, ceiling: number, limit: number) => VocabRow[]; /** - * The vocabQuery hook. Curriculum words of finished units are always - * allowed; the band adds frequency-ranked vocabulary on top. + * Every word he has met: the headword of each card that has left "new". + * + * The reworked app's gate allows these whatever unit schedules them — he + * has reviewed them, so a line built from them is reading practice, not a + * word from nowhere. Measured, leaving them out makes the gate over-fire on + * real teaching: 13 flagged messages where the shipped gate flags 7, every + * extra one a word like 몰라 or 커 that the student had in fact studied. */ -export function makeVocabQuery(query: BandQuery) { +export async function metWords(db: Db): Promise { + const rows = await db.all<{ headword: string }>( + `SELECT DISTINCT l.headword FROM card c JOIN lemma l ON l.id = c.lemma_id + WHERE c.state <> 0 ORDER BY l.headword`, + ); + return rows.map((r) => r.headword); +} + +/** + * The vocabQuery hook. Curriculum words of finished units are always + * allowed, and so is every word he has met; the band adds frequency-ranked + * vocabulary on top from Phase 2. + */ +export function makeVocabQuery(query: BandQuery, met: string[] = []) { return (unit: FlatUnit, done: FlatUnit[]): string[] => { const doneIds = new Set(done.map((u) => u.id)); const band = bandForUnit(unit.id); @@ -88,6 +107,15 @@ export function makeVocabQuery(query: BandQuery) { for (const u of done) for (const w of u.words ?? []) push(w); for (const w of unit.revisits ?? []) push(w.word); + // Then every word he has met. Not filtered by future ownership or by + // sound: a met word is allowed because he has seen it, whichever unit + // will teach it formally — the rule the shipped gate was measured with. + for (const w of met) { + if (!w || seen.has(w)) continue; + seen.add(w); + allowed.push(w); + } + // Then the band, most frequent first. Ask for extra because the filters // above will reject some of what comes back. if (band > 0) { @@ -104,10 +132,48 @@ export function makeVocabQuery(query: BandQuery) { export interface GateInputs { progress: ProgressState; bandQuery?: BandQuery; + /** Headwords of every card that has left "new" — see metWords(). */ + met?: string[]; } -export function gateFor({ progress, bandQuery }: GateInputs): Gate { - return buildGate(curriculum, progress, bandQuery ? { vocabQuery: makeVocabQuery(bandQuery) } : {}); +export function gateFor({ progress, bandQuery, met = [] }: GateInputs): Gate { + return buildGate(curriculum, progress, { + vocabQuery: makeVocabQuery(bandQuery ?? (() => []), met), + }); +} + +/* ── enforcing it ────────────────────────────────────────────────── */ + +let scaffold: Set | null = null; + +/** Unit names and the course's metalanguage — see lib/gate.js buildScaffold. */ +export const courseScaffold = (): Set => (scaffold ??= buildScaffold(curriculum)); + +/** + * What the tutor may put in front of him: everything the prompt's + * VOCABULARY list names, this unit's own new words, and its revisits. + * + * One set, deliberately. The list the tutor is TOLD and the list it is + * CHECKED against are the same words, so it is never refused for a word it + * was offered. A multi-word entry also admits its parts (몇 명 → 몇, 명), + * as the audited gate does. + */ +export function allowedSet(gate: Gate): Set { + const out = new Set(); + const add = (w: string | undefined) => { + if (!w) return; + out.add(w); + if (/\s/.test(w)) for (const part of w.split(/\s+/)) if (part) out.add(part); + }; + gate.vocabulary.forEach(add); + (gate.unit.words ?? []).forEach(add); + (gate.unit.revisits ?? []).forEach((r) => add(r.word)); + return out; +} + +/** The unit that introduces a word, for the rejection note — or "". */ +export function unitOf(word: string): string { + return UNITS.find((u) => (u.words ?? []).includes(word) || (u.revisits ?? []).some((r) => r.word === word))?.id ?? ""; } /* ── the prompt ──────────────────────────────────────────────────── */ @@ -116,32 +182,46 @@ export function gateFor({ progress, bandQuery }: GateInputs): Gate { const TASK_TYPES = ["translate", "recall", "match", "build", "choice"] as const; export type TaskKind = (typeof TASK_TYPES)[number]; +/** + * The seven focus modes, in the artifact's words. "auto" says nothing: the + * unit decides. The rest bias the session — never past the gate, which the + * shipped prompt makes absolute. + */ export const FOCUS_MODES = { auto: "", - sentence: "Bias this session toward reading whole sentences.", - vocab: "Bias this session toward vocabulary breadth — more words, more matching.", - particles: "Bias this session toward particles and what they mark.", - sound: "Bias this session toward sound changes and reading aloud in your head.", - manhwa: "Bias this session toward manhwa dialogue: 반말, contractions, sound words.", - free: "He asked to just talk. Follow his lead, but stay inside the gate.", + sentence: + "Sentence logic and the ending word. Strings of words with no particles — subject/object + ending word — for him to decode, in the 반말 register manhwa uses.", + vocab: + "Vocabulary. Drill recall and recognition, mixing in words from earlier exercises so they stick. Matching tasks work well here.", + particles: + "Particles — 은/는, 이/가, 을/를. Introduce ONE at a time, show the same sentence with and without it, and build up slowly.", + sound: + "Sound changes while reading. Words written one way and pronounced another; ask for the spoken form in 한글 in brackets, and name the rule (연음, 비음화, 격음화, 경음화, 구개음화, 유음화, ㅎ 탈락).", + manhwa: + "Real manhwa lines — short 반말 speech-bubble lines, interjections, sound words. Set the scene in one clause where it helps.", + free: "Follow his lead. Answer what he asks, and steer back to reading practice when the thread runs out.", } as const; export type FocusMode = keyof typeof FOCUS_MODES; /** {{VARIETY}} — the only anti-repetition mechanism the tutor has. */ export function varietyLine(recent: string[]): string { - const last = recent.slice(-4); - if (!last.length) return "Pick whichever exercise type suits the material."; - const unused = TASK_TYPES.filter((t) => !last.includes(t)); - return ( - `Your last exercises were: ${last.join(", ")}. ` + - (unused.length - ? `Use a different type this time — ${unused.join(" or ")}.` - : "Vary the type from the last one.") - ); + const last = recent.slice(-8); + const cold = TASK_TYPES.filter((t) => !last.includes(t)); + return [ + `Your last exercises were, most recent first: ${last.length ? [...last].reverse().join(", ") : "none yet"}.`, + "The five types are: translate (read 한글, type the English) · recall (read English, WRITE the 한글) · match · build · choice.", + cold.length + ? `You have not used ${cold.join(" or ")} recently — use one of those now unless he asked for something specific.` + : "Pick a different type from the last one.", + "recall is the one that proves memory rather than recognition: he cannot guess from a list. Use it regularly, and never run the same type three times in a row.", + ].join("\n"); } /** {{FOCUS}} — one line, or nothing at all on auto. */ -export const focusLine = (mode: FocusMode): string => FOCUS_MODES[mode] ?? ""; +export const focusLine = (mode: FocusMode): string => + FOCUS_MODES[mode] + ? `He has manually set the focus to this, so work on it rather than the unit above: ${FOCUS_MODES[mode]}` + : ""; export interface PromptInputs { template: string; @@ -183,8 +263,21 @@ function fill(template: string, name: string, value: string): string { * Assemble the system prompt. The template is prompt/tutor-system.md, * shipped unchanged — this fills its three placeholders and nothing else. */ +/** + * The prompt's body: everything below its first `---` line. + * + * tutor-system.md opens with a paragraph for whoever maintains the app — + * which placeholder is filled by what, and a note on the Agent SDK path. It + * was being sent to the model too. The file ships unchanged; the header is + * simply not part of what the tutor reads, and the artifact never sent it. + */ +export function promptBody(template: string): string { + const rule = template.match(/^---[ \t]*$/m); + return rule?.index === undefined ? template : template.slice(rule.index + rule[0].length).replace(/^\s+/, ""); +} + export function assemblePrompt({ template, gate, recent, focus }: PromptInputs): string { - let out = fill(template, "GATE", renderGate(gate)); + let out = fill(promptBody(template), "GATE", renderGate(gate)); out = fill(out, "VARIETY", varietyLine(recent)); out = fill(out, "FOCUS", focusLine(focus)); return out; diff --git a/app/src/domain/resolver.ts b/app/src/domain/resolver.ts new file mode 100644 index 0000000..3807760 --- /dev/null +++ b/app/src/domain/resolver.ts @@ -0,0 +1,170 @@ +/* One resolver for word taps and for the gate. + + "Which dictionary word is this surface form?" is asked twice — when the + learner taps a word, and when the gate decides whether the tutor was + allowed to use it — and the bundle's lib/lexicon.js exists so the two can + never drift. Get the first wrong and he is told a taught word is "not in + the word list"; get the second wrong and 닭이 is flagged as unknown when it + is 닭 with a particle. + + lib/lexicon.js is the resolver; the dictionary is only its fallback. The + order was measured on the bundle's 54 real tutor messages (audit-gate.mjs + expects 7 and 2 flagged): + + the dictionary's surface table alone → 4 · 2 too weak + lib/lexicon.js, dictionary behind it → 7 · 2 the shipped gate + + The surface table routes 마셔 to 마시다, a card he has met, so a unit-2.3 + form becomes legal in Phase 1 — exactly the ordering bug lexicon.js warns + about. lexicon.js enters the roadmap's own words first, so it does not. + The dictionary answers only when lexicon.js has no route at all. */ + +import { buildLexicon, type LexEntry, type Lexicon } from "@lib/lexicon.js"; +import { haeche, past } from "@lib/conjugation.js"; +import type { WordEntry } from "@lib/blocks.js"; +import deckJson from "@data/deck.json"; +import glossExtraJson from "@data/gloss-extra.json"; +import sentencesJson from "@data/sentences.json"; +import sfxJson from "@data/sfx.json"; +import type { Db } from "../db/types.js"; +import { UNITS } from "./gate.js"; + +interface DeckFile { + topics: Record; +} +interface GlossExtraFile { + entries: { ko: string; en: string; note?: string }[]; +} +interface SentencesFile { + sentences: { parts: [string, string][] }[]; +} +interface SfxFile { + items: { ko: string; en: string }[]; +} + +let built: Lexicon | null = null; + +/** The shipped data as lib/lexicon.js reads it — in audit-gate.mjs's order. */ +export function lexicon(): Lexicon { + built ??= buildLexicon( + { + // Scheduled words first, with no base: see the warning in lexicon.js. + roadmapWords: UNITS.flatMap((u) => u.words ?? []), + deck: Object.values((deckJson as unknown as DeckFile).topics) + .flat() + .map(([ko, , en, pos]) => ({ ko, en, pos })), + glossExtra: (glossExtraJson as unknown as GlossExtraFile).entries.map((g) => [g.ko, g.en, g.note] as const), + sentences: (sentencesJson as unknown as SentencesFile).sentences, + sfx: (sfxJson as unknown as SfxFile).items.map((i) => [i.ko, i.en] as const), + }, + { haeche, past }, + ); + return built; +} + +const MAX_IN = 900; // Android's bound-parameter ceiling; see db/writes.ts. + +/** Every dictionary headword each form is a surface of, in one pass. */ +async function dictionaryHeads(db: Db, forms: string[]): Promise> { + const out = new Map(); + for (let i = 0; i < forms.length; i += MAX_IN) { + const batch = forms.slice(i, i + MAX_IN); + const rows = await db.all<{ form: string; head: string }>( + `SELECT DISTINCT s.form AS form, l.headword AS head + FROM surface s JOIN lemma l ON l.id = s.lemma_id + WHERE s.form IN (${batch.map(() => "?").join(",")})`, + batch, + ); + for (const r of rows) out.set(r.form, [...(out.get(r.form) ?? []), r.head]); + } + return out; +} + +/** + * A synchronous heads() for a set of tokens — lib/gate.js scanTask() calls + * it inline, so the dictionary is consulted up front for exactly the tokens + * lexicon.js cannot place. + */ +export async function headsFor(db: Db, tokens: Iterable): Promise<(token: string) => string[]> { + const lex = lexicon(); + const known = new Map(); + const missing: string[] = []; + for (const t of new Set(tokens)) { + const h = lex.heads(t); + if (h.length) known.set(t, h); + else missing.push(t); + } + if (missing.length) { + for (const [form, heads] of await dictionaryHeads(db, missing)) known.set(form, heads); + } + return (token) => known.get(token) ?? lex.heads(token); +} + +/** What a tap shows. */ +export interface Gloss { + ko: string; + gloss: string; + note: string; + /** Where it came from: the shipped lexicon, the dictionary, or the tutor. */ + from: "lexicon" | "dictionary" | "tutor"; +} + +const fromLexicon = (token: string, e: LexEntry): Gloss => ({ + ko: token, + // A roadmap word is entered with its own spelling as a placeholder gloss; + // that is no gloss at all, so the dictionary is asked instead. + gloss: e.gloss === e.ko && e.src === "roadmap" ? "" : e.gloss, + note: e.note, + from: "lexicon", +}); + +/** + * Gloss many forms: lexicon.js first, then the dictionary, then — for taps + * only — whatever the tutor declared in ::words. A declared gloss explains a + * word; it never makes the word allowed, which is the gate's business. + */ +export async function glossMany( + db: Db, + forms: string[], + declared: WordEntry[] | null = null, +): Promise> { + const lex = lexicon(); + const out = new Map(); + const ask: string[] = []; + + for (const f of new Set(forms)) { + const e = lex.lookup(f); + const g = e ? fromLexicon(f, e) : null; + if (g?.gloss) out.set(f, g); + else ask.push(f); + } + + if (ask.length) { + for (let i = 0; i < ask.length; i += MAX_IN) { + const batch = ask.slice(i, i + MAX_IN); + const rows = await db.all<{ form: string; gloss: string; analysis: string; headword: string }>( + `SELECT s.form AS form, l.gloss_en AS gloss, s.analysis AS analysis, l.headword AS headword + FROM surface s JOIN lemma l ON l.id = s.lemma_id + WHERE s.form IN (${batch.map(() => "?").join(",")}) AND l.gloss_en <> '' + ORDER BY CASE l.source WHEN 'curated' THEN 0 WHEN 'grammar' THEN 1 WHEN 'curriculum' THEN 2 + WHEN 'sentence' THEN 3 WHEN 'sfx' THEN 4 ELSE 5 END, + l.freq_rank IS NULL, l.freq_rank`, + batch, + ); + for (const r of rows) { + if (out.has(r.form)) continue; + out.set(r.form, { + ko: r.form, + gloss: r.gloss, + note: r.headword !== r.form ? r.analysis : "", + from: "dictionary", + }); + } + } + } + + for (const w of declared ?? []) { + if (w.ko && w.gloss && !out.has(w.ko)) out.set(w.ko, { ko: w.ko, gloss: w.gloss, note: w.note, from: "tutor" }); + } + return out; +} diff --git a/test/domain/gate-audit.test.ts b/test/domain/gate-audit.test.ts new file mode 100644 index 0000000..27e2623 --- /dev/null +++ b/test/domain/gate-audit.test.ts @@ -0,0 +1,112 @@ +/* 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 { parse } from "@lib/blocks.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; 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 { + 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 = parse(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 아파"]); + }); +}); diff --git a/test/domain/gate.test.ts b/test/domain/gate.test.ts index 7d31734..dadff60 100644 --- a/test/domain/gate.test.ts +++ b/test/domain/gate.test.ts @@ -1,7 +1,8 @@ /* The gate — the guarantee that the tutor cannot teach out of order. These test the three refinements the app layers inside vocabQuery, plus - the prompt assembly and the confidence clamp. */ + the prompt assembly. The clamp on the tutor's progress reports is tested + against a real database in learner.test.ts. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -16,7 +17,6 @@ import { focusLine, type BandQuery, } from "@app/domain/gate.js"; -import { MAX_DELTA_PER_TURN, READY_AT } from "@app/domain/progress.js"; import { featureLevel, isReadableAt } from "@shared/phonology.mjs"; import type { ProgressState } from "@lib/gate.js"; @@ -200,8 +200,10 @@ describe("the prompt", () => { const gate = gateFor({ progress: at("2.1"), bandQuery: bandOffering("학교") }); const out = assemblePrompt({ template: SHIPPED, gate, recent: [], focus: "auto" }); - // The header must survive intact, with its backticked mentions. - expect(out).toContain("`{{GATE}}` is `renderGate()`"); + // The header paragraph is for whoever maintains the app, and is not + // sent: the prompt the model reads starts below its --- rule. + expect(out).not.toContain("Assembled per turn"); + expect(out.startsWith("You are 선생님")).toBe(true); // And the gate must land after the profile and before the pre-flight // check, which is where the shipped file puts the slot. @@ -240,44 +242,15 @@ describe("the prompt", () => { expect(out).toContain(gate.forbidden.near[0]!); }); - it("asks for a different exercise type than the last few", () => { + it("asks for a type not used recently, naming all five", () => { const line = varietyLine(["translate", "match"]); - expect(line).toContain("translate"); - expect(line.includes("build") || line.includes("choice")).toBe(true); + expect(line).toContain("most recent first: match, translate."); + expect(line).toContain("You have not used recall or build or choice recently"); + expect(line).toContain("recall is the one that proves memory"); }); - it("says nothing on auto focus", () => { + it("says nothing on auto focus, and marks a chosen focus as his choice", () => { expect(focusLine("auto")).toBe(""); - expect(focusLine("manhwa")).not.toBe(""); - }); -}); - -describe("the confidence clamp", () => { - /* The artifact wrote the model's number straight into the advancement - gate, so one hallucinated ::progress 95 could skip a unit. */ - it("limits how far one turn may climb", () => { - expect(MAX_DELTA_PER_TURN).toBeLessThan(READY_AT); - const before = 20; - const asked = 95; - const stored = asked <= before ? asked : Math.min(asked, before + MAX_DELTA_PER_TURN); - expect(stored).toBe(before + MAX_DELTA_PER_TURN); - expect(stored).toBeLessThan(READY_AT); - }); - - it("takes several honest turns to reach the advancement threshold", () => { - let c = 0; - let turns = 0; - while (c < READY_AT && turns < 50) { - c = Math.min(100, c + MAX_DELTA_PER_TURN); - turns++; - } - expect(turns).toBeGreaterThan(5); - }); - - it("honours a downward correction in full", () => { - const before = 80; - const asked = 30; - const stored = asked <= before ? asked : Math.min(asked, before + MAX_DELTA_PER_TURN); - expect(stored).toBe(30); + expect(focusLine("manhwa")).toMatch(/^He has manually set the focus to this/); }); });