/* 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. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { UNITS, VOCAB_CAP, assemblePrompt, gateFor, makeVocabQuery, unitIndex, varietyLine, 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"; /** Progress with every unit before `id` finished. */ function at(id: string): ProgressState { const i = unitIndex(id); const done: Record = {}; UNITS.slice(0, i).forEach((u) => (done[u.id] = true)); return { current: id, done, confidence: {} }; } /** A band that offers every word given, in order. */ const bandOffering = (...words: string[]): BandQuery => () => words.map((headword) => ({ headword })); describe("vocabQuery — words already taught", () => { it("offers nothing on the very first unit", () => { // buildGate's own semantics: vocabulary is what FINISHED units taught. const gate = gateFor({ progress: at("1.1"), bandQuery: bandOffering() }); expect(gate.vocabulary).toEqual([]); // …but the unit's own new words are separate, and there are plenty. expect(gate.newWords.length).toBeGreaterThan(0); }); it("grows as units are finished", () => { const early = gateFor({ progress: at("1.3"), bandQuery: bandOffering() }); const later = gateFor({ progress: at("2.5"), bandQuery: bandOffering() }); expect(later.vocabulary.length).toBeGreaterThan(early.vocabulary.length); }); it("includes every word of every finished unit", () => { const progress = at("2.3"); const gate = gateFor({ progress, bandQuery: bandOffering() }); const taught = UNITS.filter((u) => progress.done[u.id]).flatMap((u) => u.words ?? []); // Phase 1 words are sound-filtered, so check the ones that survive. const level = featureLevel(unitIndex("2.3"), unitIndex); for (const w of taught) { if (isReadableAt(w, level)) expect(gate.vocabulary, w).toContain(w); } }); }); describe("refinement 1 — a later unit's words never leak", () => { it("excludes a word the band offers but a future unit owns", () => { // 빨갛다 is introduced in 3.4. At 2.1 it must not be reachable, however // common the frequency list says it is. const owner = UNITS.find((u) => (u.words ?? []).includes("빨갛다")); expect(owner?.id).toBe("3.4"); const gate = gateFor({ progress: at("2.1"), bandQuery: bandOffering("빨갛다", "학교") }); expect(gate.vocabulary).not.toContain("빨갛다"); }); it("admits it once its own unit is finished", () => { const progress = at("3.5"); // 3.4 is done const gate = gateFor({ progress, bandQuery: bandOffering("빨갛다") }); expect(gate.vocabulary).toContain("빨갛다"); }); }); describe("refinement 2 — phase 1 is filtered by sound", () => { it("rejects a band word the learner cannot yet read", () => { // At 1.4 he has one final consonant and no compound vowels. const gate = gateFor({ progress: at("1.4"), bandQuery: bandOffering("괜찮다", "값", "밥"), }); expect(gate.vocabulary).not.toContain("괜찮다"); // 겹받침, taught at 1.7 expect(gate.vocabulary).not.toContain("값"); }); it("stops filtering once the sound phase is over", () => { const gate = gateFor({ progress: at("4.1"), bandQuery: bandOffering("괜찮다") }); expect(gate.vocabulary).toContain("괜찮다"); }); }); describe("refinement 3 — the list is capped", () => { it("never exceeds the cap, however wide the band", () => { const many = Array.from({ length: VOCAB_CAP * 4 }, (_, i) => `말${i}`); const gate = gateFor({ progress: at("6.1"), bandQuery: bandOffering(...many) }); expect(gate.vocabulary.length).toBeLessThanOrEqual(VOCAB_CAP); }); it("cuts the band, never the words he was actually taught", () => { const progress = at("5.1"); const taught = UNITS.filter((u) => progress.done[u.id]).flatMap((u) => u.words ?? []); const many = Array.from({ length: VOCAB_CAP * 4 }, (_, i) => `말${i}`); const gate = gateFor({ progress, bandQuery: bandOffering(...many) }); const level = featureLevel(unitIndex("5.1"), unitIndex); const readable = [...new Set(taught.filter((w) => isReadableAt(w, level)))]; for (const w of readable) expect(gate.vocabulary, w).toContain(w); }); it("takes the band in the order it was given — most frequent first", () => { const gate = gateFor({ progress: at("4.1"), bandQuery: bandOffering("첫째", "둘째", "셋째"), }); const band = gate.vocabulary.filter((w) => ["첫째", "둘째", "셋째"].includes(w)); expect(band).toEqual(["첫째", "둘째", "셋째"]); }); }); describe("vocabQuery is never wider than the band", () => { it("returns nothing the band did not offer and no unit taught", () => { const progress = at("3.1"); const gate = gateFor({ progress, bandQuery: bandOffering("학교") }); const taught = new Set( UNITS.filter((u) => progress.done[u.id]).flatMap((u) => u.words ?? []), ); const revisits = new Set((UNITS[unitIndex("3.1")]!.revisits ?? []).map((r) => r.word)); for (const w of gate.vocabulary) { expect(w === "학교" || taught.has(w) || revisits.has(w), w).toBe(true); } }); it("is handed the current unit and the finished list, as lib/gate.js promises", () => { let seen: { unit: string; done: number } | null = null; gateFor({ progress: at("2.2"), bandQuery: () => [], }); // buildGate calls the wrapper; check the wrapper sees what it should. const query = makeVocabQuery(() => []); const unit = UNITS[unitIndex("2.2")]!; const done = UNITS.slice(0, unitIndex("2.2")); query(unit, done); seen = { unit: unit.id, done: done.length }; expect(seen.unit).toBe("2.2"); expect(seen.done).toBeGreaterThan(0); }); }); describe("the prompt", () => { const TEMPLATE = "BEFORE\n{{GATE}}\nMIDDLE\n{{VARIETY}}\nAFTER\n{{FOCUS}}"; /* The shipped prompt, byte for byte. The synthetic TEMPLATE above names each placeholder exactly once, and that is precisely the property the real file does not have — it mentions all three in its own header paragraph before using them, which is how a first-occurrence replace went unnoticed. Anything about substitution has to be asserted here. */ const SHIPPED = readFileSync(new URL("../../prompt/tutor-system.md", import.meta.url), "utf8"); it("fills all three placeholders and leaves the rest alone", () => { const gate = gateFor({ progress: at("2.1"), bandQuery: bandOffering("학교") }); const out = assemblePrompt({ template: TEMPLATE, gate, recent: ["translate", "match"], focus: "sentence", }); expect(out).not.toContain("{{"); expect(out).toContain("BEFORE"); expect(out).toContain("MIDDLE"); expect(out).toContain("AFTER"); }); it("leaves no placeholder in the SHIPPED prompt, which names each one twice", () => { const gate = gateFor({ progress: at("2.1"), bandQuery: bandOffering("학교") }); const out = assemblePrompt({ template: SHIPPED, gate, recent: ["translate"], focus: "sentence" }); // The header paragraph mentions all three inline, in backticks. expect(SHIPPED.match(/\{\{GATE\}\}/g)).toHaveLength(2); /* No placeholder may survive as a line of its own — that is a slot that did not get filled. The backticked mentions in the header are prose about how assembly works and are left exactly as written. */ for (const name of ["GATE", "VARIETY", "FOCUS"]) { const unfilled = new RegExp(`^\\{\\{${name}\\}\\}[ \\t]*$`, "m"); expect(unfilled.test(out), `{{${name}}} slot was never filled`).toBe(false); } }); it("puts the gate in its own slot, not into the header sentence", () => { 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()`"); // And the gate must land after the profile and before the pre-flight // check, which is where the shipped file puts the slot. const knows = out.indexOf("WHAT HE KNOWS"); expect(knows).toBeGreaterThan(out.indexOf("Register: manhwa is written")); expect(knows).toBeLessThan(out.indexOf("PRE-FLIGHT CHECK")); }); it("survives a gate containing $ sequences, which replace() would eat", () => { const out = assemblePrompt({ template: "A\n{{GATE}}\nB\n{{VARIETY}}\n{{FOCUS}}", gate: { ...gateFor({ progress: at("1.1"), bandQuery: bandOffering() }) }, recent: [], focus: "auto", }); expect(out).not.toContain("$&"); }); it("renders the gate's own headings — the model keys off them", () => { const gate = gateFor({ progress: at("2.1"), bandQuery: bandOffering("학교") }); const out = assemblePrompt({ template: TEMPLATE, gate, recent: [], focus: "auto" }); for (const heading of [ "WHAT HE KNOWS", "THIS UNIT ADDS", "NOT TAUGHT YET", "VOCABULARY YOU MAY USE", ]) { expect(out, heading).toContain(heading); } }); it("names the forbidden material, so the model cannot drift forward", () => { const gate = gateFor({ progress: at("1.2"), bandQuery: bandOffering() }); const out = assemblePrompt({ template: TEMPLATE, gate, recent: [], focus: "auto" }); expect(gate.forbidden.near.length).toBeGreaterThan(0); expect(out).toContain(gate.forbidden.near[0]!); }); it("asks for a different exercise type than the last few", () => { const line = varietyLine(["translate", "match"]); expect(line).toContain("translate"); expect(line.includes("build") || line.includes("choice")).toBe(true); }); it("says nothing on auto focus", () => { 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); }); });