Senior software engineer at Qualia Labs · Co-founder of Fox.Build Makerspace · Former co-founder of FarmBot

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⁻¹).