haskell-fsrs
FSRS-7, the Free Spaced Repetition Scheduler
haskell-fsrs
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 / 1440days 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
| Module | What is in it |
|---|---|
FSRS | Re-exports everything below. |
FSRS.Types | Rating, MemoryState, the type synonyms. |
FSRS.Parameters | The 34 weights, their bounds, validation, typed views onto the blocks. |
FSRS.Algorithm | The model: forgetting curve, difficulty, stability, interval inversion. |
FSRS.Scheduler | Cards, 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.5for the difficulty weight,w - 0.3for the two decay modulators).parametersToListgives you the stored values, which is what an upstream optimiser produces and consumes. - The parameter bounds in
parameterBoundscome 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 of1e-12. - Upstream fixtures — the two numeric assertions the reference
implementation makes about FSRS-7 in its own test suite
(
test_memory_state_fsrs7andtest_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- 7.1.0
- 7.0.0