packages feed

haskell-fsrs-7.1.0: README.md

# haskell-fsrs

[![CI](https://github.com/kutyel/haskell-fsrs/actions/workflows/ci.yml/badge.svg)](https://github.com/kutyel/haskell-fsrs/actions/workflows/ci.yml)

A Haskell implementation of **FSRS-7**, the seventh version of the [Free Spaced
Repetition Scheduler](https://github.com/open-spaced-repetition) — 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

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

## Using it

```haskell
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`:

```haskell
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`](https://github.com/Expertium/fsrs-rs-speed-autoresearch)
(`fsrs-rs/src/model.rs`, `PARAM_LEN = 34`), upstreamed into `fsrs-rs` in
[PR #426](https://github.com/open-spaced-repetition/fsrs-rs/pull/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`](https://github.com/open-spaced-repetition/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:

```console
$ python3 reference/gen_golden.py
```

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

```console
$ 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`](.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](LICENSE).