chore: scaffold, CI, and the export bundle verbatim
The export bundle is the input to this port, not a sketch: the curriculum, the tutor prompt and the five logic modules are finished and tested. They land here byte-identical and stay that way. diff -r export/data data && diff -r export/lib lib diff -r export/prompt prompt && diff export/validate.mjs validate.mjs data/, lib/, prompt/ and validate.mjs sit at the repo root so validate.mjs runs verbatim with no path edits. All four are excluded from lint and formatting — they are not ours to restyle. Types for lib/ live alongside in types/ rather than as sibling .d.ts files, so the verbatim check stays a plain directory diff. CI runs the curriculum gate first, before anything else can pass: node validate.mjs PASS — 0 blocking, 0 advisory Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
86
lib/blocks.js
Normal file
86
lib/blocks.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/* The block protocol — how 선생님 drives the UI.
|
||||
The tutor writes prose plus fenced blocks; the client renders them
|
||||
as real interface and sends structured answers back. */
|
||||
|
||||
const RE = {
|
||||
task: /::task\s+(translate|match|build|choice)\s*\n([\s\S]*?)(?:\n::|$)/,
|
||||
words: /::words\s*\n([\s\S]*?)(?:\n::|$)/,
|
||||
gloss: /::gloss\s*\n([\s\S]*?)(?:\n::|$)/,
|
||||
progress: /::progress\s+(\d{1,3})\s*(?:\|\s*([^\n]*))?/,
|
||||
};
|
||||
const rows = s => s.split("\n").map(l => l.trim()).filter(l => l && !/^::/.test(l));
|
||||
const cols = l => l.split("|").map(x => x.trim());
|
||||
|
||||
export function parse(text) {
|
||||
const w = text.match(RE.words), t = text.match(RE.task);
|
||||
const g = text.match(RE.gloss), p = text.match(RE.progress);
|
||||
|
||||
const words = w ? rows(w[1]).map(l => { const c = cols(l);
|
||||
return { ko: c[0], gloss: c[1] || "", note: c[2] || "" }; }) : null;
|
||||
|
||||
let task = null;
|
||||
if (t) {
|
||||
const r = rows(t[2]);
|
||||
if (t[1] === "translate") task = { type: "translate", items: r.map(q => ({ q })) };
|
||||
if (t[1] === "match") task = { type: "match", pairs: r.map(l => { const c = cols(l);
|
||||
return { ko: c[0], gloss: c[1] }; }).filter(x => x.ko && x.gloss) };
|
||||
if (t[1] === "build") task = { type: "build", items: r.map(l => { const c = cols(l);
|
||||
return { en: c[0], chips: c.slice(1).filter(Boolean) }; }).filter(x => x.en && x.chips.length) };
|
||||
if (t[1] === "choice") task = { type: "choice", items: r.map(l => { const c = cols(l);
|
||||
return { q: c[0], options: c.slice(1).filter(Boolean) }; }).filter(x => x.q && x.options.length > 1) };
|
||||
}
|
||||
|
||||
let gloss = null;
|
||||
if (g) {
|
||||
const blocks = []; let cur = null;
|
||||
g[1].split("\n").forEach(line => {
|
||||
const l = line.trim();
|
||||
if (!l || /^::/.test(l)) return;
|
||||
if (l.startsWith("=")) { if (cur) cur.en = l.slice(1).trim(); return; }
|
||||
const c = cols(l);
|
||||
if (!cur) { cur = { parts: [], en: "" }; blocks.push(cur); }
|
||||
cur.parts.push({ ko: c[0], role: (c[1] || "N").toUpperCase()[0], gloss: c[2] || "", highlight: c[3] || "" });
|
||||
});
|
||||
const keep = blocks.filter(b => b.parts.length);
|
||||
if (keep.length) gloss = keep;
|
||||
}
|
||||
|
||||
let body = text;
|
||||
if (p) body = body.replace(p[0], "");
|
||||
if (g) body = body.replace(g[0], "");
|
||||
if (w) body = body.slice(0, body.indexOf("::words") >= 0 ? body.indexOf("::words") : body.length);
|
||||
if (t) body = body.replace(t[0], "");
|
||||
|
||||
return {
|
||||
body: body.replace(/\n{3,}/g, "\n\n").trim(),
|
||||
words, task, gloss,
|
||||
progress: p ? { score: Math.max(0, Math.min(100, +p[1])), note: (p[2] || "").trim() } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Roles a gloss part can carry, and what the UI should do with each. */
|
||||
export const ROLES = {
|
||||
S: "주어 subject", T: "주제 topic", O: "목적어 object",
|
||||
V: "서술어 predicate", P: "자리·때 place/time", C: "이음 connective",
|
||||
Q: "인용 quotation", M: "수식 modifier", N: "",
|
||||
};
|
||||
|
||||
/** Turn a completed task back into the message the student sends. */
|
||||
export function answerText(task, state, lookups = []) {
|
||||
let body;
|
||||
if (task.type === "translate")
|
||||
body = "My answers:\n" + task.items.map((it, i) =>
|
||||
`${it.q} → ${(state[i] || "").trim() || "(not sure)"}`).join("\n");
|
||||
else if (task.type === "match")
|
||||
body = "My pairings:\n" + (state.pairs.map(p => `${p.ko} = ${p.gloss}`).join("\n") || "(none)");
|
||||
else if (task.type === "build")
|
||||
body = "My sentences:\n" + task.items.map((it, i) =>
|
||||
`${it.en} → ${(state[i] || []).join(" ") || "(not sure)"}`).join("\n");
|
||||
else
|
||||
body = "My choices:\n" + task.items.map((it, i) =>
|
||||
`${it.q} → ${state[i] == null ? "(not sure)" : it.options[state[i]]}`).join("\n");
|
||||
|
||||
return body + (lookups.length
|
||||
? `\n\n(I had to look up: ${lookups.join(", ")})`
|
||||
: "\n\n(No lookups.)");
|
||||
}
|
||||
99
lib/conjugation.js
Normal file
99
lib/conjugation.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/* Korean conjugation — the 아/어 rule and the seven irregular classes.
|
||||
Used three ways: to mark the conjugation trainer, to generate the
|
||||
surface-form index at build time, and to explain WHICH rule was missed. */
|
||||
import { decompose, compose } from "./hangul.js";
|
||||
|
||||
/** Forms that do not fall out of the rules and are simply known. */
|
||||
export const IRREGULAR_FORMS = {
|
||||
"덥다":"더워","춥다":"추워","쉽다":"쉬워","어렵다":"어려워","무섭다":"무서워",
|
||||
"맵다":"매워","가깝다":"가까워",
|
||||
"듣다":"들어","걷다":"걸어","묻다":"물어",
|
||||
"모르다":"몰라","부르다":"불러","다르다":"달라","빠르다":"빨라","고르다":"골라",
|
||||
"낫다":"나아","짓다":"지어","붓다":"부어",
|
||||
"하다":"해","되다":"돼","이다":"야","그렇다":"그래","어떻다":"어때",
|
||||
};
|
||||
|
||||
/** Dictionary form → 반말 present (해체). Returns null for non-verbs. */
|
||||
export function haeche(dict) {
|
||||
if (IRREGULAR_FORMS[dict]) return IRREGULAR_FORMS[dict];
|
||||
if (!dict || dict.slice(-1) !== "다") return null;
|
||||
const stem = dict.slice(0, -1);
|
||||
if (!stem) return null;
|
||||
if (stem.slice(-1) === "하") return stem.slice(0, -1) + "해";
|
||||
|
||||
const d = decompose(stem[stem.length - 1]);
|
||||
if (!d) return null;
|
||||
const [i, m, f] = d;
|
||||
const bright = (m === 0 || m === 8); // ㅏ or ㅗ → 아, else 어
|
||||
|
||||
if (m === 18 && f === 0) { // ㅡ drops: 크다 → 커, 바쁘다 → 바빠
|
||||
let h = 4;
|
||||
if (stem.length >= 2) {
|
||||
const prev = decompose(stem[stem.length - 2]);
|
||||
if (prev && (prev[1] === 0 || prev[1] === 8)) h = 0;
|
||||
}
|
||||
return stem.slice(0, -1) + compose(i, h, 0);
|
||||
}
|
||||
if (f === 0) { // vowel-final stem contracts
|
||||
if ([0, 4, 1, 5, 6, 2].includes(m)) return stem; // 가 · 서 · 보내 · 세 · 켜
|
||||
if (m === 8) return stem.slice(0, -1) + compose(i, 9, 0); // ㅗ+아 → ㅘ 오다 → 와
|
||||
if (m === 13) return stem.slice(0, -1) + compose(i, 14, 0); // ㅜ+어 → ㅝ 주다 → 줘
|
||||
if (m === 20) return stem.slice(0, -1) + compose(i, 6, 0); // ㅣ+어 → ㅕ 마시다 → 마셔
|
||||
if (m === 11) return stem.slice(0, -1) + compose(i, 10, 0); // ㅚ+어 → ㅙ 되다 → 돼
|
||||
return stem + (bright ? "아" : "어");
|
||||
}
|
||||
return stem + (bright ? "아" : "어");
|
||||
}
|
||||
|
||||
/** 반말 present → 반말 past. 먹어 → 먹었어, 가 → 갔어, 해 → 했어. */
|
||||
export function past(present) {
|
||||
if (!present) return null;
|
||||
const d = decompose(present[present.length - 1]);
|
||||
if (!d) return null;
|
||||
if (d[2] !== 0) return present + "었어";
|
||||
return present.slice(0, -1) + compose(d[0], d[1], 20) + "어";
|
||||
}
|
||||
|
||||
export const polite = present => present ? present + "요" : null;
|
||||
|
||||
/** Which class a dictionary form belongs to — drives the "why" in feedback. */
|
||||
export function irregularClass(dict) {
|
||||
if (IRREGULAR_FORMS[dict]) {
|
||||
if (/르다$/.test(dict)) return "르";
|
||||
if (/^(듣다|걷다|묻다)$/.test(dict)) return "ㄷ";
|
||||
if (/(렇다|얗다|갛다|떻다)$/.test(dict)) return "ㅎ";
|
||||
if (/^(낫다|짓다|붓다)$/.test(dict)) return "ㅅ";
|
||||
if (/^(하다|되다|이다)$/.test(dict)) return "special";
|
||||
return "ㅂ";
|
||||
}
|
||||
const stem = dict.slice(0, -1);
|
||||
const d = decompose(stem[stem.length - 1]);
|
||||
if (!d) return "regular";
|
||||
if (d[1] === 18 && d[2] === 0) return "ㅡ";
|
||||
return "regular";
|
||||
}
|
||||
|
||||
/** Human explanation of the rule applied — shown when an answer is wrong. */
|
||||
export function explain(dict) {
|
||||
const cls = irregularClass(dict);
|
||||
if (cls !== "regular") return `${cls} 불규칙`;
|
||||
const stem = dict.slice(0, -1);
|
||||
if (stem.slice(-1) === "하") return "하다 → 해";
|
||||
const d = decompose(stem[stem.length - 1]);
|
||||
const bright = d && (d[1] === 0 || d[1] === 8);
|
||||
return `stem ${stem} · last vowel ${bright ? "ㅏ/ㅗ → 아" : "neither → 어"}`;
|
||||
}
|
||||
|
||||
/** Build-time: every surface form a learner will meet, mapped back to its lemma.
|
||||
Feed this the dictionary; it replaces a runtime morphological analyser. */
|
||||
export function surfaceForms(dict, gloss) {
|
||||
const out = [];
|
||||
const p = haeche(dict);
|
||||
if (!p) return out;
|
||||
const g = gloss.replace(/^to be /, "").replace(/^to /, "");
|
||||
out.push({ form: p, gloss: g, note: `반말, from ${dict}` });
|
||||
out.push({ form: polite(p), gloss: g, note: `polite, from ${dict}` });
|
||||
const q = past(p);
|
||||
if (q) out.push({ form: q, gloss: `${g} (past)`, note: `반말 past, from ${dict}` });
|
||||
return out;
|
||||
}
|
||||
72
lib/gate.js
Normal file
72
lib/gate.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/* The gate — what the tutor is allowed to know, say and use, right now.
|
||||
Generated from the curriculum plus progress. This is the mechanism that
|
||||
stops material being taught out of order; port it before improving it. */
|
||||
|
||||
export function flatten(curriculum) {
|
||||
const units = [];
|
||||
curriculum.phases.forEach(p => p.units.forEach(u => units.push({ ...u, phase: p.phase, phaseKo: p.ko, phaseName: p.name })));
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param curriculum curriculum.json
|
||||
* @param progress { current: "1.4", done: {"1.1":true,...}, confidence: {"1.4":62} }
|
||||
* @param opts { vocabQuery } — with a dictionary, replaces the hand-listed words
|
||||
*/
|
||||
export function buildGate(curriculum, progress, opts = {}) {
|
||||
const units = flatten(curriculum);
|
||||
const at = id => units.findIndex(u => u.id === id);
|
||||
const i = Math.max(0, at(progress.current));
|
||||
const unit = units[i];
|
||||
const done = units.filter(u => progress.done[u.id]);
|
||||
|
||||
const taught = done.flatMap(u => u.teaches);
|
||||
|
||||
// everything a later unit teaches is, by construction, forbidden now
|
||||
const future = units.filter((u, k) => k > i || (!progress.done[u.id] && k !== i));
|
||||
const near = [...new Set(future.flatMap(u => u.teaches))].slice(0, 18);
|
||||
const tail = future.length ? future[future.length - 1] : null;
|
||||
|
||||
const vocabulary = opts.vocabQuery
|
||||
? opts.vocabQuery(unit, done) // e.g. freq_rank BETWEEN …
|
||||
: [...new Set(done.flatMap(u => u.words))];
|
||||
|
||||
// spiral targets: words first met in an earlier unit that this unit should
|
||||
// deliberately bring back. Only those whose home unit is actually finished.
|
||||
const doneIds = new Set(done.map(u => u.id));
|
||||
const revisits = (unit.revisits || []).filter(r => doneIds.has(r.from)).map(r => r.word);
|
||||
|
||||
return {
|
||||
unit, phase: { n: unit.phase, ko: unit.phaseKo, name: unit.phaseName },
|
||||
taught, forbidden: { near, tailUnit: tail, count: future.length },
|
||||
vocabulary, newWords: unit.words, revisits,
|
||||
confidence: progress.confidence?.[unit.id] ?? null,
|
||||
next: units[i + 1] || null,
|
||||
finished: done.map(u => u.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Render the gate into the system prompt section. Keep the headings — the
|
||||
model keys off them, and the pre-flight check refers to them by name. */
|
||||
export function renderGate(g) {
|
||||
const L = [];
|
||||
L.push(`He is on PHASE ${g.phase.n} · ${g.phase.ko} (${g.phase.name}), UNIT ${g.unit.id} · ${g.unit.ko} (${g.unit.name})${g.unit.vocabUnit ? " — a VOCABULARY unit" : ""}.`);
|
||||
L.push(`THIS UNIT'S GOAL: ${g.unit.goal}`, "");
|
||||
L.push("════ WHAT HE KNOWS — the complete list ════");
|
||||
L.push(g.taught.length ? g.taught.map(t => "• " + t).join("\n") : "• nothing yet — this is the very first unit", "");
|
||||
L.push("════ THIS UNIT ADDS ════", g.unit.teaches.map(t => "• " + t).join("\n"));
|
||||
if (g.unit.avoid?.length)
|
||||
L.push("\nAND EXPLICITLY EXCLUDES, even if it seems natural:\n" + g.unit.avoid.map(t => "✗ " + t).join("\n"));
|
||||
L.push("", "════ NOT TAUGHT YET — MUST NOT APPEAR ════");
|
||||
L.push("Every one of these belongs to a later unit. Using any of them, even in passing, even to be helpful, breaks the sequence:");
|
||||
L.push(g.forbidden.near.length ? g.forbidden.near.map(t => "✗ " + t).join("\n") : "— nothing; this is the last unit");
|
||||
if (g.forbidden.tailUnit)
|
||||
L.push(`…and everything else on the roadmap through ${g.forbidden.tailUnit.id} ${g.forbidden.tailUnit.ko}. If a thing is not on the KNOWS list above, it is not taught. That is the whole test — you do not need to recognise it on this list to exclude it.`);
|
||||
L.push("", "════ VOCABULARY YOU MAY USE ════", g.vocabulary.join(" · ") || "(none yet)");
|
||||
L.push("NEW WORDS THIS UNIT MAY INTRODUCE — and no others:",
|
||||
g.newWords.length ? g.newWords.join(" · ")
|
||||
: "(none — this unit adds no new vocabulary on purpose. It is a contrast/synthesis unit: work it entirely with words he already has.)");
|
||||
if (g.revisits.length)
|
||||
L.push("BRING BACK ON PURPOSE — he met these earlier and they are due for reuse here:", g.revisits.join(" · "));
|
||||
return L.join("\n");
|
||||
}
|
||||
125
lib/hangul.js
Normal file
125
lib/hangul.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/* 한글 — decomposition, composition, and a 두벌식 input method.
|
||||
No dependencies. Lifted from the artifact; the IME is unit-tested
|
||||
against 먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace. */
|
||||
|
||||
export const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
|
||||
export const JUNG = "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ";
|
||||
export const JONG = " ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ";
|
||||
|
||||
const VJOIN = {"ㅗㅏ":"ㅘ","ㅗㅐ":"ㅙ","ㅗㅣ":"ㅚ","ㅜㅓ":"ㅝ","ㅜㅔ":"ㅞ","ㅜㅣ":"ㅟ","ㅡㅣ":"ㅢ"};
|
||||
const FJOIN = {"ㄱㅅ":"ㄳ","ㄴㅈ":"ㄵ","ㄴㅎ":"ㄶ","ㄹㄱ":"ㄺ","ㄹㅁ":"ㄻ","ㄹㅂ":"ㄼ",
|
||||
"ㄹㅅ":"ㄽ","ㄹㅌ":"ㄾ","ㄹㅍ":"ㄿ","ㄹㅎ":"ㅀ","ㅂㅅ":"ㅄ"};
|
||||
const FSPLIT = Object.fromEntries(Object.entries(FJOIN).map(([k,v]) => [v, [k[0], k[1]]]));
|
||||
|
||||
/** [initialIndex, medialIndex, finalIndex] or null if not a syllable block. */
|
||||
export function decompose(ch) {
|
||||
const c = ch.charCodeAt(0) - 0xAC00;
|
||||
if (c < 0 || c > 11171) return null;
|
||||
return [Math.floor(c / 588), Math.floor((c % 588) / 28), c % 28];
|
||||
}
|
||||
export function compose(i, m, f = 0) {
|
||||
return String.fromCharCode(0xAC00 + (i * 21 + m) * 28 + f);
|
||||
}
|
||||
export const isJamo = {
|
||||
initial: c => CHO.includes(c),
|
||||
medial: c => JUNG.includes(c),
|
||||
final: c => c && JONG.indexOf(c) > 0,
|
||||
};
|
||||
|
||||
/* ── 두벌식 IME ───────────────────────────────────────────
|
||||
Hold one composing buffer per input. Feed jamo with key(),
|
||||
punctuation and spaces with text(), and Backspace with back().
|
||||
Each call returns the full new value. */
|
||||
export class Composer {
|
||||
constructor() { this.reset(); }
|
||||
reset() { this.cho = null; this.jung = null; this.jong = null; }
|
||||
get empty() { return !this.cho && !this.jung && !this.jong; }
|
||||
|
||||
/** The syllable currently being assembled, as text. */
|
||||
render() {
|
||||
if (this.cho && this.jung) {
|
||||
const i = CHO.indexOf(this.cho), m = JUNG.indexOf(this.jung);
|
||||
const f = this.jong ? JONG.indexOf(this.jong) : 0;
|
||||
if (i >= 0 && m >= 0 && f >= 0) return compose(i, m, f);
|
||||
}
|
||||
return (this.cho || "") + (this.jung || "") + (this.jong || "");
|
||||
}
|
||||
/** Strip the composing tail off a value so it can be rebuilt. */
|
||||
_base(value) {
|
||||
const cur = this.render();
|
||||
return cur && value.endsWith(cur) ? value.slice(0, -cur.length) : value;
|
||||
}
|
||||
|
||||
key(value, j) {
|
||||
let base = this._base(value);
|
||||
if (isJamo.medial(j)) {
|
||||
if (this.jong) { // final splits off to start a new block
|
||||
const parts = FSPLIT[this.jong];
|
||||
let moved;
|
||||
if (parts) { this.jong = parts[0]; moved = parts[1]; }
|
||||
else { moved = this.jong; this.jong = null; }
|
||||
base += this.render();
|
||||
this.cho = moved; this.jung = j; this.jong = null;
|
||||
} else if (this.jung) {
|
||||
const join = VJOIN[this.jung + j];
|
||||
if (join) this.jung = join;
|
||||
else { base += this.render(); this.reset(); this.jung = j; }
|
||||
} else this.jung = j;
|
||||
} else {
|
||||
if (this.cho && this.jung) {
|
||||
if (this.jong) {
|
||||
const join = FJOIN[this.jong + j];
|
||||
if (join) this.jong = join;
|
||||
else { base += this.render(); this.reset(); this.cho = j; }
|
||||
} else if (isJamo.final(j)) this.jong = j;
|
||||
else { base += this.render(); this.reset(); this.cho = j; }
|
||||
} else {
|
||||
if (!this.empty) base += this.render();
|
||||
this.reset(); this.cho = j;
|
||||
}
|
||||
}
|
||||
return base + this.render();
|
||||
}
|
||||
|
||||
back(value) {
|
||||
let base = this._base(value);
|
||||
if (this.jong) {
|
||||
const parts = FSPLIT[this.jong];
|
||||
this.jong = parts ? parts[0] : null;
|
||||
} else if (this.jung) {
|
||||
let peeled = null;
|
||||
for (const [k, v] of Object.entries(VJOIN)) if (v === this.jung) peeled = k[0];
|
||||
this.jung = peeled;
|
||||
} else if (this.cho) {
|
||||
this.cho = null;
|
||||
} else { // pull a finished block back in
|
||||
const last = base.slice(-1);
|
||||
base = base.slice(0, -1);
|
||||
const d = last ? decompose(last) : null;
|
||||
if (d) {
|
||||
this.cho = CHO[d[0]]; this.jung = JUNG[d[1]];
|
||||
this.jong = d[2] ? JONG[d[2]] : null;
|
||||
if (this.jong) { const p = FSPLIT[this.jong]; this.jong = p ? p[0] : null; }
|
||||
else this.jung = null;
|
||||
}
|
||||
}
|
||||
return base + this.render();
|
||||
}
|
||||
|
||||
/** Commit the buffer and append literal text (space, punctuation). */
|
||||
text(value, t) {
|
||||
const base = this._base(value) + this.render();
|
||||
this.reset();
|
||||
return base + t;
|
||||
}
|
||||
}
|
||||
|
||||
/** Standard 두벌식 layout, top row first. Shift gives the tense pairs. */
|
||||
export const KEYBOARD = {
|
||||
rows: [
|
||||
["ㅂ","ㅈ","ㄷ","ㄱ","ㅅ","ㅛ","ㅕ","ㅑ","ㅐ","ㅔ"],
|
||||
["ㅁ","ㄴ","ㅇ","ㄹ","ㅎ","ㅗ","ㅓ","ㅏ","ㅣ"],
|
||||
["ㅋ","ㅌ","ㅊ","ㅍ","ㅠ","ㅜ","ㅡ"],
|
||||
],
|
||||
shift: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"},
|
||||
};
|
||||
46
lib/srs.js
Normal file
46
lib/srs.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/* 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);
|
||||
Reference in New Issue
Block a user