chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.
Copied byte-identical from the bundle:
lib/ lexicon.js and sync.js are new; gate.js gains enforcement,
hangul.js letter-level marking, srs.js recall evidence,
conjugation.js deconjugate(); blocks.js now takes the last
block, closes gloss at "=", and parses recall, ::result and
::confirmed
data/ curriculum.json v5 — six 다지기 phase reviews; the 371
roadmap words are unchanged and no band moves
prompt/ English-only rule, recall, LETTER-LEVEL CHECK, marking
audit-gate.mjs, run-checks.sh, fixtures/ — the word gate measured
against 54 real tutor messages
CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.
types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.
TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
87
types/lib/blocks.d.ts
vendored
87
types/lib/blocks.d.ts
vendored
@@ -6,24 +6,39 @@ export interface WordEntry {
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface TranslateTask {
|
||||
/** What parse() stamps onto whichever task it returns. */
|
||||
interface TaskMeta {
|
||||
/** How many earlier ::task blocks in the message were withdrawn. The
|
||||
LAST block wins; a non-zero count means the tutor retracted a draft. */
|
||||
retracted?: number;
|
||||
/** The block's raw rows, for the gate to read — see gate.js taskMaterial(). */
|
||||
rows?: string[];
|
||||
}
|
||||
|
||||
export interface TranslateTask extends TaskMeta {
|
||||
type: "translate";
|
||||
items: { q: string }[];
|
||||
}
|
||||
export interface MatchTask {
|
||||
/** English prompt; the student WRITES the 한글. The one type that proves
|
||||
recall rather than recognition, and the one that needs letterCheck(). */
|
||||
export interface RecallTask extends TaskMeta {
|
||||
type: "recall";
|
||||
items: { q: string; hint: string }[];
|
||||
}
|
||||
export interface MatchTask extends TaskMeta {
|
||||
type: "match";
|
||||
pairs: { ko: string; gloss: string }[];
|
||||
}
|
||||
export interface BuildTask {
|
||||
export interface BuildTask extends TaskMeta {
|
||||
type: "build";
|
||||
/** chips are given in correct order; the UI shuffles them. */
|
||||
items: { en: string; chips: string[] }[];
|
||||
}
|
||||
export interface ChoiceTask {
|
||||
export interface ChoiceTask extends TaskMeta {
|
||||
type: "choice";
|
||||
items: { q: string; options: string[] }[];
|
||||
}
|
||||
export type Task = TranslateTask | MatchTask | BuildTask | ChoiceTask;
|
||||
export type Task = TranslateTask | RecallTask | MatchTask | BuildTask | ChoiceTask;
|
||||
export type TaskType = Task["type"];
|
||||
|
||||
/** One uppercase letter; see ROLES. */
|
||||
@@ -46,11 +61,29 @@ export interface Progress {
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** One marked item from a ::result block. */
|
||||
export interface ResultRow {
|
||||
item: string;
|
||||
/** Only the literal "ok" (any case) counts as correct. */
|
||||
ok: boolean;
|
||||
/** On a wrong answer: what the tutor thinks the student mistook it for. */
|
||||
mistakenFor: string;
|
||||
}
|
||||
|
||||
export interface ParsedMessage {
|
||||
body: string;
|
||||
/** Every ::words entry in the message; the first gloss of a term wins. */
|
||||
words: WordEntry[] | null;
|
||||
/** The LAST ::task block. */
|
||||
task: Task | null;
|
||||
/** Every ::gloss block, accumulated; each closes at its "=" line. */
|
||||
gloss: GlossBlock[] | null;
|
||||
/** The last ::result block. */
|
||||
results: ResultRow[] | null;
|
||||
/** The last ::confirmed block, first column of each row. May carry a
|
||||
leading "-" meaning "put this back". */
|
||||
confirmed: string[] | null;
|
||||
/** The last ::progress line. */
|
||||
progress: Progress | null;
|
||||
}
|
||||
|
||||
@@ -61,20 +94,52 @@ export const ROLES: Record<GlossRole, string>;
|
||||
|
||||
/** State shapes accepted by answerText(), per task type. */
|
||||
export type TranslateState = string[];
|
||||
export type RecallState = string[];
|
||||
export type MatchState = { pairs: { ko: string; gloss: string }[] };
|
||||
export type BuildState = string[][];
|
||||
export type ChoiceState = (number | null)[];
|
||||
export type TaskState = TranslateState | MatchState | BuildState | ChoiceState;
|
||||
export type TaskState = TranslateState | RecallState | MatchState | BuildState | ChoiceState;
|
||||
|
||||
/** Turn a completed task back into the message the student sends.
|
||||
The state shape follows the task type, so these are overloads rather
|
||||
than one signature over a union. */
|
||||
than one signature over a union.
|
||||
|
||||
`letterBlock` is hangul.js letterCheck() output for a recall task — pass
|
||||
it, always. The tutor cannot see inside a syllable and will invent a
|
||||
diagnosis otherwise. Other task types ignore it. */
|
||||
export function answerText(
|
||||
task: TranslateTask,
|
||||
state: TranslateState,
|
||||
lookups?: string[],
|
||||
letterBlock?: string,
|
||||
): string;
|
||||
export function answerText(
|
||||
task: RecallTask,
|
||||
state: RecallState,
|
||||
lookups?: string[],
|
||||
letterBlock?: string,
|
||||
): string;
|
||||
export function answerText(
|
||||
task: MatchTask,
|
||||
state: MatchState,
|
||||
lookups?: string[],
|
||||
letterBlock?: string,
|
||||
): string;
|
||||
export function answerText(
|
||||
task: BuildTask,
|
||||
state: BuildState,
|
||||
lookups?: string[],
|
||||
letterBlock?: string,
|
||||
): string;
|
||||
export function answerText(
|
||||
task: ChoiceTask,
|
||||
state: ChoiceState,
|
||||
lookups?: string[],
|
||||
letterBlock?: string,
|
||||
): string;
|
||||
export function answerText(
|
||||
task: Task,
|
||||
state: TaskState,
|
||||
lookups?: string[],
|
||||
letterBlock?: string,
|
||||
): string;
|
||||
export function answerText(task: MatchTask, state: MatchState, lookups?: string[]): string;
|
||||
export function answerText(task: BuildTask, state: BuildState, lookups?: string[]): string;
|
||||
export function answerText(task: ChoiceTask, state: ChoiceState, lookups?: string[]): string;
|
||||
export function answerText(task: Task, state: TaskState, lookups?: string[]): string;
|
||||
|
||||
13
types/lib/conjugation.d.ts
vendored
13
types/lib/conjugation.d.ts
vendored
@@ -28,6 +28,17 @@ export interface SurfaceForm {
|
||||
|
||||
/**
|
||||
* Build-time: every surface form a learner will meet, mapped back to its lemma.
|
||||
* This is what replaces a runtime morphological analyser.
|
||||
*/
|
||||
export function surfaceForms(dict: string, gloss: string): SurfaceForm[];
|
||||
|
||||
/* ── reading an inflected form back to its dictionary entry ─────────── */
|
||||
|
||||
/** The endings the stripper tries, in the order it tries them. */
|
||||
export const ENDINGS: string[];
|
||||
|
||||
/** Every dictionary form (…다) this surface could plausibly be. Unguarded —
|
||||
pair it with a lexicon check, or 가지 (eggplant) becomes a form of 가다. */
|
||||
export function deconjugateCandidates(token: string): string[];
|
||||
|
||||
/** The first candidate `isVerb` accepts, or null. `isVerb` is required. */
|
||||
export function deconjugate(token: string, isVerb: (dictionaryForm: string) => boolean): string | null;
|
||||
|
||||
50
types/lib/gate.d.ts
vendored
50
types/lib/gate.d.ts
vendored
@@ -2,7 +2,12 @@
|
||||
|
||||
The gate is what stops material being taught out of order. buildGate()
|
||||
computes it from curriculum + progress; renderGate() turns it into the
|
||||
{{GATE}} section of prompt/tutor-system.md. */
|
||||
{{GATE}} section of prompt/tutor-system.md. The second half —
|
||||
scanTask(), proseIsKorean(), rejectionNote() — checks whether the tutor
|
||||
actually listened, and its scope was measured against real messages
|
||||
(audit-gate.mjs), not reasoned out. */
|
||||
|
||||
import type { ParsedMessage, Task } from "./blocks.js";
|
||||
|
||||
export interface Unit {
|
||||
id: string;
|
||||
@@ -10,6 +15,8 @@ export interface Unit {
|
||||
name: string;
|
||||
goal: string;
|
||||
vocabUnit: boolean;
|
||||
/** A 다지기 phase review: introduces nothing, confirms the whole phase. */
|
||||
review?: boolean;
|
||||
teaches: string[];
|
||||
avoid?: string[];
|
||||
/** Strictly NEW vocabulary. Deliberate repeats live in revisits[]. */
|
||||
@@ -78,3 +85,44 @@ export function buildGate(
|
||||
|
||||
/** Render the gate into the system prompt section. Keep the headings. */
|
||||
export function renderGate(g: Gate): string;
|
||||
|
||||
/* ── enforcing the gate ─────────────────────────────────────────────── */
|
||||
|
||||
/** Unit and phase names plus the course's metalanguage (받침, 비음화, …):
|
||||
Hangul a teacher may write without it being taught vocabulary. */
|
||||
export function buildScaffold(curriculum: Curriculum): Set<string>;
|
||||
|
||||
/** Only the side of an exercise the student must decode. Recall prompts
|
||||
are English, so a recall task contributes nothing. */
|
||||
export function taskMaterial(task: Task | null | undefined): string[];
|
||||
|
||||
export interface GateFinding {
|
||||
word: string;
|
||||
/** The unit that introduces it, or "" when no unit does. */
|
||||
unit: string;
|
||||
/** false: no gloss exists anywhere — a typo or an invented word. */
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
export interface ScanContext {
|
||||
allowed: Set<string>;
|
||||
scaffold: Set<string>;
|
||||
/** Every dictionary word this surface could be, best first. */
|
||||
heads: (token: string) => string[];
|
||||
/** The unit that introduces a word, or "". */
|
||||
unitOf: (word: string) => string;
|
||||
}
|
||||
|
||||
export function scanTask(
|
||||
parsed: Pick<ParsedMessage, "task" | "words"> | null | undefined,
|
||||
ctx: ScanContext,
|
||||
): GateFinding[];
|
||||
|
||||
/** ≥40 Hangul syllables in the prose outside blocks, and ≥50% of letters. */
|
||||
export function proseIsKorean(text: string): boolean;
|
||||
|
||||
/** What to tell the tutor when a message is sent back. */
|
||||
export function rejectionNote(findings: GateFinding[]): string;
|
||||
|
||||
/** Retries before a message is shown anyway, with its words flagged. */
|
||||
export const GATE_TRIES: number;
|
||||
|
||||
25
types/lib/hangul.d.ts
vendored
25
types/lib/hangul.d.ts
vendored
@@ -36,3 +36,28 @@ export const KEYBOARD: {
|
||||
rows: string[][];
|
||||
shift: Record<string, string>;
|
||||
};
|
||||
|
||||
/* ── letter-level marking ───────────────────────────────────────────── */
|
||||
|
||||
/** Two-consonant batchim clusters, unpacked: "ㄼ" → "ㄹ+ㅂ". */
|
||||
export const CLUSTER: Record<string, string>;
|
||||
|
||||
/** ["first consonant", "vowel", "batchim"] — the three slots of a syllable. */
|
||||
export const SLOT: string[];
|
||||
|
||||
/** "짧다" → "짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ" */
|
||||
export function spellOut(word: string): string;
|
||||
|
||||
/** The exact jamo difference, naming what was correct as well as what was
|
||||
wrong. "" when either side is empty; "identical" when they match. */
|
||||
export function letterDiff(expected: string, written: string): string;
|
||||
|
||||
export interface LetterRow {
|
||||
/** The item as the student saw it — for a recall task, the English. */
|
||||
prompt: string;
|
||||
expected: string;
|
||||
written: string;
|
||||
}
|
||||
|
||||
/** The LETTER-LEVEL CHECK block for a marking message; "" when nothing differs. */
|
||||
export function letterCheck(rows: LetterRow[]): string;
|
||||
|
||||
55
types/lib/lexicon.d.ts
vendored
Normal file
55
types/lib/lexicon.d.ts
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Declarations for lib/lexicon.js — the module itself ships unchanged.
|
||||
|
||||
One resolver shared by word lookup and the gate, so the two cannot drift.
|
||||
heads() returns EVERY route from a surface form to a dictionary word; a
|
||||
single "best" answer was the bug. */
|
||||
|
||||
export const PARTICLES: string[];
|
||||
|
||||
export interface LexEntry {
|
||||
ko: string;
|
||||
gloss: string;
|
||||
note: string;
|
||||
/** roadmap | deck | form | gloss | sentence | sfx */
|
||||
src: string;
|
||||
/** The dictionary word this entry is a form of, or "". */
|
||||
base: string;
|
||||
}
|
||||
|
||||
export class Lexicon {
|
||||
map: Map<string, LexEntry>;
|
||||
verbs: Set<string>;
|
||||
/** First writer wins: an existing entry is never replaced. */
|
||||
add(ko: string, gloss: string, note?: string, src?: string, base?: string): void;
|
||||
addVerb(dictionaryForm: string): void;
|
||||
get(ko: string): LexEntry | null;
|
||||
isVerb(ko: string): boolean;
|
||||
/** Every dictionary word this surface form could be, best first. */
|
||||
heads(token: string): string[];
|
||||
/** What to show when the student taps a word. */
|
||||
lookup(token: string): LexEntry | null;
|
||||
}
|
||||
|
||||
/** A deck row: [한글, romanization, English, POS], or the same as an object. */
|
||||
export type DeckWord =
|
||||
| { ko: string; en: string; pos: string }
|
||||
| readonly [string, string, string, string];
|
||||
|
||||
export interface LexiconSources {
|
||||
/** Entered FIRST, with no base — see the ordering warning in lexicon.js. */
|
||||
roadmapWords?: string[];
|
||||
deck?: DeckWord[];
|
||||
/** [한글, English, note?] */
|
||||
glossExtra?: (readonly [string, string, string?])[];
|
||||
sentences?: { parts?: (readonly [string, string, ...unknown[]])[] }[];
|
||||
/** [한글, English] */
|
||||
sfx?: (readonly [string, string])[];
|
||||
}
|
||||
|
||||
export function buildLexicon(
|
||||
sources: LexiconSources,
|
||||
conjugation: {
|
||||
haeche: (dict: string) => string | null;
|
||||
past: (present: string | null) => string | null;
|
||||
},
|
||||
): Lexicon;
|
||||
35
types/lib/srs.d.ts
vendored
35
types/lib/srs.d.ts
vendored
@@ -36,3 +36,38 @@ export function preview(card: Card | null | undefined, g: Grade, today: number):
|
||||
|
||||
/** Local day number, DST-safe. */
|
||||
export function dayNumber(d?: Date): number;
|
||||
|
||||
/* ── recall evidence, kept separate from the schedule ──────────────── */
|
||||
|
||||
export const LEARNED_OK: number;
|
||||
export const LEARNED_STREAK: number;
|
||||
export const LEARNED_SPAN: number;
|
||||
|
||||
export interface Evidence {
|
||||
ok: number;
|
||||
wrong: number;
|
||||
lookups: number;
|
||||
streak: number;
|
||||
/** Round of the first outcome of any kind. */
|
||||
firstRound: number;
|
||||
/** Round of the latest outcome of any kind. */
|
||||
lastRound: number;
|
||||
lastSeen: number;
|
||||
/** Distinct rounds with an outcome. */
|
||||
rounds: number;
|
||||
}
|
||||
|
||||
export function newEvidence(): Evidence;
|
||||
|
||||
/** Pure: returns a new record. A lookup is never a recall and resets the streak. */
|
||||
export function noteOutcome(
|
||||
ev: Evidence,
|
||||
outcome: "ok" | "wrong",
|
||||
round: number,
|
||||
lookedUp?: boolean,
|
||||
): Evidence;
|
||||
|
||||
export function isLearned(ev: Evidence): boolean;
|
||||
|
||||
/** Whether a tutor's ::confirmed for this word may be stored. */
|
||||
export function acceptConfirmation(ev: Evidence): boolean;
|
||||
|
||||
56
types/lib/sync.d.ts
vendored
Normal file
56
types/lib/sync.d.ts
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
/* Declarations for lib/sync.js — the module itself ships unchanged.
|
||||
|
||||
lib/sync.js syncs four whole documents. The port syncs rows (PORT.md), so
|
||||
it applies the same three gates — hydration, a counter rather than a
|
||||
clock, no silent shrinking — row by row rather than calling this module.
|
||||
It is declared here so its behaviour stays pinned by tests. */
|
||||
|
||||
export const DOCS: ("srs" | "log" | "meta" | "chat")[];
|
||||
|
||||
/** How much a copy holds. Shrinking is always deliberate, never a race. */
|
||||
export function weigh(name: string, d: unknown): number;
|
||||
|
||||
/** A document as it travels: stamp, counter, data, deliberate-shrink flag. */
|
||||
export interface RemoteDoc<T = unknown> {
|
||||
u?: number;
|
||||
v?: number;
|
||||
d?: T;
|
||||
x?: 1;
|
||||
}
|
||||
|
||||
export interface LocalDoc<T = unknown> {
|
||||
version?: number;
|
||||
stamp?: number;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/** Is the copy that just arrived later than ours? */
|
||||
export function isLater(remote: RemoteDoc, localVersion: number, localStamp: number): boolean;
|
||||
|
||||
export function reconcile(
|
||||
name: string,
|
||||
remote: RemoteDoc | null | undefined,
|
||||
local: LocalDoc,
|
||||
): "ignore" | "adopt" | "reassert";
|
||||
|
||||
export interface WriterDocState {
|
||||
version: number;
|
||||
stamp: number;
|
||||
dirty: boolean;
|
||||
hydrated: boolean;
|
||||
intent: boolean;
|
||||
}
|
||||
|
||||
export interface Writer<R> {
|
||||
state: Record<string, WriterDocState>;
|
||||
touch(name: string, deliberateShrink?: boolean): WriterDocState;
|
||||
hydrate(name: string): WriterDocState;
|
||||
adopted(name: string, remote: RemoteDoc): WriterDocState;
|
||||
reasserted(name: string, remote: RemoteDoc): WriterDocState;
|
||||
flush(name: string, data: unknown): R | null;
|
||||
}
|
||||
|
||||
export function makeWriter<R>(opts: {
|
||||
push: (name: string, body: RemoteDoc) => R;
|
||||
now?: () => number;
|
||||
}): Writer<R>;
|
||||
Reference in New Issue
Block a user