Files
Hankan/test/domain/gate.test.ts
MechaCat02 9a925a5e3f fix(gate): the gate was being spliced into the prompt's header sentence
assemblePrompt used template.replace("{{GATE}}", ...), and String.replace
with a string argument substitutes only the FIRST occurrence. All three
placeholders appear twice in tutor-system.md, because the document names
them in its own header paragraph before using them:

    Assembled per turn. `{{GATE}}` is `renderGate()` from `lib/gate.js`;
    `{{VARIETY}}` and `{{FOCUS}}` are one-liners built from recent state.

So the rendered gate replaced the backticked mention mid-sentence, and the
real slot further down was sent to the model as the literal text "{{GATE}}".
The mechanism that decides what the tutor is allowed to teach was delivered
in the wrong place, with a template token standing where it belonged, and
the same for VARIETY and FOCUS. Confirmed by capturing what the app
actually put on the wire: three unfilled placeholders at lines 60, 86 and
141.

Substitution is now anchored to a whole line, which is what distinguishes a
slot from a mention -- the header's are inline and backticked. The
replacement is a function because renderGate() output contains "$"
sequences that String.replace would otherwise interpret.

The existing tests could not have caught this. They ran against a synthetic
template naming each placeholder exactly once, which is precisely the
property the shipped file lacks. The new ones run against
prompt/tutor-system.md itself: no slot may survive unfilled, the header
must come through intact, and the gate must land between the profile and
the pre-flight check. Both fail against the old code.

Also appends a HOUSE STYLE section after the shipped prompt -- an addition
by the app, not an edit to the file, which still ships byte-identical.
It covers two things the prompt leaves to inference. The language of
explanation is never actually stated: "한글 and English only" is a rule
about retiring romanization, and everything else only implies English. A
strong model infers it; gpt-oss-20b delivered a full grammar lesson in
Korean to a student on unit 1.1 who cannot yet read it. And no-markdown is
stated outright at line 96 and was ignored anyway, so it is restated where
the consequence is visible: the app renders **bold** and nothing else, so a
table arrives as rows of literal pipes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:09:59 +02:00

284 lines
11 KiB
TypeScript

/* 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<string, boolean> = {};
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);
});
});