haskell-fsrs

FSRS-7, the Free Spaced Repetition Scheduler

haskell-fsrs

CI

A Haskell implementation of FSRS-7, the seventh version of the Free Spaced Repetition Scheduler — the memory model behind Anki's scheduler.

FSRS predicts when you are about to forget a flashcard so it can be shown to you just before that happens. It tracks three numbers per card:

  • stability — the memory's half-life, in days;
  • difficulty — how hard this particular card is for you, on a 1–10 scale;
  • a second, faster-decaying stability — FSRS-7 tracks two memory traces, which is how it tells a review minutes later apart from one weeks later;

and derives retrievability, the probability that you can recall the card right now.

The package version tracks the algorithm version, the way py-fsrs and fsrs-rs do: 7.x.y implements FSRS-7.

What is new in FSRS-7

FSRS-7 has 34 parameters, up from FSRS-6's 21. What changed:

  • A card has two memory traces, not one. Alongside the familiar stability there is a second, faster-decaying one, and each is updated by its own block of eight weights — the slow trace from the mixed retrievability, the fast trace from its own. This is what makes same-day reviews behave differently from spaced ones, where FSRS-6 switched between two formulas on a same-day / not-same-day flag.
  • The forgetting curve is a mixture of two power laws rather than one, one per trace, with the mixing weights depending on the traces' own stabilities. The curve has no closed-form inverse, so computing an interval is a root-find, not a formula.
  • The forgetting curve depends on difficulty, which it never did in any earlier version: a hard card experiences time faster, and leans more on its slow trace.
  • Post-lapse stability no longer depends on difficulty, and a lapse's difficulty step is weighted by how surprising the lapse was — forgetting a card you were expected to recall says more about it than forgetting one that was long overdue.
  • Intervals are genuinely continuous. Every earlier version was designed around whole-day intervals; FSRS-7 is the first that gives realistic predictions for same-day reviews. Ten minutes is 10 / 1440 days and the model means it.

One consequence worth knowing: retrievability is rescaled into [1e-5, 1 - 1e-5], so a card reviewed a moment ago has a retrievability just short of 1 rather than exactly 1.

Getting started

$ stack build
$ stack test
$ stack run          # a small demo: one card, graded Good ten times

Using it

import FSRS

-- Grade a brand-new card Good, then grade it again a week later.
firstReview, secondReview :: MemoryState
firstReview  = nextMemoryState defaultParameters Nothing 0 Good
secondReview = nextMemoryState defaultParameters (Just firstReview) 7 Good

-- When should it come back, if we want a 90% chance of recall?
whenDue :: Days
whenDue = nextIntervalDays defaultParameters 0.9 secondReview

-- How likely are we to recall it three days from now?
odds :: Retrievability
odds = retrievability defaultParameters 3 secondReview

Both take the whole MemoryState, not just a stability: the FSRS-7 curve mixes the two traces and is shaped by the difficulty too.

Whole-card scheduling — learning steps, due dates, lapses, fuzz — lives in FSRS.Scheduler:

import FSRS

session :: UTCTime -> (Card, ReviewLog)
session now = reviewCard defaultScheduler (newCard now) Good now

reviewCard is deterministic. If you want Anki-style interval fuzzing, use reviewCardFuzzed and hand it the random sample yourself, so scheduling stays a pure function of its inputs.

Optimising the 34 weights against a user's own review history is not part of this package. Use the upstream optimiser and feed the result to mkParameters.

Modules

ModuleWhat is in it
FSRSRe-exports everything below.
FSRS.TypesRating, MemoryState, the type synonyms.
FSRS.ParametersThe 34 weights, their bounds, validation, typed views onto the blocks.
FSRS.AlgorithmThe model: forgetting curve, difficulty, stability, interval inversion.
FSRS.SchedulerCards, due dates, learning steps, fuzz.

Provenance

FSRS.Algorithm is a transcription of the finished FSRS-7, whose reference implementation is the Rust model the algorithm's author signed off on: Expertium/fsrs-rs-speed-autoresearch (fsrs-rs/src/model.rs, PARAM_LEN = 34), upstreamed into fsrs-rs in PR #426.

Two things are worth knowing about the weights:

  • Indices 31–33 follow the reference's all-positive storage convention: they are stored shifted so that their range starts at zero, and the formulas offset them back (w - 0.5 for the difficulty weight, w - 0.3 for the two decay modulators). parametersToList gives you the stored values, which is what an upstream optimiser produces and consumes.
  • The parameter bounds in parameterBounds come from the clipper the upstream optimiser applies after every gradient step, so any weights a real optimiser produces will satisfy them.

The scheduling policy in FSRS.Scheduler is not specified upstream — only the memory model is. It follows the reference scheduler from py-fsrs, adapted to FSRS-7's continuous intervals.

Tests

Three complementary suites, 126 test cases in all:

  • Golden vectors — 5,586 of them, covering every function of the model across three parameter sets, generated by reference/fsrs7_reference.py, a pure-Python transcription of the same upstream source. Both implementations perform the same floating-point operations in the same order, so they are checked to a relative tolerance of 1e-12.
  • Upstream fixtures — the two numeric assertions the reference implementation makes about FSRS-7 in its own test suite (test_memory_state_fsrs7 and test_next_interval_fsrs7), reproduced verbatim. These are the only fixtures in the package that did not come out of our own transcription, so they are what pins the port to upstream rather than to itself.
  • Properties — invariants that should hold for every parameter vector inside the valid box: retrievability is a probability and decreases with time, a better rating never means less stability, post-lapse stability ignores difficulty, difficulty stays in range however long the history, the interval solver really does land on the desired retention, and so on.

A few properties hold only for well-behaved weights and say so. Two kinds of caveat show up:

  • Because FSRS-7 re-weights its two power laws by stability, adversarial-but-in-bounds weights can make retrievability fall as stability grows. Those properties are stated for defaultParameters.
  • Difficulty pulls the curve two ways — it speeds the slow component up, but also shifts mixing weight onto the fast one. At the trace ratio the model itself maintains the first effect wins; for an arbitrarily lopsided pair of traces neither does. Properties about difficulty and about stability are therefore stated over states at that ratio.

To regenerate the golden vectors after touching the reference:

$ python3 reference/gen_golden.py

The reference gets two checks of its own, both standard-library only:

$ python3 reference/test_reference.py   # the model's invariants, in Python
$ python3 reference/check_golden.py     # the committed vectors still match it

check_golden.py compares numerically rather than by git diff. exp and pow are not required by IEEE-754 to be correctly rounded, so the last bit of a literal can legitimately differ between the machine that generated the file and the one checking it; a textual diff would go red for reasons that have nothing to do with the model.

Continuous integration

.github/workflows/ci.yml builds and tests with Stack under --pedantic (-Wall -Werror), smoke-tests the demo, and runs both reference checks.

Licence

MIT. See LICENSE.

Install

cabal install haskell-fsrs