This is an exploration of ideas set forth in Content Aware Spaced Repetition by Giacomo Randazzo
Goal: isolate the memory model from the scheduler.
The memory model estimates a card’s forgetting curve.
The scheduler decides what to show and when.
Why split
- Swap memory models without touching UX code.
- A/B scheduler policies without retraining or changing the model.
- Keep “retention target”, “interval”, “fuzzing”, “learning steps” out of the model.
Definitions
- Forgetting curve:
R(t) → [0,1]
, probability of recall at offset t days. - Inverse:
t = R⁻¹(r)
, days until recall probability equals targetr
. - Input to model: one terminal review per day, sorted ascending.
Memory model interface
/**
* ModelMemory: returns forgetting curve and its inverse from review history.
*/
export interface ActReviewCard {
dateIso: string; // yyyy-mm-dd
grade: 1 | 2 | 3 | 4; // again=1, hard=2, good=3, easy=4
}
export interface ModelMemoryOutput {
curveForgetting: (offsetDays: number) => number; // R(t)
curveForgettingInverse: (retrievability: number) => number; // t(R)
}
export interface ModelMemory {
calcStateMemory(args: { history: readonly ActReviewCard[] }): ModelMemoryOutput;
}
FSRS-backed memory model (TypeScript)
Uses ts-fsrs
to replay the day-level history and read the final stability S.
FSRS weight w20 shapes the curve so that R(S)=0.9
.
// fsrs-model-memory.ts
import { FSRS, generatorParameters, Rating, Card } from "ts-fsrs";
export type ActReviewCard = { dateIso: string; grade: 1 | 2 | 3 | 4 };
export type ModelMemoryOutput = {
curveForgetting: (offsetDays: number) => number;
curveForgettingInverse: (retrievability: number) => number;
};
export class FsrsModelMemory {
private fsrs: FSRS;
private w20: number;
constructor(weights?: number[]) {
const params = generatorParameters({});
if (weights) (params as any).weights = weights;
this.fsrs = new FSRS(params);
this.w20 = ((params as any).weights ?? [])[20];
}
calcStateMemory(args: { history: readonly ActReviewCard[] }): ModelMemoryOutput {
const h = args.history;
if (!h.length) throw new Error("history must be non-empty");
let card = this.initCard(new Date(h[0].dateIso));
for (const r of h) {
const when = new Date(r.dateIso);
const grade = r.grade as Rating;
const { card: next } = this.fsrs.next(card, when, grade);
card = next as Card;
}
const S = Math.max(card.stability, 1e-9);
const w20 = this.w20;
const factor = Math.pow(0.9, -1 / w20) - 1; // ensures R(S)=0.9
const curveForgetting = (tDays: number) =>
Math.pow(1 + factor * (tDays / S), -w20);
const curveForgettingInverse = (r: number) => {
const rr = Math.min(Math.max(r, 1e-9), 1);
return (S / factor) * (Math.pow(rr, -1 / w20) - 1);
};
return { curveForgetting, curveForgettingInverse };
}
private initCard(firstDate: Date): Card {
return {
due: firstDate,
stability: 0,
difficulty: 0,
elapsed_days: 0,
scheduled_days: 0,
reps: 0,
lapses: 0,
state: 0 as any,
last_review: firstDate,
} as Card;
}
}
Notes:
- The model returns only
R(t)
andR⁻¹(r)
. - It does not choose intervals, fuzz, or manage steps.
Scheduler (retention and intervals live here)
The scheduler decides:
- Which cards are due (e.g.,
R(now) ≤ r
). - Next interval after a review (
t = R⁻¹(r)
), plus caps, fuzz, steps, load balancing.
// scheduler.ts
import type { ModelMemoryOutput } from "./fsrs-model-memory";
export type SchedulerConfig = {
targetRetention: number; // e.g., 0.9
maxReviewsPerDay: number;
fuzzPct?: number; // ±% on interval
};
export class Scheduler {
constructor(private cfg: SchedulerConfig) {}
selectDue<T extends { lastReviewDate: Date; mm: ModelMemoryOutput }>(
cards: T[],
now: Date
): T[] {
const r = this.cfg.targetRetention;
return cards
.filter((c) => {
const days = this.daysBetween(c.lastReviewDate, now);
return c.mm.curveForgetting(days) <= r;
})
.sort((a, b) => a.lastReviewDate.getTime() - b.lastReviewDate.getTime())
.slice(0, this.cfg.maxReviewsPerDay);
}
nextIntervalDays(mm: ModelMemoryOutput): number {
const r = this.cfg.targetRetention;
let days = Math.max(1, Math.round(mm.curveForgettingInverse(r)));
if (this.cfg.fuzzPct) {
const k = this.cfg.fuzzPct / 100;
const delta = Math.floor((Math.random() * 2 - 1) * k * days);
days = Math.max(1, days + delta);
}
return days;
}
private daysBetween(a: Date, b: Date) {
return Math.max(0, Math.floor((b.getTime() - a.getTime()) / 86_400_000));
}
}
Wiring
import { FsrsModelMemory } from "./fsrs-model-memory";
import { Scheduler } from "./scheduler";
// Build per-card model output (once per session or lazily)
const mmFsrs = new FsrsModelMemory(/* optional optimized weights */);
const mm = mmFsrs.calcStateMemory({ history }); // history: one terminal review per day
// Use in scheduling
const scheduler = new Scheduler({ targetRetention: 0.9, maxReviewsPerDay: 200, fuzzPct: 5 });
const due = scheduler.selectDue([{ lastReviewDate, mm }], new Date());
// After answer
const intervalDays = scheduler.nextIntervalDays(mm);
// set nextDue = now + intervalDays
Edge cases
- New cards and same-day learning steps: keep them in the scheduler.
- History must be one terminal review per day, sorted.
- Persist stability if you want to skip replay; the interface stays the same.
- You can swap in a content-aware model if it returns the same two functions.
Result
- Memory model = estimate forgetting curve from history.
- Scheduler = choose cards and intervals from curves and UX rules.
- The boundary is the pair
(R, R⁻¹)
.