Files
Hankan/test/domain/gate.test.ts
MechaCat02 089f303ff9 feat(gate): one resolver, the words he has met, and the audit through the port
The bundle's audit-gate.mjs measures the word gate against 54 real tutor
messages. Scored the same way, the port's gate was not the gate that was
measured:

  port's allowed set             13 · 2   over-fires on words he had met
  port's dictionary resolver      4 · 2   too weak: 마셔 → 마시다 again
  lib/lexicon.js, DB behind it    7 · 2   the shipped gate

domain/resolver.ts makes lib/lexicon.js the one resolver for word taps and
the gate, built from the shipped data in the audit's order — roadmap words
first, so a scheduled form cannot inherit its stem's permission — with the
dictionary consulted only where lexicon.js has no route. That fallback is
what the port adds over the artifact: a word the curated data does not know
is still recognised as a real word.

The allowed set gains every word he has met (any card out of "new"), as
the shipped gate has it; the frequency band stays on top from Phase 2, as
PORT.md specifies. The words the tutor is TOLD it may use and the words it
is CHECKED against are one set, so it is never refused for a word it was
offered.

test/domain/gate-audit.test.ts reproduces the audit through the app's own
code — database, loader, resolver — and gets the shipped 149-word allowed
set and the same 7 and 2 messages, word for word.

Also: {{VARIETY}} and {{FOCUS}} take the artifact's wording and know the
fifth exercise type, recall; and the prompt's maintainer header, which
explains the placeholders, is no longer sent to the model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:09:15 +02:00

257 lines
10 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. 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";
import {
UNITS,
VOCAB_CAP,
assemblePrompt,
gateFor,
makeVocabQuery,
unitIndex,
varietyLine,
focusLine,
type BandQuery,
} from "@app/domain/gate.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 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.
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 type not used recently, naming all five", () => {
const line = varietyLine(["translate", "match"]);
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, and marks a chosen focus as his choice", () => {
expect(focusLine("auto")).toBe("");
expect(focusLine("manhwa")).toMatch(/^He has manually set the focus to this/);
});
});