+
+
+
+ Hankan — 한국어 읽기
+
+
+
+
+
+
+
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 0000000..fae0c39
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@hankan/app",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "cap:sync": "cap sync"
+ },
+ "dependencies": {
+ "@capacitor-community/sqlite": "^8.0.0",
+ "@capacitor/android": "^8.0.0",
+ "@capacitor/core": "^8.0.0",
+ "@capacitor/keyboard": "^8.0.0",
+ "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@capacitor/cli": "^8.0.0",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "@vitejs/plugin-react": "^4.3.4",
+ "vite": "^6.0.5",
+ "vite-plugin-pwa": "^1.0.0"
+ }
+}
diff --git a/app/src/domain/cards.ts b/app/src/domain/cards.ts
new file mode 100644
index 0000000..ce66c9a
--- /dev/null
+++ b/app/src/domain/cards.ts
@@ -0,0 +1,185 @@
+/* SRS cards, over the database.
+
+ lib/srs.js owns the scheduling; this owns the queries. Note the
+ vocabulary: statusOf() calls a mature card "secure". The artifact said
+ "known" in its CSS and its filter and "secure" in the library — the app
+ uses the library's word everywhere. */
+
+import type { Db } from "../db/types.js";
+import { editCard, editCardReset, editStudyLog, seedCard } from "../db/writes.js";
+import { grade, markKnown, newCard, statusOf, type Card, type CardStatus, type Grade } from "@lib/srs.js";
+import { GOOD } from "@lib/srs.js";
+
+export interface CardRow extends Card {
+ lemma_id: number;
+}
+
+export interface DeckEntry {
+ lemmaId: number;
+ headword: string;
+ pos: string;
+ glossEn: string;
+ source: string;
+ card: Card | null;
+ status: CardStatus;
+}
+
+const CARD_COLUMNS = "c.state, c.ease, c.interval, c.due, c.reps, c.lapses";
+
+function toCard(row: Record): Card | null {
+ if (row.state == null) return null;
+ return {
+ state: row.state as Card["state"],
+ ease: row.ease as number,
+ interval: row.interval as number,
+ due: row.due as number,
+ reps: row.reps as number,
+ lapses: row.lapses as number,
+ };
+}
+
+/**
+ * The reviewable deck: curated words and sentence chunks, not the whole
+ * dictionary. Reviewing 30,000 dictionary entries is not a study plan.
+ */
+const REVIEWABLE = "('curated', 'sfx', 'grammar')";
+const SENTENCE_SOURCE = "('sentence')";
+
+export interface DeckOptions {
+ /** Mix glossed sentence chunks in, per the session preference. */
+ sentences?: boolean;
+ /** Only this source — used by "practise these sentences". */
+ only?: "sentences";
+}
+
+function sourceClause(opts: DeckOptions): string {
+ if (opts.only === "sentences") return `l.source IN ${SENTENCE_SOURCE}`;
+ return opts.sentences
+ ? `l.source IN ${REVIEWABLE} OR l.source IN ${SENTENCE_SOURCE}`
+ : `l.source IN ${REVIEWABLE}`;
+}
+
+export async function deck(db: Db, opts: DeckOptions = {}): Promise {
+ const rows = await db.all>(
+ `SELECT l.id AS lemmaId, l.headword, l.pos, l.gloss_en AS glossEn, l.source, ${CARD_COLUMNS}
+ FROM lemma l LEFT JOIN card c ON c.lemma_id = l.id
+ WHERE ${sourceClause(opts)}
+ ORDER BY l.headword`,
+ );
+
+ return rows.map((r) => {
+ const card = toCard(r);
+ return {
+ lemmaId: r.lemmaId as number,
+ headword: r.headword as string,
+ pos: r.pos as string,
+ glossEn: r.glossEn as string,
+ source: r.source as string,
+ card,
+ status: statusOf(card),
+ };
+ });
+}
+
+export interface Counts {
+ due: number;
+ fresh: number;
+ learning: number;
+ review: number;
+ secure: number;
+ total: number;
+}
+
+export async function counts(db: Db, today: number, opts: DeckOptions = {}): Promise {
+ const entries = await deck(db, opts);
+ const out: Counts = { due: 0, fresh: 0, learning: 0, review: 0, secure: 0, total: entries.length };
+
+ for (const e of entries) {
+ if (e.status === "new") out.fresh++;
+ else {
+ out[e.status]++;
+ if (e.card && e.card.due <= today) out.due++;
+ }
+ }
+ return out;
+}
+
+/**
+ * The queue for a session: everything due, oldest first, then up to
+ * `newPerDay` unseen cards.
+ */
+export async function buildQueue(
+ db: Db,
+ today: number,
+ newPerDay: number,
+ opts: DeckOptions = {},
+): Promise {
+ const entries = await deck(db, opts);
+
+ const due = entries
+ .filter((e) => e.card && e.card.due <= today && e.status !== "new")
+ .sort((a, b) => (a.card!.due ?? 0) - (b.card!.due ?? 0));
+
+ const fresh = entries.filter((e) => e.status === "new");
+ // Rotate the fresh pool by the day so it is not the same alphabetical
+ // prefix every morning, but is stable within a day.
+ const offset = fresh.length ? today % fresh.length : 0;
+ const rotated = [...fresh.slice(offset), ...fresh.slice(0, offset)].slice(0, Math.max(0, newPerDay));
+
+ return [...due, ...rotated];
+}
+
+/** Answer a card. The one write that stamps the clock for a review. */
+export async function answer(
+ db: Db,
+ entry: DeckEntry,
+ g: Grade,
+ today: number,
+): Promise {
+ const next = grade(entry.card ?? newCard(), g, today);
+ await editCard(db, entry.lemmaId, next);
+ await editStudyLog(db, today, { reviews: 1, correct: g >= GOOD ? 1 : 0 });
+ return next;
+}
+
+/** "I already know this" — jump straight to a secure interval. */
+export async function markAsKnown(db: Db, lemmaId: number, today: number): Promise {
+ await editCard(db, lemmaId, markKnown(today));
+}
+
+export async function forget(db: Db, lemmaId: number): Promise {
+ await editCardReset(db, lemmaId);
+}
+
+/** Pre-schedule a card without it counting as something the learner did. */
+export async function seed(db: Db, lemmaId: number, card = newCard()): Promise {
+ await seedCard(db, lemmaId, card);
+}
+
+/* ── the study log ───────────────────────────────────────────────── */
+
+export interface DayRow {
+ day: number;
+ reviews: number;
+ correct: number;
+ drills: number;
+}
+
+export async function studyLog(db: Db, sinceDay: number): Promise {
+ return db.all("SELECT * FROM study_log WHERE day >= ? ORDER BY day", [sinceDay]);
+}
+
+/** Consecutive days with any activity, counting back from today. */
+export function streakFrom(rows: DayRow[], today: number): number {
+ const active = new Set(rows.filter((r) => r.reviews + r.drills > 0).map((r) => r.day));
+ let n = 0;
+ // Today not yet studied does not break a streak that ran to yesterday.
+ let day = active.has(today) ? today : today - 1;
+ while (active.has(day)) {
+ n++;
+ day--;
+ }
+ return n;
+}
+
+export type { Card, CardStatus, Grade };
diff --git a/app/src/domain/dictionary.ts b/app/src/domain/dictionary.ts
new file mode 100644
index 0000000..ce5c29e
--- /dev/null
+++ b/app/src/domain/dictionary.ts
@@ -0,0 +1,188 @@
+/* Loading the shipped dictionary into the database.
+
+ The build emits one gzipped row dump per band (tools/dict/build.mjs). They
+ are static assets in the app bundle — and in the APK's assets on Android —
+ so they are fetched from the app's own origin and NO SERVER IS INVOLVED.
+ When the sync layer lands, only the base URL changes.
+
+ Why row dumps rather than a .sqlite3 file: adopting a binary database
+ needs sqlite3_deserialize on web and copyFromAssets on native, which is
+ two code paths for one result. A row dump is one path on both, which is
+ what "same schema, same queries" has to mean. The build still emits
+ seed.sqlite3 for inspection with an ordinary client.
+
+ Bands load as the learner reaches them. The reference band — everything
+ the dictionary knows that no band admits — is loaded on demand, and is
+ never returned by the gate's vocabQuery. */
+
+import type { Db } from "../db/types.js";
+import { insertBand, seedMeta, type LemmaRow, type SurfaceRow } from "../db/writes.js";
+import { REFERENCE_BAND } from "@shared/bands.mjs";
+
+const BASE = `${import.meta.env.BASE_URL ?? "/"}dict/`;
+
+export interface BandInfo {
+ band: number;
+ file: string;
+ lemmas: number;
+ surfaces: number;
+ bytes: number;
+ sha256: string;
+ reference: boolean;
+}
+
+export interface DictManifest {
+ builtWith: { dictionary: string; dictionaryEntries: number; frequencyForms: number };
+ totals: { lemmas: number; surfaces: number };
+ bands: BandInfo[];
+ attribution: string[];
+ notice: string;
+}
+
+interface BandPayload {
+ band: number;
+ columns: { lemma: string[]; surface: string[] };
+ lemmas: unknown[][];
+ surfaces: unknown[][];
+}
+
+let manifestPromise: Promise | null = null;
+
+export function loadManifest(): Promise {
+ manifestPromise ??= fetch(`${BASE}manifest.json`).then((r) => {
+ if (!r.ok) throw new Error(`dictionary manifest: HTTP ${r.status}`);
+ return r.json() as Promise;
+ });
+ return manifestPromise;
+}
+
+/** gzip's magic number. */
+const GZIP_MAGIC = [0x1f, 0x8b];
+
+/**
+ * Fetch and decode a band file.
+ *
+ * Whether the bytes arrive compressed depends on the host, and this app has
+ * three: the Vite dev server and most static hosts label a .gz file with
+ * `Content-Encoding: gzip`, so the browser has already decompressed it by
+ * the time we see it; Android's asset handler hands over the raw file. So
+ * sniff the magic number rather than trusting either the extension or the
+ * headers, and decompress only when it is actually still compressed.
+ */
+async function fetchBand(file: string): Promise {
+ const res = await fetch(BASE + file);
+ if (!res.ok) throw new Error(`${file}: HTTP ${res.status}`);
+
+ const buffer = await res.arrayBuffer();
+ const head = new Uint8Array(buffer, 0, Math.min(2, buffer.byteLength));
+ const compressed = head[0] === GZIP_MAGIC[0] && head[1] === GZIP_MAGIC[1];
+
+ const text = compressed
+ ? await new Response(
+ new Blob([buffer]).stream().pipeThrough(new DecompressionStream("gzip")),
+ ).text()
+ : new TextDecoder().decode(buffer);
+
+ return JSON.parse(text) as BandPayload;
+}
+
+/** Positional rows back into objects, driven by the file's own column list. */
+function toRows(columns: string[], rows: unknown[][]): T[] {
+ return rows.map((row) => {
+ const out: Record = {};
+ columns.forEach((c, i) => (out[c] = row[i] ?? null));
+ return out as T;
+ });
+}
+
+const LOADED_KEY = "dict.loadedBands";
+
+/**
+ * Band loading is a read-modify-write on one meta row, and it runs from two
+ * places at once: the store pulls the learner's bands at boot while the word
+ * rail can ask for the reference band the moment someone searches. Without a
+ * lock the later write drops the earlier one's record and that band gets
+ * downloaded and re-inserted on every launch.
+ */
+let bandLock: Promise = Promise.resolve();
+
+function serialise(fn: () => Promise): Promise {
+ const run = bandLock.then(fn, fn);
+ bandLock = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ return run;
+}
+
+async function loadedBands(db: Db): Promise> {
+ const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [LOADED_KEY]);
+ if (!row) return new Set();
+ try {
+ return new Set(JSON.parse(row.v) as number[]);
+ } catch {
+ return new Set();
+ }
+}
+
+async function rememberBand(db: Db, band: number): Promise {
+ const loaded = await loadedBands(db);
+ loaded.add(band);
+ // Bookkeeping, not a user edit — it must never carry a write timestamp.
+ await db.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES (?, ?, 0)", [
+ LOADED_KEY,
+ JSON.stringify([...loaded].sort((a, b) => a - b)),
+ ]);
+}
+
+/** Load one band if it is not already in the database. */
+export function loadBand(db: Db, band: number): Promise {
+ return serialise(async () => {
+ if ((await loadedBands(db)).has(band)) return false;
+
+ const manifest = await loadManifest();
+ const info = manifest.bands.find((b) => b.band === band);
+ if (!info) return false;
+
+ const payload = await fetchBand(info.file);
+ await insertBand(
+ db,
+ toRows(payload.columns.lemma, payload.lemmas),
+ toRows(payload.columns.surface, payload.surfaces),
+ );
+ await rememberBand(db, band);
+ return true;
+ });
+}
+
+/**
+ * Everything up to and including `band`. Called at boot with the learner's
+ * current band, and again whenever advancing a unit widens it.
+ */
+export async function ensureBands(db: Db, upto: number): Promise {
+ const added: number[] = [];
+ for (let b = 0; b <= upto; b++) {
+ if (await loadBand(db, b)) added.push(b);
+ }
+ return added;
+}
+
+/**
+ * The reference band: words the dictionary knows that no band admits. Kept
+ * out of the gate entirely — this exists so the word rail can gloss
+ * something met in the wild, not so the tutor can teach it.
+ */
+export async function ensureReferenceBand(db: Db): Promise {
+ return loadBand(db, REFERENCE_BAND);
+}
+
+export async function isReferenceLoaded(db: Db): Promise {
+ return (await loadedBands(db)).has(REFERENCE_BAND);
+}
+
+/** Record which dictionary produced the shipped data, for the About panel. */
+export async function recordProvenance(db: Db): Promise {
+ const m = await loadManifest();
+ await seedMeta(db, "dict.source", m.builtWith.dictionary);
+ await seedMeta(db, "dict.attribution", m.attribution.join("\n"));
+}
diff --git a/app/src/domain/gate.ts b/app/src/domain/gate.ts
new file mode 100644
index 0000000..782c025
--- /dev/null
+++ b/app/src/domain/gate.ts
@@ -0,0 +1,165 @@
+/* The gate — wiring lib/gate.js to the dictionary.
+
+ buildGate() already takes a `vocabQuery` hook for exactly this. Replacing
+ its default (the hand-listed words of finished units) with a band query is
+ what turns 371 typed words into something that scales, and it is the
+ mechanism that stops the tutor reaching for a word the learner has not
+ been given.
+
+ Three refinements sit inside the hook, all of them narrowing:
+
+ 1. WORDS OWNED BY A LATER UNIT ARE EXCLUDED. A band ceiling knows about
+ frequency, not about pedagogy; without this, 3.4's 빨갛다 would leak
+ into 2.1 just because it is common.
+
+ 2. PHASE 1 IS FILTERED BY SOUND. During the writing-system phase every
+ word must be phonologically legal for the unit reached, or a band
+ would hand him a 겹받침 during 1.4. Same ladder validate.mjs checks.
+
+ 3. THE LIST IS CAPPED. renderGate() inlines the vocabulary into the
+ prompt joined by " · ", and an uncapped band is tens of thousands of
+ characters. The cap takes the most frequent first, so it is strictly
+ more restrictive than the band — it cannot leak anything the band
+ would not already have allowed. The full band stays in the database
+ for the word rail.
+
+ prompt/tutor-system.md ships unchanged. {{GATE}} is the only structural
+ substitution; {{VARIETY}} and {{FOCUS}} are the one-liners it expects. */
+
+import { buildGate, flatten, renderGate } from "@lib/gate.js";
+import type { Curriculum, FlatUnit, Gate, ProgressState } from "@lib/gate.js";
+import { featureLevel, isReadableAt, LADDER_COMPLETE } from "@shared/phonology.mjs";
+import { bandForUnit, REFERENCE_BAND, ceilingForBand } from "@shared/bands.mjs";
+import curriculumJson from "@data/curriculum.json";
+
+export const curriculum = curriculumJson as unknown as Curriculum;
+export const UNITS: FlatUnit[] = flatten(curriculum);
+export const unitIndex = (id: string): number => UNITS.findIndex((u) => u.id === id);
+export const unitById = (id: string): FlatUnit | undefined => UNITS.find((u) => u.id === id);
+
+/** How many words the prompt's vocabulary section may name. */
+export const VOCAB_CAP = 800;
+
+/** Every word a not-yet-finished unit is the first to introduce. */
+function wordsOwnedByFutureUnits(done: Set): Set {
+ const owned = new Set();
+ const introduced = new Set();
+ for (const u of UNITS) {
+ for (const w of u.words ?? []) {
+ if (introduced.has(w)) continue;
+ introduced.add(w);
+ if (!done.has(u.id)) owned.add(w);
+ }
+ }
+ return owned;
+}
+
+export interface VocabRow {
+ headword: string;
+}
+
+/** Runs the band query. Injected so the gate can be built without a database. */
+export type BandQuery = (band: number, ceiling: number, limit: number) => VocabRow[];
+
+/**
+ * The vocabQuery hook. Curriculum words of finished units are always
+ * allowed; the band adds frequency-ranked vocabulary on top.
+ */
+export function makeVocabQuery(query: BandQuery) {
+ return (unit: FlatUnit, done: FlatUnit[]): string[] => {
+ const doneIds = new Set(done.map((u) => u.id));
+ const band = bandForUnit(unit.id);
+ const i = unitIndex(unit.id);
+ const level = featureLevel(i, unitIndex);
+ const soundGated = level < LADDER_COMPLETE;
+ const future = wordsOwnedByFutureUnits(doneIds);
+
+ const allowed: string[] = [];
+ const seen = new Set();
+ const push = (w: string) => {
+ if (!w || seen.has(w)) return;
+ if (future.has(w)) return; // refinement 1
+ if (soundGated && !isReadableAt(w, level)) return; // refinement 2
+ seen.add(w);
+ allowed.push(w);
+ };
+
+ // The words he has actually been taught come first and are never cut.
+ for (const u of done) for (const w of u.words ?? []) push(w);
+ for (const w of unit.revisits ?? []) push(w.word);
+
+ // Then the band, most frequent first. Ask for extra because the filters
+ // above will reject some of what comes back.
+ if (band > 0) {
+ for (const row of query(band, ceilingForBand(band), VOCAB_CAP * 3)) {
+ if (allowed.length >= VOCAB_CAP) break; // refinement 3
+ push(row.headword);
+ }
+ }
+
+ return allowed;
+ };
+}
+
+export interface GateInputs {
+ progress: ProgressState;
+ bandQuery?: BandQuery;
+}
+
+export function gateFor({ progress, bandQuery }: GateInputs): Gate {
+ return buildGate(curriculum, progress, bandQuery ? { vocabQuery: makeVocabQuery(bandQuery) } : {});
+}
+
+/* ── the prompt ──────────────────────────────────────────────────── */
+
+/** The four exercise types, so {{VARIETY}} can ask for a different one. */
+const TASK_TYPES = ["translate", "match", "build", "choice"] as const;
+export type TaskKind = (typeof TASK_TYPES)[number];
+
+export const FOCUS_MODES = {
+ auto: "",
+ sentence: "Bias this session toward reading whole sentences.",
+ vocab: "Bias this session toward vocabulary breadth — more words, more matching.",
+ particles: "Bias this session toward particles and what they mark.",
+ sound: "Bias this session toward sound changes and reading aloud in your head.",
+ manhwa: "Bias this session toward manhwa dialogue: 반말, contractions, sound words.",
+ free: "He asked to just talk. Follow his lead, but stay inside the gate.",
+} as const;
+export type FocusMode = keyof typeof FOCUS_MODES;
+
+/** {{VARIETY}} — the only anti-repetition mechanism the tutor has. */
+export function varietyLine(recent: string[]): string {
+ const last = recent.slice(-4);
+ if (!last.length) return "Pick whichever exercise type suits the material.";
+ const unused = TASK_TYPES.filter((t) => !last.includes(t));
+ return (
+ `Your last exercises were: ${last.join(", ")}. ` +
+ (unused.length
+ ? `Use a different type this time — ${unused.join(" or ")}.`
+ : "Vary the type from the last one.")
+ );
+}
+
+/** {{FOCUS}} — one line, or nothing at all on auto. */
+export const focusLine = (mode: FocusMode): string => FOCUS_MODES[mode] ?? "";
+
+export interface PromptInputs {
+ template: string;
+ gate: Gate;
+ recent: string[];
+ focus: FocusMode;
+}
+
+/**
+ * Assemble the system prompt. The template is prompt/tutor-system.md,
+ * shipped unchanged — this fills its three placeholders and nothing else.
+ */
+export function assemblePrompt({ template, gate, recent, focus }: PromptInputs): string {
+ return template
+ .replace("{{GATE}}", renderGate(gate))
+ .replace("{{VARIETY}}", varietyLine(recent))
+ .replace("{{FOCUS}}", focusLine(focus));
+}
+
+export { renderGate, REFERENCE_BAND };
+export type { Gate, ProgressState, FlatUnit };
diff --git a/app/src/domain/gloss.ts b/app/src/domain/gloss.ts
new file mode 100644
index 0000000..6e8d9a8
--- /dev/null
+++ b/app/src/domain/gloss.ts
@@ -0,0 +1,65 @@
+/* Multi-sentence ::gloss blocks.
+
+ The system prompt tells the tutor it may put several sentences in one
+ gloss block, each closed by its own "=" line. lib/blocks.js parse() sets
+ `en` when it meets "=" but never closes the block, so every sentence's
+ parts pile into one run-on line and only the last translation survives.
+
+ lib/ ships unchanged, so the fix lives here, at the call site: split the
+ block on its "=" lines and parse each sentence as its own single-sentence
+ block. The output is exactly what parse() would have produced if it closed
+ the block, so nothing downstream has to know.
+
+ (If lib/blocks.js is ever revised, the one-line fix there is `cur = null`
+ after setting `en`, and this module can go. test/lib/blocks.test.ts pins
+ the current behaviour so the change is visible when it happens.) */
+
+import { parse } from "@lib/blocks.js";
+import type { GlossBlock, ParsedMessage } from "@lib/blocks.js";
+
+const GLOSS_BLOCK = /::gloss\s*\n([\s\S]*?)(?:\n::|$)/;
+
+/** Split a gloss block's body into one chunk per "=" line. */
+function splitSentences(body: string): string[] {
+ const out: string[] = [];
+ let current: string[] = [];
+
+ for (const line of body.split("\n")) {
+ const l = line.trim();
+ if (!l || l.startsWith("::")) continue;
+ current.push(l);
+ if (l.startsWith("=")) {
+ out.push(current.join("\n"));
+ current = [];
+ }
+ }
+ // A trailing sentence with no "=" is still worth rendering.
+ if (current.length) out.push(current.join("\n"));
+
+ return out;
+}
+
+/**
+ * parse(), with multi-sentence gloss blocks split correctly.
+ * Use this everywhere instead of calling parse() directly.
+ */
+export function parseMessage(text: string): ParsedMessage {
+ const parsed = parse(text);
+ if (!parsed.gloss) return parsed;
+
+ const match = text.match(GLOSS_BLOCK);
+ if (!match?.[1]) return parsed;
+
+ const sentences = splitSentences(match[1]);
+ if (sentences.length < 2) return parsed; // the common case; nothing to fix
+
+ const blocks: GlossBlock[] = [];
+ for (const s of sentences) {
+ const one = parse(`::gloss\n${s}\n::`);
+ if (one.gloss) blocks.push(...one.gloss);
+ }
+
+ return blocks.length ? { ...parsed, gloss: blocks } : parsed;
+}
+
+export type { GlossBlock, ParsedMessage };
diff --git a/app/src/domain/lexicon.ts b/app/src/domain/lexicon.ts
new file mode 100644
index 0000000..fd2ce8c
--- /dev/null
+++ b/app/src/domain/lexicon.ts
@@ -0,0 +1,143 @@
+/* Word lookup, against the database.
+
+ In the artifact this was an in-memory Map plus a fallback that stripped
+ one of 24 particles off a token and tried again — a hand-rolled stand-in
+ for a morphological analyser. It is gone. Every conjugated form the
+ learner will meet was generated by lib/conjugation.js surfaceForms() at
+ BUILD time and stored in `surface`, so a lookup is now an index hit. */
+
+import type { Db } from "../db/types.js";
+import { REFERENCE_BAND } from "@shared/bands.mjs";
+
+export interface Entry {
+ lemmaId: number;
+ headword: string;
+ pos: string;
+ glossEn: string;
+ glossKo: string;
+ freqRank: number | null;
+ level: string | null;
+ unitBand: number;
+ source: string;
+ /** How the searched form relates to the headword, when they differ. */
+ analysis?: string;
+ /** The form actually looked up, which may be a conjugation. */
+ form?: string;
+}
+
+const COLUMNS = `
+ l.id AS lemmaId, l.headword, l.pos, l.gloss_en AS glossEn, l.gloss_ko AS glossKo,
+ l.freq_rank AS freqRank, l.level, l.unit_band AS unitBand, l.source`;
+
+const SELECT = `SELECT ${COLUMNS} FROM lemma l`;
+
+/** The same columns, joined through the surface index. */
+const SELECT_VIA_SURFACE = `
+ SELECT ${COLUMNS}, s.form AS form, s.analysis AS analysis
+ FROM lemma l JOIN surface s ON s.lemma_id = l.id`;
+
+/** Prefer a curated gloss, then a common word, then anything. */
+const RANKED = `
+ ORDER BY CASE l.source WHEN 'curated' THEN 0 WHEN 'grammar' THEN 1
+ WHEN 'sentence' THEN 2 WHEN 'sfx' THEN 3 ELSE 4 END,
+ l.freq_rank IS NULL, l.freq_rank`;
+
+/**
+ * Look one written form up. Tries the headword first, then the surface
+ * index, so 먹었어 resolves to 먹다 with "반말 past" as its analysis.
+ */
+export async function lookup(db: Db, form: string): Promise {
+ const direct = await db.get(`${SELECT} WHERE l.headword = ? ${RANKED} LIMIT 1`, [form]);
+ if (direct) return { ...direct, form };
+
+ const viaSurface = await db.get(
+ `${SELECT_VIA_SURFACE} WHERE s.form = ? ${RANKED} LIMIT 1`,
+ [form],
+ );
+ return viaSurface ? { ...viaSurface, form } : undefined;
+}
+
+/**
+ * Android's SQLite caps bound parameters at 999, so an IN (...) built from
+ * however many words a message happens to contain has to be batched. See the
+ * note on MAX_PARAMS in db/writes.ts.
+ */
+const MAX_IN = 900;
+
+function batches(items: T[], size: number): T[][] {
+ const out: T[][] = [];
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
+ return out;
+}
+
+/** Look up many forms at once — the word rail does this per message. */
+export async function lookupMany(db: Db, forms: string[]): Promise