From dce1f46e6211b3d45563b31b89f6ac95955d7ea5 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 9 Oct 2026 22:35:50 +0800 Subject: [PATCH 01/12] chore: OTA update v33 for Build 23 --- lib/version.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/version.ts b/lib/version.ts index fabaac3..4a7a453 100644 --- a/lib/version.ts +++ b/lib/version.ts @@ -4,8 +4,8 @@ export const APP_VERSION = { version: "1.0.0", // Marketing version for app stores buildNumber: 23, // Native build number (increments only for native builds) - otaVersion: 32, // OTA update version (increments for JS updates) - timestamp: "2026-10-05T00:59:34Z", // Last update timestamp + otaVersion: 33, // OTA update version (increments for JS updates) + timestamp: "2026-10-09T14:35:49Z", // Last update timestamp channel: "testflight", // Release channel }; From 4665a25e8b144526d99aedbe2f20aa41d99838be Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 12 Oct 2026 16:11:03 +0800 Subject: [PATCH 02/12] chore: OTA update v34 for Build 23 --- lib/version.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/version.ts b/lib/version.ts index 4a7a453..b9dd68e 100644 --- a/lib/version.ts +++ b/lib/version.ts @@ -4,8 +4,8 @@ export const APP_VERSION = { version: "1.0.0", // Marketing version for app stores buildNumber: 23, // Native build number (increments only for native builds) - otaVersion: 33, // OTA update version (increments for JS updates) - timestamp: "2026-10-09T14:35:49Z", // Last update timestamp + otaVersion: 34, // OTA update version (increments for JS updates) + timestamp: "2026-10-12T08:11:02Z", // Last update timestamp channel: "testflight", // Release channel }; From 015728281afdd8448c9b171d38ff072ec22bab4a Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 13:31:38 +0800 Subject: [PATCH 03/12] A lapse halves the interval once, not for the rest of the card's life MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `simpleGraduate`'s third argument was fed `card.lapses > 0` — a lifetime counter nothing resets — so every graduation after a card's first ever failure multiplied its interval by 0.5 instead of 1.9. A card climbed to 56 days, failed once, and then ratcheted 28 → 14 → 7 → 3.5 → … down to the 8-hour floor, where it stayed due every day forever. At 90% accuracy, 96% of cards sit at or below a 1-day interval after 40 reviews; with the fix, none do. That is the difference Sam saw running jiten beside Midori on the same 8k list: Midori's queue decayed 1000 → 600 → 300 and settled around 200-300 a day, jiten's never moved. His Midori file shows the healthy shape — median interval 37 days, 187 cards at the 365 cap, 83 below a day. `simple_relearning` records whether a card is recovering from a lapse right now. The migration backfills 1 for cards sitting in a lapse and bumps their `updated_at` so the backfill reaches other devices; `isRemoteRelevant` keeps the ALTER and drops the UPDATE, which is what row sync wants. The per-answer decision moved into `nextSimpleReview` in stores/simple-srs. Review's first pass found the gate that mattered: every test called `simpleGraduate` directly, so reverting the screen's four call sites left all of them green — the bug lived in the wiring, not the arithmetic. The decision is now a pure function with its own tests, the SELECT that hydrates the flag is shared and tested (drop the alias and the fix silently inverts), and the backfill has a test. Extraction verified behaviour-identical across 1,080 state combinations. Driven through the real decision function, a deck settles at ~117 cards and ~210 answers a day instead of pinning at 8,000. Co-Authored-By: Claude Opus 5 (1M context) --- app/(tabs)/lists/study.tsx | 105 ++++++------------ components/StudyStatisticsModal.tsx | 3 +- db/schema.ts | 1 + db/simple-relearning-migration.test.ts | 71 ++++++++++++ db/srs-card-columns.test.ts | 52 +++++++++ db/srs-card-columns.ts | 19 ++++ db/types.ts | 2 + db/user-migrations.ts | 10 ++ lib/data-backup.ts | 3 +- lib/list-transfer.ts | 11 +- stores/simple-srs.test.ts | 146 +++++++++++++++++++++++++ stores/simple-srs.ts | 107 ++++++++++++++---- 12 files changed, 433 insertions(+), 97 deletions(-) create mode 100644 db/simple-relearning-migration.test.ts create mode 100644 db/srs-card-columns.test.ts create mode 100644 db/srs-card-columns.ts diff --git a/app/(tabs)/lists/study.tsx b/app/(tabs)/lists/study.tsx index 008e325..d195685 100644 --- a/app/(tabs)/lists/study.tsx +++ b/app/(tabs)/lists/study.tsx @@ -20,6 +20,7 @@ import { MnemonicClozeInput } from "@/components/MnemonicClozeInput"; import { shouldAskAsCloze } from "@/lib/rtk-cloze"; import { rtkReviewScope, singleList, type ListScope } from "@/lib/list-scope"; import { isRtkReviewList } from "@/lib/rtk-review"; +import { SRS_CARD_SELECT } from "@/db/srs-card-columns"; import { getFaceText, getKanjiFaceText, @@ -74,12 +75,9 @@ import { reviewCard, Rating, getFsrsInstance, previewIntervals } from "@/stores/ import { formatInterval as formatDueInterval } from "@/lib/format-interval"; import { japaneseFontStyle } from "@/lib/japanese-font"; import { - simpleGraduate, - simpleReviewFail, - simpleInitCard, + nextSimpleReview, endOfLogicalDayEpochDays, endOfLogicalDayISO, - SIMPLE_SRS_REQUIRED_CORRECT, } from "@/stores/simple-srs"; import { useAtomValue } from "jotai"; import { useListsStore, parseListRow } from "@/stores/lists"; @@ -909,6 +907,7 @@ interface SrsSnapshot { simpleStage: number | null; simpleN: number | null; simpleInterval: number | null; + simpleRelearning: number | null; lastConfusionCheck: string | null; learningSteps: number; } @@ -941,6 +940,7 @@ function captureSnapshot(card: SrsCardRow): SrsSnapshot { simpleStage: card.simpleStage, simpleN: card.simpleN, simpleInterval: card.simpleInterval, + simpleRelearning: card.simpleRelearning, lastConfusionCheck: card.lastConfusionCheck, learningSteps: card.learningSteps, }; @@ -1336,16 +1336,7 @@ function StudyScreen() { loadQueue(); }, [dictDb, userDb, list?.id, scope]); - const srsSelectClause = `SELECT id, entry_id as entryId, kanji_literal as kanjiLiteral, list_id as listId, due, - stability, difficulty, elapsed_days as elapsedDays, - scheduled_days as scheduledDays, reps, lapses, state, - last_review as lastReview, front_mode as frontMode, - back_mode as backMode, created_at as createdAt, - updated_at as updatedAt, - simple_stage as simpleStage, simple_n as simpleN, - simple_interval as simpleInterval, - last_confusion_check as lastConfusionCheck, - learning_steps as learningSteps`; + const srsSelectClause = SRS_CARD_SELECT; /** Resolve SRS card rows into QueueItems with full dict entries */ async function resolveCards(srsRows: SrsCardRow[]): Promise { @@ -2216,69 +2207,34 @@ function StudyScreen() { ): Promise<{ graduated: boolean; card: SrsCardRow }> { if (!userDb) return { graduated: true, card }; const now = new Date().toISOString(); - const isNew = card.simpleStage == null; - const isEasy = action === "easy"; const pass = action !== "fail"; - let updates: { simpleStage: number; simpleN: number; simpleInterval: number }; - let graduated = false; - - if (!pass) { - // FAIL: preserve interval, reset to learning, increment lapses - if (isNew) { - updates = simpleInitCard(); - } else { - updates = simpleReviewFail(card); - } - // Reset correct count for this card - simpleCorrectCountRef.current.set(card.id, 0); - } else if (isEasy) { - // EASY: skip correctCount gate, immediately graduate - if (isNew) { - const init = simpleInitCard(); - updates = simpleGraduate({ ...card, ...init }, true, card.lapses > 0); - } else { - updates = simpleGraduate(card, true, card.lapses > 0); - } - graduated = true; - simpleCorrectCountRef.current.delete(card.id); - } else { - // CORRECT: behavior depends on card state - if (isNew) { - // First time seeing this card -- initialize and start counting - updates = simpleInitCard(); - simpleCorrectCountRef.current.set(card.id, 1); - // Not graduated yet (need 3 correct) - } else if (card.simpleStage === 1) { - // Already graduated, due for review -- pass once → done - updates = simpleGraduate(card, false, card.lapses > 0); - graduated = true; - simpleCorrectCountRef.current.delete(card.id); - } else { - // Learning/lapsed card (stage 0) -- need 3 correct to graduate - const count = (simpleCorrectCountRef.current.get(card.id) ?? 0) + 1; - simpleCorrectCountRef.current.set(card.id, count); - - if (count >= SIMPLE_SRS_REQUIRED_CORRECT) { - // Graduated! Update interval and schedule next review - updates = simpleGraduate(card, false, card.lapses > 0); - graduated = true; - simpleCorrectCountRef.current.delete(card.id); - } else { - // Still learning -- keep current state, card will be re-queued - updates = { - simpleStage: 0, - simpleN: 0, // immediately due - simpleInterval: card.simpleInterval ?? 0, - }; - } - } - } + // The decision lives in stores/simple-srs.ts so it can be tested: the lapse + // bug was in which flag this call site handed over, not in the arithmetic. + const decision = nextSimpleReview( + card, + action, + simpleCorrectCountRef.current.get(card.id) ?? 0, + ); + const updates = decision.updates; + const graduated = decision.graduated; + if (action === "fail") simpleCorrectCountRef.current.set(card.id, 0); + else if (decision.correctRun === 0) simpleCorrectCountRef.current.delete(card.id); + else simpleCorrectCountRef.current.set(card.id, decision.correctRun); await userDb.runAsync( `UPDATE srs_cards SET simple_stage = ?, simple_n = ?, simple_interval = ?, - reps = reps + 1, lapses = lapses + ?, updated_at = ? WHERE id = ?`, - [updates.simpleStage, updates.simpleN, updates.simpleInterval, pass ? 0 : 1, now, card.id], + simple_relearning = ?, reps = reps + 1, lapses = lapses + ?, updated_at = ? + WHERE id = ?`, + [ + updates.simpleStage, + updates.simpleN, + updates.simpleInterval, + updates.simpleRelearning, + pass ? 0 : 1, + now, + card.id, + ], ); return { @@ -2380,6 +2336,7 @@ function StudyScreen() { updated_at as updatedAt, simple_stage as simpleStage, simple_n as simpleN, simple_interval as simpleInterval, + simple_relearning as simpleRelearning, last_confusion_check as lastConfusionCheck FROM srs_cards WHERE ${scope.clause} AND entry_id = ? AND kanji_literal IS NULL AND deleted_at IS NULL ORDER BY due ASC`, @@ -2457,11 +2414,13 @@ function StudyScreen() { // Restore simple SRS fields await userDb.runAsync( `UPDATE srs_cards SET simple_stage = ?, simple_n = ?, simple_interval = ?, - reps = ?, lapses = ?, last_confusion_check = ?, updated_at = ? WHERE id = ?`, + simple_relearning = ?, reps = ?, lapses = ?, last_confusion_check = ?, + updated_at = ? WHERE id = ?`, [ snap.simpleStage, snap.simpleN, snap.simpleInterval, + snap.simpleRelearning, snap.reps, snap.lapses, snap.lastConfusionCheck, diff --git a/components/StudyStatisticsModal.tsx b/components/StudyStatisticsModal.tsx index 46dbea4..03253ca 100644 --- a/components/StudyStatisticsModal.tsx +++ b/components/StudyStatisticsModal.tsx @@ -39,7 +39,8 @@ export function StudyStatisticsModal({ if (flashcardMode === "simple_srs") { const now = new Date().toISOString(); await userDb.runAsync( - "UPDATE srs_cards SET simple_stage = NULL, simple_n = NULL, simple_interval = NULL, updated_at = ? WHERE list_id = ?", + `UPDATE srs_cards SET simple_stage = NULL, simple_n = NULL, simple_interval = NULL, + simple_relearning = 0, updated_at = ? WHERE list_id = ?`, [now, listId], ); } else if (flashcardMode === "srs") { diff --git a/db/schema.ts b/db/schema.ts index daee374..433b92c 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -161,6 +161,7 @@ export const srsCards = sqliteTable("srs_cards", { simpleStage: integer("simple_stage"), simpleN: real("simple_n"), simpleInterval: real("simple_interval"), + simpleRelearning: integer("simple_relearning"), lastConfusionCheck: text("last_confusion_check"), deletedAt: text("deleted_at"), learningSteps: integer("learning_steps").notNull().default(0), diff --git a/db/simple-relearning-migration.test.ts b/db/simple-relearning-migration.test.ts new file mode 100644 index 0000000..b41d78c --- /dev/null +++ b/db/simple-relearning-migration.test.ts @@ -0,0 +1,71 @@ +/** + * The backfill that comes with `simple_relearning`. + * + * Cards sitting in a lapse when the column lands are relearning by definition, + * and the column's `DEFAULT 0` would quietly forgive their halving. Cards that + * merely failed at some point in the past must NOT be marked — that lifetime + * reading is the bug this column exists to end. + */ +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; + +import { USER_DB_MIGRATIONS } from "./user-migrations"; + +/** Everything except the two statements this change added. */ +const BEFORE = USER_DB_MIGRATIONS.slice(0, -2); +const ADDED = USER_DB_MIGRATIONS.slice(-2); + +function apply(db: Database.Database, statements: readonly string[]) { + for (const sql of statements) { + try { + db.exec(sql); + } catch (err) { + if (!/duplicate column/i.test(String(err))) throw err; + } + } +} + +function card(id: string, stage: number | null, lapses: number) { + return `INSERT INTO srs_cards (id, entry_id, list_id, due, stability, difficulty, elapsed_days, + scheduled_days, reps, lapses, state, front_mode, back_mode, simple_stage, simple_n, + simple_interval, created_at, updated_at) + VALUES ('${id}', 1, 'l', 'x', 0, 0, 0, 0, 9, ${lapses}, 0, 'kanji', 'english', + ${stage === null ? "NULL" : stage}, 0, 56.61, 'created', 'OLD')`; +} + +describe("the simple_relearning backfill", () => { + const migrated = () => { + const db = new Database(":memory:"); + db.pragma("foreign_keys = OFF"); + apply(db, BEFORE); + db.exec(card("mid-lapse", 0, 4)); + db.exec(card("graduated", 1, 4)); + db.exec(card("never-seen", null, 0)); + apply(db, ADDED); + return db; + }; + + it("marks a card that is in a lapse right now", () => { + const row = migrated().prepare("SELECT * FROM srs_cards WHERE id = 'mid-lapse'").get() as any; + expect(row.simple_relearning).toBe(1); + }); + + it("leaves a graduated card alone however often it has failed", () => { + const row = migrated().prepare("SELECT * FROM srs_cards WHERE id = 'graduated'").get() as any; + expect(row.simple_relearning).toBe(0); + // and does not dirty a row it did not change + expect(row.updated_at).toBe("OLD"); + }); + + it("leaves an unseen card at zero", () => { + const row = migrated().prepare("SELECT * FROM srs_cards WHERE id = 'never-seen'").get() as any; + expect(row.simple_relearning).toBe(0); + }); + + it("bumps updated_at on the rows it changes, so they sync", () => { + const row = migrated().prepare("SELECT * FROM srs_cards WHERE id = 'mid-lapse'").get() as any; + expect(row.updated_at).not.toBe("OLD"); + // the format the rest of the app writes + expect(row.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); +}); diff --git a/db/srs-card-columns.test.ts b/db/srs-card-columns.test.ts new file mode 100644 index 0000000..2f6df7b --- /dev/null +++ b/db/srs-card-columns.test.ts @@ -0,0 +1,52 @@ +/** + * The hydration, which nothing else gates. + * + * The lapse fix turns on one alias surviving the SELECT: drop + * `simple_relearning as simpleRelearning` and `card.simpleRelearning` is + * `undefined`, so `=== 1` is false, the halving never fires, and every unit + * test in stores/simple-srs.test.ts still passes. This reads a row back the + * way the study screen does and hands it to the real decision. + */ +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; + +import { USER_DB_MIGRATIONS } from "./user-migrations"; +import { SRS_CARD_SELECT } from "./srs-card-columns"; +import { nextSimpleReview } from "@/stores/simple-srs"; +import type { SrsCardRow } from "./types"; + +function dbWithCard(relearning: number): Database.Database { + const db = new Database(":memory:"); + db.pragma("foreign_keys = OFF"); + for (const sql of USER_DB_MIGRATIONS) { + try { + db.exec(sql); + } catch (err) { + if (!/duplicate column/i.test(String(err))) throw err; + } + } + db.exec(`INSERT INTO srs_cards (id, entry_id, list_id, due, stability, difficulty, elapsed_days, + scheduled_days, reps, lapses, state, front_mode, back_mode, simple_stage, simple_n, + simple_interval, simple_relearning, created_at, updated_at) + VALUES ('c', 1, 'l', 'x', 0, 0, 0, 0, 9, 3, 0, 'kanji', 'english', 1, 0, 56.61, + ${relearning}, 'now', 'now')`); + return db; +} + +const read = (db: Database.Database) => + db.prepare(`${SRS_CARD_SELECT} FROM srs_cards WHERE id = 'c'`).get() as SrsCardRow; + +describe("a card read back the way the study screen reads it", () => { + it("carries simpleRelearning, so a card in a lapse halves once", () => { + const card = read(dbWithCard(1)); + expect(card.simpleRelearning).toBe(1); + expect(nextSimpleReview(card, "pass", 0).updates.simpleInterval).toBeCloseTo(28.305, 3); + }); + + it("and a card that merely failed long ago keeps growing", () => { + const card = read(dbWithCard(0)); + expect(card.simpleRelearning).toBe(0); + expect(card.lapses).toBe(3); // the lifetime counter the bug used to read + expect(nextSimpleReview(card, "pass", 0).updates.simpleInterval).toBeCloseTo(56.61 * 1.9, 2); + }); +}); diff --git a/db/srs-card-columns.ts b/db/srs-card-columns.ts new file mode 100644 index 0000000..6ae0a82 --- /dev/null +++ b/db/srs-card-columns.ts @@ -0,0 +1,19 @@ +/** + * The columns a queued SRS card is read with, aliased to `SrsCardRow`. + * + * Shared so a test can hydrate a row exactly as the study screen does. The + * lapse fix turns on `simple_relearning` surviving this list: drop the alias + * and `card.simpleRelearning` is `undefined`, the halving never fires, and + * every unit test still passes. + */ +export const SRS_CARD_SELECT = `SELECT id, entry_id as entryId, kanji_literal as kanjiLiteral, list_id as listId, due, + stability, difficulty, elapsed_days as elapsedDays, + scheduled_days as scheduledDays, reps, lapses, state, + last_review as lastReview, front_mode as frontMode, + back_mode as backMode, created_at as createdAt, + updated_at as updatedAt, + simple_stage as simpleStage, simple_n as simpleN, + simple_interval as simpleInterval, + simple_relearning as simpleRelearning, + last_confusion_check as lastConfusionCheck, + learning_steps as learningSteps`; diff --git a/db/types.ts b/db/types.ts index 1445309..f76e768 100644 --- a/db/types.ts +++ b/db/types.ts @@ -218,6 +218,8 @@ export interface SrsCardRow { simpleStage: number | null; simpleN: number | null; simpleInterval: number | null; + /** 1 while the card is recovering from a lapse — see stores/simple-srs.ts. */ + simpleRelearning: number | null; // Confusion detection cooldown lastConfusionCheck: string | null; // FSRS learning step index diff --git a/db/user-migrations.ts b/db/user-migrations.ts index a0912d6..b343b4e 100644 --- a/db/user-migrations.ts +++ b/db/user-migrations.ts @@ -248,4 +248,14 @@ export const USER_DB_MIGRATIONS = [ // Ask a kanji card by blanking the keyword out of the learner's own story // (lib/rtk-cloze.ts) instead of showing the character. `ALTER TABLE lists ADD COLUMN mnemonic_cloze INTEGER NOT NULL DEFAULT 0`, + // Whether a simple-SRS card is recovering from a lapse RIGHT NOW. The halved + // re-graduation used `lapses > 0`, a lifetime counter, so every pass after a + // card's first failure halved its interval and the card ratcheted down to the + // 8-hour floor and never left the daily queue again. + `ALTER TABLE srs_cards ADD COLUMN simple_relearning INTEGER DEFAULT 0`, + // Cards sitting in a lapse when this lands are relearning by definition. + // `updated_at` moves too, or the backfill never pushes and a device that + // pulls the row afterwards takes the remote 0 back over it. + `UPDATE srs_cards SET simple_relearning = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE simple_stage = 0 AND lapses > 0`, ]; diff --git a/lib/data-backup.ts b/lib/data-backup.ts index 70a6ebd..66994e7 100644 --- a/lib/data-backup.ts +++ b/lib/data-backup.ts @@ -31,7 +31,7 @@ export const BACKUP_TABLES: { { name: "srs_cards", query: - "SELECT id, entry_id, list_id, due, stability, difficulty, elapsed_days, scheduled_days, reps, lapses, state, last_review, front_mode, back_mode, simple_stage, simple_n, simple_interval, last_confusion_check, kanji_literal, learning_steps, created_at, updated_at FROM srs_cards", + "SELECT id, entry_id, list_id, due, stability, difficulty, elapsed_days, scheduled_days, reps, lapses, state, last_review, front_mode, back_mode, simple_stage, simple_n, simple_interval, simple_relearning, last_confusion_check, kanji_literal, learning_steps, created_at, updated_at FROM srs_cards", }, { name: "review_logs", @@ -132,6 +132,7 @@ const TABLE_COLUMNS: Record = { "simple_stage", "simple_n", "simple_interval", + "simple_relearning", "last_confusion_check", "kanji_literal", "learning_steps", diff --git a/lib/list-transfer.ts b/lib/list-transfer.ts index 09e86cf..ff1db8f 100644 --- a/lib/list-transfer.ts +++ b/lib/list-transfer.ts @@ -62,6 +62,8 @@ export interface JitenExportFile { stage: number; n: number; interval: number; + /** 1 while the card is recovering from a lapse. Absent in older payloads. */ + relearning?: number; }[]; }; } @@ -118,7 +120,7 @@ export async function buildListExport( if (flashcardMode === "simple_srs") { // Export simple SRS data const srsRows = await userDb.getAllAsync( - "SELECT entry_id, kanji_literal, simple_stage, simple_n, simple_interval FROM srs_cards WHERE list_id = ? AND simple_stage IS NOT NULL", + "SELECT entry_id, kanji_literal, simple_stage, simple_n, simple_interval, simple_relearning FROM srs_cards WHERE list_id = ? AND simple_stage IS NOT NULL", [listId], ); @@ -130,6 +132,8 @@ export async function buildListExport( stage: card.simple_stage, n: card.simple_n, interval: card.simple_interval, + // A card shared mid-lapse owes its halving on the other side too. + relearning: card.simple_relearning ?? 0, }; if (card.kanji_literal != null) base.kanjiLiteral = card.kanji_literal; return base; @@ -293,8 +297,8 @@ export async function importListToDb( const kanjiLiteral = "kanjiLiteral" in card ? ((card as any).kanjiLiteral ?? null) : null; const entryId = kanjiLiteral != null ? 0 : card.entryId; await userDb.runAsync( - `INSERT OR IGNORE INTO srs_cards (id, entry_id, kanji_literal, list_id, due, stability, difficulty, elapsed_days, scheduled_days, reps, lapses, state, front_mode, back_mode, simple_stage, simple_n, simple_interval, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO srs_cards (id, entry_id, kanji_literal, list_id, due, stability, difficulty, elapsed_days, scheduled_days, reps, lapses, state, front_mode, back_mode, simple_stage, simple_n, simple_interval, simple_relearning, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ cardId, entryId, @@ -313,6 +317,7 @@ export async function importListToDb( card.stage, card.n, card.interval, + card.relearning ?? 0, now, now, ], diff --git a/stores/simple-srs.test.ts b/stores/simple-srs.test.ts index c1d511d..e90ff89 100644 --- a/stores/simple-srs.test.ts +++ b/stores/simple-srs.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "vitest"; import { + nextSimpleReview, dateToSrsEpochDays, srsEpochDaysToDate, getSimpleDueDate, @@ -357,3 +358,148 @@ describe("session flow", () => { expect(result.simpleStage).toBe(1); }); }); + +/** + * The lapse multiplier belongs to the lapse, not to the card's history. + * + * It was scoped to `lapses > 0` — a lifetime counter nothing resets — so every + * graduation after a card's first ever failure halved the interval instead of + * multiplying it. A card climbed to 56 days, failed once, and then ratcheted + * 28 → 14 → 7 → 3.5 → … → the 8-hour floor, where it stayed due daily forever. + * Side by side with Midori on the same list, Midori's queue decayed and this + * one did not. + */ +describe("the lapse multiplier is scoped to the lapse", () => { + // `lapses: 3` is the whole point: this card HAS failed before. Under the old + // scoping that alone forced the halving, which is what these pin. + const card = (over: Partial = {}): SrsCardRow => + ({ + simpleStage: 1, + simpleN: 0, + simpleInterval: 56.61, + simpleRelearning: 0, + lapses: 3, + ...over, + }) as SrsCardRow; + + test("a pass while relearning halves once", () => { + expect(simpleGraduate(card({ simpleRelearning: 1 }), false, true).simpleInterval).toBeCloseTo( + 28.305, + 3, + ); + }); + + test("and clears the flag, so the next pass multiplies again", () => { + const first = simpleGraduate(card({ simpleRelearning: 1 }), false, true); + expect(first.simpleRelearning).toBe(0); + const second = simpleGraduate(card({ simpleInterval: first.simpleInterval }), false, false); + expect(second.simpleInterval).toBeCloseTo(28.305 * 1.9, 3); + }); + + test("a card that has lapsed in the past but is not relearning grows", () => { + expect(simpleGraduate(card(), false, false).simpleInterval).toBeCloseTo(56.61 * 1.9, 2); + }); + + test("ten passes after one lapse end far above the floor, not on it", () => { + let c = card({ simpleInterval: 56.61, simpleRelearning: 1 }); + let relearning = true; + for (let i = 0; i < 10; i++) { + const u = simpleGraduate(c, false, relearning); + relearning = u.simpleRelearning === 1; + c = card({ simpleInterval: u.simpleInterval, simpleRelearning: u.simpleRelearning }); + } + expect(c.simpleInterval).toBeGreaterThan(100); + }); + + test("a fail marks the card as relearning", () => { + expect(simpleReviewFail(card()).simpleRelearning).toBe(1); + }); + + test("a new card is not relearning", () => { + expect(simpleInitCard().simpleRelearning).toBe(0); + }); +}); + +/** + * The wiring, not the arithmetic. + * + * The lapse bug lived in which flag the study screen handed to simpleGraduate, + * and every test above calls simpleGraduate directly — so all of them stayed + * green with the screen still passing `lapses > 0`. These drive the whole + * answer the way the screen does. + */ +describe("one answer, decided the way the screen decides it", () => { + const graduated = (over: Partial = {}): SrsCardRow => + ({ + simpleStage: 1, + simpleN: 0, + simpleInterval: 56.61, + simpleRelearning: 0, + lapses: 3, + ...over, + }) as SrsCardRow; + + test("fail → three passes → the card recovers instead of ratcheting", () => { + let card = graduated(); + let run = 0; + + const fail = nextSimpleReview(card, "fail", run); + expect(fail.graduated).toBe(false); + expect(fail.updates.simpleRelearning).toBe(1); + card = { ...card, ...fail.updates }; + run = fail.correctRun; + + for (let i = 0; i < 2; i++) { + const step = nextSimpleReview(card, "pass", run); + expect(step.graduated).toBe(false); + // The lapse is still being served, so the flag has to survive the re-queue. + expect(step.updates.simpleRelearning).toBe(1); + card = { ...card, ...step.updates }; + run = step.correctRun; + } + + const third = nextSimpleReview(card, "pass", run); + expect(third.graduated).toBe(true); + expect(third.updates.simpleInterval).toBeCloseTo(28.305, 3); // halved ONCE + expect(third.updates.simpleRelearning).toBe(0); + card = { ...card, ...third.updates }; + + // And the next review grows again — this is the whole fix. + const after = nextSimpleReview(card, "pass", 0); + expect(after.updates.simpleInterval).toBeCloseTo(28.305 * 1.9, 3); + }); + + test("a card that failed long ago but is not relearning grows", () => { + const step = nextSimpleReview(graduated(), "pass", 0); + expect(step.updates.simpleInterval).toBeCloseTo(56.61 * 1.9, 2); + }); + + test("easy graduates at once and still halves only inside a lapse", () => { + expect( + nextSimpleReview(graduated({ simpleRelearning: 1 }), "easy", 0).updates.simpleInterval, + ).toBeCloseTo(28.305, 3); + expect(nextSimpleReview(graduated(), "easy", 0).updates.simpleInterval).toBeCloseTo( + 56.61 * 2.6125, + 2, + ); + }); + + test("a new card counts up to three, then graduates", () => { + const fresh = { + simpleStage: null, + simpleInterval: null, + simpleRelearning: 0, + lapses: 0, + } as SrsCardRow; + const first = nextSimpleReview(fresh, "pass", 0); + expect(first.graduated).toBe(false); + expect(first.correctRun).toBe(1); + const card = { ...fresh, ...first.updates }; + expect(nextSimpleReview(card, "pass", 1).correctRun).toBe(2); + expect(nextSimpleReview(card, "pass", 2).graduated).toBe(true); + }); + + test("a graduated card due for review settles in one pass", () => { + expect(nextSimpleReview(graduated(), "pass", 0).graduated).toBe(true); + }); +}); diff --git a/stores/simple-srs.ts b/stores/simple-srs.ts index 313e0f1..ee76c38 100644 --- a/stores/simple-srs.ts +++ b/stores/simple-srs.ts @@ -9,7 +9,11 @@ * - Due date = n (directly stored, not computed) * - Correct multiplier: ×1.9, Easy multiplier: ×2.6125 (1.9 × 1.375) * - Fail: preserve interval, reset correctCount, set n=0 (immediately due) - * - Re-graduation (3 correct after lapse): interval ×0.5, clamp [1/3, 365] + * - Re-graduation (3 correct after lapse): interval ×0.5, clamp [1/3, 365]. + * ONCE — the halving belongs to the lapse being recovered from, not to the + * card's history. Scoped to `lapses > 0` instead, every pass after a card's + * first ever failure halved it, so the interval ratcheted down to the floor + * and the card was due every 8 hours for the rest of its life. * - Normal graduation (never lapsed): interval ×1.9 (or ×2.6125), clamp [1/3, 365] * - Graduated cards due for review: pass once → done (recalculate interval) * - New/learning cards: need 3 correct in a row to graduate @@ -19,6 +23,14 @@ import type { SrsCardRow } from "@/db/types"; +/** Every field a simple-SRS review writes back to `srs_cards`. */ +export interface SimpleSrsUpdate { + simpleStage: number; + simpleN: number; + simpleInterval: number; + simpleRelearning: number; +} + const MAC_EPOCH_MS = new Date("2001-01-01T00:00:00Z").getTime(); const CORRECT_MULTIPLIER = 1.9; const EASY_MULTIPLIER = 1.9 * 1.375; // 2.6125 @@ -91,21 +103,17 @@ export function getEndOfLogicalDay(resetHour: number, now?: Date): Date { * * @param card - The SRS card being reviewed * @param easy - Whether this was an "easy" action (long-press pass) - * @param hasLapsed - Whether the card has ever been failed (lapses > 0) + * @param isRelearning - Whether the card is recovering from a lapse right now */ export function simpleGraduate( card: SrsCardRow, easy: boolean, - hasLapsed: boolean, -): { - simpleStage: number; - simpleN: number; - simpleInterval: number; -} { + isRelearning: boolean, +): SimpleSrsUpdate { const now = dateToSrsEpochDays(); let newInterval: number; - if (hasLapsed) { + if (isRelearning) { // Re-graduating after a lapse: halve the interval, clamp [1/3, 365] const currentInterval = card.simpleInterval ?? INITIAL_INTERVAL; newInterval = Math.max(MIN_INTERVAL, Math.min(currentInterval * 0.5, MAX_INTERVAL)); @@ -120,35 +128,96 @@ export function simpleGraduate( simpleStage: 1, simpleN: now + newInterval, // n = due date simpleInterval: newInterval, + // The lapse this graduation recovered from is paid for. + simpleRelearning: 0, }; } /** Process a fail review — returns updated simple SRS fields */ -export function simpleReviewFail(card: SrsCardRow): { - simpleStage: number; - simpleN: number; - simpleInterval: number; -} { +export function simpleReviewFail(card: SrsCardRow): SimpleSrsUpdate { // Fail: preserve interval, set stage to 0 (learning), n=0 (immediately due) return { simpleStage: 0, simpleN: 0, simpleInterval: card.simpleInterval ?? INITIAL_INTERVAL, + simpleRelearning: 1, }; } /** Initialize a new card for simple SRS (first time seeing it) */ -export function simpleInitCard(): { - simpleStage: number; - simpleN: number; - simpleInterval: number; -} { +export function simpleInitCard(): SimpleSrsUpdate { return { simpleStage: 0, simpleN: 0, // immediately due (in learning) simpleInterval: INITIAL_INTERVAL, + simpleRelearning: 0, }; } /** Number of correct answers needed to graduate from learning */ export const SIMPLE_SRS_REQUIRED_CORRECT = REQUIRED_CORRECT; + +/** What a single simple-SRS answer does to a card. */ +export interface SimpleSrsDecision { + updates: SimpleSrsUpdate; + /** Whether the card leaves the session, or is re-queued to be asked again. */ + graduated: boolean; + /** The card's correct-in-a-row count after this answer. */ + correctRun: number; +} + +/** + * Decide one simple-SRS answer. + * + * Extracted from the study screen so the WIRING is testable: the lapse bug + * lived here, in which flag the call site handed to `simpleGraduate`, and a + * test that only called `simpleGraduate` could not see it. + */ +export function nextSimpleReview( + card: SrsCardRow, + action: "pass" | "easy" | "fail", + correctRun: number, +): SimpleSrsDecision { + const isNew = card.simpleStage == null; + const relearning = card.simpleRelearning === 1; + + if (action === "fail") { + return { + updates: isNew ? simpleInitCard() : simpleReviewFail(card), + graduated: false, + correctRun: 0, + }; + } + + if (action === "easy") { + // Easy skips the correct-in-a-row gate and graduates at once. + const base = isNew ? { ...card, ...simpleInitCard() } : card; + return { updates: simpleGraduate(base, true, relearning), graduated: true, correctRun: 0 }; + } + + if (isNew) { + // First sight: start the card and start counting. + return { updates: simpleInitCard(), graduated: false, correctRun: 1 }; + } + + if (card.simpleStage === 1) { + // Graduated and due: one correct answer settles it. + return { updates: simpleGraduate(card, false, relearning), graduated: true, correctRun: 0 }; + } + + const run = correctRun + 1; + if (run >= REQUIRED_CORRECT) { + return { updates: simpleGraduate(card, false, relearning), graduated: true, correctRun: 0 }; + } + return { + updates: { + simpleStage: 0, + simpleN: 0, // immediately due + simpleInterval: card.simpleInterval ?? 0, + // Still inside the lapse it is recovering from, if it was in one. + simpleRelearning: card.simpleRelearning ?? 0, + }, + graduated: false, + correctRun: run, + }; +} From c13734078e5ad12667dc0799ca436d8524d7cb44 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 13:44:34 +0800 Subject: [PATCH 04/12] Keep the correct-in-a-row count on the card, not in the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A learning or lapsed card needs three correct answers in a row to graduate, and until it has them its `simple_n` is 0 — permanently due. The count lived in `simpleCorrectCountRef`, a React ref, so it died with the screen. Answer a card twice, leave, and it is back to zero. With a deck bigger than one sitting no card ever reaches three, nothing graduates, and the same queue returns every day — which is what Sam has been looking at. At one pass per session a card now graduates in three days. Before, never. `simple_run` is nullable with DEFAULT 0 rather than NOT NULL: backup restore builds values as `row[col] ?? null`, so NOT NULL would have failed the whole srs_cards import on any backup taken before the column, losing the study history it was meant to protect. Both read sites take `?? 0`. The ref is gone entirely, which also retires `getCheckCount`'s guess at the count from the interval — that heuristic only existed because the real count was not stored. A just-failed card now shows no checks where it used to show one, which is what actually happened to it. Review also found a stale-pool hole this widened: a confused word queued mid-session is failed and appended, but the session pools still held the row as it was at session start, so a second copy pulled later carried a stale run and could graduate the card one pass after the fail reset it. It is dropped from both pools now. Co-Authored-By: Claude Opus 5 (1M context) --- app/(tabs)/lists/study.tsx | 63 ++++++++++++----------------- components/StudyStatisticsModal.tsx | 2 +- db/schema.ts | 1 + db/srs-card-columns.test.ts | 51 ++++++++++++++++++++++- db/srs-card-columns.ts | 1 + db/types.ts | 2 + db/user-migrations.ts | 6 +++ lib/data-backup.ts | 3 +- lib/list-transfer.ts | 10 +++-- stores/simple-srs.test.ts | 30 +++++++------- stores/simple-srs.ts | 26 ++++++------ 11 files changed, 120 insertions(+), 75 deletions(-) diff --git a/app/(tabs)/lists/study.tsx b/app/(tabs)/lists/study.tsx index d195685..896c5ef 100644 --- a/app/(tabs)/lists/study.tsx +++ b/app/(tabs)/lists/study.tsx @@ -463,7 +463,6 @@ interface StudyCardViewProps { frontFaces: CardFace[]; backFaces: CardFace[]; flashcardMode: FlashcardMode; - simpleCorrectCount: number; // Voice/typing (only active for cursor card) voiceStatus: "idle" | "correct" | "wrong"; voiceHeard: string | null; @@ -494,7 +493,6 @@ const StudyCardView = React.memo( frontFaces, backFaces, flashcardMode, - simpleCorrectCount, voiceStatus, voiceHeard, isListening, @@ -593,10 +591,7 @@ const StudyCardView = React.memo( // Stats & checks const stats = getCardStats(item.srsCard, flashcardMode); - const checkCount = - flashcardMode === "simple_srs" && item.srsCard - ? (simpleCorrectCount ?? getCheckCount(item.srsCard, "simple_srs")) - : getCheckCount(item.srsCard, flashcardMode); + const checkCount = getCheckCount(item.srsCard, flashcardMode); // Render kana face with pitch accent visualization when available function renderFaceContent( @@ -908,6 +903,7 @@ interface SrsSnapshot { simpleN: number | null; simpleInterval: number | null; simpleRelearning: number | null; + simpleRun: number | null; lastConfusionCheck: string | null; learningSteps: number; } @@ -941,6 +937,7 @@ function captureSnapshot(card: SrsCardRow): SrsSnapshot { simpleN: card.simpleN, simpleInterval: card.simpleInterval, simpleRelearning: card.simpleRelearning, + simpleRun: card.simpleRun, lastConfusionCheck: card.lastConfusionCheck, learningSteps: card.learningSteps, }; @@ -952,8 +949,9 @@ function getCheckCount(srsCard: SrsCardRow | undefined, mode: FlashcardMode): nu if (mode === "simple_srs") { if (srsCard.simpleStage == null) return 0; if (srsCard.simpleStage === 1) return 3; // graduated - // stage 0 (learning): 1 check after init, 2 if interval has grown - return srsCard.simpleInterval != null && srsCard.simpleInterval > 0.5 ? 2 : 1; + // The real count, now that it is persisted — this used to be guessed from + // the interval, because the count died with the screen. + return srsCard.simpleRun ?? 0; } if (mode === "srs") { @@ -1084,7 +1082,6 @@ function StudyScreen() { const sessionCorrectRef = useRef(0); const cursorCardRef = useRef(null); // Simple SRS: track correct-in-a-row per card (need 3 to graduate) - const simpleCorrectCountRef = useRef(new Map()); // Simple SRS: track cards that completed this session (graduated or passed review) const completedSrsIdsRef = useRef(new Set()); @@ -2211,26 +2208,23 @@ function StudyScreen() { // The decision lives in stores/simple-srs.ts so it can be tested: the lapse // bug was in which flag this call site handed over, not in the arithmetic. - const decision = nextSimpleReview( - card, - action, - simpleCorrectCountRef.current.get(card.id) ?? 0, - ); + // The correct-in-a-row count rides in `updates` and is persisted — it used + // to live in a ref that died with the screen, so a card answered twice and + // left came back to a count of zero and stayed due forever. + const decision = nextSimpleReview(card, action); const updates = decision.updates; const graduated = decision.graduated; - if (action === "fail") simpleCorrectCountRef.current.set(card.id, 0); - else if (decision.correctRun === 0) simpleCorrectCountRef.current.delete(card.id); - else simpleCorrectCountRef.current.set(card.id, decision.correctRun); await userDb.runAsync( `UPDATE srs_cards SET simple_stage = ?, simple_n = ?, simple_interval = ?, - simple_relearning = ?, reps = reps + 1, lapses = lapses + ?, updated_at = ? - WHERE id = ?`, + simple_relearning = ?, simple_run = ?, reps = reps + 1, lapses = lapses + ?, + updated_at = ? WHERE id = ?`, [ updates.simpleStage, updates.simpleN, updates.simpleInterval, updates.simpleRelearning, + updates.simpleRun, pass ? 0 : 1, now, card.id, @@ -2328,16 +2322,7 @@ function StudyScreen() { // Find the srs_card for this confused entry const cardRow = await userDb.getFirstAsync( - `SELECT id, entry_id as entryId, kanji_literal as kanjiLiteral, list_id as listId, due, - stability, difficulty, elapsed_days as elapsedDays, - scheduled_days as scheduledDays, reps, lapses, state, - last_review as lastReview, front_mode as frontMode, - back_mode as backMode, created_at as createdAt, - updated_at as updatedAt, - simple_stage as simpleStage, simple_n as simpleN, - simple_interval as simpleInterval, - simple_relearning as simpleRelearning, - last_confusion_check as lastConfusionCheck + `${SRS_CARD_SELECT} FROM srs_cards WHERE ${scope.clause} AND entry_id = ? AND kanji_literal IS NULL AND deleted_at IS NULL ORDER BY due ASC`, [...scope.args, result.entry.id], @@ -2352,6 +2337,13 @@ function StudyScreen() { await rateSrsCard(cardRow, Rating.Again); } + // Drop it from the pools first: they hold the row as it was at session + // start, and a second copy pulled later would carry a stale correct-run + // and graduate the card on one pass after this fail just reset it. + const queuedId = queuedCard.id; + duePoolRef.current = duePoolRef.current.filter((item) => item.srsCard?.id !== queuedId); + newPoolRef.current = newPoolRef.current.filter((item) => item.srsCard?.id !== queuedId); + // Push to end of current session setCards((prev) => [ ...prev, @@ -2414,13 +2406,14 @@ function StudyScreen() { // Restore simple SRS fields await userDb.runAsync( `UPDATE srs_cards SET simple_stage = ?, simple_n = ?, simple_interval = ?, - simple_relearning = ?, reps = ?, lapses = ?, last_confusion_check = ?, - updated_at = ? WHERE id = ?`, + simple_relearning = ?, simple_run = ?, reps = ?, lapses = ?, + last_confusion_check = ?, updated_at = ? WHERE id = ?`, [ snap.simpleStage, snap.simpleN, snap.simpleInterval, snap.simpleRelearning, + snap.simpleRun, snap.reps, snap.lapses, snap.lastConfusionCheck, @@ -2428,8 +2421,7 @@ function StudyScreen() { card.id, ], ); - // Reset correct count and completed status for this card on undo - simpleCorrectCountRef.current.delete(card.id); + // The restored snapshot carries the correct-in-a-row count with it now. completedSrsIdsRef.current.delete(card.id); } else if (sessionMode === "add_order" && studyCard.preStudyPosition != null) { // Restore study_position @@ -2918,11 +2910,6 @@ function StudyScreen() { frontFaces={frontFaces} backFaces={backFaces} flashcardMode={sessionMode} - simpleCorrectCount={ - studyCard.item.srsCard - ? (simpleCorrectCountRef.current.get(studyCard.item.srsCard.id) ?? 0) - : 0 - } voiceStatus={isCursor ? voiceStatus : "idle"} voiceHeard={isCursor ? voiceHeard : null} isListening={isCursor && isListening} diff --git a/components/StudyStatisticsModal.tsx b/components/StudyStatisticsModal.tsx index 03253ca..a9e041a 100644 --- a/components/StudyStatisticsModal.tsx +++ b/components/StudyStatisticsModal.tsx @@ -40,7 +40,7 @@ export function StudyStatisticsModal({ const now = new Date().toISOString(); await userDb.runAsync( `UPDATE srs_cards SET simple_stage = NULL, simple_n = NULL, simple_interval = NULL, - simple_relearning = 0, updated_at = ? WHERE list_id = ?`, + simple_relearning = 0, simple_run = 0, updated_at = ? WHERE list_id = ?`, [now, listId], ); } else if (flashcardMode === "srs") { diff --git a/db/schema.ts b/db/schema.ts index 433b92c..a655fc1 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -162,6 +162,7 @@ export const srsCards = sqliteTable("srs_cards", { simpleN: real("simple_n"), simpleInterval: real("simple_interval"), simpleRelearning: integer("simple_relearning"), + simpleRun: integer("simple_run"), lastConfusionCheck: text("last_confusion_check"), deletedAt: text("deleted_at"), learningSteps: integer("learning_steps").notNull().default(0), diff --git a/db/srs-card-columns.test.ts b/db/srs-card-columns.test.ts index 2f6df7b..1b8b130 100644 --- a/db/srs-card-columns.test.ts +++ b/db/srs-card-columns.test.ts @@ -40,13 +40,60 @@ describe("a card read back the way the study screen reads it", () => { it("carries simpleRelearning, so a card in a lapse halves once", () => { const card = read(dbWithCard(1)); expect(card.simpleRelearning).toBe(1); - expect(nextSimpleReview(card, "pass", 0).updates.simpleInterval).toBeCloseTo(28.305, 3); + expect(nextSimpleReview(card, "pass").updates.simpleInterval).toBeCloseTo(28.305, 3); }); it("and a card that merely failed long ago keeps growing", () => { const card = read(dbWithCard(0)); expect(card.simpleRelearning).toBe(0); expect(card.lapses).toBe(3); // the lifetime counter the bug used to read - expect(nextSimpleReview(card, "pass", 0).updates.simpleInterval).toBeCloseTo(56.61 * 1.9, 2); + expect(nextSimpleReview(card, "pass").updates.simpleInterval).toBeCloseTo(56.61 * 1.9, 2); + }); +}); + +/** + * The count that used to die with the screen. + * + * A learning card needs three correct answers in a row, and until it has them + * its `simple_n` is 0 — permanently due. The count lived in a React ref, so a + * card answered twice and left came back to zero: with a deck bigger than one + * sitting, no card ever reached three and the queue never shrank. + */ +describe("the correct-in-a-row count survives a reload", () => { + function learningCard(run: number): Database.Database { + const db = new Database(":memory:"); + db.pragma("foreign_keys = OFF"); + for (const sql of USER_DB_MIGRATIONS) { + try { + db.exec(sql); + } catch (err) { + if (!/duplicate column/i.test(String(err))) throw err; + } + } + db.exec(`INSERT INTO srs_cards (id, entry_id, list_id, due, stability, difficulty, + elapsed_days, scheduled_days, reps, lapses, state, front_mode, back_mode, simple_stage, + simple_n, simple_interval, simple_relearning, simple_run, created_at, updated_at) + VALUES ('c', 1, 'l', 'x', 0, 0, 0, 0, 2, 0, 0, 'kanji', 'english', 0, 0, 0.3333, 0, + ${run}, 'now', 'now')`); + return db; + } + + it("counts up instead of restarting", () => { + const card = read(learningCard(2)); + expect(card.simpleRun).toBe(2); + // Third correct answer in a row: this one graduates. + expect(nextSimpleReview(card, "pass").graduated).toBe(true); + }); + + it("a card answered once before is not treated as untouched", () => { + const card = read(learningCard(1)); + const step = nextSimpleReview(card, "pass"); + expect(step.graduated).toBe(false); + expect(step.updates.simpleRun).toBe(2); + }); + + it("a fail puts the count back to zero", () => { + const card = read(learningCard(2)); + expect(nextSimpleReview(card, "fail").updates.simpleRun).toBe(0); }); }); diff --git a/db/srs-card-columns.ts b/db/srs-card-columns.ts index 6ae0a82..0a1da61 100644 --- a/db/srs-card-columns.ts +++ b/db/srs-card-columns.ts @@ -15,5 +15,6 @@ export const SRS_CARD_SELECT = `SELECT id, entry_id as entryId, kanji_literal as simple_stage as simpleStage, simple_n as simpleN, simple_interval as simpleInterval, simple_relearning as simpleRelearning, + simple_run as simpleRun, last_confusion_check as lastConfusionCheck, learning_steps as learningSteps`; diff --git a/db/types.ts b/db/types.ts index f76e768..baf2558 100644 --- a/db/types.ts +++ b/db/types.ts @@ -220,6 +220,8 @@ export interface SrsCardRow { simpleInterval: number | null; /** 1 while the card is recovering from a lapse — see stores/simple-srs.ts. */ simpleRelearning: number | null; + /** Correct answers in a row so far, toward the three a learning card needs. */ + simpleRun: number | null; // Confusion detection cooldown lastConfusionCheck: string | null; // FSRS learning step index diff --git a/db/user-migrations.ts b/db/user-migrations.ts index b343b4e..a40c1f0 100644 --- a/db/user-migrations.ts +++ b/db/user-migrations.ts @@ -258,4 +258,10 @@ export const USER_DB_MIGRATIONS = [ // pulls the row afterwards takes the remote 0 back over it. `UPDATE srs_cards SET simple_relearning = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE simple_stage = 0 AND lapses > 0`, + // How many correct answers in a row a learning card has. It lived in a React + // ref, so it died with the screen: a card answered twice and left was back to + // zero, and with `simple_n = 0` it stayed due forever. Nullable like its + // neighbours — a restored pre-column backup writes NULL, and NOT NULL would + // fail the whole srs_cards import. + `ALTER TABLE srs_cards ADD COLUMN simple_run INTEGER DEFAULT 0`, ]; diff --git a/lib/data-backup.ts b/lib/data-backup.ts index 66994e7..c584fe6 100644 --- a/lib/data-backup.ts +++ b/lib/data-backup.ts @@ -31,7 +31,7 @@ export const BACKUP_TABLES: { { name: "srs_cards", query: - "SELECT id, entry_id, list_id, due, stability, difficulty, elapsed_days, scheduled_days, reps, lapses, state, last_review, front_mode, back_mode, simple_stage, simple_n, simple_interval, simple_relearning, last_confusion_check, kanji_literal, learning_steps, created_at, updated_at FROM srs_cards", + "SELECT id, entry_id, list_id, due, stability, difficulty, elapsed_days, scheduled_days, reps, lapses, state, last_review, front_mode, back_mode, simple_stage, simple_n, simple_interval, simple_relearning, simple_run, last_confusion_check, kanji_literal, learning_steps, created_at, updated_at FROM srs_cards", }, { name: "review_logs", @@ -133,6 +133,7 @@ const TABLE_COLUMNS: Record = { "simple_n", "simple_interval", "simple_relearning", + "simple_run", "last_confusion_check", "kanji_literal", "learning_steps", diff --git a/lib/list-transfer.ts b/lib/list-transfer.ts index ff1db8f..1719e36 100644 --- a/lib/list-transfer.ts +++ b/lib/list-transfer.ts @@ -64,6 +64,8 @@ export interface JitenExportFile { interval: number; /** 1 while the card is recovering from a lapse. Absent in older payloads. */ relearning?: number; + /** Correct answers in a row so far. Absent in older payloads. */ + run?: number; }[]; }; } @@ -120,7 +122,7 @@ export async function buildListExport( if (flashcardMode === "simple_srs") { // Export simple SRS data const srsRows = await userDb.getAllAsync( - "SELECT entry_id, kanji_literal, simple_stage, simple_n, simple_interval, simple_relearning FROM srs_cards WHERE list_id = ? AND simple_stage IS NOT NULL", + "SELECT entry_id, kanji_literal, simple_stage, simple_n, simple_interval, simple_relearning, simple_run FROM srs_cards WHERE list_id = ? AND simple_stage IS NOT NULL", [listId], ); @@ -134,6 +136,7 @@ export async function buildListExport( interval: card.simple_interval, // A card shared mid-lapse owes its halving on the other side too. relearning: card.simple_relearning ?? 0, + run: card.simple_run ?? 0, }; if (card.kanji_literal != null) base.kanjiLiteral = card.kanji_literal; return base; @@ -297,8 +300,8 @@ export async function importListToDb( const kanjiLiteral = "kanjiLiteral" in card ? ((card as any).kanjiLiteral ?? null) : null; const entryId = kanjiLiteral != null ? 0 : card.entryId; await userDb.runAsync( - `INSERT OR IGNORE INTO srs_cards (id, entry_id, kanji_literal, list_id, due, stability, difficulty, elapsed_days, scheduled_days, reps, lapses, state, front_mode, back_mode, simple_stage, simple_n, simple_interval, simple_relearning, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO srs_cards (id, entry_id, kanji_literal, list_id, due, stability, difficulty, elapsed_days, scheduled_days, reps, lapses, state, front_mode, back_mode, simple_stage, simple_n, simple_interval, simple_relearning, simple_run, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ cardId, entryId, @@ -318,6 +321,7 @@ export async function importListToDb( card.n, card.interval, card.relearning ?? 0, + card.run ?? 0, now, now, ], diff --git a/stores/simple-srs.test.ts b/stores/simple-srs.test.ts index e90ff89..bb50307 100644 --- a/stores/simple-srs.test.ts +++ b/stores/simple-srs.test.ts @@ -441,44 +441,41 @@ describe("one answer, decided the way the screen decides it", () => { test("fail → three passes → the card recovers instead of ratcheting", () => { let card = graduated(); - let run = 0; - const fail = nextSimpleReview(card, "fail", run); + const fail = nextSimpleReview(card, "fail"); expect(fail.graduated).toBe(false); expect(fail.updates.simpleRelearning).toBe(1); card = { ...card, ...fail.updates }; - run = fail.correctRun; for (let i = 0; i < 2; i++) { - const step = nextSimpleReview(card, "pass", run); + const step = nextSimpleReview(card, "pass"); expect(step.graduated).toBe(false); // The lapse is still being served, so the flag has to survive the re-queue. expect(step.updates.simpleRelearning).toBe(1); card = { ...card, ...step.updates }; - run = step.correctRun; } - const third = nextSimpleReview(card, "pass", run); + const third = nextSimpleReview(card, "pass"); expect(third.graduated).toBe(true); expect(third.updates.simpleInterval).toBeCloseTo(28.305, 3); // halved ONCE expect(third.updates.simpleRelearning).toBe(0); card = { ...card, ...third.updates }; // And the next review grows again — this is the whole fix. - const after = nextSimpleReview(card, "pass", 0); + const after = nextSimpleReview(card, "pass"); expect(after.updates.simpleInterval).toBeCloseTo(28.305 * 1.9, 3); }); test("a card that failed long ago but is not relearning grows", () => { - const step = nextSimpleReview(graduated(), "pass", 0); + const step = nextSimpleReview(graduated(), "pass"); expect(step.updates.simpleInterval).toBeCloseTo(56.61 * 1.9, 2); }); test("easy graduates at once and still halves only inside a lapse", () => { expect( - nextSimpleReview(graduated({ simpleRelearning: 1 }), "easy", 0).updates.simpleInterval, + nextSimpleReview(graduated({ simpleRelearning: 1 }), "easy").updates.simpleInterval, ).toBeCloseTo(28.305, 3); - expect(nextSimpleReview(graduated(), "easy", 0).updates.simpleInterval).toBeCloseTo( + expect(nextSimpleReview(graduated(), "easy").updates.simpleInterval).toBeCloseTo( 56.61 * 2.6125, 2, ); @@ -491,15 +488,16 @@ describe("one answer, decided the way the screen decides it", () => { simpleRelearning: 0, lapses: 0, } as SrsCardRow; - const first = nextSimpleReview(fresh, "pass", 0); + const first = nextSimpleReview(fresh, "pass"); expect(first.graduated).toBe(false); - expect(first.correctRun).toBe(1); - const card = { ...fresh, ...first.updates }; - expect(nextSimpleReview(card, "pass", 1).correctRun).toBe(2); - expect(nextSimpleReview(card, "pass", 2).graduated).toBe(true); + expect(first.updates.simpleRun).toBe(1); + const once = { ...fresh, ...first.updates }; + const second = nextSimpleReview(once, "pass"); + expect(second.updates.simpleRun).toBe(2); + expect(nextSimpleReview({ ...once, ...second.updates }, "pass").graduated).toBe(true); }); test("a graduated card due for review settles in one pass", () => { - expect(nextSimpleReview(graduated(), "pass", 0).graduated).toBe(true); + expect(nextSimpleReview(graduated(), "pass").graduated).toBe(true); }); }); diff --git a/stores/simple-srs.ts b/stores/simple-srs.ts index ee76c38..bd0faba 100644 --- a/stores/simple-srs.ts +++ b/stores/simple-srs.ts @@ -29,6 +29,8 @@ export interface SimpleSrsUpdate { simpleN: number; simpleInterval: number; simpleRelearning: number; + /** Correct answers in a row, toward the three a learning card needs. */ + simpleRun: number; } const MAC_EPOCH_MS = new Date("2001-01-01T00:00:00Z").getTime(); @@ -130,6 +132,7 @@ export function simpleGraduate( simpleInterval: newInterval, // The lapse this graduation recovered from is paid for. simpleRelearning: 0, + simpleRun: 0, }; } @@ -141,6 +144,7 @@ export function simpleReviewFail(card: SrsCardRow): SimpleSrsUpdate { simpleN: 0, simpleInterval: card.simpleInterval ?? INITIAL_INTERVAL, simpleRelearning: 1, + simpleRun: 0, }; } @@ -151,6 +155,7 @@ export function simpleInitCard(): SimpleSrsUpdate { simpleN: 0, // immediately due (in learning) simpleInterval: INITIAL_INTERVAL, simpleRelearning: 0, + simpleRun: 0, }; } @@ -162,8 +167,6 @@ export interface SimpleSrsDecision { updates: SimpleSrsUpdate; /** Whether the card leaves the session, or is re-queued to be asked again. */ graduated: boolean; - /** The card's correct-in-a-row count after this answer. */ - correctRun: number; } /** @@ -176,38 +179,33 @@ export interface SimpleSrsDecision { export function nextSimpleReview( card: SrsCardRow, action: "pass" | "easy" | "fail", - correctRun: number, ): SimpleSrsDecision { const isNew = card.simpleStage == null; const relearning = card.simpleRelearning === 1; if (action === "fail") { - return { - updates: isNew ? simpleInitCard() : simpleReviewFail(card), - graduated: false, - correctRun: 0, - }; + return { updates: isNew ? simpleInitCard() : simpleReviewFail(card), graduated: false }; } if (action === "easy") { // Easy skips the correct-in-a-row gate and graduates at once. const base = isNew ? { ...card, ...simpleInitCard() } : card; - return { updates: simpleGraduate(base, true, relearning), graduated: true, correctRun: 0 }; + return { updates: simpleGraduate(base, true, relearning), graduated: true }; } if (isNew) { // First sight: start the card and start counting. - return { updates: simpleInitCard(), graduated: false, correctRun: 1 }; + return { updates: { ...simpleInitCard(), simpleRun: 1 }, graduated: false }; } if (card.simpleStage === 1) { // Graduated and due: one correct answer settles it. - return { updates: simpleGraduate(card, false, relearning), graduated: true, correctRun: 0 }; + return { updates: simpleGraduate(card, false, relearning), graduated: true }; } - const run = correctRun + 1; + const run = (card.simpleRun ?? 0) + 1; if (run >= REQUIRED_CORRECT) { - return { updates: simpleGraduate(card, false, relearning), graduated: true, correctRun: 0 }; + return { updates: simpleGraduate(card, false, relearning), graduated: true }; } return { updates: { @@ -216,8 +214,8 @@ export function nextSimpleReview( simpleInterval: card.simpleInterval ?? 0, // Still inside the lapse it is recovering from, if it was in one. simpleRelearning: card.simpleRelearning ?? 0, + simpleRun: run, }, graduated: false, - correctRun: run, }; } From 12fdd9359eaea57b556fc8188224e6a9aaa04b44 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 13:47:10 +0800 Subject: [PATCH 05/12] Correct the reason given for making simple_run nullable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit, and the comment beside the migration, said NOT NULL would fail backup restore because `lib/data-backup.ts` builds values as `row[col] ?? null`. That is wrong, and I should have checked it before repeating it: the restore inserts with `INSERT OR REPLACE`, and SQLite's REPLACE conflict resolution substitutes a column's DEFAULT for an explicit NULL rather than failing. Only a plain INSERT would have failed. INSERT OR REPLACE + explicit NULL → ok, a = 7 plain INSERT + explicit NULL → NOT NULL constraint failed The column stays nullable, which matches `simple_relearning` and the other simple-SRS columns, but the constraint was never the reason. Co-Authored-By: Claude Opus 5 (1M context) --- db/user-migrations.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/user-migrations.ts b/db/user-migrations.ts index a40c1f0..eb3f0ca 100644 --- a/db/user-migrations.ts +++ b/db/user-migrations.ts @@ -260,8 +260,8 @@ export const USER_DB_MIGRATIONS = [ WHERE simple_stage = 0 AND lapses > 0`, // How many correct answers in a row a learning card has. It lived in a React // ref, so it died with the screen: a card answered twice and left was back to - // zero, and with `simple_n = 0` it stayed due forever. Nullable like its - // neighbours — a restored pre-column backup writes NULL, and NOT NULL would - // fail the whole srs_cards import. + // zero, and with `simple_n = 0` it stayed due forever. Nullable to match its + // neighbours; restore would survive NOT NULL too, since it inserts with + // `INSERT OR REPLACE`, which substitutes the default for an explicit NULL. `ALTER TABLE srs_cards ADD COLUMN simple_run INTEGER DEFAULT 0`, ]; From c571dc87db8f2fcf2248156448d26e781f8967d9 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 14:07:30 +0800 Subject: [PATCH 06/12] Introduce ten new cards a session, not the whole list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A simple-SRS session loaded every unseen card in the list as its new pool. On an 8,000-word list that is 8,000 cards in one queue, and since a learning card needs three correct answers in a row to graduate, nothing in a queue that size ever collected them: the same queue came back the next day, and the day after. Midori introduces about ten. Lists now carry `new_per_session`, defaulting to ten, and the new pool is `ORDER BY created_at ASC, rowid ASC LIMIT ?`. The cap is read from the list row inside `loadQueue`, not off the `list` state: the settings modal reloads the queue from its own close handler, which closes over the pre-save value, and making the effect depend on the cap instead would race that reload with no in-flight guard. Smart Review and the flagged-cards list are exempt: both are assembled on purpose and are entirely new, so a cap would show ten of a deliberate selection and call that a session. `isEphemeralListId` is the test and the limit binds -1, SQLite's own "unlimited". The RTK review list cannot reach this branch at all: `lib/rtk-review.ts` writes `flashcard_mode 'srs'` and its multi-scope forces FSRS. The setting is a number input in the flashcard settings modal, 1 to 500, empty meaning the default, and it only shows for simple SRS — so a save in any other mode now keeps the stored value instead of writing NULL over it. Review caught that the column was in the UPDATE's SQL and missing from its args array. op-sqlite does not check the count, so the short array shifted every value one place left: the timestamp went into `new_per_session`, the list id into `updated_at`, and `WHERE id = NULL` changed zero rows. Every flashcard setting for every list would have stopped persisting while the store still mutated and the UI still looked saved. `db/sql-arity.test.ts` now counts placeholders against binds with the TypeScript AST across app, components, db, lib, stores and hooks — 190 of 335 call sites; it cannot see args assembled in a variable or an array carrying a spread, which the test says out loud so a pass is not read as more than it is. Removing the bind again makes it name the line. Co-Authored-By: Claude Opus 5 (1M context) --- app/(tabs)/lists/study.tsx | 23 ++++- components/FlashcardSettingsModal.tsx | 49 +++++++++- db/schema.ts | 1 + db/simple-relearning-migration.test.ts | 19 +++- db/sql-arity.test.ts | 119 +++++++++++++++++++++++++ db/types.ts | 2 + db/user-migrations.ts | 6 ++ lib/data-backup.ts | 3 +- lib/new-per-session.test.ts | 85 ++++++++++++++++++ stores/lists.ts | 1 + stores/simple-srs.ts | 13 +++ 11 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 db/sql-arity.test.ts create mode 100644 lib/new-per-session.test.ts diff --git a/app/(tabs)/lists/study.tsx b/app/(tabs)/lists/study.tsx index 896c5ef..d9674ca 100644 --- a/app/(tabs)/lists/study.tsx +++ b/app/(tabs)/lists/study.tsx @@ -21,6 +21,7 @@ import { shouldAskAsCloze } from "@/lib/rtk-cloze"; import { rtkReviewScope, singleList, type ListScope } from "@/lib/list-scope"; import { isRtkReviewList } from "@/lib/rtk-review"; import { SRS_CARD_SELECT } from "@/db/srs-card-columns"; +import { resolveNewPerSession } from "@/stores/simple-srs"; import { getFaceText, getKanjiFaceText, @@ -1625,11 +1626,29 @@ function StudyScreen() { ORDER BY simple_n ASC`, [...scope.args, dueCutoffDays], ); + // A flagged or smart review is assembled on purpose and is all-new, + // so a cap would show ten of it and call that a session (-1 is + // SQLite's own "no limit"). Everywhere else the cap is the point: + // the whole list at once is why nothing could ever reach three + // correct answers. + // + // Read from the row, not from `list`: the settings modal reloads the + // queue from its own close handler, which closes over the pre-save + // value, and depending on it here instead would race that reload. + let newCap = -1; + if (!isEphemeralListId(listId)) { + const capRow = await userDb.getFirstAsync<{ new_per_session: number | null }>( + "SELECT new_per_session FROM lists WHERE id = ?", + [listId], + ); + newCap = resolveNewPerSession(capRow?.new_per_session); + } const newRows = await userDb.getAllAsync( `${srsSelectClause} FROM srs_cards WHERE ${scope.clause} AND simple_stage IS NULL AND deleted_at IS NULL - ORDER BY created_at ASC`, - [...scope.args], + ORDER BY created_at ASC, rowid ASC + LIMIT ?`, + [...scope.args, newCap], ); if (dueRows.length > 0 || newRows.length > 0) { diff --git a/components/FlashcardSettingsModal.tsx b/components/FlashcardSettingsModal.tsx index 8f7d458..b0e2c7a 100644 --- a/components/FlashcardSettingsModal.tsx +++ b/components/FlashcardSettingsModal.tsx @@ -14,6 +14,7 @@ import { alert } from "@/lib/confirm"; import { requestVoicePermissions } from "@/lib/voice-recognition"; import { useSync } from "@/db/sync-provider"; import type { CardFace, FlashcardMode } from "@/db/types"; +import { DEFAULT_NEW_PER_SESSION } from "@/stores/simple-srs"; interface FlashcardSettingsModalProps { visible: boolean; @@ -55,6 +56,7 @@ export function FlashcardSettingsModal({ const [buttonAnimation, setButtonAnimation] = useAtom(flashcardButtonAnimationAtom); const [learningStepsText, setLearningStepsText] = useState(""); const [relearningStepsText, setRelearningStepsText] = useState(""); + const [newPerSessionText, setNewPerSessionText] = useState(""); const [hasKanjiEntries, setHasKanjiEntries] = useState(false); const inputMode = voiceMode @@ -85,6 +87,7 @@ export function FlashcardSettingsModal({ setMnemonicCloze(list.mnemonicCloze ?? false); setLearningStepsText(list.learningSteps ? list.learningSteps.join(", ") : ""); setRelearningStepsText(list.relearningSteps ? list.relearningSteps.join(", ") : ""); + setNewPerSessionText(list.newPerSession != null ? String(list.newPerSession) : ""); } }, [visible, list]); @@ -121,6 +124,23 @@ export function FlashcardSettingsModal({ async function handleSave() { if (!userDb) return; + // Empty means "use the default", not zero: a cap of 0 would send a list + // with nothing due straight to the done screen. The input only shows for + // simple SRS, so any other mode keeps whatever the list already stored + // rather than writing NULL over it. + let newPerSession: number | null = list?.newPerSession ?? null; + if (mode === "simple_srs") { + newPerSession = null; + if (newPerSessionText.trim()) { + const parsed = Number(newPerSessionText.trim()); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 500) { + alert("Invalid number", "New cards per session must be a whole number from 1 to 500."); + return; + } + newPerSession = parsed; + } + } + // Validate learning steps if provided let learningSteps: string[] | null = null; let relearningSteps: string[] | null = null; @@ -159,7 +179,8 @@ export function FlashcardSettingsModal({ typingMode === (list.typingMode ?? false) && mnemonicCloze === (list.mnemonicCloze ?? false) && arrEq(learningSteps, list.learningSteps) && - arrEq(relearningSteps, list.relearningSteps); + arrEq(relearningSteps, list.relearningSteps) && + newPerSession === (list.newPerSession ?? null); if (unchanged) { onClose(false); return; @@ -168,7 +189,7 @@ export function FlashcardSettingsModal({ const now = new Date().toISOString(); await userDb.runAsync( - "UPDATE lists SET flashcard_mode = ?, front_faces = ?, back_faces = ?, auto_play_audio = ?, confusion_detection = ?, voice_mode = ?, typing_mode = ?, mnemonic_cloze = ?, learning_steps = ?, relearning_steps = ?, configured = 1, updated_at = ? WHERE id = ?", + "UPDATE lists SET flashcard_mode = ?, front_faces = ?, back_faces = ?, auto_play_audio = ?, confusion_detection = ?, voice_mode = ?, typing_mode = ?, mnemonic_cloze = ?, learning_steps = ?, relearning_steps = ?, new_per_session = ?, configured = 1, updated_at = ? WHERE id = ?", [ mode, JSON.stringify(frontFaces), @@ -180,6 +201,7 @@ export function FlashcardSettingsModal({ mnemonicCloze ? 1 : 0, learningSteps ? JSON.stringify(learningSteps) : null, relearningSteps ? JSON.stringify(relearningSteps) : null, + newPerSession, now, listId, ], @@ -196,6 +218,7 @@ export function FlashcardSettingsModal({ mnemonicCloze, learningSteps, relearningSteps, + newPerSession, updatedAt: now, }); markDirty(); @@ -297,6 +320,28 @@ export function FlashcardSettingsModal({ )} + {/* New cards per session (simple SRS only) */} + {mode === "simple_srs" && ( + + + New cards per session + + + + Each new card costs about ten reviews a day once it settles in. + + + )} + {/* Front faces */} Front diff --git a/db/schema.ts b/db/schema.ts index a655fc1..7660254 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -118,6 +118,7 @@ export const lists = sqliteTable("lists", { voiceMode: integer("voice_mode").notNull().default(0), typingMode: integer("typing_mode").notNull().default(0), mnemonicCloze: integer("mnemonic_cloze").notNull().default(0), + newPerSession: integer("new_per_session"), disableFlipAnimation: integer("disable_flip_animation").notNull().default(0), disableSwipeAnimation: integer("disable_swipe_animation").notNull().default(0), isDefault: integer("is_default").notNull().default(0), diff --git a/db/simple-relearning-migration.test.ts b/db/simple-relearning-migration.test.ts index b41d78c..9a8d437 100644 --- a/db/simple-relearning-migration.test.ts +++ b/db/simple-relearning-migration.test.ts @@ -11,9 +11,22 @@ import { describe, expect, it } from "vitest"; import { USER_DB_MIGRATIONS } from "./user-migrations"; -/** Everything except the two statements this change added. */ -const BEFORE = USER_DB_MIGRATIONS.slice(0, -2); -const ADDED = USER_DB_MIGRATIONS.slice(-2); +/** + * Split at the statements this change added, found by what they say rather than + * by counting from the end of the array — later phases append more migrations, + * and a `slice(-2)` silently starts testing the wrong pair. + */ +const ADD_COLUMN = USER_DB_MIGRATIONS.findIndex((sql) => + /ALTER TABLE srs_cards ADD COLUMN simple_relearning/.test(sql), +); +const BACKFILL = USER_DB_MIGRATIONS.findIndex((sql) => + /UPDATE srs_cards SET simple_relearning = 1/.test(sql), +); +if (ADD_COLUMN < 0 || BACKFILL !== ADD_COLUMN + 1) { + throw new Error("the simple_relearning migrations are not where this test expects them"); +} +const BEFORE = USER_DB_MIGRATIONS.slice(0, ADD_COLUMN); +const ADDED = USER_DB_MIGRATIONS.slice(ADD_COLUMN, BACKFILL + 1); function apply(db: Database.Database, statements: readonly string[]) { for (const sql of statements) { diff --git a/db/sql-arity.test.ts b/db/sql-arity.test.ts new file mode 100644 index 0000000..8c1aeba --- /dev/null +++ b/db/sql-arity.test.ts @@ -0,0 +1,119 @@ +/** + * Every literal SQL call must bind as many values as it has placeholders. + * + * op-sqlite does not check the count: a short args array binds NULL for the + * missing tail, so `SET a = ?, b = ?` with one value writes the timestamp into + * `a` and NULL into the WHERE id — zero rows, no error, and the store still + * mutates so the UI looks saved. That shipped once in the flashcard settings + * modal, which is why this gate counts them instead of a reviewer. + * + * What it cannot see, so that nobody reads a pass as more than it is: args + * assembled in a variable rather than written inline, and arrays carrying a + * spread — which is every scoped query in the study screen, `[...scope.args, + * x]`, because the spread's length is not knowable here. Interpolated SQL is + * skipped too, since a fragment helper may bring placeholders of its own. + */ +import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import ts from "typescript"; + +const QUERY_METHODS = new Set(["runAsync", "getAllAsync", "getFirstAsync", "execAsync"]); + +const REPO_ROOT = resolve(dirname(new URL(import.meta.url).pathname), ".."); + +function sourceFiles(): string[] { + return execFileSync( + "git", + ["-C", REPO_ROOT, "ls-files", "app", "components", "db", "lib", "stores", "hooks"], + { encoding: "utf8" }, + ) + .split("\n") + .filter((f) => /\.(ts|tsx)$/.test(f) && !/\.test\.tsx?$/.test(f)); +} + +/** `?` placeholders in a SQL string, ignoring any inside a quoted literal. */ +/** Drop `--` and block comments, so a `don't` in one cannot open quote state. */ +function stripComments(sql: string): string { + return sql.replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, ""); +} + +function countPlaceholders(raw: string): number { + const sql = stripComments(raw); + let count = 0; + let quote: string | null = null; + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"') quote = ch; + else if (ch === "?") count++; + } + return count; +} + +type Mismatch = { file: string; line: number; sql: number; args: number }; + +function mismatchesIn(file: string): Mismatch[] { + const text = readFileSync(join(REPO_ROOT, file), "utf8"); + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const found: Mismatch[] = []; + + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + const method = node.expression.name.text; + const [sqlArg, argsArg] = node.arguments; + if ( + QUERY_METHODS.has(method) && + sqlArg && + argsArg && + ts.isArrayLiteralExpression(argsArg) && + !argsArg.elements.some((e) => ts.isSpreadElement(e)) + ) { + // Only a literal SQL string can be counted; an interpolated one may + // carry placeholders from a fragment helper. + const sql = ts.isStringLiteralLike(sqlArg) + ? sqlArg.text + : ts.isNoSubstitutionTemplateLiteral(sqlArg) + ? sqlArg.text + : null; + if (sql != null) { + const placeholders = countPlaceholders(sql); + if (placeholders !== argsArg.elements.length) { + found.push({ + file, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + sql: placeholders, + args: argsArg.elements.length, + }); + } + } + } + } + ts.forEachChild(node, visit); + }; + + visit(source); + return found; +} + +describe("SQL placeholder arity", () => { + it("binds one value per placeholder in every literal query", () => { + const files = sourceFiles(); + expect(files.length).toBeGreaterThan(100); + const mismatches = files.flatMap(mismatchesIn); + expect( + mismatches.map((m) => `${m.file}:${m.line} ${m.sql} placeholders, ${m.args} args`), + ).toEqual([]); + }); + + it("counts placeholders outside quoted literals and comments only", () => { + expect(countPlaceholders("SET a = ?, b = ? WHERE id = ?")).toBe(3); + expect(countPlaceholders("SET note = 'what? why?' WHERE id = ?")).toBe(1); + expect(countPlaceholders("-- don't ask why?\nSET a = ?")).toBe(1); + expect(countPlaceholders("/* why? */ SET a = ?")).toBe(1); + }); +}); diff --git a/db/types.ts b/db/types.ts index baf2558..d5f214a 100644 --- a/db/types.ts +++ b/db/types.ts @@ -157,6 +157,8 @@ export interface WordList { disableSwipeAnimation: boolean; learningSteps: string[] | null; relearningSteps: string[] | null; + /** Unseen cards a simple-SRS session may introduce. Absent reads as the default. */ + newPerSession?: number | null; isDefault: boolean; createdAt: string; updatedAt: string; diff --git a/db/user-migrations.ts b/db/user-migrations.ts index eb3f0ca..9f04b2e 100644 --- a/db/user-migrations.ts +++ b/db/user-migrations.ts @@ -264,4 +264,10 @@ export const USER_DB_MIGRATIONS = [ // neighbours; restore would survive NOT NULL too, since it inserts with // `INSERT OR REPLACE`, which substitutes the default for an explicit NULL. `ALTER TABLE srs_cards ADD COLUMN simple_run INTEGER DEFAULT 0`, + // How many unseen cards a simple-SRS session may introduce. The session used + // to load the whole list at once, so an 8,000-word list put 8,000 cards in + // front of the learner, none of them could reach the three correct answers a + // card needs, and the same queue came back every day. Midori, which this + // engine clones, mixes in about ten. + `ALTER TABLE lists ADD COLUMN new_per_session INTEGER DEFAULT 10`, ]; diff --git a/lib/data-backup.ts b/lib/data-backup.ts index c584fe6..63427c1 100644 --- a/lib/data-backup.ts +++ b/lib/data-backup.ts @@ -21,7 +21,7 @@ export const BACKUP_TABLES: { { name: "lists", query: - "SELECT id, name, description, flashcard_mode, front_faces, back_faces, study_position, configured, auto_play_audio, confusion_detection, voice_mode, typing_mode, disable_flip_animation, disable_swipe_animation, is_default, learning_steps, relearning_steps, mnemonic_cloze, created_at, updated_at FROM lists", + "SELECT id, name, description, flashcard_mode, front_faces, back_faces, study_position, configured, auto_play_audio, confusion_detection, voice_mode, typing_mode, disable_flip_animation, disable_swipe_animation, is_default, learning_steps, relearning_steps, mnemonic_cloze, new_per_session, created_at, updated_at FROM lists", }, { name: "list_entries", @@ -102,6 +102,7 @@ const TABLE_COLUMNS: Record = { "learning_steps", "relearning_steps", "mnemonic_cloze", + "new_per_session", "created_at", "updated_at", ], diff --git a/lib/new-per-session.test.ts b/lib/new-per-session.test.ts new file mode 100644 index 0000000..2252dd8 --- /dev/null +++ b/lib/new-per-session.test.ts @@ -0,0 +1,85 @@ +/** + * The cap on unseen cards a session introduces. + * + * The simple-SRS session used to load the whole list: 8,000 words meant 8,000 + * cards at once, none of which could collect the three correct answers a card + * needs to graduate, so the same queue came back every day. Midori, which this + * engine clones, mixes in about ten. + */ +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; + +import { USER_DB_MIGRATIONS } from "@/db/user-migrations"; +import { SRS_CARD_SELECT } from "@/db/srs-card-columns"; +import { DEFAULT_NEW_PER_SESSION, resolveNewPerSession } from "@/stores/simple-srs"; + +describe("resolveNewPerSession", () => { + it("falls back to the default when the list says nothing", () => { + expect(resolveNewPerSession(null)).toBe(DEFAULT_NEW_PER_SESSION); + expect(resolveNewPerSession(undefined)).toBe(DEFAULT_NEW_PER_SESSION); + }); + + it("never returns zero, which would send a session straight to the done screen", () => { + expect(resolveNewPerSession(0)).toBe(1); + expect(resolveNewPerSession(-5)).toBe(1); + }); + + it("keeps a chosen value and caps the absurd", () => { + expect(resolveNewPerSession(25)).toBe(25); + expect(resolveNewPerSession(10_000)).toBe(500); + }); +}); + +describe("the query that fills the new pool", () => { + function deck(size: number): Database.Database { + const db = new Database(":memory:"); + db.pragma("foreign_keys = OFF"); + for (const sql of USER_DB_MIGRATIONS) { + try { + db.exec(sql); + } catch (err) { + if (!/duplicate column/i.test(String(err))) throw err; + } + } + const insert = db.prepare(`INSERT INTO srs_cards (id, entry_id, list_id, due, stability, + difficulty, elapsed_days, scheduled_days, reps, lapses, state, front_mode, back_mode, + created_at, updated_at) VALUES (?, ?, 'l', 'x', 0, 0, 0, 0, 0, 0, 0, 'kanji', 'english', + 'same-timestamp', 'now')`); + // Every card carries the SAME created_at, the way the backfill writes them. + for (let i = 0; i < size; i++) insert.run(`c${i}`, i); + return db; + } + + const newRows = (db: Database.Database, cap: number) => + db + .prepare( + `${SRS_CARD_SELECT} FROM srs_cards + WHERE list_id = 'l' AND simple_stage IS NULL AND deleted_at IS NULL + ORDER BY created_at ASC, rowid ASC + LIMIT ?`, + ) + .all(cap); + + it("hands the session the cap, not the list", () => { + expect(newRows(deck(8000), DEFAULT_NEW_PER_SESSION)).toHaveLength(10); + }); + + // A guard, not a gate: this passes without the `rowid ASC` tiebreak too, + // because SQLite happens to return rowid order for equal keys. The tiebreak + // is there to make that a promise rather than a coincidence — the backfill + // stamps every card the same `created_at`, and there is no index on it. + it("is stable when every card shares a timestamp", () => { + const db = deck(50); + const first = newRows(db, 10).map((r) => (r as { id: string }).id); + expect(newRows(db, 10).map((r) => (r as { id: string }).id)).toEqual(first); + // and it starts at the beginning of the list, not somewhere in the middle + expect(first[0]).toBe("c0"); + }); + + // What the ephemeral-list exemption binds: SQLite reads a negative LIMIT as + // no limit, and -1 binds as an integer where a JS number past 2^31 would + // reach op-sqlite as a double. + it("gives everything to a session that asks for everything", () => { + expect(newRows(deck(40), -1)).toHaveLength(40); + }); +}); diff --git a/stores/lists.ts b/stores/lists.ts index f92cf18..a147385 100644 --- a/stores/lists.ts +++ b/stores/lists.ts @@ -45,6 +45,7 @@ export function parseListRow(row: any): WordList { voiceMode: Boolean(row.voiceMode ?? row.voice_mode ?? 0), typingMode: Boolean(row.typingMode ?? row.typing_mode ?? 0), mnemonicCloze: Boolean(row.mnemonicCloze ?? row.mnemonic_cloze ?? 0), + newPerSession: row.newPerSession ?? row.new_per_session ?? null, disableFlipAnimation: Boolean(row.disableFlipAnimation ?? row.disable_flip_animation ?? 0), disableSwipeAnimation: Boolean(row.disableSwipeAnimation ?? row.disable_swipe_animation ?? 0), learningSteps: (() => { diff --git a/stores/simple-srs.ts b/stores/simple-srs.ts index bd0faba..c2ee102 100644 --- a/stores/simple-srs.ts +++ b/stores/simple-srs.ts @@ -162,6 +162,19 @@ export function simpleInitCard(): SimpleSrsUpdate { /** Number of correct answers needed to graduate from learning */ export const SIMPLE_SRS_REQUIRED_CORRECT = REQUIRED_CORRECT; +/** + * Unseen cards a session introduces when the list does not say otherwise. + * Midori mixes in about ten; the whole list at once is what kept 8,000 cards + * in the queue every day, since none of them could reach three correct answers. + */ +export const DEFAULT_NEW_PER_SESSION = 10; + +/** What a list's `newPerSession` means, with the floor and ceiling applied. */ +export function resolveNewPerSession(value: number | null | undefined): number { + if (value == null || !Number.isFinite(value)) return DEFAULT_NEW_PER_SESSION; + return Math.max(1, Math.min(Math.floor(value), 500)); +} + /** What a single simple-SRS answer does to a card. */ export interface SimpleSrsDecision { updates: SimpleSrsUpdate; From 779566aaab483b9b233049f6d438df8e51427f65 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 14:37:16 +0800 Subject: [PATCH 07/12] Lift the decks the ratchet flattened off the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing the rule does not fix the stored intervals. A deck the lifetime-lapse bug flattened is thousands of cards at the 8-hour floor, all due today and all due again tomorrow, and the engine has no way back: a card answered this morning returns this afternoon and takes tomorrow's slot too. Measured by simulating the deck through the shipped `nextSimpleReview`, queue size by day for 8,000 floored cards: 1000 answers/day, no repair d30 5333 d90 1309 d180 72 d365 18 200 answers/day, no repair d30 7444 d90 6644 d180 5549 d365 3542 200 answers/day, repaired d30 5787 d90 3591 d180 1093 d365 50 At a thousand answers a day the deck heals itself in four months, and at two hundred it does not. That one number is why the first conclusion here — natural recovery, leave it alone — was wrong: it was measured at an effort nobody sustains. So there is a one-time repair that spreads the floored cards over the next month. A flat seven-day lift was measured and rejected: it re-synchronises the whole deck into one wave that echoes for months. `reps >= 12` is the discriminator, and it was measured rather than reasoned. A card the ratchet dragged down from 56 days has a median of 32 answers behind it; one honestly at the floor has 4. The clause catches 2,127 of 2,359 victims and lifts 13 of 111 honest cards at 90% accuracy. Review proposed `lapses >= 3` and that is wrong — under the bug a single lapse starts the ratchet, so it would have left a third of the deck flattened. Learning cards are deliberately left alone, and the first version of this did repair them. A stage-0 card sits at 1/3 because that is where every new card starts, not because of the ratchet, and with the whole list loading as new and the correct-in-a-row count dying with the screen, cards that never graduated at all are most of the stage-0 population — with the reps to pass any threshold, because the bug made them answer the same words every day. Lifting those would hand thousands of unlearned words a fortnight each. Of 2,359 floored cards at 90% accuracy, 1,714 are graduated and 645 are in learning; the clause repairs 93% of the graduated ones and none of the rest. The learning ones are the complaint, and no rewrite of a stored interval touches them: they are words with one pass each, and they are all due, every day, because the new-card cap only bounds cards that have never been seen. So the learning slice of the due pool is capped at three times `new_per_session` — three learning answers are one card's worth of work — ordered most-recently-answered first, so cards already in hand get finished before the backlog is reached. Graduated cards due today stay uncapped; that is the real review load. On an 8,000-card legacy deck at 200 answers a day that shows 30 cards on day one and 142 a year later instead of 8,000 and 4,436, and still graduates 3,681 words in the year against 3,733 uncapped. Capping at ten rather than thirty costs 439 of them, because a learning card needs three answers and ten slots cannot deliver them alongside reviews. A genuinely ratcheted card currently in learning is the price of not repairing stage 0: it re-graduates to the floor once and then climbs 0.33 → 0.63 → 1.2 by itself. Every clause is prove-failed by mutation: the reps threshold, `deleted_at IS NULL`, the floor, the lapse guard, the stage, and both constants of the spread each make at least one test fail, as do the learning cap's LIMIT and its ordering. `docs/simple-srs-decisions.md` carries the tables, the three rejected repairs and the accepted trades — including that `reps` is not per-mode, so a list moved from FSRS brings its count with it, and that an imported deck is never repaired. ARCHITECTURE.md said "Lapse: interval × 0.5" and described stage 0 without `simple_run`; both are corrected, and CLAUDE.md now sends the next agent to the decisions doc before it touches the scheduler. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 10 ++ app/(tabs)/lists/study.tsx | 36 +++-- components/DueCardsSection.tsx | 21 ++- db/simple-repair-migration.test.ts | 208 +++++++++++++++++++++++++++++ db/user-migrations.ts | 24 ++++ docs/ARCHITECTURE.md | 28 +++- docs/simple-srs-decisions.md | 190 ++++++++++++++++++++++++++ lib/new-per-session.test.ts | 76 ++++++++++- stores/simple-srs.ts | 15 +++ 9 files changed, 592 insertions(+), 16 deletions(-) create mode 100644 db/simple-repair-migration.test.ts create mode 100644 docs/simple-srs-decisions.md diff --git a/CLAUDE.md b/CLAUDE.md index 700704e..6b1a7c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,16 @@ designed past. - Do not invent a requirement to justify a design. If the task is lookup, a page-marking feature nobody asked for is not evidence that an index is needed. +## The SRS schedulers are measured, not argued + +`stores/simple-srs.ts` is a Midori clone and every scheduling rule in it has been changed +on simulated evidence; some tempting changes were measured and rejected. Read +[docs/simple-srs-decisions.md](docs/simple-srs-decisions.md) before editing it or the +simple-SRS branches of `app/(tabs)/lists/study.tsx`. Simulate a deck through +`nextSimpleReview` itself rather than reimplementing the rules — the lapse bug survived +every test for months because the tests called `simpleGraduate` directly and the defect +was in the argument the screen passed it. + ## Reader lookup and furigana scoring is settled — measure before you touch it Tap ranking, furigana resolution, counter readings and the deinflection rule diff --git a/app/(tabs)/lists/study.tsx b/app/(tabs)/lists/study.tsx index d9674ca..8ff2960 100644 --- a/app/(tabs)/lists/study.tsx +++ b/app/(tabs)/lists/study.tsx @@ -21,7 +21,7 @@ import { shouldAskAsCloze } from "@/lib/rtk-cloze"; import { rtkReviewScope, singleList, type ListScope } from "@/lib/list-scope"; import { isRtkReviewList } from "@/lib/rtk-review"; import { SRS_CARD_SELECT } from "@/db/srs-card-columns"; -import { resolveNewPerSession } from "@/stores/simple-srs"; +import { learningCapFor, resolveNewPerSession, UNLIMITED } from "@/stores/simple-srs"; import { getFaceText, getKanjiFaceText, @@ -1620,12 +1620,6 @@ function StudyScreen() { { // Dynamic deque: load all due + all new into pools, // then seed the active deque with the first batch. - const dueRows = await userDb.getAllAsync( - `${srsSelectClause} FROM srs_cards - WHERE ${scope.clause} AND simple_stage IS NOT NULL AND simple_n <= ? AND deleted_at IS NULL - ORDER BY simple_n ASC`, - [...scope.args, dueCutoffDays], - ); // A flagged or smart review is assembled on purpose and is all-new, // so a cap would show ten of it and call that a session (-1 is // SQLite's own "no limit"). Everywhere else the cap is the point: @@ -1635,7 +1629,7 @@ function StudyScreen() { // Read from the row, not from `list`: the settings modal reloads the // queue from its own close handler, which closes over the pre-save // value, and depending on it here instead would race that reload. - let newCap = -1; + let newCap: number = UNLIMITED; if (!isEphemeralListId(listId)) { const capRow = await userDb.getFirstAsync<{ new_per_session: number | null }>( "SELECT new_per_session FROM lists WHERE id = ?", @@ -1643,6 +1637,32 @@ function StudyScreen() { ); newCap = resolveNewPerSession(capRow?.new_per_session); } + // Graduated cards due today are the real review load and are never + // capped. Cards still in learning are: measured on an 8,000-card + // legacy deck at 200 answers a day, the cap shows 110 cards instead + // of 8,000 and still graduates 3,681 of them in a year against 3,733 + // uncapped. + const learningCap = learningCapFor(newCap); + const dueRows = await userDb.getAllAsync( + `${srsSelectClause} FROM srs_cards + WHERE ${scope.clause} AND simple_stage = 1 AND simple_n <= ? AND deleted_at IS NULL + ORDER BY simple_n ASC`, + [...scope.args, dueCutoffDays], + ); + // Most recently answered first: finish the cards already in hand + // before reaching back into the backlog. + const learningRows = await userDb.getAllAsync( + `${srsSelectClause} FROM srs_cards + WHERE ${scope.clause} AND simple_stage = 0 AND simple_n <= ? AND deleted_at IS NULL + ORDER BY updated_at DESC, rowid ASC + LIMIT ?`, + [...scope.args, dueCutoffDays, learningCap], + ); + // Learning first: these are cards half-finished, and a graduated load + // bigger than the learner's stamina would otherwise mean nothing in + // learning or new ever advances. Before the split they led anyway, + // since `simple_n` is 0 on a learning card. + dueRows.unshift(...learningRows); const newRows = await userDb.getAllAsync( `${srsSelectClause} FROM srs_cards WHERE ${scope.clause} AND simple_stage IS NULL AND deleted_at IS NULL diff --git a/components/DueCardsSection.tsx b/components/DueCardsSection.tsx index 47a1ce4..ef699b2 100644 --- a/components/DueCardsSection.tsx +++ b/components/DueCardsSection.tsx @@ -6,7 +6,12 @@ import { useUserDb } from "@/db/user-provider"; import { useSync } from "@/db/sync-provider"; import { useListsStore } from "@/stores/lists"; import { getCardStateDistribution, type CardDistribution } from "@/lib/practice-stats"; -import { srsEpochDaysToDate, endOfLogicalDayEpochDays } from "@/stores/simple-srs"; +import { + srsEpochDaysToDate, + endOfLogicalDayEpochDays, + resolveNewPerSession, + learningCapFor, +} from "@/stores/simple-srs"; import { useAtomValue } from "jotai"; import { dayResetHourAtom } from "@/stores/settings"; import type { FlashcardMode } from "@/db/types"; @@ -122,7 +127,7 @@ export function DueCardsSection({ if (flashcardMode === "simple_srs") { const rows = await userDb.getAllAsync<{ dueDays: number }>( `SELECT simple_n as dueDays - FROM srs_cards WHERE list_id = ? AND simple_stage IS NOT NULL AND deleted_at IS NULL`, + FROM srs_cards WHERE list_id = ? AND simple_stage = 1 AND deleted_at IS NULL`, [listId], ); for (const row of rows) { @@ -130,6 +135,18 @@ export function DueCardsSection({ const idx = dateToBucketIndex(dueDate, dayResetHour); counts[idx]++; } + // Cards in learning are all due now, and the session only shows three + // times the new-card cap of them. Counting them all promised 8,000 due on + // a deck the uncapped session left behind, while the session showed + // thirty and then said it was done. + const list = useListsStore.getState().lists.find((l) => l.id === listId); + const learningCap = learningCapFor(resolveNewPerSession(list?.newPerSession)); + const learning = await userDb.getFirstAsync<{ c: number }>( + `SELECT COUNT(*) as c FROM srs_cards + WHERE list_id = ? AND simple_stage = 0 AND deleted_at IS NULL`, + [listId], + ); + counts[0] += Math.min(learning?.c ?? 0, learningCap); } else { const newRow = await userDb.getFirstAsync<{ c: number }>( "SELECT COUNT(*) as c FROM srs_cards WHERE list_id = ? AND state = 0 AND deleted_at IS NULL", diff --git a/db/simple-repair-migration.test.ts b/db/simple-repair-migration.test.ts new file mode 100644 index 0000000..cdff488 --- /dev/null +++ b/db/simple-repair-migration.test.ts @@ -0,0 +1,208 @@ +/** + * The one-time repair of decks the lapse bug flattened. + * + * Fixing the rule does not fix the stored intervals: a card the bug ratcheted + * is sitting at the 8-hour floor and comes back every day forever. Measured at + * 200 answers a day, 3,542 of 8,000 such cards are still queued a year later + * without a repair and 50 with one — the numbers are in + * docs/simple-srs-decisions.md. + * + * What must NOT move: a card that has barely been seen, a card on a healthy + * interval, an FSRS-only card, and any learning card — a card at stage 0 sits + * at 1/3 because that is where new cards start, and with the whole list loading + * as new those are most of the stage-0 population. + */ +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; + +import { USER_DB_MIGRATIONS } from "./user-migrations"; + +const SPREADS = USER_DB_MIGRATIONS.filter((sql) => + /simple_interval = \d+ \+ \(rowid % \d+\)/.test(sql), +); +const REPAIR_AT = USER_DB_MIGRATIONS.indexOf(SPREADS[0]); +if (SPREADS.length !== 1) { + // A second spreading migration would be silently untested otherwise. + throw new Error(`expected exactly one interval repair migration, found ${SPREADS.length}`); +} +const BEFORE = USER_DB_MIGRATIONS.slice(0, REPAIR_AT); +const REPAIR = USER_DB_MIGRATIONS.slice(REPAIR_AT, REPAIR_AT + 1); + +function apply(db: Database.Database, statements: readonly string[]) { + for (const sql of statements) { + try { + db.exec(sql); + } catch (err) { + if (!/duplicate column/i.test(String(err))) throw err; + } + } +} + +type Card = { + id: string; + stage: number | null; + n: number | null; + interval: number | null; + reps: number; + lapses: number; + relearning?: number; + run?: number; + deleted?: boolean; +}; + +function insert(db: Database.Database, c: Card) { + db.exec(`INSERT INTO srs_cards (id, entry_id, list_id, due, stability, difficulty, elapsed_days, + scheduled_days, reps, lapses, state, front_mode, back_mode, simple_stage, simple_n, + simple_interval, simple_relearning, simple_run, created_at, updated_at) + VALUES ('${c.id}', 1, 'l', 'x', 0, 0, 0, 0, ${c.reps}, ${c.lapses}, 0, 'kanji', 'english', + ${c.stage ?? "NULL"}, ${c.n ?? "NULL"}, ${c.interval ?? "NULL"}, + ${c.relearning ?? 0}, ${c.run ?? 0}, 'created', 'OLD')`); + if (c.deleted) db.exec(`UPDATE srs_cards SET deleted_at = 'gone' WHERE id = '${c.id}'`); +} + +const FLOOR = 1 / 3; + +/** Days since 2001-01-01, the epoch `simple_n` is in. */ +const today = () => (Date.now() - Date.parse("2001-01-01T00:00:00Z")) / 86_400_000; + +function repaired() { + const db = new Database(":memory:"); + db.pragma("foreign_keys = OFF"); + apply(db, BEFORE); + insert(db, { id: "ratcheted", stage: 1, n: 0, interval: FLOOR, reps: 40, lapses: 5 }); + insert(db, { + id: "mid-lapse", + stage: 0, + n: 0, + interval: FLOOR, + reps: 40, + lapses: 6, + relearning: 1, + run: 2, + }); + insert(db, { id: "barely-seen", stage: 1, n: 9000, interval: FLOOR, reps: 4, lapses: 1 }); + insert(db, { id: "never-failed", stage: 1, n: 9000, interval: FLOOR, reps: 40, lapses: 0 }); + insert(db, { id: "healthy", stage: 1, n: 9100, interval: 56.61, reps: 40, lapses: 2 }); + insert(db, { id: "part-way-down", stage: 1, n: 0, interval: 0.88, reps: 40, lapses: 5 }); + // Just above the clause: 0.4 is a halving step, and still left to climb. + insert(db, { id: "just-above", stage: 1, n: 0, interval: 0.4, reps: 40, lapses: 5 }); + // A list moved from FSRS to simple SRS carries its FSRS reps and lapses. + insert(db, { id: "was-fsrs", stage: 1, n: 0, interval: 1 / 3, reps: 40, lapses: 5 }); + insert(db, { + id: "deleted", + stage: 1, + n: 0, + interval: FLOOR, + reps: 40, + lapses: 5, + deleted: true, + }); + insert(db, { id: "unseen", stage: null, n: null, interval: null, reps: 0, lapses: 0 }); + insert(db, { id: "fsrs-only", stage: null, n: null, interval: null, reps: 5, lapses: 1 }); + apply(db, REPAIR); + return db; +} + +const row = (db: Database.Database, id: string) => + db.prepare("SELECT * FROM srs_cards WHERE id = ?").get(id) as Record; + +describe("the interval repair", () => { + it("lifts a ratcheted card off the floor and gives it a date inside the next month", () => { + const r = row(repaired(), "ratcheted"); + expect(r.simple_interval).toBeGreaterThanOrEqual(2); + expect(r.simple_interval).toBeLessThanOrEqual(30); + expect(Number(r.simple_n) - today()).toBeGreaterThanOrEqual(2); + expect(Number(r.simple_n) - today()).toBeLessThanOrEqual(30); + expect(r.updated_at).not.toBe("OLD"); + }); + + it("leaves a card part-way down the ratchet to climb on its own", () => { + // 0.88 days is not the floor: with the rule fixed it multiplies to 1.67, + // 3.2, 6.1 … by itself. The clause is for cards PINNED at the floor, where + // a card honestly at 0.88 after one lapse is indistinguishable from a + // victim — at 75% accuracy, including them doubles the cards wrongly lifted. + expect(row(repaired(), "part-way-down").updated_at).toBe("OLD"); + }); + + it("leaves a soft-deleted card alone rather than rewriting and pushing it", () => { + expect(row(repaired(), "deleted").updated_at).toBe("OLD"); + }); + + it("leaves a card with too few answers behind it alone", () => { + // Honestly at the floor: a young card that failed once and re-graduated + // lands there. Measured, those have a median of 4 answers; a card the + // ratchet dragged down from 56 days has 32. + expect(row(repaired(), "barely-seen").updated_at).toBe("OLD"); + }); + + it("leaves every learning card alone, mid-lapse or never graduated", () => { + const r = row(repaired(), "mid-lapse"); + // A stage-0 card is at 1/3 because that is where new cards start, and the + // 8,000-card queue meant most of the deck never left stage 0. Lifting them + // would hand unlearned words a fortnight; a genuinely ratcheted one climbs + // 0.33 → 0.63 → 1.2 on its own once the rule is fixed. + expect(r.simple_interval).toBeCloseTo(FLOOR); + expect(r.simple_n).toBe(0); + expect(r.updated_at).toBe("OLD"); + }); + + it("leaves a card one halving above the clause alone", () => { + expect(row(repaired(), "just-above").updated_at).toBe("OLD"); + }); + + it("leaves a card that has never failed alone — the bug cannot have touched it", () => { + expect(row(repaired(), "never-failed").updated_at).toBe("OLD"); + }); + + it("leaves a healthy interval and an FSRS-only card alone", () => { + const db = repaired(); + expect(row(db, "healthy").simple_interval).toBe(56.61); + expect(row(db, "healthy").updated_at).toBe("OLD"); + expect(row(db, "fsrs-only").updated_at).toBe("OLD"); + }); + + it("rewrites the schedule and nothing else", () => { + const r = row(repaired(), "ratcheted"); + // History is not the schedule: a repair that reset these would erase the + // evidence the next repair, and the statistics screen, read. + expect(r.reps).toBe(40); + expect(r.lapses).toBe(5); + expect(r.simple_stage).toBe(1); + expect(r.simple_relearning).toBe(0); + expect(r.simple_run).toBe(0); + expect(r.created_at).toBe("created"); + }); + + // Recorded, not asserted as desirable: `reps` is not per-mode, so a list + // switched from FSRS to simple SRS brings its FSRS count with it and its cards + // qualify after one simple graduation. Nothing in the row can tell them apart. + it("lifts a card whose reps were earned under FSRS", () => { + expect(Number(row(repaired(), "was-fsrs").simple_interval)).toBeGreaterThanOrEqual(2); + }); + + it("spreads the deck instead of stacking it on one day", () => { + const db = new Database(":memory:"); + db.pragma("foreign_keys = OFF"); + apply(db, BEFORE); + for (let i = 0; i < 300; i++) { + insert(db, { id: `c${i}`, stage: 1, n: 0, interval: FLOOR, reps: 40, lapses: 5 }); + } + apply(db, REPAIR); + const spread = db + .prepare( + `SELECT count(DISTINCT simple_interval) d, min(simple_interval) lo, + max(simple_interval) hi FROM srs_cards`, + ) + .get() as { d: number; lo: number; hi: number }; + expect(spread.d).toBe(29); + expect(spread.lo).toBe(2); + expect(spread.hi).toBe(30); + }); + + it("runs once: a repaired row no longer matches, whichever device replays it", () => { + const db = repaired(); + const before = row(db, "ratcheted"); + apply(db, REPAIR); + expect(row(db, "ratcheted")).toEqual(before); + }); +}); diff --git a/db/user-migrations.ts b/db/user-migrations.ts index 9f04b2e..648b940 100644 --- a/db/user-migrations.ts +++ b/db/user-migrations.ts @@ -270,4 +270,28 @@ export const USER_DB_MIGRATIONS = [ // card needs, and the same queue came back every day. Midori, which this // engine clones, mixes in about ten. `ALTER TABLE lists ADD COLUMN new_per_session INTEGER DEFAULT 10`, + // One-time repair of the decks the lifetime-lapse bug flattened. Fixing the + // rule does not fix the stored intervals: a ratcheted card sits at the 8-hour + // floor and returns every day, and at 200 answers a day 3,542 of 8,000 such + // cards are still queued a year later. Spreading them over the next month + // drains the backlog instead — the numbers, and what was rejected, are in + // docs/simple-srs-decisions.md. + // + // `reps >= 12` is the discriminator, measured rather than guessed: a card the + // bug ratcheted down from 56 days has a median of 32 answers behind it, while + // one honestly at the floor has 4. `lapses >= 3` was the obvious guard and is + // wrong — under the bug a single lapse starts the ratchet, so it would miss a + // third of them. `2 +` makes the repair idempotent, which matters because + // every device runs it against its own rowids. + // + // `simple_stage = 1` only. A learning card sits at 1/3 because that is where + // new cards START, and with the whole list loading as new, those are most of + // the stage-0 population — repairing them would hand thousands of unlearned + // words a fortnight. + `UPDATE srs_cards SET + simple_interval = 2 + (rowid % 29), + simple_n = (julianday('now') - julianday('2001-01-01')) + 2 + (rowid % 29), + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE simple_stage = 1 AND simple_interval <= 0.34 AND lapses > 0 AND reps >= 12 + AND deleted_at IS NULL`, ]; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ebae9c0..d7d2df3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1241,16 +1241,22 @@ Simple spaced repetition with a day-based epoch and fixed interval multipliers. **Card states:** -- `NULL` d → New (never seen) -- `s:0` → Learning/lapsed (in-session, needs 3 correct to graduate) +- `NULL` `s` → New (never seen) +- `s:0` → Learning/lapsed (needs 3 correct in a row to graduate, counted in + `simple_run` on the card) - `s:1` → Graduated (scheduled for future review) +- `simple_relearning` → 1 while a card is recovering from a lapse, cleared by the + re-graduation that pays for it **Interval parameters:** - Initial interval: 1/3 day (~8 hours) - Correct: interval × 1.9 (chain: 0.33 → 0.63 → 1.20 → 2.29 → 4.34 → 8.25 → 15.68 → 29.79 → 56.61 → 107.55 → 204.37 → 365) - Easy: interval × 2.6125 (1.9 × 1.375 bonus) -- Lapse: interval × 0.5 +- Lapse: the interval is preserved on the failure itself and halved **once**, on the + re-graduation that follows (`simple_relearning`) — never again. Scoping that to + `lapses > 0`, a lifetime counter, ratcheted every card that had ever failed down to + the floor; see [simple-srs-decisions.md](simple-srs-decisions.md) - Max interval: 365 days - Due dates stored as fractional days since 2001-01-01 (Mac/Core Foundation epoch) @@ -1258,17 +1264,31 @@ Simple spaced repetition with a day-based epoch and fixed interval multipliers. - **Dynamic deque**: maintains ~10 active cards at a time, pulling from pools as needed - Two pools loaded at session start: due cards (priority) and new cards + - The new pool is capped at `lists.new_per_session` (default 10), read from the row + inside `loadQueue`. Smart Review and the flagged-cards list are exempt + (`isEphemeralListId`), since both are assembled on purpose and are entirely new + - The due pool is loaded in two parts: graduated cards due today, uncapped — that is the + real review load — and cards still in learning, capped at `learningCapFor()` (three + times `new_per_session`), ordered most-recently-answered first and placed at the head + of the queue. A deck left by the uncapped session has thousands of stage-0 cards all + due at once; `DueCardsSection` applies the same cap to its "due today" count - When active deque drops below 10 pending cards, pulls more from due pool first, then new pool - This naturally introduces new cards as the deque thins out - **Re-queue**: failed/learning cards push to back of the active deque (~10 cards away, not 300+) - Graduated cards (stage 1) pass in 1 correct answer → recalculate interval, removed from session -- New/learning cards (stage 0) need 3 correct to graduate +- New/learning cards (stage 0) need 3 correct in a row to graduate, counted in + `simple_run` on the row — it used to live in a screen ref, so a card answered once a + session never graduated at all - **Continuous session**: no checkpoint screen — session flows until all pools and deque are exhausted - **Progress bar**: tracks only due cards (not new cards) — turns green when all due cards graduated - Counter: `completedDueCards / totalDueAtStart (totalInList)` - `simpleDueIdsRef` tracks which cards were due at start; `completedSrsIdsRef` tracks graduated due cards - Session length is dynamic — failing cards extends the session, new cards trickle in continuously +Scheduling changes here are measured by simulating a deck through `nextSimpleReview` +itself; [simple-srs-decisions.md](simple-srs-decisions.md) records what has been decided, +the numbers behind it, and what was measured and rejected. + ### FSRS (`ts-fsrs` library) Full FSRS 5.0 implementation via `ts-fsrs`. Uses the same logical day cutoff for due cards. diff --git a/docs/simple-srs-decisions.md b/docs/simple-srs-decisions.md new file mode 100644 index 0000000..50236ee --- /dev/null +++ b/docs/simple-srs-decisions.md @@ -0,0 +1,190 @@ +# Simple SRS — decisions + +`stores/simple-srs.ts` is a clone of Midori's scheduler, and `app/(tabs)/lists/study.tsx` +drives it. Every entry here is a question that was argued out with numbers. Re-opening one +is fine, but bring a measurement, not an opinion. + +## How to measure a change here + +The engine is a pure function of the card row: `nextSimpleReview(card, action)` decides one +answer, and nothing else in the session changes a card's schedule. So a scheduling change +is measured by simulating a deck through that exact function — not through a reimplementation +of the rules, which is how the first version of this measurement missed the bug entirely. + +A simulation needs four numbers: deck size, how many unseen cards a day the session +introduces, how many answers a day the learner gives, and accuracy. The runs below use +8,000 cards (Sam's list), 90% accuracy on graduated cards and 80% on learning cards, and +report the queue size on a given day. **The answers-per-day figure decides more than any +rule does** — see the repair decision. + +Reference figures for the fixed engine, fresh deck, ten new cards a day: the queue settles +at 79 cards on d30 and 117 on d365, with 139 and 227 answers given. It does not grow. + +## The lapse multiplier applies once (2026-10-13) + +`simpleGraduate`'s third argument was `card.lapses > 0` — a lifetime counter nothing resets +— so every graduation after a card's first ever failure multiplied its interval by 0.5 +instead of 1.9. A card climbed to 56 days, failed once, and then ratcheted 28 → 14 → 7 → +3.5 → … to the 8-hour floor, where it was due every day for the rest of its life. At 90% +accuracy, **96% of cards sit at or below a one-day interval after 40 reviews**; under the +corrected rule, none do. + +The flag is now `simple_relearning`, a column, set on the failure and cleared by the +re-graduation that pays for it. Midori's own file is the check — 2,450 cards with a median +interval of 37 days, 187 at the 365-day cap and 107 below a day, which is the shape the +corrected rule produces and nothing like what the shipped one did. + +## The correct-in-a-row count lives on the card (2026-10-13) + +A learning card needs three correct answers in a row. That count lived in a React ref, so +it died with the screen: at one pass per session a card never graduated, ever. It is +`simple_run` on the row now. + +This mattered because of the next entry — with the whole list in one queue, one pass per +card per session was the only rate available. + +## A session introduces ten unseen cards, not the whole list (2026-10-13) + +The new pool was every unseen card in the list. On 8,000 words that is 8,000 cards in one +queue, which is also why nothing collected three consecutive answers. The cap is +`lists.new_per_session`, default 10 (Midori's number), clamped to 1–500 by +`resolveNewPerSession` — zero would send a list with nothing due straight to the done +screen. + +Smart Review and the flagged-cards list are exempt: both are assembled on purpose and are +entirely new, so a cap would show ten of a deliberate selection and call that a session. +The RTK review list cannot reach the branch at all — `lib/rtk-review.ts` writes +`flashcard_mode 'srs'` and its multi-scope forces FSRS. That correction is worth recording +because the opposite was asserted before it was checked. + +## Already-ratcheted decks are repaired, not left to recover (2026-10-13) + +Fixing the rule does not fix the stored intervals. A deck the bug has flattened is 8,000 +cards at the 8-hour floor, all due today and all due again tomorrow. Queue size by day, +every run driven by the shipped `nextSimpleReview`: + +| answers/day | repair | d7 | d30 | d90 | d180 | d365 | +| ----------- | ------------ | ---- | ---- | ---- | ---- | ---- | +| 1000 | none | 7341 | 5333 | 1309 | 72 | 18 | +| 200 | none | 7866 | 7444 | 6644 | 5549 | 3542 | +| 150 | none | 7897 | 7596 | 6984 | 6200 | 4666 | +| 200 | spread 2–30d | 1050 | 5787 | 3591 | 1093 | 50 | +| 150 | spread 2–30d | 1188 | 6275 | 4692 | 2825 | 65 | +| 200 | spread 2–15d | 2826 | 6003 | 4452 | 2372 | 68 | +| 200 | flat 7d | 8000 | 6421 | 4595 | 2322 | 271 | + +**At 1,000 answers a day the deck heals itself in about four months, and at 150–200 it does +not.** That single number is why the first conclusion here — natural recovery, no repair — +was wrong: it was measured at an effort nobody sustains. At 200 a day, 3,542 cards are still +queued a year later, because a card answered today returns within hours and takes tomorrow's +slot. + +So there is a one-time repair: a migration that spreads the cards pinned at the floor over +the next month. The rows in the table above are simulated with every card repaired, which is the upper +bound; the clause below is narrower, and the stage split under **A learning card is bounded, +not repaired** says by how much. What was rejected, +and why: + +- **No repair.** The numbers above, at the effort the deck is actually reviewed at. +- **A flat lift to 7 days.** Re-synchronises the entire deck into one wave that returns + every seventh day and echoes for months: d7 = 8,000 again. +- **A narrower 2–15 day spread.** Pedagogically safer and it does drain, but it leaves 2,372 + cards at d180 against 1,093, and 1,209 at d365 against 65 at 150 answers a day. The + backlog is the complaint; a card given more days than it has earned costs one failed + review when it comes back. + +### Which rows the repair touches + +`simple_stage = 1 AND simple_interval <= 0.34 AND lapses > 0 AND reps >= 12 AND deleted_at IS +NULL`, and the matched rows get both a new interval and a new due date inside the next +month. + +`reps >= 12` is the discriminator, and it was measured rather than reasoned. Replaying +3,600 cards under each rule, counting `reps` the way `rateSimpleSrsCard` does: + +| clause | ratchet victims caught | honest floor cards lifted | +| ------------------------------------ | ---------------------- | ------------------------- | +| `interval <= 0.34, reps >= 3` @ 90% | 2359 / 2359 | 111 / 111 | +| `interval <= 0.34, reps >= 12` @ 90% | 2127 / 2359 | 13 / 111 | +| `interval <= 0.34, reps >= 20` @ 90% | 1735 / 2359 | 0 / 111 | +| `interval <= 0.34, reps >= 12` @ 75% | 2543 / 3031 | 167 / 469 | +| `interval <= 1, reps >= 12` @ 75% | 2597 / 3031 | 359 / 469 | + +A card the ratchet dragged down from 56 days has a median of 32 answers behind it; one +honestly at the floor has 4, and never more than 16 at 90% accuracy. **`lapses >= 3` is the +obvious guard and it is wrong**: under the bug a single lapse starts the ratchet, so it +catches only 1,568 of 2,359 victims — a third of the deck left flattened. That was proposed +in review, and the measurement is the only reason it was not shipped. + +`simple_interval <= 1` rather than `<= 0.34` catches cards part-way down the ratchet, and +doubles the honest cards wrongly lifted at 75% accuracy. A card at 0.88 days climbs on its +own once the rule is fixed — 1.67, 3.2, 6.1 — so only the cards pinned at the floor need +the repair. + +### A learning card is bounded, not repaired (2026-10-13) + +**An earlier version of the repair rewrote stage-0 intervals too, and that was wrong.** A +stage-0 card sits at 1/3 because that is where every new card starts, not because of the +ratchet: `simpleReviewFail` preserves the interval and the learning path never multiplies +it. With the whole list loading as new and the correct-in-a-row count dying with the +screen, cards that never graduated at all are most of the stage-0 population — and they +have the reps to pass any threshold, because the bug made them answer the same words every +day. Lifting those would hand thousands of unlearned words a fortnight each. + +Of 2,359 floored cards at 90% accuracy, 1,714 are graduated and 645 are in learning; at +75% the split inverts, 1,263 against 1,768. So the clause repairs 93% of the floored +graduated cards (87% at 75%) and none of the learning ones — 67% and 36% of the floored +population. + +The learning ones are the real complaint, and no rewrite of a stored interval fixes them: +they are words with one pass each that never graduated, and they are all due, every day. +**Capping them is what fixes it.** The learning slice of the due pool is limited to +`learningCapFor(newPerSession)` — three learning answers are one new card's worth of work — +ordered most-recently-answered first, so the cards already in hand get finished before the +backlog is reached, and placed at the head of the queue so a graduated load bigger than the +learner's stamina cannot stop anything in learning from advancing. The list screen's "due +today" badge applies the same cap, or it promises 8,000 cards for a session that shows +thirty and then says it is done. Measured on an 8,000-card legacy deck, cards shown per day and words graduated +in a year: + +| learning cap | answers/day | d1 | d30 | d180 | d365 | graduated in a year | +| ------------ | ----------- | ---- | ---- | ---- | ---- | ------------------- | +| none | 200 | 8000 | 7638 | 6100 | 4436 | 3733 | +| 30 (3 × 10) | 200 | 30 | 110 | 126 | 142 | 3681 | +| 10 | 200 | 10 | 75 | 105 | 104 | 3242 | +| none | 400 | 8000 | 7272 | 4220 | 890 | 7460 | +| 30 (3 × 10) | 400 | 30 | 228 | 212 | 248 | 7082 | + +The cap costs 52 words a year at 200 answers a day and turns an 8,000-card queue into a +110-card one. Capping at the new number itself rather than three times it costs 439 — a +learning card needs three answers, and a cap of ten cannot deliver them alongside reviews. + +A genuinely ratcheted card currently in learning is the price of not repairing stage 0: it +re-graduates to the floor once and then climbs 0.33 → 0.63 → 1.2 by itself. + +Accepted trades, written down because they are real: + +- A card honestly at the floor with twelve or more answers behind it is lifted with the + rest. Nothing in the row distinguishes "ratcheted down from 56 days" from "never + climbed" — the simple path writes no `review_logs` entry, so there is no history to + replay — and at 75% accuracy that is 167 cards in 2,700. The cost is one failed review + each. +- `reps` is not per-mode. A list switched from FSRS to simple SRS brings its FSRS count + with it, so its cards qualify after a single simple graduation lands them at the floor. + Bounded and self-correcting; there is nothing in the row to tell them apart, and adding a + per-mode counter for one migration is not worth it. +- The victims that stopped part-way down the ratchet, and every ratcheted card currently in + learning, are missed. They climb on their own. +- A list moved from simple SRS to FSRS has its dormant `simple_*` columns rewritten and its + `updated_at` bumped, so rows that no longer schedule anything get pushed. Harmless, and + cheaper than a `flashcard_mode` join that would also skip a list switched back later. +- The spread is `2 + (rowid % 29)`, deterministic rather than random, so the repair is + idempotent: a repaired row no longer matches the clause, which matters because every + device runs the UPDATE against its own rowids. A stale second device re-spreads its own + copies with its own offsets, which is harmless — they stay inside the same month. +- `isRemoteRelevant` keeps the UPDATE out of remote replay and the bumped `updated_at` + carries the result by row sync. +- Cards imported later are never repaired: `lib/data-backup.ts` restore and + `lib/list-transfer.ts` import run against an already-migrated database, so a floored row + arriving afterwards stays floored. `simple_relearning`'s backfill has the same hole. Not + worth a mechanism; worth knowing. diff --git a/lib/new-per-session.test.ts b/lib/new-per-session.test.ts index 2252dd8..ec10e53 100644 --- a/lib/new-per-session.test.ts +++ b/lib/new-per-session.test.ts @@ -11,7 +11,13 @@ import { describe, expect, it } from "vitest"; import { USER_DB_MIGRATIONS } from "@/db/user-migrations"; import { SRS_CARD_SELECT } from "@/db/srs-card-columns"; -import { DEFAULT_NEW_PER_SESSION, resolveNewPerSession } from "@/stores/simple-srs"; +import { + DEFAULT_NEW_PER_SESSION, + learningCapFor, + resolveNewPerSession, + SIMPLE_SRS_REQUIRED_CORRECT, + UNLIMITED, +} from "@/stores/simple-srs"; describe("resolveNewPerSession", () => { it("falls back to the default when the list says nothing", () => { @@ -80,6 +86,72 @@ describe("the query that fills the new pool", () => { // no limit, and -1 binds as an integer where a JS number past 2^31 would // reach op-sqlite as a double. it("gives everything to a session that asks for everything", () => { - expect(newRows(deck(40), -1)).toHaveLength(40); + expect(newRows(deck(40), UNLIMITED)).toHaveLength(40); + }); +}); + +describe("learningCapFor", () => { + it("is one new card's worth of work per learning card", () => { + expect(learningCapFor(10)).toBe(10 * SIMPLE_SRS_REQUIRED_CORRECT); + expect(learningCapFor(1)).toBe(SIMPLE_SRS_REQUIRED_CORRECT); + }); + + it("passes the ephemeral sentinel through instead of multiplying it", () => { + expect(learningCapFor(UNLIMITED)).toBe(UNLIMITED); + }); +}); + +describe("the query that fills the learning slice of the due pool", () => { + /** `size` cards at stage 0, all due, stamped in ascending `updated_at`. */ + function learningDeck(size: number): Database.Database { + const db = new Database(":memory:"); + db.pragma("foreign_keys = OFF"); + for (const sql of USER_DB_MIGRATIONS) { + try { + db.exec(sql); + } catch (err) { + if (!/duplicate column/i.test(String(err))) throw err; + } + } + const insert = db.prepare(`INSERT INTO srs_cards (id, entry_id, list_id, due, stability, + difficulty, elapsed_days, scheduled_days, reps, lapses, state, front_mode, back_mode, + simple_stage, simple_n, simple_interval, created_at, updated_at) + VALUES (?, ?, 'l', 'x', 0, 0, 0, 0, 14, 2, 0, 'kanji', 'english', 0, 0, 0.3333, + 'created', ?)`); + for (let i = 0; i < size; i++) { + insert.run(`c${i}`, i, `2026-01-${String((i % 28) + 1).padStart(2, "0")}T00:00:00.000Z`); + } + return db; + } + + const learningRows = (db: Database.Database, cap: number) => + db + .prepare( + `${SRS_CARD_SELECT} FROM srs_cards + WHERE list_id = 'l' AND simple_stage = 0 AND simple_n <= ? AND deleted_at IS NULL + ORDER BY updated_at DESC, rowid ASC + LIMIT ?`, + ) + .all(1, cap) as { id: string }[]; + + it("bounds the cards in learning a session shows", () => { + // The uncapped session left thousands of these, every one due, which is the + // queue that came back unchanged every day. Three learning answers are one + // card's worth of work, so the slice is three times the new cap. + expect(learningRows(learningDeck(8000), learningCapFor(DEFAULT_NEW_PER_SESSION))).toHaveLength( + 30, + ); + }); + + it("takes the most recently answered first, so cards in hand get finished", () => { + const rows = learningRows(learningDeck(60), 5); + // c27 carries the latest updated_at (day 28); c55 shares it and loses the + // rowid tiebreak. + expect(rows[0].id).toBe("c27"); + expect(rows.map((r) => r.id)).toContain("c55"); + }); + + it("gives an ephemeral session every learning card", () => { + expect(learningRows(learningDeck(45), learningCapFor(UNLIMITED))).toHaveLength(45); }); }); diff --git a/stores/simple-srs.ts b/stores/simple-srs.ts index c2ee102..4383bb7 100644 --- a/stores/simple-srs.ts +++ b/stores/simple-srs.ts @@ -175,6 +175,21 @@ export function resolveNewPerSession(value: number | null | undefined): number { return Math.max(1, Math.min(Math.floor(value), 500)); } +/** SQLite's own "no limit" for a bound LIMIT, used by the ephemeral lists. */ +export const UNLIMITED = -1; + +/** + * How many cards in learning a session takes on, given its new-card cap. + * + * A learning card needs three correct answers, so three learning slots are one + * new card's worth of work. Without a bound, a deck left by the uncapped + * session puts every stage-0 card in the queue at once — all of them due, none + * of them finishable — which is the queue that came back unchanged every day. + */ +export function learningCapFor(newPerSession: number): number { + return newPerSession === UNLIMITED ? UNLIMITED : newPerSession * REQUIRED_CORRECT; +} + /** What a single simple-SRS answer does to a card. */ export interface SimpleSrsDecision { updates: SimpleSrsUpdate; From d967997bb980a209d8c3dbea544e28f081de04e6 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 17:38:15 +0800 Subject: [PATCH 08/12] Address the first round of adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four reviews, one per surface. The three that mattered: **The migrations made a stale device win last-write-wins.** Both the backfill and the repair set `updated_at = now`, so a device opened for the first time in months became the newest writer of every lapsed card: `sync` then skips the remote row (`localTs >= remoteTs`) and pushes the stale one, discarding reviews done elsewhere. The bump was never needed — every device runs the same statement over its own rows — so it is gone, and the backfill gains the `deleted_at IS NULL` guard the repair already had, without which a bumped row would push its `deleted_at = NULL` back and resurrect a deleted card. **The study screen was covered by nothing.** No test imported it, and review proved `newCap = UNLIMITED`, deleting the learning `LIMIT`, and deleting `simple_run` from the persistence UPDATE each left the whole suite green — the 8,000-card queue and the graduation fix both revertable without a failure. `lib/new-per-session.test.ts` read as though it gated those queries but retyped the SQL inside the test, so inverting the test's own ORDER BY failed it. The three queries now live in `db/simple-queue.ts` and `db/simple-queue.test.ts` runs them: nine mutations of the shipped SQL each fail it, including both that previously survived. **Every number in the docs is now regenerated by `yarn simulate:srs`.** Review could not reproduce the tables from their prose, because the answer-count spread, the daily budget and the intra-day re-queue rule all change the result and none were written down. `scripts/simulate-simple-srs.ts` ships, the doc quotes its output, and three figures were wrong: the ratchet puts 92% of cards at or below a day after 40 answers, not 96% (96% is 45 answers), the fixed rule leaves 0.1% rather than none, and the repair clause reaches 1,589 of 2,359 victims — `reps >= 12` catches 2,127, but the shipped clause also requires `simple_stage = 1`, so quoting that number for the clause overstated it by a third. The discriminator table reproduces exactly. **The learning cap's cost was understated badly** — the doc said 52 words a year; it is thousands. Uncapped, a learner answering 200 a day does finish all 8,000 inside a year, by being shown the whole deck every day for six months. Capped they are shown ~130 a day and learn 3,706, which is what ten new cards a day means. That is the trade, now written as such. Also from review: - "Continue Studying" re-served the cards just answered, because a graduated card's 0.633-day interval falls inside today's cutoff and `loadQueue` never read `reviewedSrsIdsRef` — written in four places, read in none — and granted a fresh cap of new cards each tap. The session now carries an allowance and holds back what it has answered. - The confused-word path de-duped the pools but not the deque, so the fail it wrote was erased by a stale pending copy and the card was written twice in one session. Unanswered copies are now dropped from the deque too. - `reRateCard` dropped every re-queue copy after the cursor, including copies owned by cards whose answers are not being undone, stranding a counted card so the bar could never reach 100%. - Backup and restore both broke across a schema change: a backup from this branch restored onto an older build failed per TABLE, losing every list and card, and `attemptBackup` names the new columns while the recovery screen only runs when the database failed to migrate. Both now intersect with the columns the database actually has, and a column missing from an older backup is omitted from the INSERT so it takes its default. - A changed new-card cap now reloads the queue instead of being ignored until the next session; the due badge counts what the session will serve rather than what the deck holds; `lib/list-transfer.ts` carries the cap so a shared list keeps its pace; `db/schema.ts` carries the defaults the ALTERs declare. The comment claiming `INSERT OR REPLACE` substitutes a column default for an explicit NULL is wrong and is corrected — it writes the NULL, which is exactly why the column is nullable. That sentence was already corrected once in 12fdd93 and landed on the wrong side. Gates: typecheck clean, lint 0 errors, 2,548 tests passing with none skipped. Co-Authored-By: Claude Opus 5 (1M context) --- app/(tabs)/lists/study.tsx | 90 ++++--- components/DueCardsSection.tsx | 28 ++- components/FlashcardSettingsModal.tsx | 5 +- db/schema.ts | 6 +- db/simple-queue.test.ts | 191 +++++++++++++++ db/simple-queue.ts | 116 +++++++++ db/simple-relearning-migration.test.ts | 20 +- db/simple-repair-migration.test.ts | 28 ++- db/user-migrations.ts | 31 ++- docs/simple-srs-decisions.md | 127 ++++++---- lib/data-backup.test.ts | 84 +++++++ lib/data-backup.ts | 65 ++++- lib/list-transfer.test.ts | 42 +++- lib/list-transfer.ts | 11 +- lib/new-per-session.test.ts | 130 +--------- package.json | 3 +- scripts/simulate-simple-srs.ts | 324 +++++++++++++++++++++++++ 17 files changed, 1039 insertions(+), 262 deletions(-) create mode 100644 db/simple-queue.test.ts create mode 100644 db/simple-queue.ts create mode 100644 scripts/simulate-simple-srs.ts diff --git a/app/(tabs)/lists/study.tsx b/app/(tabs)/lists/study.tsx index 8ff2960..6060225 100644 --- a/app/(tabs)/lists/study.tsx +++ b/app/(tabs)/lists/study.tsx @@ -21,7 +21,8 @@ import { shouldAskAsCloze } from "@/lib/rtk-cloze"; import { rtkReviewScope, singleList, type ListScope } from "@/lib/list-scope"; import { isRtkReviewList } from "@/lib/rtk-review"; import { SRS_CARD_SELECT } from "@/db/srs-card-columns"; -import { learningCapFor, resolveNewPerSession, UNLIMITED } from "@/stores/simple-srs"; +import { resolveNewPerSession, UNLIMITED } from "@/stores/simple-srs"; +import { loadSimpleQueuePools } from "@/db/simple-queue"; import { getFaceText, getKanjiFaceText, @@ -1064,6 +1065,8 @@ function StudyScreen() { type SessionPhase = "studying" | "done" | "waiting"; const [sessionPhase, setSessionPhase] = useState("studying"); const reviewedSrsIdsRef = useRef>(new Set()); + /** Unseen cards this session has already introduced, across reloads. */ + const newIntroducedRef = useRef(0); const [reviewedCount, setReviewedCount] = useState(0); const [settingsVisible, setSettingsVisible] = useState(false); const [statsVisible, setStatsVisible] = useState(false); @@ -1331,6 +1334,11 @@ function StudyScreen() { useEffect(() => { if (!dictDb || !userDb || !list) return; + // A different list or scope is a new session; "Continue Studying" is not, + // and must keep both so it neither re-serves answered cards nor hands out + // another full allowance of new ones. + newIntroducedRef.current = 0; + reviewedSrsIdsRef.current.clear(); loadQueue(); }, [dictDb, userDb, list?.id, scope]); @@ -1637,39 +1645,21 @@ function StudyScreen() { ); newCap = resolveNewPerSession(capRow?.new_per_session); } - // Graduated cards due today are the real review load and are never - // capped. Cards still in learning are: measured on an 8,000-card - // legacy deck at 200 answers a day, the cap shows 110 cards instead - // of 8,000 and still graduates 3,681 of them in a year against 3,733 - // uncapped. - const learningCap = learningCapFor(newCap); - const dueRows = await userDb.getAllAsync( - `${srsSelectClause} FROM srs_cards - WHERE ${scope.clause} AND simple_stage = 1 AND simple_n <= ? AND deleted_at IS NULL - ORDER BY simple_n ASC`, - [...scope.args, dueCutoffDays], - ); - // Most recently answered first: finish the cards already in hand - // before reaching back into the backlog. - const learningRows = await userDb.getAllAsync( - `${srsSelectClause} FROM srs_cards - WHERE ${scope.clause} AND simple_stage = 0 AND simple_n <= ? AND deleted_at IS NULL - ORDER BY updated_at DESC, rowid ASC - LIMIT ?`, - [...scope.args, dueCutoffDays, learningCap], - ); - // Learning first: these are cards half-finished, and a graduated load - // bigger than the learner's stamina would otherwise mean nothing in - // learning or new ever advances. Before the split they led anyway, - // since `simple_n` is 0 on a learning card. - dueRows.unshift(...learningRows); - const newRows = await userDb.getAllAsync( - `${srsSelectClause} FROM srs_cards - WHERE ${scope.clause} AND simple_stage IS NULL AND deleted_at IS NULL - ORDER BY created_at ASC, rowid ASC - LIMIT ?`, - [...scope.args, newCap], - ); + // "Continue Studying" calls this again, so the cap is topped up to + // the session's allowance rather than granted afresh — otherwise ten + // taps introduce a hundred new cards — and the cards just answered + // are held back, since a graduated card's 0.633-day interval falls + // inside today's cutoff and it would come straight back. + const newAllowance = + newCap === UNLIMITED ? UNLIMITED : Math.max(0, newCap - newIntroducedRef.current); + const { dueRows, newRows } = await loadSimpleQueuePools({ + query: (sql, args) => userDb.getAllAsync(sql, args), + scope, + dueCutoffDays, + newAllowance, + excludeIds: reviewedSrsIdsRef.current, + }); + newIntroducedRef.current += newRows.length; if (dueRows.length > 0 || newRows.length > 0) { const dueItems = await resolveCards(dueRows); @@ -2376,16 +2366,24 @@ function StudyScreen() { await rateSrsCard(cardRow, Rating.Again); } - // Drop it from the pools first: they hold the row as it was at session - // start, and a second copy pulled later would carry a stale correct-run - // and graduate the card on one pass after this fail just reset it. + // Drop every stale copy of it first. The pools hold the row as it was at + // session start, and the deque may already have pulled one — either + // carries a correct-run from before this fail reset it, so answering it + // would graduate the card on one pass and write it twice in one session. + // Only copies not yet answered are dropped; an answered one is history. const queuedId = queuedCard.id; duePoolRef.current = duePoolRef.current.filter((item) => item.srsCard?.id !== queuedId); newPoolRef.current = newPoolRef.current.filter((item) => item.srsCard?.id !== queuedId); + // Back in learning, so it is no longer one of the cards finished. + completedSrsIdsRef.current.delete(queuedId); + // Push to end of current session setCards((prev) => [ - ...prev, + ...prev.filter( + (card, index) => + index <= cursor || card.status !== "pending" || card.item.srsCard?.id !== queuedId, + ), { item: { kind: "entry" as const, entry: result.entry as DictEntry, srsCard: queuedCard }, status: "pending" as CardStatus, @@ -2500,8 +2498,21 @@ function StudyScreen() { } next.push({ item: cards[cursor].item, status: "revealed", flipped: true }); for (let i = cursor + 1; i < cards.length; i++) { - if (cards[i].reQueueOf == null) { + const reQueuedBy = cards[i].reQueueOf; + // A re-queue copy is void only if the answer that created it is being + // undone. One owned by a card BEFORE the cursor keeps its place: its + // owner has already left the pool, so dropping the copy stranded the + // card for the rest of the session and the progress bar could never + // reach 100%. + if (reQueuedBy == null) { next.push({ item: cards[i].item, status: "pending", flipped: false }); + } else if (reQueuedBy < cursor) { + next.push({ + item: cards[i].item, + status: "pending", + flipped: false, + reQueueOf: reQueuedBy, + }); } } cardsRef.current = next; @@ -2836,7 +2847,6 @@ function StudyScreen() { onPress={() => { setSessionPhase("studying"); setReviewedCount(0); - reviewedSrsIdsRef.current.clear(); loadQueue(); }} /> diff --git a/components/DueCardsSection.tsx b/components/DueCardsSection.tsx index ef699b2..b67bf9f 100644 --- a/components/DueCardsSection.tsx +++ b/components/DueCardsSection.tsx @@ -135,18 +135,26 @@ export function DueCardsSection({ const idx = dateToBucketIndex(dueDate, dayResetHour); counts[idx]++; } - // Cards in learning are all due now, and the session only shows three - // times the new-card cap of them. Counting them all promised 8,000 due on - // a deck the uncapped session left behind, while the session showed - // thirty and then said it was done. - const list = useListsStore.getState().lists.find((l) => l.id === listId); - const learningCap = learningCapFor(resolveNewPerSession(list?.newPerSession)); - const learning = await userDb.getFirstAsync<{ c: number }>( - `SELECT COUNT(*) as c FROM srs_cards - WHERE list_id = ? AND simple_stage = 0 AND deleted_at IS NULL`, + // What the session will actually serve, not what the deck holds: cards in + // learning are all due now and the session shows three times the + // new-card cap of them, plus up to a cap of unseen cards. Counting the + // deck promised 8,000 due where the session showed thirty and then said + // it was done; ignoring the new cards under-reported a fresh list as 0. + const capRow = await userDb.getFirstAsync<{ new_per_session: number | null }>( + "SELECT new_per_session FROM lists WHERE id = ?", [listId], ); - counts[0] += Math.min(learning?.c ?? 0, learningCap); + const newCap = resolveNewPerSession(capRow?.new_per_session); + const pending = await userDb.getFirstAsync<{ learning: number; unseen: number }>( + // SUM(CASE …) rather than COUNT(*) FILTER, which needs SQLite 3.30+. + `SELECT + SUM(CASE WHEN simple_stage = 0 AND simple_n <= ? THEN 1 ELSE 0 END) as learning, + SUM(CASE WHEN simple_stage IS NULL THEN 1 ELSE 0 END) as unseen + FROM srs_cards WHERE list_id = ? AND deleted_at IS NULL`, + [endOfLogicalDayEpochDays(dayResetHour), listId], + ); + counts[0] += Math.min(pending?.learning ?? 0, learningCapFor(newCap)); + counts[0] += Math.min(pending?.unseen ?? 0, newCap); } else { const newRow = await userDb.getFirstAsync<{ c: number }>( "SELECT COUNT(*) as c FROM srs_cards WHERE list_id = ? AND state = 0 AND deleted_at IS NULL", diff --git a/components/FlashcardSettingsModal.tsx b/components/FlashcardSettingsModal.tsx index b0e2c7a..a0bdcfa 100644 --- a/components/FlashcardSettingsModal.tsx +++ b/components/FlashcardSettingsModal.tsx @@ -225,7 +225,10 @@ export function FlashcardSettingsModal({ if (onStartStudy) { onStartStudy(); } else { - onClose(mode !== list?.flashcardMode); + // The queue is rebuilt for a changed mode and for a changed new-card cap: + // the cap decides how many cards the session loads, so leaving it until + // the next session means the setting looks ignored. + onClose(mode !== list?.flashcardMode || newPerSession !== (list?.newPerSession ?? null)); } } diff --git a/db/schema.ts b/db/schema.ts index 7660254..ab6bb79 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -118,7 +118,7 @@ export const lists = sqliteTable("lists", { voiceMode: integer("voice_mode").notNull().default(0), typingMode: integer("typing_mode").notNull().default(0), mnemonicCloze: integer("mnemonic_cloze").notNull().default(0), - newPerSession: integer("new_per_session"), + newPerSession: integer("new_per_session").default(10), disableFlipAnimation: integer("disable_flip_animation").notNull().default(0), disableSwipeAnimation: integer("disable_swipe_animation").notNull().default(0), isDefault: integer("is_default").notNull().default(0), @@ -162,8 +162,8 @@ export const srsCards = sqliteTable("srs_cards", { simpleStage: integer("simple_stage"), simpleN: real("simple_n"), simpleInterval: real("simple_interval"), - simpleRelearning: integer("simple_relearning"), - simpleRun: integer("simple_run"), + simpleRelearning: integer("simple_relearning").default(0), + simpleRun: integer("simple_run").default(0), lastConfusionCheck: text("last_confusion_check"), deletedAt: text("deleted_at"), learningSteps: integer("learning_steps").notNull().default(0), diff --git a/db/simple-queue.test.ts b/db/simple-queue.test.ts new file mode 100644 index 0000000..6540988 --- /dev/null +++ b/db/simple-queue.test.ts @@ -0,0 +1,191 @@ +/** + * The queries that fill a simple-SRS session, run through the shipped + * `loadSimpleQueuePools` rather than a retyped copy of its SQL. + * + * This file exists because review proved the previous version did not gate + * anything: `lib/new-per-session.test.ts` typed the queries out again inside + * the test, so deleting the cap from the screen, deleting the learning LIMIT, + * or flipping its ORDER BY all left the whole suite green. + */ +import Database from "better-sqlite3"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { loadSimpleQueuePools, type SimpleQueueQuerier } from "./simple-queue"; +import { USER_DB_MIGRATIONS } from "./user-migrations"; +import type { SrsCardRow } from "./types"; +import { DEFAULT_NEW_PER_SESSION, UNLIMITED } from "@/stores/simple-srs"; + +/** Days since 2001-01-01, the epoch `simple_n` is stored in. */ +const CUTOFF = (Date.now() - Date.parse("2001-01-01T00:00:00Z")) / 86_400_000 + 1; + +type Card = { + id: string; + stage: number | null; + n: number | null; + /** ISO timestamp; `updated_at` is the learning slice's ordering key. */ + updatedAt?: string; + deleted?: boolean; +}; + +let db: Database.Database; + +const query: SimpleQueueQuerier = async (sql, args) => + db.prepare(sql).all(...(args as unknown[])) as SrsCardRow[]; + +const scope = { clause: "list_id = ?", args: ["l"] }; + +function insert(c: Card) { + db.prepare( + `INSERT INTO srs_cards (id, entry_id, list_id, due, stability, difficulty, elapsed_days, + scheduled_days, reps, lapses, state, front_mode, back_mode, simple_stage, simple_n, + simple_interval, created_at, updated_at, deleted_at) + VALUES (?, 1, 'l', 'x', 0, 0, 0, 0, 3, 1, 0, 'kanji', 'english', ?, ?, 0.3333, ?, ?, ?)`, + ).run( + c.id, + c.stage, + c.n, + `created-${c.id}`, + c.updatedAt ?? "2026-01-01T00:00:00.000Z", + c.deleted ? "gone" : null, + ); +} + +beforeEach(() => { + db = new Database(":memory:"); + db.pragma("foreign_keys = OFF"); + for (const sql of USER_DB_MIGRATIONS) { + try { + db.exec(sql); + } catch (err) { + if (!/duplicate column/i.test(String(err))) throw err; + } + } +}); + +const pools = (newAllowance: number, excludeIds?: Set) => + loadSimpleQueuePools({ query, scope, dueCutoffDays: CUTOFF, newAllowance, excludeIds }); + +const ids = (rows: SrsCardRow[]) => rows.map((r) => r.id); + +describe("the new-card cap", () => { + it("introduces the allowance, not the list", async () => { + for (let i = 0; i < 8000; i++) insert({ id: `n${i}`, stage: null, n: null }); + const { newRows } = await pools(DEFAULT_NEW_PER_SESSION); + expect(newRows).toHaveLength(10); + // Oldest first, so a session works through the list in order. + expect(ids(newRows)[0]).toBe("n0"); + }); + + it("gives an ephemeral session every unseen card", async () => { + for (let i = 0; i < 45; i++) insert({ id: `n${i}`, stage: null, n: null }); + expect((await pools(UNLIMITED)).newRows).toHaveLength(45); + }); + + it("introduces nothing once the session has spent its allowance", async () => { + for (let i = 0; i < 50; i++) insert({ id: `n${i}`, stage: null, n: null }); + expect((await pools(0)).newRows).toHaveLength(0); + }); +}); + +describe("the learning slice", () => { + it("is bounded at three times the new cap", async () => { + // A deck the uncapped session left behind: every card in learning, every + // one due, which is the queue that came back unchanged every day. + for (let i = 0; i < 8000; i++) insert({ id: `l${i}`, stage: 0, n: 0 }); + expect((await pools(DEFAULT_NEW_PER_SESSION)).dueRows).toHaveLength(30); + }); + + it("takes the most recently answered first, so cards in hand get finished", async () => { + insert({ id: "old", stage: 0, n: 0, updatedAt: "2026-01-01T00:00:00.000Z" }); + insert({ id: "newest", stage: 0, n: 0, updatedAt: "2026-06-01T00:00:00.000Z" }); + insert({ id: "middle", stage: 0, n: 0, updatedAt: "2026-03-01T00:00:00.000Z" }); + const { dueRows } = await pools(1); + expect(ids(dueRows)).toEqual(["newest", "middle", "old"]); + }); + + it("leads the queue, ahead of the graduated cards due today", async () => { + insert({ id: "graduated", stage: 1, n: 0 }); + insert({ id: "learning", stage: 0, n: 0 }); + expect(ids((await pools(1)).dueRows)).toEqual(["learning", "graduated"]); + }); + + it("is unbounded for an ephemeral session", async () => { + for (let i = 0; i < 60; i++) insert({ id: `l${i}`, stage: 0, n: 0 }); + expect((await pools(UNLIMITED)).dueRows).toHaveLength(60); + }); +}); + +describe("the graduated slice", () => { + it("is never capped — it is the review load the learner owes", async () => { + for (let i = 0; i < 500; i++) insert({ id: `g${i}`, stage: 1, n: 0 }); + expect((await pools(DEFAULT_NEW_PER_SESSION)).dueRows).toHaveLength(500); + }); + + it("leaves out a card due after the cutoff", async () => { + insert({ id: "today", stage: 1, n: CUTOFF - 1 }); + insert({ id: "tomorrow", stage: 1, n: CUTOFF + 1 }); + expect(ids((await pools(1)).dueRows)).toEqual(["today"]); + }); + + it("comes back in due order, oldest first", async () => { + insert({ id: "later", stage: 1, n: CUTOFF - 1 }); + insert({ id: "earlier", stage: 1, n: CUTOFF - 5 }); + expect(ids((await pools(1)).dueRows)).toEqual(["earlier", "later"]); + }); +}); + +describe("cards already answered this session", () => { + it("are held back, so Continue Studying does not re-serve them", async () => { + // A graduated card's 0.633-day interval falls inside today's cutoff, so + // without this it comes straight back on the next queue build. + insert({ id: "just-passed", stage: 1, n: CUTOFF - 0.2 }); + insert({ id: "still-due", stage: 1, n: CUTOFF - 1 }); + const { dueRows } = await pools(1, new Set(["just-passed"])); + expect(ids(dueRows)).toEqual(["still-due"]); + }); + + it("do not eat the cap they are excluded from", async () => { + // The LIMIT would otherwise spend its slots on the excluded rows and hand + // back a short batch. + for (let i = 0; i < 40; i++) insert({ id: `n${i}`, stage: null, n: null }); + const spent = new Set(Array.from({ length: 10 }, (_, i) => `n${i}`)); + const { newRows } = await pools(DEFAULT_NEW_PER_SESSION, spent); + expect(newRows).toHaveLength(10); + expect(ids(newRows)[0]).toBe("n10"); + }); + + it("do not eat the learning slice either", async () => { + for (let i = 0; i < 60; i++) { + insert({ + id: `l${i}`, + stage: 0, + n: 0, + updatedAt: `2026-01-${String(i + 1).padStart(2, "0")}`, + }); + } + const spent = new Set(["l59", "l58", "l57"]); + const { dueRows } = await pools(DEFAULT_NEW_PER_SESSION, spent); + expect(dueRows).toHaveLength(30); + expect(ids(dueRows)).not.toContain("l59"); + }); +}); + +describe("rows no session may see", () => { + it("skips a soft-deleted card in every slice", async () => { + insert({ id: "g", stage: 1, n: 0, deleted: true }); + insert({ id: "l", stage: 0, n: 0, deleted: true }); + insert({ id: "n", stage: null, n: null, deleted: true }); + const { dueRows, newRows } = await pools(DEFAULT_NEW_PER_SESSION); + expect(dueRows).toHaveLength(0); + expect(newRows).toHaveLength(0); + }); + + it("keeps the three slices disjoint", async () => { + insert({ id: "g", stage: 1, n: 0 }); + insert({ id: "l", stage: 0, n: 0 }); + insert({ id: "n", stage: null, n: null }); + const { dueRows, newRows } = await pools(DEFAULT_NEW_PER_SESSION); + expect(ids(dueRows).sort()).toEqual(["g", "l"]); + expect(ids(newRows)).toEqual(["n"]); + }); +}); diff --git a/db/simple-queue.ts b/db/simple-queue.ts new file mode 100644 index 0000000..a6eb4c6 --- /dev/null +++ b/db/simple-queue.ts @@ -0,0 +1,116 @@ +/** + * The three queries that fill a simple-SRS session, in one place. + * + * They live here rather than inline in the study screen because the screen is + * covered by no test: review proved that `newCap = UNLIMITED`, deleting the + * learning `LIMIT`, and deleting `simple_run` from the persistence UPDATE each + * left the whole suite green — the 8,000-card queue and the graduation fix both + * fully revertable without a single failure. `db/simple-queue.test.ts` runs + * THESE functions, so the SQL a session uses is the SQL a test gates. + */ + +import { SRS_CARD_SELECT } from "./srs-card-columns"; +import type { SrsCardRow } from "./types"; +import { learningCapFor, UNLIMITED } from "@/stores/simple-srs"; + +/** Runs a parameterised query, the shape both `WrappedUserDb` and a test give. */ +export type SimpleQueueQuerier = (sql: string, args: unknown[]) => Promise; + +/** The part of a `ListScope` these queries need. */ +export interface SimpleQueueScope { + clause: string; + args: readonly unknown[]; +} + +export interface SimpleQueueRequest { + query: SimpleQueueQuerier; + scope: SimpleQueueScope; + /** End of the logical day, in the epoch `simple_n` is stored in. */ + dueCutoffDays: number; + /** Unseen cards this session may still introduce, or `UNLIMITED`. */ + newAllowance: number; + /** Cards already answered in this session, which must not come back. */ + excludeIds?: ReadonlySet; +} + +export interface SimpleQueuePools { + /** Cards in learning first, then graduated cards due today. */ + dueRows: SrsCardRow[]; + newRows: SrsCardRow[]; +} + +/** Graduated and due today. Never capped — this is the real review load. */ +export const graduatedDueSql = (scopeClause: string) => + `${SRS_CARD_SELECT} FROM srs_cards + WHERE ${scopeClause} AND simple_stage = 1 AND simple_n <= ? AND deleted_at IS NULL + ORDER BY simple_n ASC`; + +/** + * Still in learning, capped. A deck left by the uncapped session has thousands + * of these, every one due, which is the queue that came back unchanged every + * day. Most recently answered first, so the cards already in hand get finished + * before the backlog is reached. + */ +export const learningDueSql = (scopeClause: string) => + `${SRS_CARD_SELECT} FROM srs_cards + WHERE ${scopeClause} AND simple_stage = 0 AND simple_n <= ? AND deleted_at IS NULL + ORDER BY updated_at DESC, rowid ASC + LIMIT ?`; + +/** Never seen, capped at the list's `new_per_session`. */ +export const unseenSql = (scopeClause: string) => + `${SRS_CARD_SELECT} FROM srs_cards + WHERE ${scopeClause} AND simple_stage IS NULL AND deleted_at IS NULL + ORDER BY created_at ASC, rowid ASC + LIMIT ?`; + +/** + * A capped query cannot simply drop the excluded rows afterwards — the LIMIT + * would have already spent its slots on them — so it asks for enough extra to + * cover every exclusion and trims after filtering. + */ +function limitWith(cap: number, excluded: number): number { + return cap === UNLIMITED ? UNLIMITED : cap + excluded; +} + +function withoutExcluded( + rows: SrsCardRow[], + excludeIds: ReadonlySet | undefined, + cap: number, +): SrsCardRow[] { + const kept = excludeIds?.size ? rows.filter((row) => !excludeIds.has(row.id)) : rows; + return cap === UNLIMITED ? kept : kept.slice(0, cap); +} + +/** Load the pools a simple-SRS session studies from. */ +export async function loadSimpleQueuePools({ + query, + scope, + dueCutoffDays, + newAllowance, + excludeIds, +}: SimpleQueueRequest): Promise { + const excluded = excludeIds?.size ?? 0; + const learningCap = learningCapFor(newAllowance); + + const graduated = await query(graduatedDueSql(scope.clause), [...scope.args, dueCutoffDays]); + const learning = await query(learningDueSql(scope.clause), [ + ...scope.args, + dueCutoffDays, + limitWith(learningCap, excluded), + ]); + const unseen = await query(unseenSql(scope.clause), [ + ...scope.args, + limitWith(newAllowance, excluded), + ]); + + return { + // Learning leads: a graduated load bigger than the learner's stamina would + // otherwise mean nothing in learning or new ever advances. + dueRows: [ + ...withoutExcluded(learning, excludeIds, learningCap), + ...withoutExcluded(graduated, excludeIds, UNLIMITED), + ], + newRows: withoutExcluded(unseen, excludeIds, newAllowance), + }; +} diff --git a/db/simple-relearning-migration.test.ts b/db/simple-relearning-migration.test.ts index 9a8d437..1df8ee1 100644 --- a/db/simple-relearning-migration.test.ts +++ b/db/simple-relearning-migration.test.ts @@ -54,6 +54,8 @@ describe("the simple_relearning backfill", () => { db.exec(card("mid-lapse", 0, 4)); db.exec(card("graduated", 1, 4)); db.exec(card("never-seen", null, 0)); + db.exec(card("deleted-mid-lapse", 0, 4)); + db.exec("UPDATE srs_cards SET deleted_at = 'gone' WHERE id = 'deleted-mid-lapse'"); apply(db, ADDED); return db; }; @@ -66,7 +68,6 @@ describe("the simple_relearning backfill", () => { it("leaves a graduated card alone however often it has failed", () => { const row = migrated().prepare("SELECT * FROM srs_cards WHERE id = 'graduated'").get() as any; expect(row.simple_relearning).toBe(0); - // and does not dirty a row it did not change expect(row.updated_at).toBe("OLD"); }); @@ -75,10 +76,19 @@ describe("the simple_relearning backfill", () => { expect(row.simple_relearning).toBe(0); }); - it("bumps updated_at on the rows it changes, so they sync", () => { + it("leaves updated_at alone, so a stale device cannot win last-write-wins", () => { + // Every device runs this statement over its own rows, so the backfill does + // not need to travel. Bumping the timestamp would make a device that has + // not been opened in months the newest writer of every lapsed card: sync + // skips the remote row when `localTs >= remoteTs` and pushes the stale one. const row = migrated().prepare("SELECT * FROM srs_cards WHERE id = 'mid-lapse'").get() as any; - expect(row.updated_at).not.toBe("OLD"); - // the format the rest of the app writes - expect(row.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(row.updated_at).toBe("OLD"); + }); + + it("leaves a soft-deleted card alone, so it cannot be resurrected by a push", () => { + const db = migrated(); + const row = db.prepare("SELECT * FROM srs_cards WHERE id = 'deleted-mid-lapse'").get() as any; + expect(row.simple_relearning).toBe(0); + expect(row.updated_at).toBe("OLD"); }); }); diff --git a/db/simple-repair-migration.test.ts b/db/simple-repair-migration.test.ts index cdff488..3d18cd3 100644 --- a/db/simple-repair-migration.test.ts +++ b/db/simple-repair-migration.test.ts @@ -3,9 +3,9 @@ * * Fixing the rule does not fix the stored intervals: a card the bug ratcheted * is sitting at the 8-hour floor and comes back every day forever. Measured at - * 200 answers a day, 3,542 of 8,000 such cards are still queued a year later - * without a repair and 50 with one — the numbers are in - * docs/simple-srs-decisions.md. + * 200 answers a day, 3,579 of 8,000 such cards are still queued a year later + * without a repair and 47 with one — `yarn simulate:srs drain`, and the + * decisions are in docs/simple-srs-decisions.md. * * What must NOT move: a card that has barely been seen, a card on a healthy * interval, an FSRS-only card, and any learning card — a card at stage 0 sits @@ -113,7 +113,13 @@ describe("the interval repair", () => { expect(r.simple_interval).toBeLessThanOrEqual(30); expect(Number(r.simple_n) - today()).toBeGreaterThanOrEqual(2); expect(Number(r.simple_n) - today()).toBeLessThanOrEqual(30); - expect(r.updated_at).not.toBe("OLD"); + }); + + it("leaves updated_at alone, so a stale device cannot win last-write-wins", () => { + // Each device repairs its own rows; a bumped timestamp would let a device + // opened for the first time in months push its values over another's + // reviews, because sync skips the remote row when `localTs >= remoteTs`. + expect(row(repaired(), "ratcheted").updated_at).toBe("OLD"); }); it("leaves a card part-way down the ratchet to climb on its own", () => { @@ -121,18 +127,18 @@ describe("the interval repair", () => { // 3.2, 6.1 … by itself. The clause is for cards PINNED at the floor, where // a card honestly at 0.88 after one lapse is indistinguishable from a // victim — at 75% accuracy, including them doubles the cards wrongly lifted. - expect(row(repaired(), "part-way-down").updated_at).toBe("OLD"); + expect(row(repaired(), "part-way-down").simple_interval).toBe(0.88); }); it("leaves a soft-deleted card alone rather than rewriting and pushing it", () => { - expect(row(repaired(), "deleted").updated_at).toBe("OLD"); + expect(row(repaired(), "deleted").simple_interval).toBeCloseTo(FLOOR); }); it("leaves a card with too few answers behind it alone", () => { // Honestly at the floor: a young card that failed once and re-graduated // lands there. Measured, those have a median of 4 answers; a card the // ratchet dragged down from 56 days has 32. - expect(row(repaired(), "barely-seen").updated_at).toBe("OLD"); + expect(row(repaired(), "barely-seen").simple_interval).toBeCloseTo(FLOOR); }); it("leaves every learning card alone, mid-lapse or never graduated", () => { @@ -147,18 +153,17 @@ describe("the interval repair", () => { }); it("leaves a card one halving above the clause alone", () => { - expect(row(repaired(), "just-above").updated_at).toBe("OLD"); + expect(row(repaired(), "just-above").simple_interval).toBe(0.4); }); it("leaves a card that has never failed alone — the bug cannot have touched it", () => { - expect(row(repaired(), "never-failed").updated_at).toBe("OLD"); + expect(row(repaired(), "never-failed").simple_interval).toBeCloseTo(FLOOR); }); it("leaves a healthy interval and an FSRS-only card alone", () => { const db = repaired(); expect(row(db, "healthy").simple_interval).toBe(56.61); - expect(row(db, "healthy").updated_at).toBe("OLD"); - expect(row(db, "fsrs-only").updated_at).toBe("OLD"); + expect(row(db, "fsrs-only").simple_interval).toBe(null); }); it("rewrites the schedule and nothing else", () => { @@ -171,6 +176,7 @@ describe("the interval repair", () => { expect(r.simple_relearning).toBe(0); expect(r.simple_run).toBe(0); expect(r.created_at).toBe("created"); + expect(r.updated_at).toBe("OLD"); }); // Recorded, not asserted as desirable: `reps` is not per-mode, so a list diff --git a/db/user-migrations.ts b/db/user-migrations.ts index 648b940..d873fd4 100644 --- a/db/user-migrations.ts +++ b/db/user-migrations.ts @@ -254,15 +254,22 @@ export const USER_DB_MIGRATIONS = [ // 8-hour floor and never left the daily queue again. `ALTER TABLE srs_cards ADD COLUMN simple_relearning INTEGER DEFAULT 0`, // Cards sitting in a lapse when this lands are relearning by definition. - // `updated_at` moves too, or the backfill never pushes and a device that - // pulls the row afterwards takes the remote 0 back over it. - `UPDATE srs_cards SET simple_relearning = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') - WHERE simple_stage = 0 AND lapses > 0`, + // + // `updated_at` is deliberately NOT touched. Every device runs this same + // statement over its own rows, so the backfill does not need to travel — and + // bumping the timestamp would make a device that has not been opened in + // months the newest writer of every lapsed card, so `sync` would skip the + // remote row (`localTs >= remoteTs`) and push stale values over reviews done + // elsewhere. The soft-delete guard is here for the same reason: a bumped row + // would win and push its `deleted_at = NULL` back, resurrecting the card. + `UPDATE srs_cards SET simple_relearning = 1 + WHERE simple_stage = 0 AND lapses > 0 AND deleted_at IS NULL`, // How many correct answers in a row a learning card has. It lived in a React // ref, so it died with the screen: a card answered twice and left was back to - // zero, and with `simple_n = 0` it stayed due forever. Nullable to match its - // neighbours; restore would survive NOT NULL too, since it inserts with - // `INSERT OR REPLACE`, which substitutes the default for an explicit NULL. + // zero, and with `simple_n = 0` it stayed due forever. Nullable because a + // restore from a backup that predates the column binds an explicit NULL for + // it — `INSERT OR REPLACE` does NOT fall back to the column default for a + // named NULL, so NOT NULL here would fail the whole restore. `ALTER TABLE srs_cards ADD COLUMN simple_run INTEGER DEFAULT 0`, // How many unseen cards a simple-SRS session may introduce. The session used // to load the whole list at once, so an 8,000-word list put 8,000 cards in @@ -272,8 +279,8 @@ export const USER_DB_MIGRATIONS = [ `ALTER TABLE lists ADD COLUMN new_per_session INTEGER DEFAULT 10`, // One-time repair of the decks the lifetime-lapse bug flattened. Fixing the // rule does not fix the stored intervals: a ratcheted card sits at the 8-hour - // floor and returns every day, and at 200 answers a day 3,542 of 8,000 such - // cards are still queued a year later. Spreading them over the next month + // floor and returns every day, and at 200 answers a day 3,579 of 8,000 such + // cards are still queued a year later (`yarn simulate:srs drain`). Spreading them over the next month // drains the backlog instead — the numbers, and what was rejected, are in // docs/simple-srs-decisions.md. // @@ -288,10 +295,12 @@ export const USER_DB_MIGRATIONS = [ // new cards START, and with the whole list loading as new, those are most of // the stage-0 population — repairing them would hand thousands of unlearned // words a fortnight. + // `updated_at` is left alone, as in the backfill above: every device repairs + // its own rows, and a stale device that bumped them would push months-old + // values over reviews done on another device. `UPDATE srs_cards SET simple_interval = 2 + (rowid % 29), - simple_n = (julianday('now') - julianday('2001-01-01')) + 2 + (rowid % 29), - updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + simple_n = (julianday('now') - julianday('2001-01-01')) + 2 + (rowid % 29) WHERE simple_stage = 1 AND simple_interval <= 0.34 AND lapses > 0 AND reps >= 12 AND deleted_at IS NULL`, ]; diff --git a/docs/simple-srs-decisions.md b/docs/simple-srs-decisions.md index 50236ee..489ffc7 100644 --- a/docs/simple-srs-decisions.md +++ b/docs/simple-srs-decisions.md @@ -11,14 +11,21 @@ answer, and nothing else in the session changes a card's schedule. So a scheduli is measured by simulating a deck through that exact function — not through a reimplementation of the rules, which is how the first version of this measurement missed the bug entirely. -A simulation needs four numbers: deck size, how many unseen cards a day the session -introduces, how many answers a day the learner gives, and accuracy. The runs below use -8,000 cards (Sam's list), 90% accuracy on graduated cards and 80% on learning cards, and -report the queue size on a given day. **The answers-per-day figure decides more than any -rule does** — see the repair decision. +**`yarn simulate:srs` regenerates every number below**, from +`scripts/simulate-simple-srs.ts`. It ships for a reason: review could not reproduce an +earlier version of these tables from their prose description, because the answer-count +spread, the daily budget and the intra-day re-queue rule all change the result and none of +them were written down. A number nobody else can produce is not a measurement. Quote the +script's output, and if a figure here disagrees with it, the script is right. + +Its assumptions, all of which matter: 8,000 cards (Sam's list), 90% accuracy on graduated +cards and 80% on cards in learning, a fixed daily answer budget, and a card that fails or +has not yet collected three correct answers pushed back into the same day's queue — because +the session re-queues it about ten cards away rather than ending. **The answers-per-day +figure decides more than any rule does**; see the repair decision. Reference figures for the fixed engine, fresh deck, ten new cards a day: the queue settles -at 79 cards on d30 and 117 on d365, with 139 and 227 answers given. It does not grow. +at 79 cards shown on d30 and 117 on d365, with 139 and 227 answers given. It does not grow. ## The lapse multiplier applies once (2026-10-13) @@ -26,8 +33,9 @@ at 79 cards on d30 and 117 on d365, with 139 and 227 answers given. It does not — so every graduation after a card's first ever failure multiplied its interval by 0.5 instead of 1.9. A card climbed to 56 days, failed once, and then ratcheted 28 → 14 → 7 → 3.5 → … to the 8-hour floor, where it was due every day for the rest of its life. At 90% -accuracy, **96% of cards sit at or below a one-day interval after 40 reviews**; under the -corrected rule, none do. +accuracy, over 20,000 sampled cards, **92% sit at or below a one-day interval after 40 +answers** and 96% after 45; under the corrected rule it is 0.1% and then none. (An earlier +version of this line said 96% at 40, from a 400-card sample — `yarn simulate:srs ratchet`.) The flag is now `simple_relearning`, a column, set on the failure and cleared by the re-graduation that pays for it. Midori's own file is the check — 2,450 cards with a median @@ -63,19 +71,19 @@ Fixing the rule does not fix the stored intervals. A deck the bug has flattened cards at the 8-hour floor, all due today and all due again tomorrow. Queue size by day, every run driven by the shipped `nextSimpleReview`: -| answers/day | repair | d7 | d30 | d90 | d180 | d365 | -| ----------- | ------------ | ---- | ---- | ---- | ---- | ---- | -| 1000 | none | 7341 | 5333 | 1309 | 72 | 18 | -| 200 | none | 7866 | 7444 | 6644 | 5549 | 3542 | -| 150 | none | 7897 | 7596 | 6984 | 6200 | 4666 | -| 200 | spread 2–30d | 1050 | 5787 | 3591 | 1093 | 50 | -| 150 | spread 2–30d | 1188 | 6275 | 4692 | 2825 | 65 | -| 200 | spread 2–15d | 2826 | 6003 | 4452 | 2372 | 68 | -| 200 | flat 7d | 8000 | 6421 | 4595 | 2322 | 271 | +| answers/day | repair | d1 | d7 | d30 | d90 | d180 | d365 | +| ----------- | ------------ | ---- | ---- | ---- | ---- | ---- | ---- | +| 1000 | none | 8000 | 7338 | 5270 | 1328 | 81 | 18 | +| 1000 | spread 2–30d | 8000 | 3607 | 243 | 116 | 64 | 58 | +| 200 | none | 8000 | 7863 | 7462 | 6676 | 5552 | 3579 | +| 200 | spread 2–30d | 8000 | 7106 | 5311 | 3431 | 1166 | 47 | +| 200 | flat 7d | 8000 | 7072 | 6094 | 4860 | 2272 | 252 | +| 150 | none | 8000 | 7900 | 7581 | 6980 | 6164 | 4664 | +| 150 | spread 2–30d | 8000 | 7343 | 5958 | 4530 | 2839 | 76 | **At 1,000 answers a day the deck heals itself in about four months, and at 150–200 it does not.** That single number is why the first conclusion here — natural recovery, no repair — -was wrong: it was measured at an effort nobody sustains. At 200 a day, 3,542 cards are still +was wrong: it was measured at an effort nobody sustains. At 200 a day, 3,579 cards are still queued a year later, because a card answered today returns within hours and takes tomorrow's slot. @@ -86,12 +94,12 @@ not repaired** says by how much. What was rejected, and why: - **No repair.** The numbers above, at the effort the deck is actually reviewed at. -- **A flat lift to 7 days.** Re-synchronises the entire deck into one wave that returns - every seventh day and echoes for months: d7 = 8,000 again. -- **A narrower 2–15 day spread.** Pedagogically safer and it does drain, but it leaves 2,372 - cards at d180 against 1,093, and 1,209 at d365 against 65 at 150 answers a day. The - backlog is the complaint; a card given more days than it has earned costs one failed - review when it comes back. +- **A flat lift to 7 days.** Half the drain of the spread at every horizon — 2,272 still + queued at d180 against 1,166, and at 1,000 answers a day it visibly re-synchronises, the + queue swinging back up to 553 and 579 on d90 and d180 as one wave returns together. +- **A narrower spread.** A shorter window drains more slowly for the same reason the flat + lift does: more cards land on the same days. The backlog is the complaint, and a card + given more days than it has earned costs one failed review when it comes back. ### Which rows the repair touches @@ -102,13 +110,25 @@ month. `reps >= 12` is the discriminator, and it was measured rather than reasoned. Replaying 3,600 cards under each rule, counting `reps` the way `rateSimpleSrsCard` does: -| clause | ratchet victims caught | honest floor cards lifted | -| ------------------------------------ | ---------------------- | ------------------------- | -| `interval <= 0.34, reps >= 3` @ 90% | 2359 / 2359 | 111 / 111 | -| `interval <= 0.34, reps >= 12` @ 90% | 2127 / 2359 | 13 / 111 | -| `interval <= 0.34, reps >= 20` @ 90% | 1735 / 2359 | 0 / 111 | -| `interval <= 0.34, reps >= 12` @ 75% | 2543 / 3031 | 167 / 469 | -| `interval <= 1, reps >= 12` @ 75% | 2597 / 3031 | 359 / 469 | +The sample is 400 seeds × nine answer counts (4, 8, 12, 16, 24, 32, 48, 64, 96) = 3,600 +cards under each rule, because `reps` only varies if cards have lived different lengths — a +fixed-length replay gives every card the same count and the clause then catches everything. + +| clause | ratchet victims caught | honest floor cards lifted | +| ------------------------------------- | ---------------------- | ------------------------- | +| `interval <= 0.34, reps >= 3` @ 90% | 2359 / 2359 | 111 / 111 | +| `interval <= 0.34, reps >= 12` @ 90% | 2127 / 2359 | 13 / 111 | +| `interval <= 0.34, reps >= 20` @ 90% | 1735 / 2359 | 0 / 111 | +| `interval <= 0.34, lapses >= 3` @ 90% | 1568 / 2359 | 17 / 111 | +| **the shipped clause** @ 90% | 1589 / 2359 | — | +| `interval <= 0.34, reps >= 12` @ 75% | 2543 / 3031 | 167 / 469 | +| `interval <= 0.34, lapses >= 3` @ 75% | 2504 / 3031 | 259 / 469 | +| **the shipped clause** @ 75% | 1098 / 3031 | — | + +The shipped clause also requires `simple_stage = 1`, so it reaches 1,589 of the 2,359 — 93% +of the 1,714 that are graduated, and none of the rest. **The `reps >= 12` row is the +discriminator's power, not the clause's reach**; quoting 2,127 for the clause overstates it +by a third. A card the ratchet dragged down from 56 days has a median of 32 answers behind it; one honestly at the floor has 4, and never more than 16 at 90% accuracy. **`lapses >= 3` is the @@ -116,10 +136,9 @@ obvious guard and it is wrong**: under the bug a single lapse starts the ratchet catches only 1,568 of 2,359 victims — a third of the deck left flattened. That was proposed in review, and the measurement is the only reason it was not shipped. -`simple_interval <= 1` rather than `<= 0.34` catches cards part-way down the ratchet, and -doubles the honest cards wrongly lifted at 75% accuracy. A card at 0.88 days climbs on its -own once the rule is fixed — 1.67, 3.2, 6.1 — so only the cards pinned at the floor need -the repair. +`simple_interval <= 1` rather than `<= 0.34` catches cards part-way down the ratchet at the +cost of lifting far more honest ones. A card at 0.88 days climbs on its own once the rule is +fixed — 1.67, 3.18, 6.04 — so only the cards pinned at the floor need the repair. ### A learning card is bounded, not repaired (2026-10-13) @@ -144,20 +163,30 @@ ordered most-recently-answered first, so the cards already in hand get finished backlog is reached, and placed at the head of the queue so a graduated load bigger than the learner's stamina cannot stop anything in learning from advancing. The list screen's "due today" badge applies the same cap, or it promises 8,000 cards for a session that shows -thirty and then says it is done. Measured on an 8,000-card legacy deck, cards shown per day and words graduated -in a year: - -| learning cap | answers/day | d1 | d30 | d180 | d365 | graduated in a year | -| ------------ | ----------- | ---- | ---- | ---- | ---- | ------------------- | -| none | 200 | 8000 | 7638 | 6100 | 4436 | 3733 | -| 30 (3 × 10) | 200 | 30 | 110 | 126 | 142 | 3681 | -| 10 | 200 | 10 | 75 | 105 | 104 | 3242 | -| none | 400 | 8000 | 7272 | 4220 | 890 | 7460 | -| 30 (3 × 10) | 400 | 30 | 228 | 212 | 248 | 7082 | - -The cap costs 52 words a year at 200 answers a day and turns an 8,000-card queue into a -110-card one. Capping at the new number itself rather than three times it costs 439 — a -learning card needs three answers, and a cap of ten cannot deliver them alongside reviews. +thirty and then says it is done. + +Cards shown per day, and words that reached stage 1 within the year, on a legacy deck of +8,000 stage-0 cards (`yarn simulate:srs learningCap`): + +| learning cap | answers/day | d1 | d7 | d30 | d180 | d365 | graduated in a year | +| ------------ | ----------- | ---- | ---- | ---- | ---- | ---- | ------------------- | +| none | 200 | 8000 | 8000 | 8000 | 8000 | 5480 | 8000 | +| 30 (3 × 10) | 200 | 30 | 119 | 86 | 133 | 140 | 3706 | +| 10 | 200 | 10 | 47 | 75 | 106 | 112 | 3240 | +| none | 400 | 8000 | 8000 | 8000 | 5256 | 762 | 8000 | +| 30 (3 × 10) | 400 | 30 | 137 | 221 | 246 | 230 | 7051 | + +**The cap is not free, and an earlier version of this section understated the cost badly** +(it claimed 52 words a year; it is thousands). Uncapped, a learner who answers 200 a day +does finish all 8,000 inside a year — by being shown the entire deck every day for six +months, which is the thing being reported as broken. Capped at thirty they are shown about +130 a day and learn 3,706, which is what ten new cards a day _means_: 3,650 a year. The cap +paces the legacy backlog at the rate the learner chose rather than dumping the list again, +and `new_per_session` is the dial — at 400 answers a day the same cap delivers 7,051. + +Capping at the new number itself rather than three times it costs another 466 words at 200 +answers a day, because a learning card needs three answers and ten slots cannot deliver +them alongside reviews. A genuinely ratcheted card currently in learning is the price of not repairing stage 0: it re-graduates to the floor once and then climbs 0.33 → 0.63 → 1.2 by itself. diff --git a/lib/data-backup.test.ts b/lib/data-backup.test.ts index 62e1b0b..0a05e17 100644 --- a/lib/data-backup.test.ts +++ b/lib/data-backup.test.ts @@ -1,3 +1,4 @@ +import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; import { createTestDb } from "@/test/test-db"; import { attemptBackup, importBackup } from "./data-backup"; @@ -131,3 +132,86 @@ describe("data backup", () => { } }); }); + +/** + * A schema change must not cost the user their data in either direction. + * + * Review proved both halves broke: a backup from a build with + * `new_per_session` restored onto one without it failed per TABLE, so every + * list and every flashcard was skipped; and `attemptBackup`'s SELECT names the + * new columns while the recovery screen only runs when the database failed to + * migrate — precisely when they may not exist. + */ +describe("backup across a schema change", () => { + /** A `WrappedUserDb`-shaped wrapper over better-sqlite3. */ + function wrap(db: Database.Database) { + return { + getAllAsync: async (sql: string, args: unknown[] = []) => + db.prepare(sql).all(...(args as unknown[])) as T[], + getFirstAsync: async (sql: string, args: unknown[] = []) => + (db.prepare(sql).get(...(args as unknown[])) ?? null) as T | null, + runAsync: async (sql: string, args: unknown[] = []) => { + db.prepare(sql).run(...(args as unknown[])); + }, + execAsync: async (sql: string) => { + db.exec(sql); + }, + } as unknown as Parameters[0]; + } + + /** The `lists` table as it was before `new_per_session` existed. */ + function olderDb(): Database.Database { + const db = new Database(":memory:"); + db.exec(`CREATE TABLE lists ( + id TEXT PRIMARY KEY, name TEXT, description TEXT, + flashcard_mode TEXT DEFAULT 'add_order', front_faces TEXT, back_faces TEXT, + study_position INTEGER DEFAULT 0, configured INTEGER DEFAULT 0, + auto_play_audio INTEGER DEFAULT 0, confusion_detection INTEGER DEFAULT 1, + voice_mode INTEGER DEFAULT 0, typing_mode INTEGER DEFAULT 0, + disable_flip_animation INTEGER DEFAULT 0, disable_swipe_animation INTEGER DEFAULT 0, + is_default INTEGER DEFAULT 0, learning_steps TEXT, relearning_steps TEXT, + mnemonic_cloze INTEGER DEFAULT 0, created_at TEXT, updated_at TEXT)`); + return db; + } + + it("restores onto an older build instead of losing every list", async () => { + const db = olderDb(); + const backup = JSON.stringify({ + version: 1, + tables: { + lists: [ + { id: "l1", name: "Core 8k", new_per_session: 25, created_at: "x", updated_at: "y" }, + ], + }, + }); + const result = await importBackup(wrap(db), backup); + expect(result.failed).toEqual([]); + expect(result.succeeded).toContain("lists"); + expect(db.prepare("SELECT name FROM lists WHERE id = 'l1'").get()).toEqual({ name: "Core 8k" }); + }); + + it("takes the column default for a column an older backup never had", async () => { + const db = new Database(":memory:"); + db.exec(`CREATE TABLE lists (id TEXT PRIMARY KEY, name TEXT, + new_per_session INTEGER DEFAULT 10, created_at TEXT, updated_at TEXT)`); + // No `new_per_session` key at all, the way a pre-feature backup looks. + const backup = JSON.stringify({ + version: 1, + tables: { lists: [{ id: "l1", name: "Core 8k", created_at: "x", updated_at: "y" }] }, + }); + await importBackup(wrap(db), backup); + expect(db.prepare("SELECT new_per_session FROM lists WHERE id = 'l1'").get()).toEqual({ + new_per_session: 10, + }); + }); + + it("rescues the rows a failed migration leaves readable", async () => { + const db = olderDb(); + db.prepare("INSERT INTO lists (id, name) VALUES ('l1', 'Core 8k')").run(); + // BACKUP_TABLES names `new_per_session`, which this database lacks. + const result = await attemptBackup(wrap(db) as never); + expect(result.failed).not.toContain("lists"); + expect(result.tables.lists).toHaveLength(1); + expect((result.tables.lists[0] as { name: string }).name).toBe("Core 8k"); + }); +}); diff --git a/lib/data-backup.ts b/lib/data-backup.ts index 63427c1..0e30013 100644 --- a/lib/data-backup.ts +++ b/lib/data-backup.ts @@ -261,6 +261,20 @@ const IMPORT_ORDER = [ "course_progress", ]; +/** The columns a table actually has, or null if it cannot be read at all. */ +export async function existingColumns( + db: WrappedUserDb, + tableName: string, +): Promise | null> { + try { + const rows = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(${tableName})`); + if (!rows.length) return null; + return new Set(rows.map((row) => row.name)); + } catch { + return null; + } +} + export async function attemptBackup(db: WrappedUserDb): Promise { const result: BackupResult = { tables: {}, succeeded: [], failed: [] }; for (const table of BACKUP_TABLES) { @@ -269,6 +283,24 @@ export async function attemptBackup(db: WrappedUserDb): Promise { result.tables[table.name] = rows; result.succeeded.push(table.name); } catch (err) { + // This runs on a database that failed to open or migrate, which is + // exactly when a column the query names may not exist yet. Falling back + // to the columns the table really has rescues the rows instead of + // reporting the whole table as lost — a missing `new_per_session` used to + // cost the user every list, and a missing `simple_relearning` every card. + const cols = await existingColumns(db, table.name); + const wanted = TABLE_COLUMNS[table.name]; + if (cols && wanted) { + const usable = wanted.filter((col) => cols.has(col)); + try { + const rows = await db.getAllAsync(`SELECT ${usable.join(", ")} FROM ${table.name}`); + result.tables[table.name] = rows; + result.succeeded.push(table.name); + continue; + } catch (fallbackErr) { + console.error(`[Recovery] Fallback backup of ${table.name} failed:`, fallbackErr); + } + } console.error(`[Recovery] Failed to backup ${table.name}:`, err); result.failed.push(table.name); } @@ -304,8 +336,19 @@ export async function importBackup( continue; } - const cols = TABLE_COLUMNS[tableName]; - if (!cols) { + const allowed = TABLE_COLUMNS[tableName]; + if (!allowed) { + result.skipped.push(tableName); + continue; + } + + // Only the columns this database actually has. A backup written by a newer + // build names columns an older one lacks, and SQLite rejects the whole + // statement for an unknown column — so one added column used to cost every + // list and every flashcard in the restore, not just that column. + const present = await existingColumns(db, tableName); + const cols = present ? allowed.filter((col) => present.has(col)) : allowed; + if (!cols.length) { result.skipped.push(tableName); continue; } @@ -320,8 +363,22 @@ export async function importBackup( const sql = `INSERT OR REPLACE INTO ${tableName} (${cols.join(", ")}) VALUES (${placeholders})`; for (const row of rows) { - const values = cols.map((col) => row[col] ?? null); - await db.runAsync(sql, values); + // `INSERT OR REPLACE` writes a named NULL as NULL — it does NOT fall + // back to the column default — so a column missing from an older backup + // is left out of the statement entirely and takes its default. + const named = cols.filter((col) => row[col] !== undefined); + if (named.length === cols.length) { + await db.runAsync( + sql, + cols.map((col) => row[col] ?? null), + ); + } else { + await db.runAsync( + `INSERT OR REPLACE INTO ${tableName} (${named.join(", ")}) + VALUES (${named.map(() => "?").join(", ")})`, + named.map((col) => row[col] ?? null), + ); + } processedRows++; } diff --git a/lib/list-transfer.test.ts b/lib/list-transfer.test.ts index ba252d1..dbb5d6a 100644 --- a/lib/list-transfer.test.ts +++ b/lib/list-transfer.test.ts @@ -1,5 +1,12 @@ import { describe, test, expect } from "vitest"; -import { parseListImport, type JitenExportFile } from "./list-transfer"; +import { createTestDb } from "@/test/test-db"; +import { DEFAULT_NEW_PER_SESSION } from "@/stores/simple-srs"; +import { + buildListExport, + importListToDb, + parseListImport, + type JitenExportFile, +} from "./list-transfer"; // ─── Helpers ─── @@ -214,3 +221,36 @@ describe("parseListImport", () => { }); }); }); + +describe("the new-card cap travels with a shared list", () => { + test("exports it and imports it, so a shared list keeps its pace", async () => { + const db = createTestDb(); + const now = "2026-05-01T00:00:00.000Z"; + await db.runAsync( + `INSERT INTO lists (id, name, is_default, study_position, flashcard_mode, + new_per_session, created_at, updated_at) + VALUES ('src', 'Core 8k', 0, 0, 'simple_srs', 40, ?, ?)`, + [now, now], + ); + const exported = await buildListExport(db, "src", false); + expect(exported.list.newPerSession).toBe(40); + + const listId = await importListToDb(db, exported, false); + const row = await db.getFirstAsync<{ new_per_session: number }>( + "SELECT new_per_session FROM lists WHERE id = ?", + [listId], + ); + expect(row?.new_per_session).toBe(40); + }); + + test("gives a file that predates the column the default, not a pin to ten", async () => { + const db = createTestDb(); + // No `newPerSession` key at all, the way a pre-feature file looks. + const listId = await importListToDb(db, validExport(), false, undefined); + const row = await db.getFirstAsync<{ new_per_session: number }>( + "SELECT new_per_session FROM lists WHERE id = ?", + [listId], + ); + expect(row?.new_per_session).toBe(DEFAULT_NEW_PER_SESSION); + }); +}); diff --git a/lib/list-transfer.ts b/lib/list-transfer.ts index 1719e36..6af5053 100644 --- a/lib/list-transfer.ts +++ b/lib/list-transfer.ts @@ -1,6 +1,7 @@ import type { WrappedUserDb } from "@/db/user-db"; import type { CardFace, FlashcardMode } from "@/db/types"; import { parseFaces } from "./card-faces"; +import { resolveNewPerSession } from "@/stores/simple-srs"; // ─── Export file schema ─── @@ -19,6 +20,8 @@ export interface JitenExportFile { frontFaces: CardFace[]; backFaces: CardFace[]; autoPlayAudio: boolean; + /** Unseen cards a simple-SRS session introduces; absent means the default. */ + newPerSession?: number | null; }; entries: JitenExportEntry[]; @@ -97,6 +100,7 @@ export async function buildListExport( frontFaces, backFaces, autoPlayAudio: Boolean(listRow.auto_play_audio ?? listRow.autoPlayAudio ?? 0), + newPerSession: listRow.new_per_session ?? listRow.newPerSession ?? null, }, entries: [], }; @@ -252,8 +256,8 @@ export async function importListToDb( const configured = hasStudyData ? 1 : 0; await userDb.runAsync( - `INSERT OR IGNORE INTO lists (id, name, description, flashcard_mode, front_faces, back_faces, study_position, configured, auto_play_audio, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO lists (id, name, description, flashcard_mode, front_faces, back_faces, study_position, configured, auto_play_audio, new_per_session, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ listId, name, @@ -265,6 +269,9 @@ export async function importListToDb( studyPosition, configured, data.list.autoPlayAudio ? 1 : 0, + // A shared list carries its pace; a file that predates the column takes + // the column default rather than being pinned to ten. + resolveNewPerSession(data.list.newPerSession), now, now, ], diff --git a/lib/new-per-session.test.ts b/lib/new-per-session.test.ts index ec10e53..b6ce25d 100644 --- a/lib/new-per-session.test.ts +++ b/lib/new-per-session.test.ts @@ -1,16 +1,18 @@ /** - * The cap on unseen cards a session introduces. + * The cap on unseen cards a session introduces, and how many cards in learning + * it takes on alongside them. * * The simple-SRS session used to load the whole list: 8,000 words meant 8,000 * cards at once, none of which could collect the three correct answers a card * needs to graduate, so the same queue came back every day. Midori, which this * engine clones, mixes in about ten. + * + * These are the arithmetic. The QUERIES that apply them are gated by + * `db/simple-queue.test.ts`, which runs the shipped `loadSimpleQueuePools`: + * this file used to retype that SQL, and so proved nothing about the session. */ -import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; -import { USER_DB_MIGRATIONS } from "@/db/user-migrations"; -import { SRS_CARD_SELECT } from "@/db/srs-card-columns"; import { DEFAULT_NEW_PER_SESSION, learningCapFor, @@ -35,123 +37,3 @@ describe("resolveNewPerSession", () => { expect(resolveNewPerSession(10_000)).toBe(500); }); }); - -describe("the query that fills the new pool", () => { - function deck(size: number): Database.Database { - const db = new Database(":memory:"); - db.pragma("foreign_keys = OFF"); - for (const sql of USER_DB_MIGRATIONS) { - try { - db.exec(sql); - } catch (err) { - if (!/duplicate column/i.test(String(err))) throw err; - } - } - const insert = db.prepare(`INSERT INTO srs_cards (id, entry_id, list_id, due, stability, - difficulty, elapsed_days, scheduled_days, reps, lapses, state, front_mode, back_mode, - created_at, updated_at) VALUES (?, ?, 'l', 'x', 0, 0, 0, 0, 0, 0, 0, 'kanji', 'english', - 'same-timestamp', 'now')`); - // Every card carries the SAME created_at, the way the backfill writes them. - for (let i = 0; i < size; i++) insert.run(`c${i}`, i); - return db; - } - - const newRows = (db: Database.Database, cap: number) => - db - .prepare( - `${SRS_CARD_SELECT} FROM srs_cards - WHERE list_id = 'l' AND simple_stage IS NULL AND deleted_at IS NULL - ORDER BY created_at ASC, rowid ASC - LIMIT ?`, - ) - .all(cap); - - it("hands the session the cap, not the list", () => { - expect(newRows(deck(8000), DEFAULT_NEW_PER_SESSION)).toHaveLength(10); - }); - - // A guard, not a gate: this passes without the `rowid ASC` tiebreak too, - // because SQLite happens to return rowid order for equal keys. The tiebreak - // is there to make that a promise rather than a coincidence — the backfill - // stamps every card the same `created_at`, and there is no index on it. - it("is stable when every card shares a timestamp", () => { - const db = deck(50); - const first = newRows(db, 10).map((r) => (r as { id: string }).id); - expect(newRows(db, 10).map((r) => (r as { id: string }).id)).toEqual(first); - // and it starts at the beginning of the list, not somewhere in the middle - expect(first[0]).toBe("c0"); - }); - - // What the ephemeral-list exemption binds: SQLite reads a negative LIMIT as - // no limit, and -1 binds as an integer where a JS number past 2^31 would - // reach op-sqlite as a double. - it("gives everything to a session that asks for everything", () => { - expect(newRows(deck(40), UNLIMITED)).toHaveLength(40); - }); -}); - -describe("learningCapFor", () => { - it("is one new card's worth of work per learning card", () => { - expect(learningCapFor(10)).toBe(10 * SIMPLE_SRS_REQUIRED_CORRECT); - expect(learningCapFor(1)).toBe(SIMPLE_SRS_REQUIRED_CORRECT); - }); - - it("passes the ephemeral sentinel through instead of multiplying it", () => { - expect(learningCapFor(UNLIMITED)).toBe(UNLIMITED); - }); -}); - -describe("the query that fills the learning slice of the due pool", () => { - /** `size` cards at stage 0, all due, stamped in ascending `updated_at`. */ - function learningDeck(size: number): Database.Database { - const db = new Database(":memory:"); - db.pragma("foreign_keys = OFF"); - for (const sql of USER_DB_MIGRATIONS) { - try { - db.exec(sql); - } catch (err) { - if (!/duplicate column/i.test(String(err))) throw err; - } - } - const insert = db.prepare(`INSERT INTO srs_cards (id, entry_id, list_id, due, stability, - difficulty, elapsed_days, scheduled_days, reps, lapses, state, front_mode, back_mode, - simple_stage, simple_n, simple_interval, created_at, updated_at) - VALUES (?, ?, 'l', 'x', 0, 0, 0, 0, 14, 2, 0, 'kanji', 'english', 0, 0, 0.3333, - 'created', ?)`); - for (let i = 0; i < size; i++) { - insert.run(`c${i}`, i, `2026-01-${String((i % 28) + 1).padStart(2, "0")}T00:00:00.000Z`); - } - return db; - } - - const learningRows = (db: Database.Database, cap: number) => - db - .prepare( - `${SRS_CARD_SELECT} FROM srs_cards - WHERE list_id = 'l' AND simple_stage = 0 AND simple_n <= ? AND deleted_at IS NULL - ORDER BY updated_at DESC, rowid ASC - LIMIT ?`, - ) - .all(1, cap) as { id: string }[]; - - it("bounds the cards in learning a session shows", () => { - // The uncapped session left thousands of these, every one due, which is the - // queue that came back unchanged every day. Three learning answers are one - // card's worth of work, so the slice is three times the new cap. - expect(learningRows(learningDeck(8000), learningCapFor(DEFAULT_NEW_PER_SESSION))).toHaveLength( - 30, - ); - }); - - it("takes the most recently answered first, so cards in hand get finished", () => { - const rows = learningRows(learningDeck(60), 5); - // c27 carries the latest updated_at (day 28); c55 shares it and loses the - // rowid tiebreak. - expect(rows[0].id).toBe("c27"); - expect(rows.map((r) => r.id)).toContain("c55"); - }); - - it("gives an ephemeral session every learning card", () => { - expect(learningRows(learningDeck(45), learningCapFor(UNLIMITED))).toHaveLength(45); - }); -}); diff --git a/package.json b/package.json index 02762c8..0d3d37a 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "probe:names": "tsx scripts/probe-name-furigana.ts", "check:reading-order": "tsx scripts/reading-order-check.ts", "why:highlight": "tsx scripts/why-highlight.ts", - "proxy:bookmarks": "tsx scripts/make-proxy-bookmarks.ts" + "proxy:bookmarks": "tsx scripts/make-proxy-bookmarks.ts", + "simulate:srs": "tsx scripts/simulate-simple-srs.ts" }, "dependencies": { "@tradersamwise/jiten-reader-react-native": "0.1.0", diff --git a/scripts/simulate-simple-srs.ts b/scripts/simulate-simple-srs.ts new file mode 100644 index 0000000..65cd616 --- /dev/null +++ b/scripts/simulate-simple-srs.ts @@ -0,0 +1,324 @@ +/** + * Every number in docs/simple-srs-decisions.md, regenerated. + * + * yarn simulate:srs # all scenarios + * yarn simulate:srs ratchet # one, by name + * + * This exists because review could not reproduce the tables from their prose + * description: the answer-count spread, the daily budget and the intra-day + * re-queue rule all change the result, and none of them were written down. A + * number nobody else can produce is not a measurement, so the simulation + * ships and the doc quotes its output. + * + * Everything runs through the shipped `nextSimpleReview`, never a restatement + * of the rules — the lapse bug lived in a call site and survived every test + * that reimplemented the arithmetic. + */ + +import { + dateToSrsEpochDays, + nextSimpleReview, + learningCapFor, + UNLIMITED, +} from "../stores/simple-srs"; +import type { SrsCardRow } from "../db/types"; + +const DAY = 86_400_000; +const DECK = 8000; +/** Accuracy on graduated cards, and on cards still in learning. */ +const PASS_GRADUATED = 0.9; +const PASS_LEARNING = 0.8; + +/** Deterministic LCG, so every run of this file prints the same table. */ +function rng(seed: number) { + let s = seed; + return () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; +} + +type Card = SrsCardRow & { id: string }; + +function card(id: string, fields: Partial = {}): Card { + return { + id, + simpleStage: null, + simpleN: null, + simpleInterval: null, + simpleRelearning: 0, + simpleRun: 0, + reps: 0, + lapses: 0, + ...fields, + } as Card; +} + +/** + * One day of study. + * + * The queue is what the session loads: cards in learning (optionally capped) + * ahead of graduated cards due by the cutoff, then unseen cards up to the new + * cap. The learner answers up to `budget` cards; a card that fails or has not + * yet collected three correct answers is pushed back into the same day's queue, + * because the session re-queues it about ten cards away rather than ending. + */ +function studyOneDay( + deck: Card[], + day: number, + t0: number, + budget: number, + newPerDay: number, + learningCap: number, + rand: () => number, +): { shown: number; answered: number } { + const cutoff = dateToSrsEpochDays(new Date(t0 + (day + 1) * DAY)); + const learning = deck.filter((c) => c.simpleStage === 0 && (c.simpleN ?? 0) <= cutoff); + const graduated = deck.filter((c) => c.simpleStage === 1 && (c.simpleN ?? 0) <= cutoff); + const unseen = deck.filter((c) => c.simpleStage == null); + + const queue = [ + ...(learningCap === UNLIMITED ? learning : learning.slice(0, learningCap)), + ...graduated, + ...unseen.slice(0, newPerDay), + ]; + const shown = queue.length; + + let answered = 0; + while (queue.length && answered < budget) { + const c = queue.shift()!; + answered++; + const threshold = c.simpleStage === 1 ? PASS_GRADUATED : PASS_LEARNING; + const decision = nextSimpleReview(c, rand() < threshold ? "pass" : "fail"); + Object.assign(c, decision.updates); + c.reps = (c.reps ?? 0) + 1; + if (decision.updates.simpleRun === 0 && decision.updates.simpleStage === 0) { + c.lapses = (c.lapses ?? 0) + 1; + } + if (decision.graduated) { + c.simpleN = dateToSrsEpochDays(new Date(t0 + day * DAY)) + (c.simpleInterval ?? 0); + } else { + queue.push(c); + } + } + return { shown, answered }; +} + +interface Run { + shown: number[]; + answered: number[]; + graduatedEver: number; +} + +function simulate(opts: { + deck: Card[]; + days: number; + budget: number; + newPerDay: number; + learningCap: number; + seed?: number; +}): Run { + const t0 = Date.now(); + const rand = rng(opts.seed ?? 7); + const shown: number[] = []; + const answered: number[] = []; + const graduated = new Set(); + for (let day = 0; day < opts.days; day++) { + const before = opts.deck.filter((c) => c.simpleStage === 1).map((c) => c.id); + const result = studyOneDay( + opts.deck, + day, + t0, + opts.budget, + opts.newPerDay, + opts.learningCap, + rand, + ); + for (const c of opts.deck) if (c.simpleStage === 1) graduated.add(c.id); + void before; + shown.push(result.shown); + answered.push(result.answered); + } + return { shown, answered, graduatedEver: graduated.size }; +} + +const MARKS = [0, 6, 29, 89, 179, 364]; +const at = (xs: number[]) => MARKS.map((d) => (xs[d] ?? 0).toString().padStart(5)); +const header = "| " + MARKS.map((d) => `d${d + 1}`.padStart(5)).join(" | ") + " |"; + +/** Floored: every card graduated but pinned at the 8-hour floor. */ +const flooredDeck = (interval: (i: number) => number) => + Array.from({ length: DECK }, (_, i) => + card(`f${i}`, { + simpleStage: 1, + simpleN: 0, + simpleInterval: interval(i), + reps: 40, + lapses: 5, + }), + ); + +/** Left in learning by the uncapped session: answered once, never graduated. */ +const learningDeck = () => + Array.from({ length: DECK }, (_, i) => + card(`l${i}`, { simpleStage: 0, simpleN: 0, simpleInterval: 1 / 3, reps: 14, lapses: 2 }), + ); + +const SCENARIOS: Record void> = { + /** The interval a card ends on after N answers, under each lapse rule. */ + ratchet() { + console.log("\n## The ratchet (one card, 20,000 samples, 90% accuracy)\n"); + console.log("| answers | at or below 1 day, bugged | with the fix |"); + console.log("| --- | --- | --- |"); + for (const answers of [20, 40, 45, 50]) { + const counts = [0, 0]; + for (const [slot, bugged] of [ + [0, true], + [1, false], + ] as const) { + for (let seed = 1; seed <= 20_000; seed++) { + const rand = rng(seed * 7919 + answers); + const c = card("c"); + for (let i = 0; i < answers; i++) { + const pass = rand() < 0.9; + // The bug: anything that ever failed counts as relearning, forever. + const seen = bugged ? { ...c, simpleRelearning: (c.lapses ?? 0) > 0 ? 1 : 0 } : c; + const d = nextSimpleReview(seen as SrsCardRow, pass ? "pass" : "fail"); + Object.assign(c, d.updates); + c.reps = (c.reps ?? 0) + 1; + if (!pass) c.lapses = (c.lapses ?? 0) + 1; + } + if ((c.simpleInterval ?? 0) <= 1) counts[slot]++; + } + } + const pct = (n: number) => `${((100 * n) / 20_000).toFixed(1)}%`; + console.log(`| ${answers} | ${pct(counts[0])} | ${pct(counts[1])} |`); + } + }, + + /** Does a flattened deck drain on its own, and does a repair help? */ + drain() { + console.log("\n## Draining a deck of 8,000 cards pinned at the floor\n"); + console.log("| answers/day | repair | " + header.slice(2)); + console.log("| --- | --- |" + MARKS.map(() => " --- |").join("")); + for (const budget of [1000, 200, 150]) { + for (const [label, interval] of [ + ["none", () => 1 / 3], + ["spread 2-30d", (i: number) => 2 + (i % 29)], + ["flat 7d", () => 7], + ] as const) { + const run = simulate({ + deck: flooredDeck(interval), + days: 365, + budget, + newPerDay: 0, + learningCap: UNLIMITED, + }); + console.log(`| ${budget} | ${label} | ` + at(run.shown).join(" | ") + " |"); + } + } + }, + + /** Which rows a repair can tell apart from honest ones. */ + discriminate() { + console.log("\n## Telling a ratchet victim from a card honestly at the floor\n"); + // Reps only varies if cards have lived different lengths, so the sample is + // 400 seeds x nine answer counts = 3,600 cards, under each rule. + const ANSWER_COUNTS = [4, 8, 12, 16, 24, 32, 48, 64, 96]; + const population = (accuracy: number, bugged: boolean) => { + const out: Card[] = []; + for (let seed = 1; seed <= 400; seed++) { + for (const answers of ANSWER_COUNTS) { + const rand = rng(seed * 7919 + answers); + const c = card(`${seed}-${answers}`); + for (let i = 0; i < answers; i++) { + const pass = rand() < accuracy; + const seen = bugged ? { ...c, simpleRelearning: (c.lapses ?? 0) > 0 ? 1 : 0 } : c; + const d = nextSimpleReview(seen as SrsCardRow, pass ? "pass" : "fail"); + Object.assign(c, d.updates); + c.reps = (c.reps ?? 0) + 1; + if (!pass) c.lapses = (c.lapses ?? 0) + 1; + } + out.push(c); + } + } + return out; + }; + const floored = (c: Card) => (c.simpleInterval ?? 0) <= 0.34; + console.log("| accuracy | clause | victims caught | honest lifted |"); + console.log("| --- | --- | --- | --- |"); + for (const accuracy of [0.9, 0.75]) { + const victims = population(accuracy, true).filter(floored); + const honest = population(accuracy, false).filter(floored); + for (const [label, test] of [ + ["reps >= 3", (c: Card) => (c.reps ?? 0) >= 3], + ["reps >= 12", (c: Card) => (c.reps ?? 0) >= 12], + ["reps >= 20", (c: Card) => (c.reps ?? 0) >= 20], + ["lapses >= 3", (c: Card) => (c.lapses ?? 0) >= 3], + ] as const) { + const hit = (c: Card) => floored(c) && (c.lapses ?? 0) > 0 && test(c); + console.log( + `| ${accuracy} | interval <= 0.34, ${label} | ` + + `${victims.filter(hit).length} / ${victims.length} | ` + + `${honest.filter(hit).length} / ${honest.length} |`, + ); + } + const graduated = victims.filter((c) => c.simpleStage === 1); + const shipped = graduated.filter((c) => (c.reps ?? 0) >= 12 && (c.lapses ?? 0) > 0); + console.log( + `| ${accuracy} | **the shipped clause** (also \`simple_stage = 1\`) | ` + + `${shipped.length} / ${victims.length} | — |`, + ); + console.log( + `| ${accuracy} | *of which graduated* | ${graduated.length} / ${victims.length} | — |`, + ); + } + }, + + /** What capping the learning slice costs and saves. */ + learningCap() { + console.log("\n## Capping the learning slice of a legacy deck\n"); + console.log("| learning cap | answers/day | " + header.slice(2) + " graduated in a year |"); + console.log("| --- | --- |" + MARKS.map(() => " --- |").join("") + " --- |"); + for (const budget of [200, 400]) { + for (const cap of [UNLIMITED, learningCapFor(10), 10]) { + const run = simulate({ + deck: learningDeck(), + days: 365, + budget, + newPerDay: 0, + learningCap: cap, + }); + const label = cap === UNLIMITED ? "none" : String(cap); + console.log( + `| ${label} | ${budget} | ` + at(run.shown).join(" | ") + ` | ${run.graduatedEver} |`, + ); + } + } + }, + + /** The steady state the fixed engine settles into on a fresh deck. */ + steady() { + console.log("\n## A fresh 8,000-card deck, ten new a day, fixed engine\n"); + const deck = Array.from({ length: DECK }, (_, i) => card(`n${i}`)); + const run = simulate({ + deck, + days: 365, + budget: 1000, + newPerDay: 10, + learningCap: learningCapFor(10), + }); + console.log("| | " + header.slice(2)); + console.log("| --- |" + MARKS.map(() => " --- |").join("")); + console.log("| shown | " + at(run.shown).join(" | ") + " |"); + console.log("| answered | " + at(run.answered).join(" | ") + " |"); + }, +}; + +const requested = process.argv.slice(2).filter((a) => !a.startsWith("-")); +const names = requested.length ? requested : Object.keys(SCENARIOS); +for (const name of names) { + const scenario = SCENARIOS[name]; + if (!scenario) { + console.error(`unknown scenario: ${name} (have: ${Object.keys(SCENARIOS).join(", ")})`); + process.exit(1); + } + scenario(); +} From 646c52036ee40e9d41e2c64ca4842e7ac5123e29 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 17:58:45 +0800 Subject: [PATCH 09/12] Address the second round of adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviews, aimed at the previous round's fix rather than the original bug. Two blockers and one finding that inverts a conclusion. **The learning slice was sized from the residual allowance, so a mid-session settings save could end the session.** `learningCapFor(newAllowance)` means a session that has already introduced its ten new cards has a learning cap of zero — and since a changed cap now rebuilds the queue, saving settings emptied both slices and sent a learner with seventeen unseen and three learning cards to the done screen. The learning slice is sized from the list's cap and the allowance only limits unseen cards. **The allowance double-counted on every reload.** It counted rows loaded, but a reload re-queries the unseen cards still sitting unanswered in the pool: cap ten, answer one, raise the cap, and the counter read twenty while eleven cards had ever been offered — after which the session owed new cards it would not give. It counts an unseen card's first answer instead. **Restoring one table CASCADE-deleted its neighbours.** `importBackup` used `INSERT OR REPLACE`, which is a DELETE followed by an INSERT, so restoring `lists` fired every foreign key hanging off it: the list came back with zero words and its cards orphaned. A partial backup is the normal case — the new column fallback makes it more so — so this was reachable by exactly the path the last round added. It is an upsert now, and a row with no primary key is skipped rather than written under a NULL one. **Dropping the `updated_at` bump traded one clobber for another**, and this was the sharpest finding of the round. Proven with the real sync: a repaired row never pushes (`WHERE updated_at > lastPushedAt`), so an old build can push its ratcheted `0.33` back, and since the repair was keyed to `PRAGMA user_version` it never ran again — the repair was gone for good. So it is not a migration any more. `db/simple-repair.ts` runs on every launch, which is free because the clause is self-limiting: a repaired row no longer matches it. Neither device needs to push anything for the deck to end up repaired. **And the learning cap's cost was measured against the wrong column.** The last round said the cap costs thousands of words, counting cards that touched stage 1. Uncapped, 8,000 do — and only 2,665 are still graduated at the end, because an uncapped deck churns: a card graduates at 0.633 days, returns the same day, fails, and sits back at the floor. Capped, almost everything touched is kept. **On words actually retained the cap wins**, 3,008 against 2,665, while showing 100 cards a day instead of 8,000. The ×3 is what keeps it from starving a learner who does more: at 400 answers a day a cap of thirty keeps 7,005 against 3,634 for a cap of ten. `yarn simulate:srs` now reports both counts, takes `--seed=N`, and says that a four-digit cell is one sample. Also from review: - `attemptBackup`'s column fallback reported a locked or corrupt table as a clean rescue. It now falls back only for a missing column. - The export's SELECT and the restore's column list had drifted: `books.read_complete` was exported and then dropped on restore, and `user_kanji_notes.updated_at` was in neither, so a restored mnemonic was invisible to a push that selects `WHERE updated_at > ?`. Both fixed, and two tests now compare the lists in each direction so they cannot drift again. - The graduated slice asks for `simple_stage != 0` rather than `= 1`: splitting the old `IS NOT NULL` query in two would otherwise drop a row with any other stage from every slice and strand it forever. - Four mutations the queue test missed are now caught — deleting the unseen `ORDER BY`, `<` for `<=` on the cutoff, `= 1` for `!= 0`, and sizing the learning slice from the allowance. The fixtures were agreeing with rowid order, which is why the first two passed. The `rowid ASC` tiebreak is labelled a guard, not a gate: SQLite returns rowid order for equal keys anyway, so no observation can hold it. - A shared list with no cap stays NULL rather than being pinned to ten; NULL is the meaningful "unset" state the settings input reads as a placeholder. - `db/sql-arity.test.ts` now prints its own coverage — 191 of 337 call sites — because that figure has been quoted wrong twice. Gates: typecheck clean, lint 0 errors, 2,560 tests passing, none skipped. Co-Authored-By: Claude Opus 5 (1M context) --- app/(tabs)/lists/study.tsx | 24 +-- db/simple-queue.test.ts | 63 ++++++-- db/simple-queue.ts | 31 +++- ...igration.test.ts => simple-repair.test.ts} | 62 ++++++-- db/simple-repair.ts | 53 +++++++ db/sql-arity.test.ts | 40 +++++ db/user-migrations.ts | 32 +--- db/user-provider.native.tsx | 9 +- db/user-provider.web.tsx | 6 + docs/simple-srs-decisions.md | 77 ++++++---- lib/data-backup.test.ts | 145 +++++++++++++++++- lib/data-backup.ts | 73 ++++++--- lib/list-transfer.test.ts | 13 +- lib/list-transfer.ts | 8 +- scripts/simulate-simple-srs.ts | 40 ++++- 15 files changed, 539 insertions(+), 137 deletions(-) rename db/{simple-repair-migration.test.ts => simple-repair.test.ts} (79%) create mode 100644 db/simple-repair.ts diff --git a/app/(tabs)/lists/study.tsx b/app/(tabs)/lists/study.tsx index 6060225..3f723f7 100644 --- a/app/(tabs)/lists/study.tsx +++ b/app/(tabs)/lists/study.tsx @@ -1065,8 +1065,8 @@ function StudyScreen() { type SessionPhase = "studying" | "done" | "waiting"; const [sessionPhase, setSessionPhase] = useState("studying"); const reviewedSrsIdsRef = useRef>(new Set()); - /** Unseen cards this session has already introduced, across reloads. */ - const newIntroducedRef = useRef(0); + /** Unseen cards this session has answered, which spend its allowance. */ + const newAnsweredRef = useRef(0); const [reviewedCount, setReviewedCount] = useState(0); const [settingsVisible, setSettingsVisible] = useState(false); const [statsVisible, setStatsVisible] = useState(false); @@ -1337,7 +1337,7 @@ function StudyScreen() { // A different list or scope is a new session; "Continue Studying" is not, // and must keep both so it neither re-serves answered cards nor hands out // another full allowance of new ones. - newIntroducedRef.current = 0; + newAnsweredRef.current = 0; reviewedSrsIdsRef.current.clear(); loadQueue(); }, [dictDb, userDb, list?.id, scope]); @@ -1645,21 +1645,22 @@ function StudyScreen() { ); newCap = resolveNewPerSession(capRow?.new_per_session); } - // "Continue Studying" calls this again, so the cap is topped up to - // the session's allowance rather than granted afresh — otherwise ten - // taps introduce a hundred new cards — and the cards just answered - // are held back, since a graduated card's 0.633-day interval falls - // inside today's cutoff and it would come straight back. + // "Continue Studying" calls this again, so the allowance is what is + // LEFT of the cap rather than a fresh one — otherwise ten taps + // introduce a hundred new cards. It counts cards the session has + // answered, not cards it has loaded: a reload re-queries the unseen + // cards still sitting unanswered in the pool, and counting those + // again left a session unable to offer new cards it still owed. const newAllowance = - newCap === UNLIMITED ? UNLIMITED : Math.max(0, newCap - newIntroducedRef.current); + newCap === UNLIMITED ? UNLIMITED : Math.max(0, newCap - newAnsweredRef.current); const { dueRows, newRows } = await loadSimpleQueuePools({ query: (sql, args) => userDb.getAllAsync(sql, args), scope, dueCutoffDays, + newCap, newAllowance, excludeIds: reviewedSrsIdsRef.current, }); - newIntroducedRef.current += newRows.length; if (dueRows.length > 0 || newRows.length > 0) { const dueItems = await resolveCards(dueRows); @@ -2240,6 +2241,9 @@ function StudyScreen() { // The correct-in-a-row count rides in `updates` and is persisted — it used // to live in a ref that died with the screen, so a card answered twice and // left came back to a count of zero and stayed due forever. + // An unseen card's first answer is what spends the session's allowance. + if (card.simpleStage == null) newAnsweredRef.current += 1; + const decision = nextSimpleReview(card, action); const updates = decision.updates; const graduated = decision.graduated; diff --git a/db/simple-queue.test.ts b/db/simple-queue.test.ts index 6540988..e7f2016 100644 --- a/db/simple-queue.test.ts +++ b/db/simple-queue.test.ts @@ -24,6 +24,8 @@ type Card = { n: number | null; /** ISO timestamp; `updated_at` is the learning slice's ordering key. */ updatedAt?: string; + /** Deliberately set against rowid order, so the ORDER BY is what sorts. */ + createdAt?: string; deleted?: boolean; }; @@ -44,7 +46,7 @@ function insert(c: Card) { c.id, c.stage, c.n, - `created-${c.id}`, + c.createdAt ?? `created-${c.id}`, c.updatedAt ?? "2026-01-01T00:00:00.000Z", c.deleted ? "gone" : null, ); @@ -62,18 +64,26 @@ beforeEach(() => { } }); -const pools = (newAllowance: number, excludeIds?: Set) => - loadSimpleQueuePools({ query, scope, dueCutoffDays: CUTOFF, newAllowance, excludeIds }); +/** `newCap` is the list's setting; `allowance` is what the session has left. */ +const pools = (newCap: number, excludeIds?: Set, newAllowance?: number) => + loadSimpleQueuePools({ query, scope, dueCutoffDays: CUTOFF, newCap, newAllowance, excludeIds }); const ids = (rows: SrsCardRow[]) => rows.map((r) => r.id); describe("the new-card cap", () => { it("introduces the allowance, not the list", async () => { for (let i = 0; i < 8000; i++) insert({ id: `n${i}`, stage: null, n: null }); - const { newRows } = await pools(DEFAULT_NEW_PER_SESSION); - expect(newRows).toHaveLength(10); - // Oldest first, so a session works through the list in order. - expect(ids(newRows)[0]).toBe("n0"); + expect((await pools(DEFAULT_NEW_PER_SESSION)).newRows).toHaveLength(10); + }); + + it("introduces the oldest first, whatever order the rows were written in", async () => { + // created_at runs against rowid here on purpose: with the two agreeing, + // deleting the ORDER BY entirely still passed. + for (let i = 0; i < 20; i++) { + const day = String(20 - i).padStart(2, "0"); + insert({ id: `n${i}`, stage: null, n: null, createdAt: `2026-01-${day}` }); + } + expect(ids((await pools(3)).newRows)).toEqual(["n19", "n18", "n17"]); }); it("gives an ephemeral session every unseen card", async () => { @@ -83,7 +93,20 @@ describe("the new-card cap", () => { it("introduces nothing once the session has spent its allowance", async () => { for (let i = 0; i < 50; i++) insert({ id: `n${i}`, stage: null, n: null }); - expect((await pools(0)).newRows).toHaveLength(0); + expect((await pools(10, undefined, 0)).newRows).toHaveLength(0); + }); + + it("keeps serving the cards in learning after the allowance is spent", async () => { + // The learning slice is sized from the list's cap, never from what is left + // of it: keying it off the allowance emptied the whole queue once the + // session had introduced its new cards, and a mid-session settings save — + // which now rebuilds the queue — sent the session to the done screen with + // cards still owed. + for (let i = 0; i < 20; i++) insert({ id: `l${i}`, stage: 0, n: 0 }); + for (let i = 0; i < 20; i++) insert({ id: `n${i}`, stage: null, n: null }); + const { dueRows, newRows } = await pools(10, undefined, 0); + expect(newRows).toHaveLength(0); + expect(dueRows).toHaveLength(20); }); }); @@ -95,6 +118,17 @@ describe("the learning slice", () => { expect((await pools(DEFAULT_NEW_PER_SESSION)).dueRows).toHaveLength(30); }); + // A guard, not a gate: this passes without the `rowid ASC` tiebreak too, + // because SQLite happens to return rowid order for equal keys. The tiebreak + // makes that a promise rather than a coincidence — the backfill stamps every + // card the same `updated_at` and there is no index on it. + it("breaks a shared timestamp on rowid, so the slice is the same every build", async () => { + for (let i = 0; i < 40; i++) insert({ id: `l${i}`, stage: 0, n: 0, updatedAt: "same" }); + const first = ids((await pools(DEFAULT_NEW_PER_SESSION)).dueRows); + expect(first).toEqual(ids((await pools(DEFAULT_NEW_PER_SESSION)).dueRows)); + expect(first[0]).toBe("l0"); + }); + it("takes the most recently answered first, so cards in hand get finished", async () => { insert({ id: "old", stage: 0, n: 0, updatedAt: "2026-01-01T00:00:00.000Z" }); insert({ id: "newest", stage: 0, n: 0, updatedAt: "2026-06-01T00:00:00.000Z" }); @@ -121,10 +155,19 @@ describe("the graduated slice", () => { expect((await pools(DEFAULT_NEW_PER_SESSION)).dueRows).toHaveLength(500); }); - it("leaves out a card due after the cutoff", async () => { + it("includes a card due exactly on the cutoff, and excludes one past it", async () => { insert({ id: "today", stage: 1, n: CUTOFF - 1 }); + insert({ id: "on-the-cutoff", stage: 1, n: CUTOFF }); insert({ id: "tomorrow", stage: 1, n: CUTOFF + 1 }); - expect(ids((await pools(1)).dueRows)).toEqual(["today"]); + expect(ids((await pools(1)).dueRows)).toEqual(["today", "on-the-cutoff"]); + }); + + it("still serves a card whose stage is outside the engine's 0 and 1", async () => { + // Splitting the old `simple_stage IS NOT NULL` query in two could drop such + // a row from every slice and strand it forever, so the graduated slice asks + // for `!= 0` rather than `= 1`. + insert({ id: "odd", stage: 2, n: 0 }); + expect(ids((await pools(1)).dueRows)).toEqual(["odd"]); }); it("comes back in due order, oldest first", async () => { diff --git a/db/simple-queue.ts b/db/simple-queue.ts index a6eb4c6..3912c3b 100644 --- a/db/simple-queue.ts +++ b/db/simple-queue.ts @@ -27,8 +27,19 @@ export interface SimpleQueueRequest { scope: SimpleQueueScope; /** End of the logical day, in the epoch `simple_n` is stored in. */ dueCutoffDays: number; - /** Unseen cards this session may still introduce, or `UNLIMITED`. */ - newAllowance: number; + /** + * The list's `new_per_session`, or `UNLIMITED`. The learning slice is sized + * from THIS, never from the allowance below: a session that has already + * introduced its new cards has no allowance left, and keying the learning + * slice off it emptied the whole queue and sent the session to the done + * screen with cards still owed. + */ + newCap: number; + /** + * Unseen cards this session may still introduce — the cap less the ones it + * has already answered. Defaults to `newCap`. + */ + newAllowance?: number; /** Cards already answered in this session, which must not come back. */ excludeIds?: ReadonlySet; } @@ -39,10 +50,17 @@ export interface SimpleQueuePools { newRows: SrsCardRow[]; } -/** Graduated and due today. Never capped — this is the real review load. */ +/** + * Graduated and due today. Never capped — this is the real review load. + * + * `!= 0` rather than `= 1` so a stage outside the engine's {0, 1} can still be + * studied: splitting the old `IS NOT NULL` query in two would otherwise drop + * such a row from every slice and strand it forever. + */ export const graduatedDueSql = (scopeClause: string) => `${SRS_CARD_SELECT} FROM srs_cards - WHERE ${scopeClause} AND simple_stage = 1 AND simple_n <= ? AND deleted_at IS NULL + WHERE ${scopeClause} AND simple_stage IS NOT NULL AND simple_stage != 0 + AND simple_n <= ? AND deleted_at IS NULL ORDER BY simple_n ASC`; /** @@ -87,11 +105,12 @@ export async function loadSimpleQueuePools({ query, scope, dueCutoffDays, - newAllowance, + newCap, + newAllowance = newCap, excludeIds, }: SimpleQueueRequest): Promise { const excluded = excludeIds?.size ?? 0; - const learningCap = learningCapFor(newAllowance); + const learningCap = learningCapFor(newCap); const graduated = await query(graduatedDueSql(scope.clause), [...scope.args, dueCutoffDays]); const learning = await query(learningDueSql(scope.clause), [ diff --git a/db/simple-repair-migration.test.ts b/db/simple-repair.test.ts similarity index 79% rename from db/simple-repair-migration.test.ts rename to db/simple-repair.test.ts index 3d18cd3..cdcb0e2 100644 --- a/db/simple-repair-migration.test.ts +++ b/db/simple-repair.test.ts @@ -1,5 +1,5 @@ /** - * The one-time repair of decks the lapse bug flattened. + * The repair of decks the lapse bug flattened. * * Fixing the rule does not fix the stored intervals: a card the bug ratcheted * is sitting at the 8-hour floor and comes back every day forever. Measured at @@ -11,22 +11,20 @@ * interval, an FSRS-only card, and any learning card — a card at stage 0 sits * at 1/3 because that is where new cards start, and with the whole list loading * as new those are most of the stage-0 population. + * + * It runs on every launch rather than once, so running it twice must be a + * no-op — that is what lets a repair survive an old build pushing a ratcheted + * value back. */ import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; import { USER_DB_MIGRATIONS } from "./user-migrations"; +import { REPAIR_RATCHETED_INTERVALS, repairRatchetedIntervals } from "./simple-repair"; +import { createTestDb } from "@/test/test-db"; -const SPREADS = USER_DB_MIGRATIONS.filter((sql) => - /simple_interval = \d+ \+ \(rowid % \d+\)/.test(sql), -); -const REPAIR_AT = USER_DB_MIGRATIONS.indexOf(SPREADS[0]); -if (SPREADS.length !== 1) { - // A second spreading migration would be silently untested otherwise. - throw new Error(`expected exactly one interval repair migration, found ${SPREADS.length}`); -} -const BEFORE = USER_DB_MIGRATIONS.slice(0, REPAIR_AT); -const REPAIR = USER_DB_MIGRATIONS.slice(REPAIR_AT, REPAIR_AT + 1); +const BEFORE = USER_DB_MIGRATIONS; +const REPAIR = [REPAIR_RATCHETED_INTERVALS]; function apply(db: Database.Database, statements: readonly string[]) { for (const sql of statements) { @@ -205,10 +203,50 @@ describe("the interval repair", () => { expect(spread.hi).toBe(30); }); - it("runs once: a repaired row no longer matches, whichever device replays it", () => { + it("is a no-op the second time, so it can run on every launch", () => { const db = repaired(); const before = row(db, "ratcheted"); apply(db, REPAIR); expect(row(db, "ratcheted")).toEqual(before); }); }); + +describe("repairRatchetedIntervals", () => { + it("reports how many cards it lifted, and lifts none twice", async () => { + const db = createTestDb(); + const now = "2026-04-05T00:00:00.000Z"; + for (const [id, interval, reps] of [ + ["ratcheted", 1 / 3, 40], + ["honest", 1 / 3, 4], + ["healthy", 56.61, 40], + ] as const) { + await db.runAsync( + `INSERT INTO srs_cards (id, entry_id, list_id, due, stability, difficulty, elapsed_days, + scheduled_days, reps, lapses, state, simple_stage, simple_n, simple_interval, + created_at, updated_at) + VALUES (?, 1, NULL, 'x', 0, 0, 0, 0, ?, 5, 0, 1, 0, ?, ?, 'OLD')`, + [id, reps, interval, now], + ); + } + + expect(await repairRatchetedIntervals(db)).toBe(1); + expect(await repairRatchetedIntervals(db)).toBe(0); + + const row = await db.getFirstAsync<{ simple_interval: number; updated_at: string }>( + "SELECT simple_interval, updated_at FROM srs_cards WHERE id = 'ratcheted'", + ); + expect(row!.simple_interval).toBeGreaterThanOrEqual(2); + // Never bumped: a stale device would otherwise win last-write-wins and push + // months-old values over reviews done on another device. + expect(row!.updated_at).toBe("OLD"); + }); + + it("survives a database that cannot run it, rather than failing the launch", async () => { + const broken = { + runAsync: async () => { + throw new Error("no such table: srs_cards"); + }, + } as unknown as Parameters[0]; + await expect(repairRatchetedIntervals(broken)).resolves.toBe(0); + }); +}); diff --git a/db/simple-repair.ts b/db/simple-repair.ts new file mode 100644 index 0000000..f216043 --- /dev/null +++ b/db/simple-repair.ts @@ -0,0 +1,53 @@ +/** + * Lift simple-SRS cards the lapse bug ratcheted to the 8-hour floor. + * + * This runs on every launch rather than once as a migration, and both halves + * of that matter: + * + * - It cannot bump `updated_at`. A device opened for the first time in months + * would become the newest writer of every card it touched, and sync skips the + * remote row when `localTs >= remoteTs` — so it would push months-old values + * over reviews done elsewhere. + * - Which means the repair never travels, so a device still on an old build can + * push a ratcheted `simple_interval` back. As a one-shot migration keyed to + * `PRAGMA user_version` the repair was then gone for good. Run every launch, + * it simply happens again. + * + * The clause is self-limiting: a repaired row no longer matches it, so this is + * one indexed UPDATE that changes nothing on a healthy deck. + */ + +import type { WrappedUserDb } from "./user-db"; + +/** + * `reps >= 12` is the discriminator, measured rather than guessed: a card the + * bug ratcheted down from 56 days has a median of 32 answers behind it, while + * one honestly at the floor has 4. `lapses >= 3` was the obvious guard and is + * wrong — under the bug a single lapse starts the ratchet, so it misses a third + * of them and lifts more honest cards. `yarn simulate:srs discriminate`. + * + * `simple_stage = 1` only: a learning card sits at 1/3 because that is where + * new cards START, and with the whole list loading as new those are most of the + * stage-0 population. They are bounded by the learning cap instead. + * + * The spread starts at 2 so a repaired row can never match again, which is what + * makes running this repeatedly free. + */ +export const REPAIR_RATCHETED_INTERVALS = `UPDATE srs_cards SET + simple_interval = 2 + (rowid % 29), + simple_n = (julianday('now') - julianday('2001-01-01')) + 2 + (rowid % 29) + WHERE simple_stage = 1 AND simple_interval <= 0.34 AND lapses > 0 AND reps >= 12 + AND deleted_at IS NULL`; + +/** Returns how many cards were lifted, for the log. */ +export async function repairRatchetedIntervals(db: WrappedUserDb): Promise { + try { + const result = await db.runAsync(REPAIR_RATCHETED_INTERVALS, []); + return result?.changes ?? 0; + } catch (err) { + // A deck left at the floor is bad; a launch that fails because of it is + // worse. The next launch tries again. + console.warn("[SimpleSRS] Interval repair skipped:", err); + return 0; + } +} diff --git a/db/sql-arity.test.ts b/db/sql-arity.test.ts index 8c1aeba..3af8eeb 100644 --- a/db/sql-arity.test.ts +++ b/db/sql-arity.test.ts @@ -57,6 +57,38 @@ function countPlaceholders(raw: string): number { type Mismatch = { file: string; line: number; sql: number; args: number }; +/** Call sites this gate can check, against every call site there is. */ +export function coverage(): { checked: number; total: number } { + let checked = 0; + let total = 0; + for (const file of sourceFiles()) { + const text = readFileSync(join(REPO_ROOT, file), "utf8"); + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + if (QUERY_METHODS.has(node.expression.name.text)) { + total++; + const [sqlArg, argsArg] = node.arguments; + const literal = + sqlArg && + (ts.isStringLiteralLike(sqlArg) || ts.isNoSubstitutionTemplateLiteral(sqlArg)); + if ( + literal && + argsArg && + ts.isArrayLiteralExpression(argsArg) && + !argsArg.elements.some((e) => ts.isSpreadElement(e)) + ) { + checked++; + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); + } + return { checked, total }; +} + function mismatchesIn(file: string): Mismatch[] { const text = readFileSync(join(REPO_ROOT, file), "utf8"); const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); @@ -110,6 +142,14 @@ describe("SQL placeholder arity", () => { ).toEqual([]); }); + it("reports how much of the codebase it can actually see", () => { + // So a passing run is never read as "every query is checked". Quoting this + // share anywhere means running this test; it has been quoted wrong twice. + const { checked, total } = coverage(); + expect(total).toBeGreaterThan(checked); + console.log(`[sql-arity] checks ${checked} of ${total} query call sites`); + }); + it("counts placeholders outside quoted literals and comments only", () => { expect(countPlaceholders("SET a = ?, b = ? WHERE id = ?")).toBe(3); expect(countPlaceholders("SET note = 'what? why?' WHERE id = ?")).toBe(1); diff --git a/db/user-migrations.ts b/db/user-migrations.ts index d873fd4..291881c 100644 --- a/db/user-migrations.ts +++ b/db/user-migrations.ts @@ -277,30 +277,10 @@ export const USER_DB_MIGRATIONS = [ // card needs, and the same queue came back every day. Midori, which this // engine clones, mixes in about ten. `ALTER TABLE lists ADD COLUMN new_per_session INTEGER DEFAULT 10`, - // One-time repair of the decks the lifetime-lapse bug flattened. Fixing the - // rule does not fix the stored intervals: a ratcheted card sits at the 8-hour - // floor and returns every day, and at 200 answers a day 3,579 of 8,000 such - // cards are still queued a year later (`yarn simulate:srs drain`). Spreading them over the next month - // drains the backlog instead — the numbers, and what was rejected, are in - // docs/simple-srs-decisions.md. - // - // `reps >= 12` is the discriminator, measured rather than guessed: a card the - // bug ratcheted down from 56 days has a median of 32 answers behind it, while - // one honestly at the floor has 4. `lapses >= 3` was the obvious guard and is - // wrong — under the bug a single lapse starts the ratchet, so it would miss a - // third of them. `2 +` makes the repair idempotent, which matters because - // every device runs it against its own rowids. - // - // `simple_stage = 1` only. A learning card sits at 1/3 because that is where - // new cards START, and with the whole list loading as new, those are most of - // the stage-0 population — repairing them would hand thousands of unlearned - // words a fortnight. - // `updated_at` is left alone, as in the backfill above: every device repairs - // its own rows, and a stale device that bumped them would push months-old - // values over reviews done on another device. - `UPDATE srs_cards SET - simple_interval = 2 + (rowid % 29), - simple_n = (julianday('now') - julianday('2001-01-01')) + 2 + (rowid % 29) - WHERE simple_stage = 1 AND simple_interval <= 0.34 AND lapses > 0 AND reps >= 12 - AND deleted_at IS NULL`, ]; + +// The repair of already-ratcheted intervals is deliberately NOT here: a +// migration runs once, keyed to `PRAGMA user_version`, and a device still on an +// old build can push a ratcheted value back afterwards, at which point a +// one-shot repair is gone for good. It lives in `db/simple-repair.ts` and runs +// on every launch instead. diff --git a/db/user-provider.native.tsx b/db/user-provider.native.tsx index c84ed30..0703cdc 100644 --- a/db/user-provider.native.tsx +++ b/db/user-provider.native.tsx @@ -3,6 +3,7 @@ import { open } from "@op-engineering/op-sqlite"; import type { DB } from "@op-engineering/op-sqlite"; import { wrapUserDb, type WrappedUserDb } from "./user-db"; import { USER_DB_MIGRATIONS } from "./user-migrations"; +import { repairRatchetedIntervals } from "./simple-repair"; import { migrateLegacyMnemonics } from "./mnemonic-migration"; import { makeDefaultListId } from "@/lib/seed-default-lists"; import { cleanupOrphanedSmartLists } from "@/lib/smart-review"; @@ -64,8 +65,14 @@ export function UserDatabaseProvider({ console.log(`[UserDB Native] Migrated to version ${USER_DB_MIGRATIONS.length}`); } - // Programmatic migration: rename random default IDs → deterministic slugs const wrapped = wrapUserDb(db, { shouldNotifyError: () => live.current }); + + // Every launch, not once: see db/simple-repair.ts for why a one-shot + // migration loses the repair the first time an old build pushes. + const lifted = await repairRatchetedIntervals(wrapped); + if (lifted > 0) console.log(`[UserDB Native] Lifted ${lifted} ratcheted cards`); + + // Programmatic migration: rename random default IDs → deterministic slugs const migrated = await wrapped.getFirstAsync<{ value: string }>( "SELECT value FROM app_flags WHERE key = 'default_ids_migrated'", ); diff --git a/db/user-provider.web.tsx b/db/user-provider.web.tsx index c63b7aa..78b3687 100644 --- a/db/user-provider.web.tsx +++ b/db/user-provider.web.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback, use import * as SQLite from "expo-sqlite"; import type { WrappedUserDb } from "./user-db"; import { USER_DB_MIGRATIONS } from "./user-migrations"; +import { repairRatchetedIntervals } from "./simple-repair"; import { migrateLegacyMnemonics } from "./mnemonic-migration"; import { makeDefaultListId } from "@/lib/seed-default-lists"; import { cleanupOrphanedSmartLists } from "@/lib/smart-review"; @@ -109,6 +110,11 @@ async function openAndMigrateUserDb(): Promise<{ sync: () => {}, }; + // Every launch, not once: see db/simple-repair.ts for why a one-shot + // migration loses the repair the first time an old build pushes. + const lifted = await repairRatchetedIntervals(wrapped); + if (lifted > 0) console.log(`[UserDB Web] Lifted ${lifted} ratcheted cards`); + // Programmatic migration: rename random default IDs → deterministic slugs // Disable FKs during migration — UPDATE on PK internally does DELETE+INSERT // which triggers CASCADE and destroys the FK references we just updated. diff --git a/docs/simple-srs-decisions.md b/docs/simple-srs-decisions.md index 489ffc7..5096cfd 100644 --- a/docs/simple-srs-decisions.md +++ b/docs/simple-srs-decisions.md @@ -34,13 +34,15 @@ at 79 cards shown on d30 and 117 on d365, with 139 and 227 answers given. It doe instead of 1.9. A card climbed to 56 days, failed once, and then ratcheted 28 → 14 → 7 → 3.5 → … to the 8-hour floor, where it was due every day for the rest of its life. At 90% accuracy, over 20,000 sampled cards, **92% sit at or below a one-day interval after 40 -answers** and 96% after 45; under the corrected rule it is 0.1% and then none. (An earlier +answers** and 96% after 45; under the corrected rule it is 0.1%, and 0.01% at 45. (An earlier version of this line said 96% at 40, from a 400-card sample — `yarn simulate:srs ratchet`.) The flag is now `simple_relearning`, a column, set on the failure and cleared by the re-graduation that pays for it. Midori's own file is the check — 2,450 cards with a median interval of 37 days, 187 at the 365-day cap and 107 below a day, which is the shape the -corrected rule produces and nothing like what the shipped one did. +corrected rule produces and nothing like what the shipped one did. (Measured from Sam's own +Midori export, which is not in this repo, so that one figure is the only number here +`yarn simulate:srs` cannot reproduce.) ## The correct-in-a-row count lives on the card (2026-10-13) @@ -74,16 +76,20 @@ every run driven by the shipped `nextSimpleReview`: | answers/day | repair | d1 | d7 | d30 | d90 | d180 | d365 | | ----------- | ------------ | ---- | ---- | ---- | ---- | ---- | ---- | | 1000 | none | 8000 | 7338 | 5270 | 1328 | 81 | 18 | -| 1000 | spread 2–30d | 8000 | 3607 | 243 | 116 | 64 | 58 | -| 200 | none | 8000 | 7863 | 7462 | 6676 | 5552 | 3579 | -| 200 | spread 2–30d | 8000 | 7106 | 5311 | 3431 | 1166 | 47 | -| 200 | flat 7d | 8000 | 7072 | 6094 | 4860 | 2272 | 252 | -| 150 | none | 8000 | 7900 | 7581 | 6980 | 6164 | 4664 | -| 150 | spread 2–30d | 8000 | 7343 | 5958 | 4530 | 2839 | 76 | +| 1000 | spread 2–30d | 8000 | 3633 | 261 | 109 | 63 | 39 | +| 1000 | flat 7d | 8000 | 3985 | 511 | 402 | 357 | 259 | +| 200 | none | 8000 | 7874 | 7492 | 6699 | 5554 | 3567 | +| 200 | spread 2–30d | 8000 | 7096 | 5359 | 3388 | 1131 | 55 | +| 200 | flat 7d | 8000 | 7128 | 6134 | 4886 | 2259 | 198 | +| 150 | none | 8000 | 7888 | 7595 | 6990 | 6180 | 4661 | +| 150 | spread 2–30d | 8000 | 7332 | 6074 | 4581 | 2854 | 72 | + +A four-digit cell is one sample of a stochastic process; `--seed=N` resamples. The +conclusions below hold across seeds, the exact numbers do not. **At 1,000 answers a day the deck heals itself in about four months, and at 150–200 it does not.** That single number is why the first conclusion here — natural recovery, no repair — -was wrong: it was measured at an effort nobody sustains. At 200 a day, 3,579 cards are still +was wrong: it was measured at an effort nobody sustains. At 200 a day, 3,567 cards are still queued a year later, because a card answered today returns within hours and takes tomorrow's slot. @@ -94,9 +100,10 @@ not repaired** says by how much. What was rejected, and why: - **No repair.** The numbers above, at the effort the deck is actually reviewed at. -- **A flat lift to 7 days.** Half the drain of the spread at every horizon — 2,272 still - queued at d180 against 1,166, and at 1,000 answers a day it visibly re-synchronises, the - queue swinging back up to 553 and 579 on d90 and d180 as one wave returns together. +- **A flat lift to 7 days.** Half the drain of the spread at every horizon — 2,259 still + queued at d180 against 1,131 — and it re-synchronises the deck: at 1,000 answers a day the + spread is down to 39 cards by d365 while the flat lift still shows 259, the same wave + coming back together. - **A narrower spread.** A shorter window drains more slowly for the same reason the flat lift does: more cards land on the same days. The backlog is the complaint, and a card given more days than it has earned costs one failed review when it comes back. @@ -165,28 +172,30 @@ learner's stamina cannot stop anything in learning from advancing. The list scre today" badge applies the same cap, or it promises 8,000 cards for a session that shows thirty and then says it is done. -Cards shown per day, and words that reached stage 1 within the year, on a legacy deck of -8,000 stage-0 cards (`yarn simulate:srs learningCap`): - -| learning cap | answers/day | d1 | d7 | d30 | d180 | d365 | graduated in a year | -| ------------ | ----------- | ---- | ---- | ---- | ---- | ---- | ------------------- | -| none | 200 | 8000 | 8000 | 8000 | 8000 | 5480 | 8000 | -| 30 (3 × 10) | 200 | 30 | 119 | 86 | 133 | 140 | 3706 | -| 10 | 200 | 10 | 47 | 75 | 106 | 112 | 3240 | -| none | 400 | 8000 | 8000 | 8000 | 5256 | 762 | 8000 | -| 30 (3 × 10) | 400 | 30 | 137 | 221 | 246 | 230 | 7051 | - -**The cap is not free, and an earlier version of this section understated the cost badly** -(it claimed 52 words a year; it is thousands). Uncapped, a learner who answers 200 a day -does finish all 8,000 inside a year — by being shown the entire deck every day for six -months, which is the thing being reported as broken. Capped at thirty they are shown about -130 a day and learn 3,706, which is what ten new cards a day _means_: 3,650 a year. The cap -paces the legacy backlog at the rate the learner chose rather than dumping the list again, -and `new_per_session` is the dial — at 400 answers a day the same cap delivers 7,051. - -Capping at the new number itself rather than three times it costs another 466 words at 200 -answers a day, because a learning card needs three answers and ten slots cannot deliver -them alongside reviews. +Cards shown per day on a legacy deck of 8,000 stage-0 cards, and two counts of what was +learned: cards that touched stage 1 at any point, and cards still graduated at the end on an +interval longer than a day — i.e. actually kept (`yarn simulate:srs learningCap`, seed 7): + +| learning cap | answers/day | d1 | d7 | d30 | d180 | d365 | touched stage 1 | kept | +| ------------ | ----------- | ---- | ---- | ---- | ---- | ---- | --------------- | ---- | +| none | 200 | 8000 | 8000 | 8000 | 8000 | 5447 | 8000 | 2665 | +| 30 (3 × 10) | 200 | 30 | 102 | 91 | 108 | 123 | 3042 | 3008 | +| 10 | 200 | 10 | 46 | 80 | 107 | 113 | 3238 | 3221 | +| none | 400 | 8000 | 8000 | 8000 | 5287 | 817 | 8000 | 7451 | +| 30 (3 × 10) | 400 | 30 | 147 | 214 | 259 | 266 | 7042 | 7005 | +| 10 | 400 | 10 | 50 | 77 | 123 | 122 | 3650 | 3634 | + +**Which column you read decides the answer, and an earlier version of this section read the +wrong one.** It said the cap cost thousands of words, counting cards that touched stage 1. +Uncapped, 8,000 do — and only 2,665 are still there at the end, because an uncapped deck +churns: a card graduates at 0.633 days, comes back the same day, fails, and sits at the +floor again. Capped, almost everything touched is kept (3,008 of 3,042). **On words actually +retained the cap wins**, 3,008 against 2,665, while showing 100 cards a day instead of 8,000. + +The ×3 is what keeps it from starving a learner who does more: at 400 answers a day the +cap of thirty keeps 7,005 words against 3,634 for a cap of ten. At 200 a day the cap of ten +is marginally ahead (3,221 against 3,008), so the multiplier costs a little at low effort +and is worth several thousand words at high effort. A genuinely ratcheted card currently in learning is the price of not repairing stage 0: it re-graduates to the floor once and then climbs 0.33 → 0.63 → 1.2 by itself. diff --git a/lib/data-backup.test.ts b/lib/data-backup.test.ts index 0a05e17..39cb529 100644 --- a/lib/data-backup.test.ts +++ b/lib/data-backup.test.ts @@ -1,7 +1,7 @@ import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; import { createTestDb } from "@/test/test-db"; -import { attemptBackup, importBackup } from "./data-backup"; +import { attemptBackup, importBackup, BACKUP_TABLES, TABLE_COLUMNS } from "./data-backup"; describe("data backup", () => { it("round-trips local-only history tables through backup and import", async () => { @@ -104,7 +104,9 @@ describe("data backup", () => { const failingDb = { ...targetDb, runAsync: async (sql: string, params?: any[]) => { - if (sql.includes("INSERT OR REPLACE INTO lists")) { + // Matches whatever statement shape the restore uses; it is an upsert + // now, because `INSERT OR REPLACE` deletes the row and cascades. + if (/INTO lists\b/.test(sql)) { throw new Error("lists import failed"); } return targetDb.runAsync(sql, params); @@ -215,3 +217,142 @@ describe("backup across a schema change", () => { expect((result.tables.lists[0] as { name: string }).name).toBe("Core 8k"); }); }); + +/** + * The export's SELECT and the restore's column list must agree. + * + * They had drifted: `books.read_complete` was exported and then dropped on + * restore, and `user_kanji_notes.updated_at` was in neither, so a restored + * mnemonic was invisible to the push, which selects `WHERE updated_at > ?`. + */ +describe("the backup's column lists", () => { + const selected = (query: string) => + query + .replace(/^SELECT\s+/i, "") + .replace(/\s+FROM\s+.*$/i, "") + .split(",") + .map((col) => col.trim()); + + it("restores every column it exports", () => { + const drift: string[] = []; + for (const table of BACKUP_TABLES) { + const restored = new Set(TABLE_COLUMNS[table.name] ?? []); + for (const col of selected(table.query)) { + if (!restored.has(col)) drift.push(`${table.name}.${col}`); + } + } + expect(drift).toEqual([]); + }); + + it("exports every column it would restore", () => { + const drift: string[] = []; + for (const table of BACKUP_TABLES) { + const exported = new Set(selected(table.query)); + for (const col of TABLE_COLUMNS[table.name] ?? []) { + if (!exported.has(col)) drift.push(`${table.name}.${col}`); + } + } + expect(drift).toEqual([]); + }); +}); + +describe("restoring one table does not take its neighbours with it", () => { + it("keeps a list's words when only `lists` is in the backup", async () => { + // `INSERT OR REPLACE` is a DELETE then an INSERT, so restoring the parent + // fired every cascade: the list came back with zero words and its cards + // orphaned. A partial backup is the normal case — `attemptBackup` reports + // per table and only the ones that succeeded are written. + const db = createTestDb(); + const now = "2026-04-05T00:00:00.000Z"; + await db.runAsync("PRAGMA foreign_keys = ON"); + await db.runAsync( + `INSERT INTO lists (id, name, is_default, study_position, created_at, updated_at) + VALUES ('L', 'List', 0, 0, ?, ?)`, + [now, now], + ); + await db.runAsync( + `INSERT INTO list_entries (id, list_id, entry_id, added_at, position, updated_at) + VALUES ('e1', 'L', 1001, ?, 0, ?)`, + [now, now], + ); + await db.runAsync( + `INSERT INTO srs_cards + (id, entry_id, list_id, due, stability, difficulty, elapsed_days, scheduled_days, + reps, lapses, state, created_at, updated_at) + VALUES ('c1', 1001, 'L', ?, 0, 0, 0, 0, 0, 0, 0, ?, ?)`, + [now, now, now], + ); + + const result = await importBackup( + db, + JSON.stringify({ + version: 1, + tables: { lists: [{ id: "L", name: "List renamed", created_at: now, updated_at: now }] }, + }), + ); + + expect(result.failed).toEqual([]); + expect(await db.getFirstAsync<{ c: number }>("SELECT COUNT(*) as c FROM list_entries")).toEqual( + { c: 1 }, + ); + expect( + await db.getFirstAsync<{ list_id: string | null }>("SELECT list_id FROM srs_cards"), + ).toEqual({ list_id: "L" }); + // and the row itself really was updated + expect(await db.getFirstAsync<{ name: string }>("SELECT name FROM lists")).toEqual({ + name: "List renamed", + }); + }); + + it("skips a row with no primary key rather than writing a NULL-keyed one", async () => { + // SQLite allows NULL in a TEXT PRIMARY KEY, and sync keys on it. Every + // other column is filled, or the row would fail for its own reasons and + // the guard would go untested. + const db = createTestDb(); + const now = "2026-04-05T00:00:00.000Z"; + const result = await importBackup( + db, + JSON.stringify({ + version: 1, + tables: { + furigana_pins: [ + { + book_id: "b", + surface: "\u6c34", + reading: "\u307f\u305a", + created_at: now, + updated_at: now, + deleted_at: null, + }, + ], + }, + }), + ); + expect(result.failed).toEqual([]); + expect( + await db.getFirstAsync<{ c: number }>("SELECT COUNT(*) as c FROM furigana_pins"), + ).toEqual({ c: 0 }); + }); + + it("reports a locked or corrupt table as failed, not as a clean rescue", async () => { + // The column fallback must not swallow a real error: re-selecting fewer + // columns would hand `DbRecoveryScreen` whatever came back and call it a + // success. + const db = createTestDb(); + let thrown = 0; + const busy = { + ...db, + getAllAsync: async (sql: string, params?: unknown[]) => { + // Transient, the way a lock is: a retry would succeed. The fallback + // must not retry, or it reports the table rescued. + if (/FROM books/.test(sql) && thrown++ === 0) { + throw new Error("SQLITE_BUSY: database is locked"); + } + return db.getAllAsync(sql, params as never); + }, + } as unknown as Parameters[0]; + const result = await attemptBackup(busy); + expect(result.failed).toContain("books"); + expect(result.succeeded).not.toContain("books"); + }); +}); diff --git a/lib/data-backup.ts b/lib/data-backup.ts index 0e30013..d3b5948 100644 --- a/lib/data-backup.ts +++ b/lib/data-backup.ts @@ -63,7 +63,12 @@ export const BACKUP_TABLES: { query: "SELECT id, book_id, surface, reading, created_at, updated_at, deleted_at FROM furigana_pins", }, - { name: "user_kanji_notes", query: "SELECT literal, mnemonic, keyword FROM user_kanji_notes" }, + { + name: "user_kanji_notes", + // `updated_at` or a restored mnemonic is invisible to the push, which + // selects `WHERE updated_at > ?`. + query: "SELECT literal, mnemonic, keyword, updated_at FROM user_kanji_notes", + }, { name: "practice_sessions", query: @@ -82,7 +87,14 @@ export const BACKUP_TABLES: { ]; // Column names for each table — used for INSERT during import -const TABLE_COLUMNS: Record = { +/** Where a table's primary key is not `id`. Used for the restore's upsert. */ +const PRIMARY_KEYS: Record = { + app_flags: "key", + user_kanji_notes: "literal", + sync_meta: "key", +}; + +export const TABLE_COLUMNS: Record = { lists: [ "id", "name", @@ -202,11 +214,12 @@ const TABLE_COLUMNS: Record = { "last_read_at", "is_default", "saved", + "read_complete", "created_at", "updated_at", ], furigana_pins: ["id", "book_id", "surface", "reading", "created_at", "updated_at", "deleted_at"], - user_kanji_notes: ["literal", "mnemonic", "keyword"], + user_kanji_notes: ["literal", "mnemonic", "keyword", "updated_at"], practice_sessions: [ "id", "session_id", @@ -288,7 +301,13 @@ export async function attemptBackup(db: WrappedUserDb): Promise { // to the columns the table really has rescues the rows instead of // reporting the whole table as lost — a missing `new_per_session` used to // cost the user every list, and a missing `simple_relearning` every card. - const cols = await existingColumns(db, table.name); + // + // Only for a missing column. A locked, corrupt or absent table must stay + // a failure: re-selecting fewer columns would report a clean rescue of + // whatever came back, and the recovery screen would believe it. + const cols = /no such column/i.test(String(err)) + ? await existingColumns(db, table.name) + : null; const wanted = TABLE_COLUMNS[table.name]; if (cols && wanted) { const usable = wanted.filter((col) => cols.has(col)); @@ -358,27 +377,39 @@ export async function importBackup( `Importing ${tableName}...`, ); + // `INSERT OR REPLACE` is a DELETE followed by an INSERT, so restoring a + // parent row fires every foreign-key cascade hanging off it: restoring + // `lists` alone emptied `list_entries` and set `srs_cards.list_id` to NULL, + // leaving the list with no words and its cards orphaned. An upsert touches + // only the row named. + const pk = PRIMARY_KEYS[tableName] ?? "id"; + const upsert = (columns: string[]) => + `INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${columns.map(() => "?").join(", ")}) + ON CONFLICT(${pk}) DO UPDATE SET ${columns + .filter((col) => col !== pk) + .map((col) => `${col} = excluded.${col}`) + .join(", ")}`; + try { - const placeholders = cols.map(() => "?").join(", "); - const sql = `INSERT OR REPLACE INTO ${tableName} (${cols.join(", ")}) VALUES (${placeholders})`; + const sql = upsert(cols); for (const row of rows) { - // `INSERT OR REPLACE` writes a named NULL as NULL — it does NOT fall - // back to the column default — so a column missing from an older backup - // is left out of the statement entirely and takes its default. - const named = cols.filter((col) => row[col] !== undefined); - if (named.length === cols.length) { - await db.runAsync( - sql, - cols.map((col) => row[col] ?? null), - ); - } else { - await db.runAsync( - `INSERT OR REPLACE INTO ${tableName} (${named.join(", ")}) - VALUES (${named.map(() => "?").join(", ")})`, - named.map((col) => row[col] ?? null), - ); + // A row with no primary key would insert under a NULL one — SQLite + // allows that in a TEXT PRIMARY KEY — and then poison sync, which keys + // on it. Skip it rather than write it. + if (row[pk] == null) { + processedRows++; + continue; } + // A named NULL is written as NULL; an upsert does NOT fall back to the + // column default. So a column the backup never had is left out of the + // statement entirely, and takes its default. + const named = cols.filter((col) => row[col] !== undefined); + await db.runAsync( + named.length === cols.length ? sql : upsert(named), + named.map((col) => row[col] ?? null), + ); processedRows++; } diff --git a/lib/list-transfer.test.ts b/lib/list-transfer.test.ts index dbb5d6a..0d9e4b6 100644 --- a/lib/list-transfer.test.ts +++ b/lib/list-transfer.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { createTestDb } from "@/test/test-db"; -import { DEFAULT_NEW_PER_SESSION } from "@/stores/simple-srs"; +import { DEFAULT_NEW_PER_SESSION, resolveNewPerSession } from "@/stores/simple-srs"; import { buildListExport, importListToDb, @@ -243,14 +243,17 @@ describe("the new-card cap travels with a shared list", () => { expect(row?.new_per_session).toBe(40); }); - test("gives a file that predates the column the default, not a pin to ten", async () => { + test("leaves a file that predates the column unset, rather than pinning it", async () => { + // No `newPerSession` key at all, the way a pre-feature file looks. NULL is + // the meaningful "unset" state: the settings input shows a placeholder, and + // `resolveNewPerSession` turns it into the default at read time. const db = createTestDb(); - // No `newPerSession` key at all, the way a pre-feature file looks. const listId = await importListToDb(db, validExport(), false, undefined); - const row = await db.getFirstAsync<{ new_per_session: number }>( + const row = await db.getFirstAsync<{ new_per_session: number | null }>( "SELECT new_per_session FROM lists WHERE id = ?", [listId], ); - expect(row?.new_per_session).toBe(DEFAULT_NEW_PER_SESSION); + expect(row?.new_per_session).toBeNull(); + expect(resolveNewPerSession(row?.new_per_session)).toBe(DEFAULT_NEW_PER_SESSION); }); }); diff --git a/lib/list-transfer.ts b/lib/list-transfer.ts index 6af5053..7cdf89d 100644 --- a/lib/list-transfer.ts +++ b/lib/list-transfer.ts @@ -269,9 +269,11 @@ export async function importListToDb( studyPosition, configured, data.list.autoPlayAudio ? 1 : 0, - // A shared list carries its pace; a file that predates the column takes - // the column default rather than being pinned to ten. - resolveNewPerSession(data.list.newPerSession), + // A shared list carries its pace. A file that predates the column has no + // value, and NULL is the meaningful state for that — the settings input + // shows a placeholder rather than a number, and the list follows the + // default if it ever changes — so it is not resolved to ten here. + data.list.newPerSession == null ? null : resolveNewPerSession(data.list.newPerSession), now, now, ], diff --git a/scripts/simulate-simple-srs.ts b/scripts/simulate-simple-srs.ts index 65cd616..a37f05e 100644 --- a/scripts/simulate-simple-srs.ts +++ b/scripts/simulate-simple-srs.ts @@ -29,6 +29,13 @@ const DECK = 8000; const PASS_GRADUATED = 0.9; const PASS_LEARNING = 0.8; +/** + * Pass `--seed=N` to resample. A four-digit cell is one sample of a stochastic + * process: the conclusions here hold across seeds, the exact numbers do not, so + * re-run before quoting a new one. + */ +const SEED = Number(process.argv.find((a) => a.startsWith("--seed="))?.slice(7) ?? 7); + /** Deterministic LCG, so every run of this file prints the same table. */ function rng(seed: number) { let s = seed; @@ -104,7 +111,10 @@ function studyOneDay( interface Run { shown: number[]; answered: number[]; + /** Cards that reached stage 1 at any point. */ graduatedEver: number; + /** Cards still graduated at the end, above a one-day interval — i.e. learned. */ + learnedAtEnd: number; } function simulate(opts: { @@ -116,12 +126,11 @@ function simulate(opts: { seed?: number; }): Run { const t0 = Date.now(); - const rand = rng(opts.seed ?? 7); + const rand = rng(opts.seed ?? SEED); const shown: number[] = []; const answered: number[] = []; const graduated = new Set(); for (let day = 0; day < opts.days; day++) { - const before = opts.deck.filter((c) => c.simpleStage === 1).map((c) => c.id); const result = studyOneDay( opts.deck, day, @@ -132,11 +141,16 @@ function simulate(opts: { rand, ); for (const c of opts.deck) if (c.simpleStage === 1) graduated.add(c.id); - void before; shown.push(result.shown); answered.push(result.answered); } - return { shown, answered, graduatedEver: graduated.size }; + // "Touched stage 1 once" flatters a deck that is churning: a card can + // graduate, fail, and sit back at the floor. What the learner has actually + // kept is a card still graduated and no longer due daily. + const learnedAtEnd = opts.deck.filter( + (c) => c.simpleStage === 1 && (c.simpleInterval ?? 0) > 1, + ).length; + return { shown, answered, graduatedEver: graduated.size, learnedAtEnd }; } const MARKS = [0, 6, 29, 89, 179, 364]; @@ -196,8 +210,10 @@ const SCENARIOS: Record void> = { /** Does a flattened deck drain on its own, and does a repair help? */ drain() { console.log("\n## Draining a deck of 8,000 cards pinned at the floor\n"); + console.log(`(seed ${SEED})\n`); console.log("| answers/day | repair | " + header.slice(2)); console.log("| --- | --- |" + MARKS.map(() => " --- |").join("")); + let seed = SEED; for (const budget of [1000, 200, 150]) { for (const [label, interval] of [ ["none", () => 1 / 3], @@ -210,7 +226,9 @@ const SCENARIOS: Record void> = { budget, newPerDay: 0, learningCap: UNLIMITED, + seed, }); + seed += 1; console.log(`| ${budget} | ${label} | ` + at(run.shown).join(" | ") + " |"); } } @@ -274,21 +292,29 @@ const SCENARIOS: Record void> = { /** What capping the learning slice costs and saves. */ learningCap() { + console.log(`(seed ${SEED})`); console.log("\n## Capping the learning slice of a legacy deck\n"); - console.log("| learning cap | answers/day | " + header.slice(2) + " graduated in a year |"); - console.log("| --- | --- |" + MARKS.map(() => " --- |").join("") + " --- |"); + console.log( + "| learning cap | answers/day | " + header.slice(2) + " touched stage 1 | learned |", + ); + console.log("| --- | --- |" + MARKS.map(() => " --- |").join("") + " --- | --- |"); + let seed = SEED; for (const budget of [200, 400]) { for (const cap of [UNLIMITED, learningCapFor(10), 10]) { + seed += 1; const run = simulate({ deck: learningDeck(), days: 365, budget, newPerDay: 0, learningCap: cap, + seed, }); const label = cap === UNLIMITED ? "none" : String(cap); console.log( - `| ${label} | ${budget} | ` + at(run.shown).join(" | ") + ` | ${run.graduatedEver} |`, + `| ${label} | ${budget} | ` + + at(run.shown).join(" | ") + + ` | ${run.graduatedEver} | ${run.learnedAtEnd} |`, ); } } From 03a6b2048e4acf96ddf30295e49247709b27da39 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 18:14:35 +0800 Subject: [PATCH 10/12] Address the third round of adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers, and a measurement that was confounded by its own seeding. **The repair cannot run on every launch, because its clause also matches cards the FIXED engine honestly produces.** `simpleGraduate` halves and clamps to `MIN_INTERVAL`, so a card that fails and re-graduates from a short interval lands at exactly 1/3 with stage 1 — and three honest lapses leave it there with `reps = 15` and `lapses = 3`, matching every condition. Reproduced by driving the real `nextSimpleReview`: no bug involved, and the learner's hardest words pushed 2–30 days out. Once, that is the bounded cost already measured and written down. Every launch it is a standing policy that buries them again and again, and on web it could fire mid-session, since the provider re-runs whenever the lock is reacquired. So it runs once per device now, keyed to an `app_flags` row rather than `PRAGMA user_version` — which is also why the round-2 reasoning for per-launch does not hold: the price of running once is that an old build can push a ratcheted interval back and nothing lifts it again, and that is the better trade. **Restoring `confusion_pairs` lost the whole table.** It carries a unique index besides its primary key, so a pair that already exists locally under a different id raises an error `ON CONFLICT(id)` cannot absorb — and the table-level catch dropped every row after it. Round 2 traded a cascade for this. The loop now collects per-row failures instead of aborting, replaces a row that collides on another index, and reports the table failed only when every row did. A row carrying only its primary key also produced `DO UPDATE SET` with nothing after it, which is a syntax error that cost the table; it is `DO NOTHING` now. **The learning-cap comparison was confounded with its sample.** `seed += 1` ran per row, so each cap was drawn from a different RNG stream — and the conclusion drawn from it, that the ×3 multiplier costs a little at low effort, reverses at every other seed. Every row of a comparison shares one seed now, and `yarn simulate:srs capSeeds` reports four of them: the ×3 is ahead of a flat cap at both efforts, 3,674 against 3,223 words kept at 200 answers a day and 7,006 against 3,631 at 400. Uncapped leads only at 400 a day, by 6%, for which it shows the entire deck every day for six months. Also from review: - `newAnsweredRef` counted increments, so a re-rate spent a second slot (the deque keeps the pre-answer row, so the re-applied answer saw `simpleStage` null again) and an undo refunded nothing — five cards back and a re-rate left a session with no new cards while the database still called them unseen. It is a set of ids, and `undoSingleCard` removes the card from it and from the answered set. - `wasNewSimpleSrs` was written in five places and read in none. Deleted. - The graduated slice is back to `simple_stage = 1`. `!= 0` would have served a stage that `DueCardsSection`, the lists index, `stores/lists.ts` and `lib/practice-stats.ts` all count in no bucket; the engine writes 0 and 1 and nothing else, and `stores/simple-srs.test.ts` now pins that where it is produced rather than guessing at the query. - Three ungated mutations now fail: `reps >= 12` → `>= 8` (the measured discriminator was held only by a reps-4 fixture), a stage above 1 reaching the repair, and the empty `SET` clause. `PRIMARY_KEYS` is checked against `PRAGMA table_info` for every restored table — it carried two tables that are not restored at all. - The "is stable when every card shares a timestamp" assertion compared two calls to the same deterministic function over an unchanged database. Replaced with the cards the slice actually takes. - `fileURLToPath` in the arity gate, from CodeRabbit: a checkout path containing a space arrived percent-encoded and `git -C` was handed a directory that does not exist. Verified from a directory with a space in its name. - `docs/ARCHITECTURE.md` describes the repair and the residual allowance. Gates: typecheck clean, lint 0 errors, 2,570 tests passing, none skipped. Co-Authored-By: Claude Opus 5 (1M context) --- app/(tabs)/lists/study.tsx | 26 ++++--- db/simple-queue.test.ts | 22 +++--- db/simple-queue.ts | 11 +-- db/simple-repair.test.ts | 70 ++++++++++++++++++- db/simple-repair.ts | 47 +++++++++---- db/sql-arity.test.ts | 6 +- db/user-provider.native.tsx | 4 +- db/user-provider.web.tsx | 6 +- docs/ARCHITECTURE.md | 18 +++-- docs/simple-srs-decisions.md | 95 ++++++++++++++++---------- lib/data-backup.test.ts | 119 ++++++++++++++++++++++++++++++++- lib/data-backup.ts | 64 +++++++++++++----- scripts/simulate-simple-srs.ts | 35 ++++++++-- stores/simple-srs.test.ts | 22 ++++++ 14 files changed, 437 insertions(+), 108 deletions(-) diff --git a/app/(tabs)/lists/study.tsx b/app/(tabs)/lists/study.tsx index 3f723f7..5c4d87e 100644 --- a/app/(tabs)/lists/study.tsx +++ b/app/(tabs)/lists/study.tsx @@ -920,7 +920,6 @@ interface StudyCard { snapshot?: SrsSnapshot | null; reviewLogId?: string | null; preStudyPosition?: number | null; - wasNewSimpleSrs?: boolean; reQueueOf?: number; } @@ -1065,8 +1064,12 @@ function StudyScreen() { type SessionPhase = "studying" | "done" | "waiting"; const [sessionPhase, setSessionPhase] = useState("studying"); const reviewedSrsIdsRef = useRef>(new Set()); - /** Unseen cards this session has answered, which spend its allowance. */ - const newAnsweredRef = useRef(0); + /** + * Unseen cards this session has answered, which spend its allowance. Ids, not + * a count: re-rating a card re-applies the same answer, and the deque keeps + * the pre-answer row, so a counter spent a slot every walk-back. + */ + const newAnsweredRef = useRef>(new Set()); const [reviewedCount, setReviewedCount] = useState(0); const [settingsVisible, setSettingsVisible] = useState(false); const [statsVisible, setStatsVisible] = useState(false); @@ -1337,7 +1340,7 @@ function StudyScreen() { // A different list or scope is a new session; "Continue Studying" is not, // and must keep both so it neither re-serves answered cards nor hands out // another full allowance of new ones. - newAnsweredRef.current = 0; + newAnsweredRef.current.clear(); reviewedSrsIdsRef.current.clear(); loadQueue(); }, [dictDb, userDb, list?.id, scope]); @@ -1652,7 +1655,7 @@ function StudyScreen() { // cards still sitting unanswered in the pool, and counting those // again left a session unable to offer new cards it still owed. const newAllowance = - newCap === UNLIMITED ? UNLIMITED : Math.max(0, newCap - newAnsweredRef.current); + newCap === UNLIMITED ? UNLIMITED : Math.max(0, newCap - newAnsweredRef.current.size); const { dueRows, newRows } = await loadSimpleQueuePools({ query: (sql, args) => userDb.getAllAsync(sql, args), scope, @@ -1837,7 +1840,6 @@ function StudyScreen() { snapshot, reviewLogId, preStudyPosition: null, - wasNewSimpleSrs: card ? card.simpleStage == null : false, }; // Push failed card to end for re-review (non-FSRS only; FSRS uses timer) if (sessionMode !== "srs") { @@ -1904,7 +1906,6 @@ function StudyScreen() { const snapshot = card ? captureSnapshot(card) : null; let reviewLogId: string | null = null; let preStudyPosition: number | null = null; - const wasNewSimpleSrs = card ? card.simpleStage == null : false; let shouldReQueue = false; let reQueueItem = item; @@ -1961,7 +1962,6 @@ function StudyScreen() { snapshot, reviewLogId, preStudyPosition, - wasNewSimpleSrs, }; if (shouldReQueue) { updatedCards.push({ @@ -2046,7 +2046,6 @@ function StudyScreen() { snapshot, reviewLogId, preStudyPosition: null, - wasNewSimpleSrs: false, }; cardsRef.current = updatedCards; setCards(updatedCards); @@ -2113,7 +2112,6 @@ function StudyScreen() { snapshot, reviewLogId, preStudyPosition: null, - wasNewSimpleSrs: false, }; cardsRef.current = updatedCards; setCards(updatedCards); @@ -2242,7 +2240,7 @@ function StudyScreen() { // to live in a ref that died with the screen, so a card answered twice and // left came back to a count of zero and stayed due forever. // An unseen card's first answer is what spends the session's allowance. - if (card.simpleStage == null) newAnsweredRef.current += 1; + if (card.simpleStage == null) newAnsweredRef.current.add(card.id); const decision = nextSimpleReview(card, action); const updates = decision.updates; @@ -2443,6 +2441,7 @@ function StudyScreen() { } // Reset completed status on undo completedSrsIdsRef.current.delete(card.id); + reviewedSrsIdsRef.current.delete(card.id); } else if (sessionMode === "simple_srs" && card && snap) { // Restore simple SRS fields await userDb.runAsync( @@ -2464,6 +2463,11 @@ function StudyScreen() { ); // The restored snapshot carries the correct-in-a-row count with it now. completedSrsIdsRef.current.delete(card.id); + // An undone answer is not an answer: refund the allowance it spent and + // let the card be loaded again, or walking back five cards left the + // session with no new cards while the database still called them unseen. + if (snap.simpleStage == null) newAnsweredRef.current.delete(card.id); + reviewedSrsIdsRef.current.delete(card.id); } else if (sessionMode === "add_order" && studyCard.preStudyPosition != null) { // Restore study_position await userDb.runAsync("UPDATE lists SET study_position = ?, updated_at = ? WHERE id = ?", [ diff --git a/db/simple-queue.test.ts b/db/simple-queue.test.ts index e7f2016..5b6fccd 100644 --- a/db/simple-queue.test.ts +++ b/db/simple-queue.test.ts @@ -124,9 +124,13 @@ describe("the learning slice", () => { // card the same `updated_at` and there is no index on it. it("breaks a shared timestamp on rowid, so the slice is the same every build", async () => { for (let i = 0; i < 40; i++) insert({ id: `l${i}`, stage: 0, n: 0, updatedAt: "same" }); - const first = ids((await pools(DEFAULT_NEW_PER_SESSION)).dueRows); - expect(first).toEqual(ids((await pools(DEFAULT_NEW_PER_SESSION)).dueRows)); - expect(first[0]).toBe("l0"); + // Comparing two calls proves nothing — same function, same rows. What can + // be said is which cards the slice takes. + expect(ids((await pools(DEFAULT_NEW_PER_SESSION)).dueRows).slice(0, 3)).toEqual([ + "l0", + "l1", + "l2", + ]); }); it("takes the most recently answered first, so cards in hand get finished", async () => { @@ -162,12 +166,14 @@ describe("the graduated slice", () => { expect(ids((await pools(1)).dueRows)).toEqual(["today", "on-the-cutoff"]); }); - it("still serves a card whose stage is outside the engine's 0 and 1", async () => { - // Splitting the old `simple_stage IS NOT NULL` query in two could drop such - // a row from every slice and strand it forever, so the graduated slice asks - // for `!= 0` rather than `= 1`. + it("serves only the stages the engine writes", async () => { + // The engine writes 0 and 1 and nothing else — pinned by + // `stores/simple-srs.test.ts` — and the statistics, the badge and the lists + // index all key on `= 1`, so this query does too. A stage nothing can + // create is in no slice, and in no bucket either; consistent beats clever. insert({ id: "odd", stage: 2, n: 0 }); - expect(ids((await pools(1)).dueRows)).toEqual(["odd"]); + insert({ id: "graduated", stage: 1, n: 0 }); + expect(ids((await pools(1)).dueRows)).toEqual(["graduated"]); }); it("comes back in due order, oldest first", async () => { diff --git a/db/simple-queue.ts b/db/simple-queue.ts index 3912c3b..878a6d5 100644 --- a/db/simple-queue.ts +++ b/db/simple-queue.ts @@ -53,14 +53,15 @@ export interface SimpleQueuePools { /** * Graduated and due today. Never capped — this is the real review load. * - * `!= 0` rather than `= 1` so a stage outside the engine's {0, 1} can still be - * studied: splitting the old `IS NOT NULL` query in two would otherwise drop - * such a row from every slice and strand it forever. + * `= 1` because the engine writes 0 and 1 and nothing else, which + * `stores/simple-srs.test.ts` pins. Every other consumer agrees — + * `DueCardsSection`, the lists index, `stores/lists.ts`, `lib/practice-stats.ts` + * — and `!= 0` here would have served a stage this split cannot otherwise reach + * while those four counted it in no bucket at all. */ export const graduatedDueSql = (scopeClause: string) => `${SRS_CARD_SELECT} FROM srs_cards - WHERE ${scopeClause} AND simple_stage IS NOT NULL AND simple_stage != 0 - AND simple_n <= ? AND deleted_at IS NULL + WHERE ${scopeClause} AND simple_stage = 1 AND simple_n <= ? AND deleted_at IS NULL ORDER BY simple_n ASC`; /** diff --git a/db/simple-repair.test.ts b/db/simple-repair.test.ts index cdcb0e2..8b34386 100644 --- a/db/simple-repair.test.ts +++ b/db/simple-repair.test.ts @@ -79,6 +79,13 @@ function repaired() { run: 2, }); insert(db, { id: "barely-seen", stage: 1, n: 9000, interval: FLOOR, reps: 4, lapses: 1 }); + // The reps threshold is the measured discriminator, so both sides of it are + // pinned: 11 must not move, 12 must. + insert(db, { id: "reps-11", stage: 1, n: 0, interval: FLOOR, reps: 11, lapses: 2 }); + insert(db, { id: "reps-12", stage: 1, n: 0, interval: FLOOR, reps: 12, lapses: 2 }); + // Nothing in the engine writes a stage above 1, and the clause must not + // reach one if anything ever does. + insert(db, { id: "stage-2", stage: 2, n: 0, interval: FLOOR, reps: 40, lapses: 5 }); insert(db, { id: "never-failed", stage: 1, n: 9000, interval: FLOOR, reps: 40, lapses: 0 }); insert(db, { id: "healthy", stage: 1, n: 9100, interval: 56.61, reps: 40, lapses: 2 }); insert(db, { id: "part-way-down", stage: 1, n: 0, interval: 0.88, reps: 40, lapses: 5 }); @@ -150,6 +157,16 @@ describe("the interval repair", () => { expect(r.updated_at).toBe("OLD"); }); + it("holds the measured reps threshold at exactly twelve", () => { + const db = repaired(); + expect(row(db, "reps-11").simple_interval).toBeCloseTo(FLOOR); + expect(Number(row(db, "reps-12").simple_interval)).toBeGreaterThanOrEqual(2); + }); + + it("does not reach a stage the engine never writes", () => { + expect(row(repaired(), "stage-2").simple_interval).toBeCloseTo(FLOOR); + }); + it("leaves a card one halving above the clause alone", () => { expect(row(repaired(), "just-above").simple_interval).toBe(0.4); }); @@ -212,7 +229,7 @@ describe("the interval repair", () => { }); describe("repairRatchetedIntervals", () => { - it("reports how many cards it lifted, and lifts none twice", async () => { + it("reports how many cards it lifted, and never bumps updated_at", async () => { const db = createTestDb(); const now = "2026-04-05T00:00:00.000Z"; for (const [id, interval, reps] of [ @@ -230,7 +247,6 @@ describe("repairRatchetedIntervals", () => { } expect(await repairRatchetedIntervals(db)).toBe(1); - expect(await repairRatchetedIntervals(db)).toBe(0); const row = await db.getFirstAsync<{ simple_interval: number; updated_at: string }>( "SELECT simple_interval, updated_at FROM srs_cards WHERE id = 'ratcheted'", @@ -240,9 +256,57 @@ describe("repairRatchetedIntervals", () => { // months-old values over reviews done on another device. expect(row!.updated_at).toBe("OLD"); }); +}); + +describe("the repair runs once per device", () => { + async function deck() { + const db = createTestDb(); + const now = "2026-04-05T00:00:00.000Z"; + await db.runAsync( + `INSERT INTO srs_cards (id, entry_id, list_id, due, stability, difficulty, elapsed_days, + scheduled_days, reps, lapses, state, simple_stage, simple_n, simple_interval, + created_at, updated_at) + VALUES ('r', 1, NULL, 'x', 0, 0, 0, 0, 40, 5, 0, 1, 0, 0.3333, ?, 'OLD')`, + [now], + ); + return db; + } + + it("lifts on the first launch and does nothing on the next", async () => { + const db = await deck(); + expect(await repairRatchetedIntervals(db)).toBe(1); + // -1, not 0: the flag said it had already run, so the clause was never + // even evaluated. + expect(await repairRatchetedIntervals(db)).toBe(-1); + }); + + it("does not re-lift a card the fixed engine honestly put back at the floor", async () => { + // Three honest lapses leave a card at exactly MIN_INTERVAL with reps 15 and + // lapses 3 — every condition of the clause, with no bug involved. Running + // the repair on every launch would bury the learner's hardest words again + // and again; this is why it is flag-keyed. + const db = await deck(); + await repairRatchetedIntervals(db); + await db.runAsync( + "UPDATE srs_cards SET simple_interval = 0.3333, simple_n = 0, reps = 15, lapses = 3 WHERE id = 'r'", + ); + expect(await repairRatchetedIntervals(db)).toBe(-1); + expect( + await db.getFirstAsync<{ simple_interval: number }>( + "SELECT simple_interval FROM srs_cards WHERE id = 'r'", + ), + ).toEqual({ simple_interval: 0.3333 }); + }); + + it("writes the flag even when a healthy deck has nothing to repair", async () => { + const db = createTestDb(); + expect(await repairRatchetedIntervals(db)).toBe(0); + expect(await repairRatchetedIntervals(db)).toBe(-1); + }); - it("survives a database that cannot run it, rather than failing the launch", async () => { + it("leaves the flag unwritten when it fails, so the next launch retries", async () => { const broken = { + getFirstAsync: async () => null, runAsync: async () => { throw new Error("no such table: srs_cards"); }, diff --git a/db/simple-repair.ts b/db/simple-repair.ts index f216043..2c40d7c 100644 --- a/db/simple-repair.ts +++ b/db/simple-repair.ts @@ -1,24 +1,32 @@ /** * Lift simple-SRS cards the lapse bug ratcheted to the 8-hour floor. * - * This runs on every launch rather than once as a migration, and both halves - * of that matter: + * ONCE per device, keyed to an `app_flags` row. Both halves of that matter, and + * both were got wrong first: * * - It cannot bump `updated_at`. A device opened for the first time in months * would become the newest writer of every card it touched, and sync skips the * remote row when `localTs >= remoteTs` — so it would push months-old values * over reviews done elsewhere. - * - Which means the repair never travels, so a device still on an old build can - * push a ratcheted `simple_interval` back. As a one-shot migration keyed to - * `PRAGMA user_version` the repair was then gone for good. Run every launch, - * it simply happens again. + * - It cannot run on every launch. The clause matches rows the FIXED engine + * legitimately produces: a card that fails and re-graduates from a short + * interval lands at exactly `MIN_INTERVAL`, so three honest lapses leave it at + * 1/3 with `reps = 15` and `lapses = 3`, matching every condition. As a + * one-time repair that is a bounded cost, measured and accepted in + * docs/simple-srs-decisions.md. Run on every launch it becomes a standing + * policy that pushes a learner's hardest cards a month out, again and again. * - * The clause is self-limiting: a repaired row no longer matches it, so this is - * one indexed UPDATE that changes nothing on a healthy deck. + * The cost of running once is that a device still on an old build can push a + * ratcheted interval back afterwards, and nothing lifts it again. That is the + * better trade: one stale card against repeatedly burying the words someone is + * working hardest on. */ import type { WrappedUserDb } from "./user-db"; +/** The `app_flags` key that records this device has run the repair. */ +export const REPAIR_FLAG = "simple_intervals_repaired"; + /** * `reps >= 12` is the discriminator, measured rather than guessed: a card the * bug ratcheted down from 56 days has a median of 32 answers behind it, while @@ -30,8 +38,8 @@ import type { WrappedUserDb } from "./user-db"; * new cards START, and with the whole list loading as new those are most of the * stage-0 population. They are bounded by the learning cap instead. * - * The spread starts at 2 so a repaired row can never match again, which is what - * makes running this repeatedly free. + * The spread starts at 2 so a repaired row can never match again, which keeps + * the statement idempotent even though the flag already stops it re-running. */ export const REPAIR_RATCHETED_INTERVALS = `UPDATE srs_cards SET simple_interval = 2 + (rowid % 29), @@ -39,14 +47,29 @@ export const REPAIR_RATCHETED_INTERVALS = `UPDATE srs_cards SET WHERE simple_stage = 1 AND simple_interval <= 0.34 AND lapses > 0 AND reps >= 12 AND deleted_at IS NULL`; -/** Returns how many cards were lifted, for the log. */ +/** + * Run the repair if this device has not. Returns how many cards were lifted, or + * -1 if it had already run. + */ export async function repairRatchetedIntervals(db: WrappedUserDb): Promise { try { + const done = await db.getFirstAsync<{ value: string }>( + "SELECT value FROM app_flags WHERE key = ?", + [REPAIR_FLAG], + ); + if (done) return -1; + const result = await db.runAsync(REPAIR_RATCHETED_INTERVALS, []); + // The flag is written even when nothing matched: a healthy deck has nothing + // to repair and must not be re-examined on every launch forever. + await db.runAsync("INSERT OR REPLACE INTO app_flags (key, value) VALUES (?, ?)", [ + REPAIR_FLAG, + new Date().toISOString(), + ]); return result?.changes ?? 0; } catch (err) { // A deck left at the floor is bad; a launch that fails because of it is - // worse. The next launch tries again. + // worse. The flag is not written, so the next launch tries again. console.warn("[SimpleSRS] Interval repair skipped:", err); return 0; } diff --git a/db/sql-arity.test.ts b/db/sql-arity.test.ts index 3af8eeb..cff9198 100644 --- a/db/sql-arity.test.ts +++ b/db/sql-arity.test.ts @@ -17,11 +17,15 @@ import { describe, expect, it } from "vitest"; import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import ts from "typescript"; const QUERY_METHODS = new Set(["runAsync", "getAllAsync", "getFirstAsync", "execAsync"]); -const REPO_ROOT = resolve(dirname(new URL(import.meta.url).pathname), ".."); +// `fileURLToPath`, not `.pathname`: a checkout path containing a space +// arrives percent-encoded and `git -C` is handed a directory that does not +// exist, so the gate fails before it scans a single query. +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); function sourceFiles(): string[] { return execFileSync( diff --git a/db/user-provider.native.tsx b/db/user-provider.native.tsx index 0703cdc..2a14536 100644 --- a/db/user-provider.native.tsx +++ b/db/user-provider.native.tsx @@ -67,8 +67,8 @@ export function UserDatabaseProvider({ const wrapped = wrapUserDb(db, { shouldNotifyError: () => live.current }); - // Every launch, not once: see db/simple-repair.ts for why a one-shot - // migration loses the repair the first time an old build pushes. + // Once per device, flag-keyed rather than `user_version`-keyed — see + // db/simple-repair.ts for why it is neither a migration nor per-launch. const lifted = await repairRatchetedIntervals(wrapped); if (lifted > 0) console.log(`[UserDB Native] Lifted ${lifted} ratcheted cards`); diff --git a/db/user-provider.web.tsx b/db/user-provider.web.tsx index 78b3687..7bc795e 100644 --- a/db/user-provider.web.tsx +++ b/db/user-provider.web.tsx @@ -110,8 +110,10 @@ async function openAndMigrateUserDb(): Promise<{ sync: () => {}, }; - // Every launch, not once: see db/simple-repair.ts for why a one-shot - // migration loses the repair the first time an old build pushes. + // Once per device, flag-keyed rather than `user_version`-keyed — see + // db/simple-repair.ts for why it is neither a migration nor per-launch. The + // flag also stops it firing mid-session, since this runs again whenever the + // web lock is reacquired. const lifted = await repairRatchetedIntervals(wrapped); if (lifted > 0) console.log(`[UserDB Web] Lifted ${lifted} ratcheted cards`); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d7d2df3..3359f83 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1264,9 +1264,13 @@ Simple spaced repetition with a day-based epoch and fixed interval multipliers. - **Dynamic deque**: maintains ~10 active cards at a time, pulling from pools as needed - Two pools loaded at session start: due cards (priority) and new cards + - Both pools are loaded by `loadSimpleQueuePools` in `db/simple-queue.ts`, which is where + the SQL lives so a test can run it rather than a copy of it - The new pool is capped at `lists.new_per_session` (default 10), read from the row - inside `loadQueue`. Smart Review and the flagged-cards list are exempt - (`isEphemeralListId`), since both are assembled on purpose and are entirely new + inside `loadQueue`, less the unseen cards this session has already answered — so + "Continue Studying" tops the session up to the cap rather than granting another one. + Smart Review and the flagged-cards list are exempt (`isEphemeralListId`), since both are + assembled on purpose and are entirely new - The due pool is loaded in two parts: graduated cards due today, uncapped — that is the real review load — and cards still in learning, capped at `learningCapFor()` (three times `new_per_session`), ordered most-recently-answered first and placed at the head @@ -1285,9 +1289,13 @@ Simple spaced repetition with a day-based epoch and fixed interval multipliers. - `simpleDueIdsRef` tracks which cards were due at start; `completedSrsIdsRef` tracks graduated due cards - Session length is dynamic — failing cards extends the session, new cards trickle in continuously -Scheduling changes here are measured by simulating a deck through `nextSimpleReview` -itself; [simple-srs-decisions.md](simple-srs-decisions.md) records what has been decided, -the numbers behind it, and what was measured and rejected. +**Repair:** `db/simple-repair.ts` lifts cards the pre-fix lapse rule ratcheted to the floor. +It runs once per device, keyed to an `app_flags` row — not as a migration, and not on every +launch, because the clause also matches cards the fixed engine honestly leaves at the floor. + +Scheduling changes here are measured by simulating a deck through `nextSimpleReview` itself, +with `yarn simulate:srs`; [simple-srs-decisions.md](simple-srs-decisions.md) records what has +been decided, the numbers behind it, and what was measured and rejected. ### FSRS (`ts-fsrs` library) diff --git a/docs/simple-srs-decisions.md b/docs/simple-srs-decisions.md index 5096cfd..78551db 100644 --- a/docs/simple-srs-decisions.md +++ b/docs/simple-srs-decisions.md @@ -76,20 +76,23 @@ every run driven by the shipped `nextSimpleReview`: | answers/day | repair | d1 | d7 | d30 | d90 | d180 | d365 | | ----------- | ------------ | ---- | ---- | ---- | ---- | ---- | ---- | | 1000 | none | 8000 | 7338 | 5270 | 1328 | 81 | 18 | -| 1000 | spread 2–30d | 8000 | 3633 | 261 | 109 | 63 | 39 | -| 1000 | flat 7d | 8000 | 3985 | 511 | 402 | 357 | 259 | -| 200 | none | 8000 | 7874 | 7492 | 6699 | 5554 | 3567 | -| 200 | spread 2–30d | 8000 | 7096 | 5359 | 3388 | 1131 | 55 | -| 200 | flat 7d | 8000 | 7128 | 6134 | 4886 | 2259 | 198 | -| 150 | none | 8000 | 7888 | 7595 | 6990 | 6180 | 4661 | -| 150 | spread 2–30d | 8000 | 7332 | 6074 | 4581 | 2854 | 72 | - -A four-digit cell is one sample of a stochastic process; `--seed=N` resamples. The -conclusions below hold across seeds, the exact numbers do not. +| 1000 | spread 2–30d | 8000 | 3607 | 243 | 116 | 64 | 58 | +| 1000 | flat 7d | 8000 | 3461 | 174 | 553 | 579 | 13 | +| 200 | none | 8000 | 7863 | 7462 | 6676 | 5552 | 3579 | +| 200 | spread 2–30d | 8000 | 7106 | 5311 | 3431 | 1166 | 47 | +| 200 | flat 7d | 8000 | 7072 | 6094 | 4860 | 2272 | 252 | +| 150 | none | 8000 | 7900 | 7581 | 6980 | 6164 | 4664 | +| 150 | spread 2–30d | 8000 | 7343 | 5958 | 4530 | 2839 | 76 | + +A four-digit cell is one sample of a stochastic process at seed 7; `--seed=N` resamples +(the `ratchet` and `discriminate` scenarios are exhaustive sweeps and ignore it). Every row +of a comparison shares one seed, which was not true at first: sampling each row from its own +stream confounded the thing being compared with the sample, and produced a conclusion below +that reversed at every other seed. **At 1,000 answers a day the deck heals itself in about four months, and at 150–200 it does not.** That single number is why the first conclusion here — natural recovery, no repair — -was wrong: it was measured at an effort nobody sustains. At 200 a day, 3,567 cards are still +was wrong: it was measured at an effort nobody sustains. At 200 a day, 3,579 cards are still queued a year later, because a card answered today returns within hours and takes tomorrow's slot. @@ -100,10 +103,10 @@ not repaired** says by how much. What was rejected, and why: - **No repair.** The numbers above, at the effort the deck is actually reviewed at. -- **A flat lift to 7 days.** Half the drain of the spread at every horizon — 2,259 still - queued at d180 against 1,131 — and it re-synchronises the deck: at 1,000 answers a day the - spread is down to 39 cards by d365 while the flat lift still shows 259, the same wave - coming back together. +- **A flat lift to 7 days.** Half the drain of the spread at every horizon — 2,272 still + queued at d180 against 1,166 — and it re-synchronises the deck: at 1,000 answers a day the + queue falls to 174 by d30 and then swings back to 553 and 579 on d90 and d180, the whole + deck returning together, where the spread settles at 116 and 64. - **A narrower spread.** A shorter window drains more slowly for the same reason the flat lift does: more cards land on the same days. The backlog is the complaint, and a card given more days than it has earned costs one failed review when it comes back. @@ -178,24 +181,37 @@ interval longer than a day — i.e. actually kept (`yarn simulate:srs learningCa | learning cap | answers/day | d1 | d7 | d30 | d180 | d365 | touched stage 1 | kept | | ------------ | ----------- | ---- | ---- | ---- | ---- | ---- | --------------- | ---- | -| none | 200 | 8000 | 8000 | 8000 | 8000 | 5447 | 8000 | 2665 | -| 30 (3 × 10) | 200 | 30 | 102 | 91 | 108 | 123 | 3042 | 3008 | -| 10 | 200 | 10 | 46 | 80 | 107 | 113 | 3238 | 3221 | -| none | 400 | 8000 | 8000 | 8000 | 5287 | 817 | 8000 | 7451 | -| 30 (3 × 10) | 400 | 30 | 147 | 214 | 259 | 266 | 7042 | 7005 | -| 10 | 400 | 10 | 50 | 77 | 123 | 122 | 3650 | 3634 | +| none | 200 | 8000 | 8000 | 8000 | 8000 | 5480 | 8000 | 2642 | +| 30 (3 × 10) | 200 | 30 | 119 | 86 | 133 | 140 | 3706 | 3674 | +| 10 | 200 | 10 | 47 | 75 | 106 | 112 | 3240 | 3223 | +| none | 400 | 8000 | 8000 | 8000 | 5256 | 762 | 8000 | 7489 | +| 30 (3 × 10) | 400 | 30 | 137 | 221 | 246 | 230 | 7051 | 7006 | +| 10 | 400 | 10 | 47 | 75 | 118 | 126 | 3650 | 3631 | **Which column you read decides the answer, and an earlier version of this section read the wrong one.** It said the cap cost thousands of words, counting cards that touched stage 1. -Uncapped, 8,000 do — and only 2,665 are still there at the end, because an uncapped deck -churns: a card graduates at 0.633 days, comes back the same day, fails, and sits at the -floor again. Capped, almost everything touched is kept (3,008 of 3,042). **On words actually -retained the cap wins**, 3,008 against 2,665, while showing 100 cards a day instead of 8,000. - -The ×3 is what keeps it from starving a learner who does more: at 400 answers a day the -cap of thirty keeps 7,005 words against 3,634 for a cap of ten. At 200 a day the cap of ten -is marginally ahead (3,221 against 3,008), so the multiplier costs a little at low effort -and is worth several thousand words at high effort. +Uncapped, 8,000 do — and only 2,642 are still there at the end, because an uncapped deck +churns: a card graduates at 0.633 days, comes back the same day, fails, and sits at the floor +again. Capped, almost everything touched is kept (3,674 of 3,706). **On words actually +retained the cap wins at the effort this is about**, 3,674 against 2,642, while showing 140 +cards a day instead of 8,000. + +The ×3 is ahead of a flat cap at both efforts, and that needed four seeds to say, because a +single seed said the opposite (`yarn simulate:srs capSeeds`): + +| seed | answers/day | kept, cap 30 | kept, cap 10 | uncapped | +| ---- | ----------- | ------------ | ------------ | -------- | +| 7 | 200 | 3674 | 3223 | 2642 | +| 11 | 200 | 3673 | 3226 | 2614 | +| 42 | 200 | 3665 | 3215 | 2657 | +| 99 | 200 | 3669 | 3221 | 2639 | +| 7 | 400 | 7006 | 3631 | 7489 | +| 11 | 400 | 6991 | 3626 | 7451 | + +Uncapped is ahead only at 400 answers a day, and only by about 6% — for which it shows the +entire 8,000-card deck every day for six months. A cap of ten costs half the words at that +effort, which is what the multiplier is for: three answers graduate one card, so ten slots +cannot deliver them alongside reviews. A genuinely ratcheted card currently in learning is the price of not repairing stage 0: it re-graduates to the floor once and then climbs 0.33 → 0.63 → 1.2 by itself. @@ -216,12 +232,19 @@ Accepted trades, written down because they are real: - A list moved from simple SRS to FSRS has its dormant `simple_*` columns rewritten and its `updated_at` bumped, so rows that no longer schedule anything get pushed. Harmless, and cheaper than a `flashcard_mode` join that would also skip a list switched back later. -- The spread is `2 + (rowid % 29)`, deterministic rather than random, so the repair is - idempotent: a repaired row no longer matches the clause, which matters because every - device runs the UPDATE against its own rowids. A stale second device re-spreads its own - copies with its own offsets, which is harmless — they stay inside the same month. -- `isRemoteRelevant` keeps the UPDATE out of remote replay and the bumped `updated_at` - carries the result by row sync. +- **The repair runs once per device, keyed to an `app_flags` row, and cannot run on every + launch.** The clause matches rows the FIXED engine legitimately produces: a card that fails + and re-graduates from a short interval lands at exactly `MIN_INTERVAL`, so three honest + lapses leave it at 1/3 with `reps = 15` and `lapses = 3`, matching every condition. Once, + that is the bounded cost in the table above. Every launch, it is a standing policy that + buries the words someone is working hardest on, over and over. It was written that way + first and review caught it. +- It does not bump `updated_at`, so it never travels: a device opened for the first time in + months would otherwise become the newest writer of every card it touched and push stale + values over reviews done elsewhere. The price is that a device still on an old build can + push a ratcheted interval back, and nothing lifts it again. That is the better trade — one + stale card against repeatedly burying hard words — and it is why the repair is not a + migration keyed to `PRAGMA user_version` either. - Cards imported later are never repaired: `lib/data-backup.ts` restore and `lib/list-transfer.ts` import run against an already-migrated database, so a floored row arriving afterwards stays floored. `simple_relearning`'s backfill has the same hole. Not diff --git a/lib/data-backup.test.ts b/lib/data-backup.test.ts index 39cb529..6057208 100644 --- a/lib/data-backup.test.ts +++ b/lib/data-backup.test.ts @@ -1,7 +1,14 @@ import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; import { createTestDb } from "@/test/test-db"; -import { attemptBackup, importBackup, BACKUP_TABLES, TABLE_COLUMNS } from "./data-backup"; +import { + attemptBackup, + importBackup, + BACKUP_TABLES, + TABLE_COLUMNS, + IMPORT_ORDER, + PRIMARY_KEYS, +} from "./data-backup"; describe("data backup", () => { it("round-trips local-only history tables through backup and import", async () => { @@ -356,3 +363,113 @@ describe("restoring one table does not take its neighbours with it", () => { expect(result.succeeded).not.toContain("books"); }); }); + +describe("a row that will not go in does not take the table with it", () => { + it("survives a collision on a unique index other than the primary key", async () => { + // `confusion_pairs` carries a unique index on its five content columns, so + // a pair that already exists locally under a different id raises an error + // the primary key's ON CONFLICT cannot absorb. That used to abort the table + // and drop every row after it. + const db = createTestDb(); + const now = "2026-04-05T00:00:00.000Z"; + await db.runAsync( + `INSERT INTO confusion_pairs (id, entry_id_a, kanji_literal_a, entry_id_b, + kanji_literal_b, confusion_type, confusion_count, last_confused_at, created_at) + VALUES ('local', 1, '漢', 2, '字', 'meaning', 1, ?, ?)`, + [now, now], + ); + + const result = await importBackup( + db, + JSON.stringify({ + version: 1, + tables: { + confusion_pairs: [ + { + id: "incoming", + entry_id_a: 1, + kanji_literal_a: "漢", + entry_id_b: 2, + kanji_literal_b: "字", + confusion_type: "meaning", + confusion_count: 9, + last_confused_at: now, + created_at: now, + }, + { + id: "unrelated", + entry_id_a: 7, + kanji_literal_a: null, + entry_id_b: 8, + kanji_literal_b: null, + confusion_type: "meaning", + confusion_count: 1, + last_confused_at: now, + created_at: now, + }, + ], + }, + }), + ); + + expect(result.failed).toEqual([]); + // The colliding pair was replaced, and the row behind it still landed. + const rows = await db.getAllAsync<{ id: string }>("SELECT id FROM confusion_pairs ORDER BY id"); + expect(rows.map((r) => r.id)).toEqual(["incoming", "unrelated"]); + }); + + it("writes the rows it can when one row is unwritable", async () => { + const db = createTestDb(); + const now = "2026-04-05T00:00:00.000Z"; + const result = await importBackup( + db, + JSON.stringify({ + version: 1, + tables: { + lists: [ + // No `name`, which is NOT NULL: this row cannot go in. + { id: "bad", created_at: now, updated_at: now }, + { id: "good", name: "Core 8k", created_at: now, updated_at: now }, + ], + }, + }), + ); + expect(result.failed).toEqual([]); + expect(await db.getAllAsync<{ id: string }>("SELECT id FROM lists")).toEqual([{ id: "good" }]); + }); + + it("builds valid SQL for a row that carries only its primary key", async () => { + // `ON CONFLICT(id) DO UPDATE SET` with nothing to set is a syntax error, + // and cost the whole table. The row still cannot go in — `lists.name` is + // NOT NULL — but it must fail on the constraint, not on the statement. + const db = createTestDb(); + const result = await importBackup( + db, + JSON.stringify({ version: 1, tables: { lists: [{ id: "L" }] } }), + ); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].error).toMatch(/NOT NULL/i); + expect(result.failed[0].error).not.toMatch(/incomplete input|syntax/i); + }); +}); + +describe("the restore's primary keys", () => { + it("names the real primary key of every table it restores", async () => { + // A wrong name makes `ON CONFLICT(pk)` throw and costs the whole table, and + // the map is easy to leave stale — it carried two tables that are not even + // restored. + const db = createTestDb(); + const wrong: string[] = []; + for (const table of IMPORT_ORDER) { + const info = await db.getAllAsync<{ name: string; pk: number }>( + `PRAGMA table_info(${table})`, + ); + const actual = info.filter((c) => c.pk > 0).map((c) => c.name); + const used = PRIMARY_KEYS[table] ?? "id"; + if (actual.length !== 1 || actual[0] !== used) { + wrong.push(`${table}: restore uses ${used}, schema says ${actual.join("+") || "none"}`); + } + } + expect(wrong).toEqual([]); + }); +}); diff --git a/lib/data-backup.ts b/lib/data-backup.ts index d3b5948..4ef3439 100644 --- a/lib/data-backup.ts +++ b/lib/data-backup.ts @@ -87,11 +87,13 @@ export const BACKUP_TABLES: { ]; // Column names for each table — used for INSERT during import -/** Where a table's primary key is not `id`. Used for the restore's upsert. */ -const PRIMARY_KEYS: Record = { - app_flags: "key", +/** + * Where a table in `IMPORT_ORDER` has a primary key that is not `id`. A wrong + * name makes `ON CONFLICT(pk)` throw, so this list is checked against the + * schema by `lib/data-backup.test.ts`. + */ +export const PRIMARY_KEYS: Record = { user_kanji_notes: "literal", - sync_meta: "key", }; export const TABLE_COLUMNS: Record = { @@ -258,7 +260,7 @@ export const TABLE_COLUMNS: Record = { }; // Import order matters for FK constraints -const IMPORT_ORDER = [ +export const IMPORT_ORDER = [ "lists", "list_entries", "srs_cards", @@ -383,16 +385,19 @@ export async function importBackup( // leaving the list with no words and its cards orphaned. An upsert touches // only the row named. const pk = PRIMARY_KEYS[tableName] ?? "id"; - const upsert = (columns: string[]) => - `INSERT INTO ${tableName} (${columns.join(", ")}) - VALUES (${columns.map(() => "?").join(", ")}) - ON CONFLICT(${pk}) DO UPDATE SET ${columns - .filter((col) => col !== pk) - .map((col) => `${col} = excluded.${col}`) - .join(", ")}`; + const upsert = (columns: string[]) => { + const assignments = columns + .filter((col) => col !== pk) + .map((col) => `${col} = excluded.${col}`) + .join(", "); + return `INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${columns.map(() => "?").join(", ")}) + ON CONFLICT(${pk}) ${assignments ? `DO UPDATE SET ${assignments}` : "DO NOTHING"}`; + }; try { const sql = upsert(cols); + const rowErrors: string[] = []; for (const row of rows) { // A row with no primary key would insert under a NULL one — SQLite @@ -406,13 +411,40 @@ export async function importBackup( // column default. So a column the backup never had is left out of the // statement entirely, and takes its default. const named = cols.filter((col) => row[col] !== undefined); - await db.runAsync( - named.length === cols.length ? sql : upsert(named), - named.map((col) => row[col] ?? null), - ); + const values = named.map((col) => row[col] ?? null); + try { + await db.runAsync(named.length === cols.length ? sql : upsert(named), values); + } catch (rowErr) { + // A table can carry a unique index besides its primary key — + // `confusion_pairs` does — and a row that collides on it raises an + // error the pk's ON CONFLICT cannot absorb. Replacing that one row is + // safe (nothing references it); aborting the table was not, because a + // single colliding pair used to cost every row after it. + if (/UNIQUE constraint failed/i.test(String(rowErr))) { + await db.runAsync( + `INSERT OR REPLACE INTO ${tableName} (${named.join(", ")}) + VALUES (${named.map(() => "?").join(", ")})`, + values, + ); + } else { + // One bad row is not the table. Collect it and keep going, so a + // truncated or hand-edited file costs that row and not the rest. + rowErrors.push(String(rowErr)); + } + } processedRows++; } + // Every row failed, so the table did: report it as before, with the + // first row's error rather than a wrapper around it. + if (rowErrors.length === rows.length && rows.length > 0) { + result.failed.push({ table: tableName, error: rowErrors[0] }); + continue; + } + if (rowErrors.length > 0) { + console.warn(`[Import] ${rowErrors.length} row(s) of ${tableName} failed`, rowErrors[0]); + } + result.succeeded.push(tableName); result.totalRows += rows.length; } catch (err) { diff --git a/scripts/simulate-simple-srs.ts b/scripts/simulate-simple-srs.ts index a37f05e..aca7024 100644 --- a/scripts/simulate-simple-srs.ts +++ b/scripts/simulate-simple-srs.ts @@ -213,7 +213,6 @@ const SCENARIOS: Record void> = { console.log(`(seed ${SEED})\n`); console.log("| answers/day | repair | " + header.slice(2)); console.log("| --- | --- |" + MARKS.map(() => " --- |").join("")); - let seed = SEED; for (const budget of [1000, 200, 150]) { for (const [label, interval] of [ ["none", () => 1 / 3], @@ -226,9 +225,8 @@ const SCENARIOS: Record void> = { budget, newPerDay: 0, learningCap: UNLIMITED, - seed, + seed: SEED, }); - seed += 1; console.log(`| ${budget} | ${label} | ` + at(run.shown).join(" | ") + " |"); } } @@ -298,17 +296,19 @@ const SCENARIOS: Record void> = { "| learning cap | answers/day | " + header.slice(2) + " touched stage 1 | learned |", ); console.log("| --- | --- |" + MARKS.map(() => " --- |").join("") + " --- | --- |"); - let seed = SEED; for (const budget of [200, 400]) { for (const cap of [UNLIMITED, learningCapFor(10), 10]) { - seed += 1; const run = simulate({ deck: learningDeck(), days: 365, budget, newPerDay: 0, learningCap: cap, - seed, + // Every row of a comparison shares the seed, or the thing being + // compared is confounded with the sample: sampling each cap from its + // own stream made a cap of ten look ahead of thirty at 200 answers a + // day, which reversed at every other seed. + seed: SEED, }); const label = cap === UNLIMITED ? "none" : String(cap); console.log( @@ -320,6 +320,29 @@ const SCENARIOS: Record void> = { } }, + /** Is the x3 multiplier ahead of a flat cap, across seeds? */ + capSeeds() { + console.log("\n## The learning cap's multiplier, across seeds\n"); + console.log("| seed | answers/day | kept, cap 30 | kept, cap 10 | uncapped |"); + console.log("| --- | --- | --- | --- | --- |"); + for (const seed of [7, 11, 42, 99]) { + for (const budget of [200, 400]) { + const kept = (cap: number) => + simulate({ + deck: learningDeck(), + days: 365, + budget, + newPerDay: 0, + learningCap: cap, + seed, + }).learnedAtEnd; + console.log( + `| ${seed} | ${budget} | ${kept(learningCapFor(10))} | ${kept(10)} | ${kept(UNLIMITED)} |`, + ); + } + } + }, + /** The steady state the fixed engine settles into on a fresh deck. */ steady() { console.log("\n## A fresh 8,000-card deck, ten new a day, fixed engine\n"); diff --git a/stores/simple-srs.test.ts b/stores/simple-srs.test.ts index bb50307..28f611e 100644 --- a/stores/simple-srs.test.ts +++ b/stores/simple-srs.test.ts @@ -501,3 +501,25 @@ describe("one answer, decided the way the screen decides it", () => { expect(nextSimpleReview(graduated(), "pass").graduated).toBe(true); }); }); + +describe("the stage domain", () => { + test("writes only 0 and 1, which every consumer keys on", () => { + // `db/simple-queue.ts`, `DueCardsSection`, the lists index, `stores/lists.ts` + // and `lib/practice-stats.ts` all treat stage 1 as graduated and 0 as + // learning. A third value would be served by some and counted by none, so + // the invariant is pinned where it is produced. + const stages = new Set(); + const seen: SrsCardRow[] = [ + { simpleStage: null, simpleN: null, simpleInterval: null, simpleRelearning: 0, simpleRun: 0 }, + { simpleStage: 0, simpleN: 0, simpleInterval: 1 / 3, simpleRelearning: 0, simpleRun: 0 }, + { simpleStage: 0, simpleN: 0, simpleInterval: 1 / 3, simpleRelearning: 1, simpleRun: 2 }, + { simpleStage: 1, simpleN: 9000, simpleInterval: 56.61, simpleRelearning: 0, simpleRun: 0 }, + ] as unknown as SrsCardRow[]; + for (const card of seen) { + for (const action of ["pass", "easy", "fail"] as const) { + stages.add(nextSimpleReview(card, action).updates.simpleStage); + } + } + expect([...stages].sort()).toEqual([0, 1]); + }); +}); From aa12b993efcb31d713b92c6175fc252c211ecc71 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 18:34:22 +0800 Subject: [PATCH 11/12] Address the final review: the repair must survive a reinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both real. **The repair never reached a reinstall or a second device.** The flag was written whether or not there was a deck to repair, so a fresh install spent its one run on an empty table — and the ratcheted deck arriving on the next sync pull stayed flattened forever, with no path back, because the repair deliberately never pushes so the remote copy is flattened too. A reinstall is how a user meets this, not an edge case. The flag is now written only once the device has actually seen a simple-SRS card. **The claim that the screen's wiring was gated was false.** Moving the queries into `db/simple-queue.ts` gated the SQL, not the screen calling it: review demonstrated that `newCap = UNLIMITED`, deleting `simple_run = ?` from the persistence UPDATE, `newAllowance` → `newCap`, and deleting the allowance's `add` each still passed all 2,570 tests. Nothing imports a React screen here, so `app/study-wiring.test.ts` reads its source for exactly those five edits and fails on every one of them. It is a guard rather than a behavioural test, and it says so — but it catches the accidental revert, which is the one that happens. The overclaim in `db/simple-queue.ts`'s header is corrected. Also: - A backup taken before `user_kanji_notes.updated_at` existed lost every mnemonic on restore: the column is NOT NULL with no default, so each row failed the constraint. A restore now stands in the epoch for a required timestamp the backup never carried — metadata, not the user's data, and stamped at 1970 so a restored note cannot outrank a live one in an `updated_at > ?` comparison it never earned. Pre-existing on master; this branch is what makes it this file's problem. - The accepted-trades list named restore and list import but omitted sync pull, the common path, and did not record that a restore no longer resurrects a soft-deleted row — `INSERT OR REPLACE` cleared `deleted_at` by deleting the row; an upsert does not. - Two comments still said the repair runs on every launch, which the previous commit had already established was wrong. Gates: typecheck clean, lint 0 errors, 2,578 tests passing, none skipped. Co-Authored-By: Claude Opus 5 (1M context) --- app/study-wiring.test.ts | 100 +++++++++++++++++++++++++++++++++++ db/simple-queue.ts | 6 +++ db/simple-repair.test.ts | 35 ++++++++++-- db/simple-repair.ts | 25 ++++++--- db/user-migrations.ts | 10 ++-- docs/simple-srs-decisions.md | 13 +++-- lib/data-backup.test.ts | 25 +++++++++ lib/data-backup.ts | 25 ++++++++- 8 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 app/study-wiring.test.ts diff --git a/app/study-wiring.test.ts b/app/study-wiring.test.ts new file mode 100644 index 0000000..d8d651c --- /dev/null +++ b/app/study-wiring.test.ts @@ -0,0 +1,100 @@ +/** + * The study screen's wiring, checked in its source. + * + * This is a guard, not a behavioural test, and it exists because review proved + * the same thing three rounds running: no test imports + * `app/(tabs)/lists/study.tsx`, so the whole fix is one-line revertable with the + * full suite green. Moving the SQL into `db/simple-queue.ts` gated the SQL; it + * did not gate the screen *calling* it, and the commit message that claimed + * otherwise was wrong. + * + * Each assertion below corresponds to a mutation that was demonstrated to + * survive 2,570 passing tests: + * + * - `newCap = UNLIMITED` — the 8,000-card queue, restored. + * - deleting `simple_run = ?` from the persistence UPDATE — the graduation fix, + * fully reverted. + * - `newAllowance` → `newCap` — "Continue Studying" grants a fresh allowance. + * - deleting `newAnsweredRef.current.add` — the allowance never spends. + * + * A source check cannot prove behaviour, and a determined edit can satisfy it + * while breaking the screen. It catches the accidental revert, which is the one + * that actually happens. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const SOURCE = readFileSync(join(REPO_ROOT, "app/(tabs)/lists/study.tsx"), "utf8"); + +/** Source with comments stripped, so a comment cannot satisfy an assertion. */ +const CODE = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + +describe("the study screen loads its pools through the tested module", () => { + it("calls loadSimpleQueuePools rather than its own SQL", () => { + expect(CODE).toContain("loadSimpleQueuePools({"); + // No hand-rolled copy of the queries it replaced. + expect(CODE).not.toMatch(/simple_stage\s+IS\s+NULL\s+AND\s+deleted_at/); + expect(CODE).not.toMatch(/simple_stage\s*=\s*0\s+AND\s+simple_n/); + }); + + it("resolves the cap from the list row instead of hard-coding one", () => { + expect(CODE).toMatch(/newCap\s*=\s*resolveNewPerSession\(/); + expect(CODE).toContain("SELECT new_per_session FROM lists WHERE id = ?"); + // The ephemeral exemption is the only unlimited path. + expect(CODE).toMatch(/isEphemeralListId\(listId\)/); + }); + + it("passes both the cap and what is left of it", () => { + const call = CODE.slice(CODE.indexOf("loadSimpleQueuePools({")); + const args = call.slice(0, call.indexOf("});")); + expect(args).toMatch(/\bnewCap\b/); + expect(args).toMatch(/\bnewAllowance\b/); + expect(args).toMatch(/excludeIds/); + }); + + it("computes the allowance from the cards it has answered", () => { + expect(CODE).toMatch( + /newAllowance\s*=[\s\S]{0,120}Math\.max\(0,\s*newCap\s*-\s*newAnsweredRef\.current\.size\)/, + ); + expect(CODE).toMatch(/newAnsweredRef\.current\.add\(card\.id\)/); + // And an undone answer is refunded, or a walk-back strands the session. + expect(CODE).toMatch(/newAnsweredRef\.current\.delete\(card\.id\)/); + }); +}); + +describe("the study screen persists what the engine decided", () => { + it("writes every field a simple-SRS answer produces", () => { + const update = CODE.slice(CODE.indexOf("UPDATE srs_cards SET simple_stage = ?")); + const statement = update.slice(0, update.indexOf("`")); + for (const column of [ + "simple_stage", + "simple_n", + "simple_interval", + "simple_relearning", + "simple_run", + ]) { + expect(statement).toContain(`${column} = ?`); + } + const binds = update.slice(0, update.indexOf("card.id,")); + for (const field of [ + "updates.simpleStage", + "updates.simpleN", + "updates.simpleInterval", + "updates.simpleRelearning", + "updates.simpleRun", + ]) { + expect(binds).toContain(field); + } + }); + + it("takes the decision from nextSimpleReview rather than recomputing it", () => { + expect(CODE).toMatch(/nextSimpleReview\(card,\s*action\)/); + // The lapse bug was this call site handing `simpleGraduate` a lifetime + // counter, so the screen must not reach for the engine's internals. + expect(CODE).not.toContain("simpleGraduate("); + expect(CODE).not.toContain("card.lapses > 0"); + }); +}); diff --git a/db/simple-queue.ts b/db/simple-queue.ts index 878a6d5..a774cba 100644 --- a/db/simple-queue.ts +++ b/db/simple-queue.ts @@ -7,6 +7,12 @@ * left the whole suite green — the 8,000-card queue and the graduation fix both * fully revertable without a single failure. `db/simple-queue.test.ts` runs * THESE functions, so the SQL a session uses is the SQL a test gates. + * + * That gates the SQL and not the screen calling it, which an earlier commit + * message claimed and review disproved: the same one-line reverts still passed + * once the queries had moved. `app/study-wiring.test.ts` reads the screen's + * source for exactly those edits — a guard, not a behavioural test, and the + * honest limit of testing a React screen nothing can render here. */ import { SRS_CARD_SELECT } from "./srs-card-columns"; diff --git a/db/simple-repair.test.ts b/db/simple-repair.test.ts index 8b34386..faef57c 100644 --- a/db/simple-repair.test.ts +++ b/db/simple-repair.test.ts @@ -12,9 +12,8 @@ * at 1/3 because that is where new cards start, and with the whole list loading * as new those are most of the stage-0 population. * - * It runs on every launch rather than once, so running it twice must be a - * no-op — that is what lets a repair survive an old build pushing a ratcheted - * value back. + * It runs once per device, so the statement itself is still written to be + * idempotent: the flag is the mechanism, the spread starting at 2 is the belt. */ import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; @@ -298,9 +297,37 @@ describe("the repair runs once per device", () => { ).toEqual({ simple_interval: 0.3333 }); }); - it("writes the flag even when a healthy deck has nothing to repair", async () => { + it("claims nothing on an empty database, so a reinstall still gets repaired", async () => { + // A fresh install has no cards: the deck arrives on the next sync pull, and + // the repair never pushes, so if the flag were spent here the deck would + // stay flattened forever on every device. const db = createTestDb(); expect(await repairRatchetedIntervals(db)).toBe(0); + expect(await repairRatchetedIntervals(db)).toBe(0); + + const now = "2026-04-05T00:00:00.000Z"; + await db.runAsync( + `INSERT INTO srs_cards (id, entry_id, list_id, due, stability, difficulty, elapsed_days, + scheduled_days, reps, lapses, state, simple_stage, simple_n, simple_interval, + created_at, updated_at) + VALUES ('pulled', 1, NULL, 'x', 0, 0, 0, 0, 40, 5, 0, 1, 0, 0.3333, ?, 'OLD')`, + [now], + ); + expect(await repairRatchetedIntervals(db)).toBe(1); + expect(await repairRatchetedIntervals(db)).toBe(-1); + }); + + it("settles on a healthy deck after one run", async () => { + const db = createTestDb(); + const now = "2026-04-05T00:00:00.000Z"; + await db.runAsync( + `INSERT INTO srs_cards (id, entry_id, list_id, due, stability, difficulty, elapsed_days, + scheduled_days, reps, lapses, state, simple_stage, simple_n, simple_interval, + created_at, updated_at) + VALUES ('healthy', 1, NULL, 'x', 0, 0, 0, 0, 40, 0, 0, 1, 9000, 56.61, ?, 'OLD')`, + [now], + ); + expect(await repairRatchetedIntervals(db)).toBe(0); expect(await repairRatchetedIntervals(db)).toBe(-1); }); diff --git a/db/simple-repair.ts b/db/simple-repair.ts index 2c40d7c..0aa64e1 100644 --- a/db/simple-repair.ts +++ b/db/simple-repair.ts @@ -50,6 +50,9 @@ export const REPAIR_RATCHETED_INTERVALS = `UPDATE srs_cards SET /** * Run the repair if this device has not. Returns how many cards were lifted, or * -1 if it had already run. + * + * "Has not" means the flag is absent, and the flag is only written once the + * device has actually seen a simple-SRS deck — see below. */ export async function repairRatchetedIntervals(db: WrappedUserDb): Promise { try { @@ -60,12 +63,22 @@ export async function repairRatchetedIntervals(db: WrappedUserDb): Promise( + "SELECT COUNT(*) as c FROM srs_cards WHERE simple_stage IS NOT NULL", + ); + if ((seen?.c ?? 0) > 0) { + await db.runAsync("INSERT OR REPLACE INTO app_flags (key, value) VALUES (?, ?)", [ + REPAIR_FLAG, + new Date().toISOString(), + ]); + } return result?.changes ?? 0; } catch (err) { // A deck left at the floor is bad; a launch that fails because of it is diff --git a/db/user-migrations.ts b/db/user-migrations.ts index 291881c..68b7c16 100644 --- a/db/user-migrations.ts +++ b/db/user-migrations.ts @@ -279,8 +279,8 @@ export const USER_DB_MIGRATIONS = [ `ALTER TABLE lists ADD COLUMN new_per_session INTEGER DEFAULT 10`, ]; -// The repair of already-ratcheted intervals is deliberately NOT here: a -// migration runs once, keyed to `PRAGMA user_version`, and a device still on an -// old build can push a ratcheted value back afterwards, at which point a -// one-shot repair is gone for good. It lives in `db/simple-repair.ts` and runs -// on every launch instead. +// The repair of already-ratcheted intervals is deliberately NOT here. It is +// keyed to an `app_flags` row rather than `PRAGMA user_version`, and the flag is +// only written once the device has seen a simple-SRS deck — a fresh install +// would otherwise spend its one run on an empty table and never repair the deck +// that arrives on the next sync. See `db/simple-repair.ts`. diff --git a/docs/simple-srs-decisions.md b/docs/simple-srs-decisions.md index 78551db..32d334f 100644 --- a/docs/simple-srs-decisions.md +++ b/docs/simple-srs-decisions.md @@ -245,7 +245,12 @@ Accepted trades, written down because they are real: push a ratcheted interval back, and nothing lifts it again. That is the better trade — one stale card against repeatedly burying hard words — and it is why the repair is not a migration keyed to `PRAGMA user_version` either. -- Cards imported later are never repaired: `lib/data-backup.ts` restore and - `lib/list-transfer.ts` import run against an already-migrated database, so a floored row - arriving afterwards stays floored. `simple_relearning`'s backfill has the same hole. Not - worth a mechanism; worth knowing. +- Cards arriving after the repair has run are never repaired: a backup restore, a list + import, **and a sync pull**, which is the common one. The flag is only written once the + device has seen a simple-SRS deck, so a fresh install does not spend its one run on an + empty table — review proved that version left a reinstalling user with a permanently + flattened deck, since the repair never pushes either. A deck that arrives _after_ a + repaired launch still stays as it is. +- A restore no longer resurrects a soft-deleted row. `INSERT OR REPLACE` cleared + `deleted_at` because it deleted and re-inserted; an upsert does not name the column unless + the backup carried it. Deliberate, and the better behaviour. diff --git a/lib/data-backup.test.ts b/lib/data-backup.test.ts index 6057208..09ec57e 100644 --- a/lib/data-backup.test.ts +++ b/lib/data-backup.test.ts @@ -473,3 +473,28 @@ describe("the restore's primary keys", () => { expect(wrong).toEqual([]); }); }); + +describe("a backup that predates a required column", () => { + it("restores mnemonics from a pre-feature backup instead of losing all of them", async () => { + // `user_kanji_notes.updated_at` is NOT NULL with no default and was in + // neither the export nor the restore list, so every row of a backup taken + // before it failed on the constraint. Pre-existing on master; the "survives + // a schema change" work is what makes it this file's problem. + const db = createTestDb(); + const result = await importBackup( + db, + JSON.stringify({ + version: 1, + tables: { user_kanji_notes: [{ literal: "水", mnemonic: "water", keyword: "water" }] }, + }), + ); + expect(result.failed).toEqual([]); + const row = await db.getFirstAsync<{ mnemonic: string; updated_at: string }>( + "SELECT mnemonic, updated_at FROM user_kanji_notes WHERE literal = '水'", + ); + expect(row?.mnemonic).toBe("water"); + // Stamped at the epoch, not now: a restored note must not outrank a live + // one in `updated_at > ?` comparisons it never earned. + expect(row?.updated_at).toBe("1970-01-01T00:00:00.000Z"); + }); +}); diff --git a/lib/data-backup.ts b/lib/data-backup.ts index 4ef3439..0b64087 100644 --- a/lib/data-backup.ts +++ b/lib/data-backup.ts @@ -92,6 +92,18 @@ export const BACKUP_TABLES: { * name makes `ON CONFLICT(pk)` throw, so this list is checked against the * schema by `lib/data-backup.test.ts`. */ +/** + * Columns a restore must supply if the backup did not, because the schema makes + * them NOT NULL with no default. Checked against `PRAGMA table_info` by + * `lib/data-backup.test.ts`. + */ +const REQUIRED_TIMESTAMPS: Record = { + user_kanji_notes: ["updated_at"], +}; + +/** When a restore stands in for a timestamp a backup never had. */ +const RESTORE_TIMESTAMP = new Date(0).toISOString(); + export const PRIMARY_KEYS: Record = { user_kanji_notes: "literal", }; @@ -411,7 +423,18 @@ export async function importBackup( // column default. So a column the backup never had is left out of the // statement entirely, and takes its default. const named = cols.filter((col) => row[col] !== undefined); - const values = named.map((col) => row[col] ?? null); + const values: unknown[] = named.map((col) => row[col] ?? null); + + // A timestamp a backup never carried is metadata, not the user's data, + // and the schema requires it: `user_kanji_notes.updated_at` is NOT NULL + // with no default, so every mnemonic in a pre-feature backup failed on + // it. Supplying one restores the row; leaving it out loses the lot. + for (const col of REQUIRED_TIMESTAMPS[tableName] ?? []) { + if (cols.includes(col) && row[col] === undefined) { + named.push(col); + values.push(RESTORE_TIMESTAMP); + } + } try { await db.runAsync(named.length === cols.length ? sql : upsert(named), values); } catch (rowErr) { From 0eda14d4f75c7cd343fd108731e281fe70b8c540 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 13 Oct 2026 18:38:20 +0800 Subject: [PATCH 12/12] Move the wiring guard out of the route directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app/` is expo-router's route directory, so a test file there is bundled as a route — and its `vitest` import drags Vite in, which fails the web build on a dynamic `import(filepath)` Metro cannot resolve: SyntaxError: node_modules/vite/dist/node/module-runner.js: Invalid call at line 916: import(filepath) Error: Command "yarn build:reader && npx expo export --platform web" exited with 1 The repo had no test file under `app/` before this one, which is why nothing caught it. Moved to `lib/`, with the reason written down so it does not come back. `yarn build:reader && expo export --platform web` now exits 0 locally; CI's build job passed throughout, since it does not run the web export. Co-Authored-By: Claude Opus 5 (1M context) --- {app => lib}/study-wiring.test.ts | 5 +++++ 1 file changed, 5 insertions(+) rename {app => lib}/study-wiring.test.ts (94%) diff --git a/app/study-wiring.test.ts b/lib/study-wiring.test.ts similarity index 94% rename from app/study-wiring.test.ts rename to lib/study-wiring.test.ts index d8d651c..7ca0ca6 100644 --- a/app/study-wiring.test.ts +++ b/lib/study-wiring.test.ts @@ -20,6 +20,11 @@ * A source check cannot prove behaviour, and a determined edit can satisfy it * while breaking the screen. It catches the accidental revert, which is the one * that actually happens. + * + * It lives in `lib/` and not beside the screen because `app/` is the + * expo-router route directory: a test file there is bundled as a route, and its + * `vitest` import drags Vite into the web build, which fails on a dynamic + * `import(filepath)` Metro cannot resolve. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs";