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
+
{!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");
+});