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>
284 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|