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>
89 lines
4.1 KiB
JavaScript
89 lines
4.1 KiB
JavaScript
/* SM-2 lite. Four grades, day-granularity intervals. */
|
|
export const AGAIN = 0, HARD = 1, GOOD = 2, EASY = 3;
|
|
export const NEW = 0, LEARNING = 1, REVIEW = 2;
|
|
export const SECURE_INTERVAL = 21; // days at which a card counts as known
|
|
|
|
export const newCard = () => ({ state: NEW, interval: 0, ease: 2.5, due: 0, reps: 0, lapses: 0 });
|
|
|
|
export function grade(card, g, today) {
|
|
const c = { ...card };
|
|
if (c.state === NEW || c.state === LEARNING) {
|
|
if (g <= HARD) { c.state = LEARNING; c.interval = 0; c.due = today; }
|
|
else if (g === GOOD){ c.state = REVIEW; c.interval = 1; c.due = today + 1; }
|
|
else { c.state = REVIEW; c.interval = 4; c.due = today + 4; }
|
|
} else {
|
|
if (g === AGAIN) { c.ease = Math.max(1.3, c.ease - 0.2); c.lapses++; c.state = LEARNING; c.interval = 0; c.due = today; }
|
|
else if (g === HARD) { c.ease = Math.max(1.3, c.ease - 0.15); c.interval = Math.max(1, Math.round(c.interval * 1.2)); c.due = today + c.interval; }
|
|
else if (g === GOOD) { c.interval = Math.max(1, Math.round(c.interval * c.ease)); c.due = today + c.interval; }
|
|
else { c.ease = Math.min(3, c.ease + 0.15); c.interval = Math.max(2, Math.round(c.interval * c.ease * 1.3)); c.due = today + c.interval; }
|
|
c.interval = Math.min(c.interval, 365);
|
|
c.due = Math.min(c.due, today + 365);
|
|
}
|
|
c.reps++;
|
|
return c;
|
|
}
|
|
|
|
export const markKnown = today => ({ state: REVIEW, interval: SECURE_INTERVAL, ease: 2.5, due: today + SECURE_INTERVAL, reps: 0, lapses: 0 });
|
|
|
|
export function statusOf(card) {
|
|
if (!card || card.state === NEW) return "new";
|
|
if (card.state === LEARNING) return "learning";
|
|
return card.interval >= SECURE_INTERVAL ? "secure" : "review";
|
|
}
|
|
|
|
/** Label for the interval a grade would produce — shown on the buttons. */
|
|
export function preview(card, g, today) {
|
|
const c = grade(card || newCard(), g, today);
|
|
if (c.interval === 0) return "again now";
|
|
if (c.interval === 1) return "1 day";
|
|
if (c.interval < 30) return `${c.interval} days`;
|
|
const mo = Math.round(c.interval / 30);
|
|
return `${mo} month${mo === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
/** Local day number, DST-safe. */
|
|
export const dayNumber = (d = new Date()) =>
|
|
Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 864e5);
|
|
|
|
/* ── Recall evidence, kept separate from the schedule ──────────────────
|
|
The SRS above decides WHEN to show a card. This decides whether the
|
|
student actually knows the word, and the two must not be conflated.
|
|
|
|
The artifact's tutor kept certifying words on a single correct answer.
|
|
Getting a word right once proves nothing: it may be a guess, or it may
|
|
still be in the student's head from the line above. So a word counts as
|
|
learned only after three corrects, in three separate rounds, spread over
|
|
at least five rounds — which forces at least one genuine recall rather
|
|
than an echo. A lookup resets the streak: answering correctly after
|
|
looking the word up is not recall.
|
|
|
|
Enforce this in the CLIENT. The prompt asks the tutor to hold the line
|
|
and he does not; the artifact only stopped premature certification once
|
|
the app refused to store it. */
|
|
|
|
export const LEARNED_OK = 3, LEARNED_STREAK = 2, LEARNED_SPAN = 5;
|
|
|
|
export const newEvidence = () => ({
|
|
ok: 0, wrong: 0, lookups: 0, streak: 0,
|
|
firstRound: 0, lastRound: 0, lastSeen: 0, rounds: 0,
|
|
});
|
|
|
|
/** @param outcome "ok" | "wrong" @param round monotonic round counter */
|
|
export function noteOutcome(ev, outcome, round, lookedUp) {
|
|
const e = { ...ev };
|
|
if (e.firstRound === 0) e.firstRound = round;
|
|
if (e.lastSeen !== round) { e.rounds++; e.lastSeen = round; }
|
|
e.lastRound = round;
|
|
if (lookedUp) { e.lookups++; e.streak = 0; return e; } // not recall
|
|
if (outcome === "ok") { e.ok++; e.streak++; }
|
|
else { e.wrong++; e.streak = 0; }
|
|
return e;
|
|
}
|
|
|
|
export const isLearned = ev =>
|
|
ev.ok >= LEARNED_OK && ev.streak >= LEARNED_STREAK &&
|
|
ev.rounds >= LEARNED_OK && (ev.lastRound - ev.firstRound) >= LEARNED_SPAN;
|
|
|
|
/** Reject a tutor's ::confirmed for a word the evidence does not support. */
|
|
export const acceptConfirmation = ev => isLearned(ev);
|