Repository navigation
feat(studio): experimental FSRS-4.5 scheduler for flashcards (off by default) #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
| 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<Storage, "getItem" | "setItem">; | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a learner completes a quiz and then rates the same flashcard within 12 hours, Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After the global review log exceeds 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 }; | ||
| } | ||
| 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"); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new runtime import of
fsrsIntervalDaysandfsrsMemoryFromHistoryreaches directly intoscientific-pedagogy/replay.ts, even though that module's explicitindex.tsdoes not expose the replay implementation. This coupleslearning-studioto 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 👍 / 👎.