Senior software engineer blogging about software systems, computing history, and practical engineering.

Separating Schedulers from Memory Models

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 target r.
  • 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) and R⁻¹(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⁻¹).