feat(dict): every roadmap word is a card, tagged with its unit

The reworked app's learner model hangs off cards: recall evidence, the
phase-review checklist, the practice set, and the gate's "words he has
met". So every word a unit introduces has to be studiable — and 16 of the
371 were not. Eleven existed only as sentence chunks or dictionary rows
outside the review deck, and five (봐 읽어 갔어 봤어 먹었어) nowhere at all.

The build now marks exactly one reviewable lemma per roadmap word with the
unit that introduces it. Where the deck has the word, its row is chosen
deterministically (deck order, then source, then part of speech) — the
artifact tagged whichever card came last, which put the evidence for 이, 눈
and 저 on the wrong meaning. The sixteen get a `curriculum` lemma of their
own, glossed from the curated verb they conjugate (자 is "sleep", the 반말
of 자다 — not the dictionary's "ruler"), else from the sentence that uses
them, else the dictionary.

Lemmas also carry their topic, which the vocabulary filters need.
dict:assert gains the guarantee: 371/371 roadmap words as one card each.
Migration 7 adds the two columns; the rows arrive with the dictionary
reload a changed build now triggers on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 20:03:09 +02:00
parent f8183d786f
commit 48987b96ae
17 changed files with 205 additions and 44 deletions

View File

@@ -177,6 +177,19 @@ export const MIGRATIONS: Migration[] = [
`,
run: rekeyLemmas,
},
{
id: 7,
name: "curriculum words are cards — a lemma knows its topic and the unit that introduces it",
sql: /* sql */ `
-- Reference data from the band files, like the rest of lemma: not
-- synced, no updated_at. The build marks exactly one reviewable lemma
-- per roadmap word with its unit. The rows arrive with the next band
-- load, which a changed dictionary triggers on its own.
ALTER TABLE lemma ADD COLUMN topic TEXT;
ALTER TABLE lemma ADD COLUMN unit_id TEXT;
CREATE INDEX IF NOT EXISTS lemma_unit ON lemma(unit_id);
`,
},
];
/**

View File

@@ -385,6 +385,10 @@ export interface LemmaRow {
gloss_ko: string;
unit_band: number;
source: string;
/** The deck topic, or "수업 <unit> <name>" for a curriculum word. */
topic?: string | null;
/** Set on exactly one lemma per roadmap word: the unit that introduces it. */
unit_id?: string | null;
}
export interface SurfaceRow {
@@ -414,10 +418,10 @@ export async function insertBand(
surfaces: SurfaceRow[],
): Promise<void> {
await db.tx(async (tx) => {
const lemmaChunk = chunkFor(9);
const lemmaChunk = chunkFor(11);
for (let i = 0; i < lemmas.length; i += lemmaChunk) {
const slice = lemmas.slice(i, i + lemmaChunk);
const values = slice.map(() => "(?,?,?,?,?,?,?,?,?)").join(",");
const values = slice.map(() => "(?,?,?,?,?,?,?,?,?,?,?)").join(",");
const params: Params = slice.flatMap((l) => [
l.id,
l.headword,
@@ -428,10 +432,12 @@ export async function insertBand(
l.gloss_ko,
l.unit_band,
l.source,
l.topic ?? null,
l.unit_id ?? null,
]);
await tx.run(
`INSERT OR REPLACE INTO lemma
(id, headword, pos, freq_rank, level, gloss_en, gloss_ko, unit_band, source)
(id, headword, pos, freq_rank, level, gloss_en, gloss_ko, unit_band, source, topic, unit_id)
VALUES ${values}`,
params,
);

View File

@@ -20,6 +20,9 @@ export interface DeckEntry {
pos: string;
glossEn: string;
source: string;
topic: string | null;
/** The unit that introduces this word, for a roadmap word's card. */
unitId: string | null;
card: Card | null;
status: CardStatus;
}
@@ -44,7 +47,10 @@ function toCard(row: Record<string, unknown>): Card | null {
*/
// 'custom' is here so the learner's own words are reviewable like any
// other — adding a word you cannot then study would be pointless.
const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom')";
// 'curriculum' is a roadmap word the curated deck does not hold (먹어, 봤어):
// every word a unit introduces is a card, because the recall evidence and
// the phase review both hang off one.
const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom', 'curriculum')";
const SENTENCE_SOURCE = "('sentence')";
export interface DeckOptions {
@@ -63,7 +69,8 @@ function sourceClause(opts: DeckOptions): string {
export async function deck(db: Db, opts: DeckOptions = {}): Promise<DeckEntry[]> {
const rows = await db.all<Record<string, unknown>>(
`SELECT l.id AS lemmaId, l.headword, l.pos, l.gloss_en AS glossEn, l.source, ${CARD_COLUMNS}
`SELECT l.id AS lemmaId, l.headword, l.pos, l.gloss_en AS glossEn, l.source,
l.topic, l.unit_id AS unitId, ${CARD_COLUMNS}
FROM lemma l LEFT JOIN card c ON c.lemma_id = l.id
WHERE ${sourceClause(opts)}
ORDER BY l.headword`,
@@ -77,6 +84,8 @@ export async function deck(db: Db, opts: DeckOptions = {}): Promise<DeckEntry[]>
pos: r.pos as string,
glossEn: r.glossEn as string,
source: r.source as string,
topic: (r.topic as string | null) ?? null,
unitId: (r.unitId as string | null) ?? null,
card,
status: statusOf(card),
};

View File

@@ -39,7 +39,8 @@ const SELECT_VIA_SURFACE = `
/** 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,
WHEN 'curriculum' THEN 2 WHEN 'sentence' THEN 3
WHEN 'sfx' THEN 4 ELSE 5 END,
l.freq_rank IS NULL, l.freq_rank`;
/**

View File

@@ -80,7 +80,7 @@ async function readBandWords(db: Db, band: number, ceiling: number): Promise<str
`SELECT DISTINCT headword FROM lemma
WHERE unit_band <= ? AND unit_band < ?
AND (freq_rank IS NOT NULL AND freq_rank <= ?
OR source IN ('curated','grammar','sentence','sfx'))
OR source IN ('curated','grammar','sentence','sfx','curriculum'))
ORDER BY freq_rank IS NULL, freq_rank
LIMIT ?`,
[band, REFERENCE_BAND, ceiling, VOCAB_CAP * 3],