Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/research/ELOS_LEARNING_INTELLIGENCE_2_0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 11 additions & 4 deletions src/components/learning-studio/LearningStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -484,6 +482,15 @@ export function LearningStudio() {
</div>
<div className="rounded-lg border p-2 text-sm" aria-label="Comparaison des planificateurs">
<p className="font-medium">Planificateurs sur ton historique</p>
<fieldset className="mt-1 space-y-1 text-xs">
<legend className="sr-only">Planificateur des flashcards</legend>
{([["CURRENT_V1", "Leitner (actuel)"], ["FSRS_4_5", "FSRS-4.5 (expérimental)"]] as const).map(([value, label]) => (
<label key={value} className="mr-3 inline-flex items-center gap-1">
<input type="radio" name="studio-scheduler" checked={(state.scheduler ?? "CURRENT_V1") === value} onChange={() => persist({ ...state, scheduler: value })} />{label}
</label>
))}
<p className="text-[11px] text-slate-500">S’applique aux prochaines notes de flashcards. FSRS a mieux prédit le rappel sur 6 historiques Anki publics ; à confirmer sur le tien.</p>
</fieldset>
{!replay.enough ? (
<p className="mt-1 text-xs text-slate-500">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.</p>
) : (
Expand Down
27 changes: 25 additions & 2 deletions src/modules/learning-studio/core.ts
Original file line number Diff line number Diff line change
@@ -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";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expose the scheduler through the module's public API

The new runtime import of fsrsIntervalDays and fsrsMemoryFromHistory reaches directly into scientific-pedagogy/replay.ts, even though that module's explicit index.ts does not expose the replay implementation. This couples learning-studio to another module's internal evaluation file; publish a stable scheduler API or move the shared scheduling primitives behind an appropriate boundary.

AGENTS.md reference: AGENTS.md:L26-L26

Useful? React with 👍 / 👎.

import type {
FeynmanFeedback, Flashcard, MindMapNode, PassageHit, QuizQuestion, SheetEntry, SocraticPrompt,
StudioCitation, StudioDocument, StudioQuizAttempt,
Expand Down Expand Up @@ -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;

Expand All @@ -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<Storage, "getItem" | "setItem">;
const EMPTY: StudioState = { version: 1, reviews: {}, attempts: [] };
Expand All @@ -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;
}
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep quiz attempts out of flashcard scheduling

When a learner completes a quiz and then rates the same flashcard within 12 hours, studioReviewEvents puts the quiz result immediately before the new rating, and fsrsMemoryFromHistory ignores the rating as a same-session repeat. Consequently, a correct quiz followed by an explicit FAIL schedules the card from the quiz success (four days for a new card) instead of the failure (one day); rebuild scheduling history from flashcard ratings only, or otherwise ensure the current rating is always applied.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist FSRS memory before truncating its source history

After the global review log exceeds MAX_REVIEW_LOG, writeStudioState persists only its last 2,000 entries; following a reload, this reconstruction therefore loses older per-card events and can treat a previously mature but infrequently reviewed card as new, resetting its next interval on the next rating. Persist per-card FSRS memory or compact history per card rather than making scheduling depend on a globally truncated log.

AGENTS.md reference: AGENTS.md:L30-L31

Useful? React with 👍 / 👎.

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 };
}
30 changes: 27 additions & 3 deletions src/modules/scientific-pedagogy/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) };
}

Expand Down Expand Up @@ -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)));
}
50 changes: 50 additions & 0 deletions tests/unit/learning-studio/fsrs-scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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");
});
Loading