From a6b1649f97e391888ff77ab58408bce0c22811ea Mon Sep 17 00:00:00 2001 From: Euloge Date: Fri, 23 Oct 2026 06:53:17 +0200 Subject: [PATCH] feat(studio): experimental FSRS-4.5 scheduler for flashcards (off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the recommendation from the real-log evaluation without changing the default: the Plan tab offers Leitner (current, default) or FSRS-4.5 (experimental). FSRS rebuilds each card's memory from its own review log (no new stored state; switching back is always possible) and schedules the next review when predicted recall reaches 90 % (interval = stability, 1–365 days). Co-Authored-By: Claude Opus 5.5 --- .../ELOS_LEARNING_INTELLIGENCE_2_0.md | 6 ++- .../learning-studio/LearningStudio.tsx | 15 ++++-- src/modules/learning-studio/core.ts | 27 +++++++++- src/modules/scientific-pedagogy/replay.ts | 30 +++++++++-- .../learning-studio/fsrs-scheduler.test.ts | 50 +++++++++++++++++++ 5 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 tests/unit/learning-studio/fsrs-scheduler.test.ts diff --git a/docs/research/ELOS_LEARNING_INTELLIGENCE_2_0.md b/docs/research/ELOS_LEARNING_INTELLIGENCE_2_0.md index fe55356..db7dcdf 100644 --- a/docs/research/ELOS_LEARNING_INTELLIGENCE_2_0.md +++ b/docs/research/ELOS_LEARNING_INTELLIGENCE_2_0.md @@ -221,8 +221,10 @@ Limites : utilisateurs d’Anki et non d’ELOS (intervalles choisis par l’alg « intervalle visant 90 % » faute de probabilité native ; poids FSRS non personnalisés ; 6 utilisateurs ; une meilleure prédiction de la mémoire ne prouve pas un meilleur apprentissage. -Recommandation : introduire FSRS-4.5 comme planificateur **expérimental** (désactivé par défaut), comparé dans l’app -sur l’historique des apprenants ELOS (onglet Plan) ; décision de remplacement de `CURRENT_V1` réservée à l’humain. +Recommandation appliquée : FSRS-4.5 est disponible comme planificateur **expérimental** des flashcards du Studio +(onglet Plan, désactivé par défaut ; mémoire reconstruite depuis le journal de révisions, retour à Leitner possible à +tout moment). Remplacer `CURRENT_V1` par défaut reste une décision humaine, à prendre après comparaison sur +l’historique des apprenants ELOS. ## 11. Protocole d’évaluation pédagogique avec des apprenants (prêt à lancer) diff --git a/src/components/learning-studio/LearningStudio.tsx b/src/components/learning-studio/LearningStudio.tsx index af9aea6..0ac735f 100644 --- a/src/components/learning-studio/LearningStudio.tsx +++ b/src/components/learning-studio/LearningStudio.tsx @@ -12,7 +12,7 @@ import { dayKey } from "@/modules/daily-english/local-mission"; import type { ReviewOutcome } from "@/modules/daily-english/spaced-review"; import { answerFromSources, citationLabel, deriveFlashcards, deriveQuiz, difficulties, feynmanFeedback, gradeQuiz, mindMap, - readStudioState, reviewCard, revisionPlan, revisionSheet, searchPassages, socraticPrompt, studioReviewEvents, verifyCitation, writeStudioState, + rateCard, readStudioState, revisionPlan, revisionSheet, searchPassages, socraticPrompt, studioReviewEvents, verifyCitation, writeStudioState, type GroundedAnswer, type KeyValueStorage, type StudioState, } from "@/modules/learning-studio/core"; import { SYNTHETIC_DOCUMENTS } from "@/modules/learning-studio/fixtures"; @@ -150,9 +150,7 @@ export function LearningStudio() { function rate(outcome: ReviewOutcome) { if (!currentCard) return; - persist({ ...state, - reviews: { ...state.reviews, [currentCard.id]: reviewCard(state.reviews[currentCard.id], outcome, today) }, - reviewLog: [...(state.reviewLog ?? []), { cardId: currentCard.id, at: new Date().toISOString(), outcome }] }); + persist(rateCard(state, currentCard.id, outcome, new Date(), today)); setFlipped(false); setCardIndex((index) => index + 1); } @@ -484,6 +482,15 @@ export function LearningStudio() {

Planificateurs sur ton historique

+
+ Planificateur des flashcards + {([["CURRENT_V1", "Leitner (actuel)"], ["FSRS_4_5", "FSRS-4.5 (expérimental)"]] as const).map(([value, label]) => ( + + ))} +

S’applique aux prochaines notes de flashcards. FSRS a mieux prédit le rappel sur 6 historiques Anki publics ; à confirmer sur le tien.

+
{!replay.enough ? (

Pas encore assez d’historique : {replay.predictions}/{MIN_PREDICTIONS} révisions espacées d’au moins 12 h. Continue à réviser, la comparaison s’affichera ici.

) : ( diff --git a/src/modules/learning-studio/core.ts b/src/modules/learning-studio/core.ts index 5c3e8e2..5e96eb3 100644 --- a/src/modules/learning-studio/core.ts +++ b/src/modules/learning-studio/core.ts @@ -1,6 +1,6 @@ import type { AskCourseAnswer } from "../academic-workspace/types"; import { addDays, applyReview, type DayKey, type ReviewOutcome, type ReviewState } from "../daily-english/spaced-review"; -import type { ReviewEvent } from "../scientific-pedagogy/replay"; +import { fsrsIntervalDays, fsrsMemoryFromHistory, type ReviewEvent } from "../scientific-pedagogy/replay"; import type { FeynmanFeedback, Flashcard, MindMapNode, PassageHit, QuizQuestion, SheetEntry, SocraticPrompt, StudioCitation, StudioDocument, StudioQuizAttempt, @@ -289,6 +289,8 @@ export const STUDIO_STATE_KEY = "engineer-learning-os:learning-studio:v1"; const MAX_ATTEMPTS = 50; /** One flashcard rating; kept so schedulers can be compared on the learner's real history. */ +export type StudioScheduler = "CURRENT_V1" | "FSRS_4_5"; + export type CardReviewLogEntry = Readonly<{ cardId: string; at: string; outcome: ReviewOutcome }>; const MAX_REVIEW_LOG = 2000; @@ -298,6 +300,8 @@ export type StudioState = Readonly<{ attempts: readonly StudioQuizAttempt[]; /** Added later: absent in older saved states. */ reviewLog?: readonly CardReviewLogEntry[]; + /** Experimental FSRS scheduling; absent = current Leitner ladder. */ + scheduler?: StudioScheduler; }>; export type KeyValueStorage = Pick; const EMPTY: StudioState = { version: 1, reviews: {}, attempts: [] }; @@ -309,7 +313,8 @@ export function readStudioState(storage: KeyValueStorage | null): StudioState { const reviewLog = Array.isArray(parsed.reviewLog) ? parsed.reviewLog.filter((entry) => typeof entry?.cardId === "string" && typeof entry.at === "string" && ["SUCCESS", "PARTIAL", "FAIL"].includes(entry.outcome)) : []; - return { version: 1, reviews: parsed.reviews ?? {}, attempts: parsed.attempts.filter((attempt) => attempt?.canonical === false), reviewLog }; + const scheduler: StudioScheduler = parsed.scheduler === "FSRS_4_5" ? "FSRS_4_5" : "CURRENT_V1"; + return { version: 1, reviews: parsed.reviews ?? {}, attempts: parsed.attempts.filter((attempt) => attempt?.canonical === false), reviewLog, scheduler }; } catch { return EMPTY; } @@ -334,3 +339,21 @@ export function studioReviewEvents(state: StudioState): ReviewEvent[] { ...state.attempts.flatMap((attempt) => attempt.items.map((item) => ({ itemId: item.cardId, at: Date.parse(attempt.at), success: item.correct }))), ].filter((event) => Number.isFinite(event.at)); } + +/** + * Records a flashcard rating and schedules the card with the selected scheduler. + * FSRS (experimental) rebuilds the card's memory from its own review log, so no + * extra state is stored and switching back to Leitner is always possible. + */ +export function rateCard(state: StudioState, cardId: string, outcome: ReviewOutcome, now: Date, today: DayKey): StudioState { + const reviewLog = [...(state.reviewLog ?? []), { cardId, at: now.toISOString(), outcome }]; + let next: ReviewState; + if (state.scheduler === "FSRS_4_5") { + const history = studioReviewEvents({ ...state, reviewLog }).filter((event) => event.itemId === cardId); + const memory = fsrsMemoryFromHistory(history); + next = { lastReviewedDay: today, nextReviewDay: addDays(today, memory ? fsrsIntervalDays(memory.stability) : 1) }; + } else { + next = reviewCard(state.reviews[cardId], outcome, today); + } + return { ...state, reviews: { ...state.reviews, [cardId]: next }, reviewLog }; +} diff --git a/src/modules/scientific-pedagogy/replay.ts b/src/modules/scientific-pedagogy/replay.ts index 2071024..b00c9bd 100644 --- a/src/modules/scientific-pedagogy/replay.ts +++ b/src/modules/scientific-pedagogy/replay.ts @@ -41,16 +41,16 @@ export const FSRS_4_5_DEFAULT_WEIGHTS = [ ] as const; const DECAY = -0.5; const FACTOR = 19 / 81; -type Grade = 1 | 3; // Again | Good: the log only knows failure / success +export type Grade = 1 | 3; // Again | Good: the log only knows failure / success export function fsrsRetrievability(elapsedDays: number, stability: number): number { return Math.pow(1 + (FACTOR * Math.max(0, elapsedDays)) / stability, DECAY); } -type FsrsMemory = Readonly<{ stability: number; difficulty: number }>; +export type FsrsMemory = Readonly<{ stability: number; difficulty: number }>; const clampDifficulty = (value: number) => Math.min(10, Math.max(1, value)); -function fsrsInit(grade: Grade, w = FSRS_4_5_DEFAULT_WEIGHTS): FsrsMemory { +export function fsrsInit(grade: Grade, w = FSRS_4_5_DEFAULT_WEIGHTS): FsrsMemory { return { stability: w[grade - 1], difficulty: clampDifficulty(w[4] - w[5] * (grade - 3)) }; } @@ -116,3 +116,27 @@ export function replaySchedulers(events: readonly ReviewEvent[]): ReplayReport { scores: [score("CURRENT_V1", leitner), score("FSRS_4_5", fsrs)], }; } + +/** + * FSRS memory of one item rebuilt from its own history (same rules as the replay: + * same-session repeats ignored). Returns null when the item was never reviewed. + */ +export function fsrsMemoryFromHistory(history: readonly Readonly<{ at: number; success: boolean }>[]): FsrsMemory | null { + let memory: FsrsMemory | null = null; + let last = 0; + for (const event of [...history].sort((a, b) => a.at - b.at)) { + const grade: Grade = event.success ? 3 : 1; + if (memory === null) { memory = fsrsInit(grade); last = event.at; continue; } + const elapsed = (event.at - last) / DAY_MS; + if (elapsed < 0.5) continue; + memory = fsrsUpdate(memory, elapsed, grade); + last = event.at; + } + return memory; +} + +/** Days until predicted recall falls to the target (0.9 by default: the interval equals the stability). */ +export function fsrsIntervalDays(stability: number, targetRetention = 0.9): number { + const days = (stability / FACTOR) * (Math.pow(targetRetention, 1 / DECAY) - 1); + return Math.min(365, Math.max(1, Math.round(days))); +} diff --git a/tests/unit/learning-studio/fsrs-scheduler.test.ts b/tests/unit/learning-studio/fsrs-scheduler.test.ts new file mode 100644 index 0000000..645f5bf --- /dev/null +++ b/tests/unit/learning-studio/fsrs-scheduler.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { rateCard, readStudioState, writeStudioState, STUDIO_STATE_KEY, type StudioState } from "../../../src/modules/learning-studio/core.ts"; +import { fsrsIntervalDays, fsrsMemoryFromHistory, FSRS_4_5_DEFAULT_WEIGHTS } from "../../../src/modules/scientific-pedagogy/replay.ts"; + +const base: StudioState = { version: 1, reviews: {}, attempts: [], reviewLog: [] }; +const DAY = 86_400_000; + +test("FSRS interval equals stability at 90 % target and is bounded", () => { + assert.equal(fsrsIntervalDays(10), 10); + assert.equal(fsrsIntervalDays(0.2), 1); + assert.equal(fsrsIntervalDays(5000), 365); + assert.ok(fsrsIntervalDays(10, 0.8) > fsrsIntervalDays(10, 0.95)); +}); + +test("FSRS memory is rebuilt from the item's own history", () => { + assert.equal(fsrsMemoryFromHistory([]), null); + const first = fsrsMemoryFromHistory([{ at: 0, success: true }])!; + assert.equal(first.stability, FSRS_4_5_DEFAULT_WEIGHTS[2]); + const later = fsrsMemoryFromHistory([{ at: 0, success: true }, { at: 4 * DAY, success: true }])!; + assert.ok(later.stability > first.stability); +}); + +test("Leitner stays the default scheduler", () => { + const next = rateCard(base, "c1", "SUCCESS", new Date(2026, 9, 23, 10), "2026-10-23"); + assert.deepEqual(next.reviews.c1, { lastReviewedDay: "2026-10-23", nextReviewDay: "2026-10-24" }); + assert.equal(next.reviewLog?.length, 1); +}); + +test("experimental FSRS schedules from the review log and lengthens after spaced successes", () => { + let state: StudioState = { ...base, scheduler: "FSRS_4_5" }; + state = rateCard(state, "c1", "SUCCESS", new Date(Date.UTC(2026, 9, 1, 10)), "2026-10-01"); + const firstGap = Math.round((Date.parse(state.reviews.c1.nextReviewDay!) - Date.parse("2026-10-01")) / DAY); + assert.equal(firstGap, Math.round(FSRS_4_5_DEFAULT_WEIGHTS[2])); + state = rateCard(state, "c1", "SUCCESS", new Date(Date.UTC(2026, 9, 5, 10)), "2026-10-05"); + const secondGap = Math.round((Date.parse(state.reviews.c1.nextReviewDay!) - Date.parse("2026-10-05")) / DAY); + assert.ok(secondGap > firstGap, `${secondGap} > ${firstGap}`); + state = rateCard(state, "c1", "FAIL", new Date(Date.UTC(2026, 9, 20, 10)), "2026-10-20"); + const lapseGap = Math.round((Date.parse(state.reviews.c1.nextReviewDay!) - Date.parse("2026-10-20")) / DAY); + assert.ok(lapseGap >= 1 && lapseGap < secondGap, `after a lapse ${lapseGap} < ${secondGap}`); +}); + +test("the scheduler choice survives storage and unknown values fall back to Leitner", () => { + const values = new Map(); + const storage = { getItem: (k: string) => values.get(k) ?? null, setItem: (k: string, v: string) => { values.set(k, v); } }; + writeStudioState(storage, { ...base, scheduler: "FSRS_4_5" }); + assert.equal(readStudioState(storage).scheduler, "FSRS_4_5"); + values.set(STUDIO_STATE_KEY, JSON.stringify({ version: 1, reviews: {}, attempts: [], scheduler: "SM2" })); + assert.equal(readStudioState(storage).scheduler, "CURRENT_V1"); +});