/* 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);