aboutsummaryrefslogtreecommitdiff
path: root/docs/design/fsrs-spec.org
blob: e2e308a59ffd36c2a44bb48d796bb26adc12d4a8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
#+TITLE: FSRS scheduler for org-drill — v0 design spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-27

* Status

Draft v0 — pending decisions marked =DECIDE:= inline.  The companion todo
entry is =todo.org=:125 [#B] "FSRS (Free Spaced Repetition Scheduler)
Algorithm".  No implementation has started.

* Context and motivation

org-drill ships three SuperMemo-family schedulers — SM2, SM5, and Simple8.
They share an interval/repeats/EF/failures/meanq/total-repeats state model
inherited from the SuperMemo 2 paper and refined since.  The card-state
struct refactor (#147) bundles those fields into =org-drill-card-state= and
makes plugging a new scheduler into the =cl-case= dispatch a one-arm change
in =org-drill-smart-reschedule= and =org-drill-hypothetical-next-review-date=.

FSRS (Free Spaced Repetition Scheduler) is the algorithm Anki has migrated
to since 2024.  It uses the DSR memory model (Difficulty, Stability,
Retrievability) and a 17-to-21-parameter formula family fit on hundreds of
millions of real reviews.  Published evaluations show materially better
retention than SM* at the same review load.  Adding it as a fourth option
to =org-drill-spaced-repetition-algorithm= lets a user opt in without
disturbing existing SM-family decks.

* Goals

- A fourth value =fsrs= for =org-drill-spaced-repetition-algorithm=.
- The =fsrs= arm of the =cl-case= in =smart-reschedule= and
  =hypothetical-next-review-date= calls =org-drill-determine-next-interval-fsrs=
  with the same shape the SM-family arms now use (a state value + the
  rating quality + algorithm-specific extras).
- Per-card FSRS state persisted to org properties.  Existing SM-style
  properties stay untouched on cards that haven't been switched.
- Test coverage at parity with the SM* test files: Normal / Boundary /
  Error per public function, plus reference-vector tests checking the
  algorithm output against the canonical FSRS implementation.

* Non-goals (v1)

- *No parameter optimizer.*  Anki's FSRS shines because of parameter
  fitting on each user's review history.  v1 ships fixed default
  parameters only; the optimizer becomes its own todo and ticket.  Once
  the algorithm is in the tree, plugging an optimizer in front of it is
  additive.
- *No retro-migration heuristic.*  A card switched from SM* to FSRS
  cold-starts on its next review — FSRS treats it as a fresh first review.
  Anki has heuristics that estimate initial =D= and =S= from prior review
  history; we explicitly defer that as a follow-on.
- *No bulk rescheduling pass.*  Switching algorithms does not retroactively
  rewrite SCHEDULED dates on existing cards.  Cards keep their current
  SCHEDULED until their next review.

* Version pin

=DECIDE:= FSRS-4.5 (recommended) vs FSRS-6.x.

The current stable upstream is FSRS-6.3.1 (March 2026) with 21
parameters.  FSRS-4.5 is the last release whose generic defaults provide
meaningful retention improvement over SM-family scheduling without
optimization.  The FSRS community's own guidance — quoting Expertium's
algorithm explainer, March 2026 — is that "generic v6 defaults offer
minimal improvement over v4.5" because v6's gains come from optimizing
=w[17]..w[20]= on a user's data.

Since v1 ships without an optimizer (above), the pragmatic pin is
*FSRS-4.5*: 17 parameters, well-documented defaults, the most-published
test vectors, and the version with the largest delta over SM* in the
no-optimizer regime.  Upgrading to v6 becomes a separate ticket alongside
the optimizer one — by then there'd be a real reason to take v6's
additional surface.

Reference parameters (FSRS-4.5 defaults, source: =py-fsrs= up to v4 and
the original =fsrs4anki= release notes):

#+begin_src elisp
;; w[0..16], FSRS-4.5
(0.4 0.6 2.4 5.8 4.93 0.94 0.86 0.01 1.49 0.14 0.94 2.18 0.05 0.34 1.26 0.29 2.61)
#+end_src

(=DECIDE:= confirm this exact tuple before code lands.  The 4.5 defaults
were tuned twice during 4.x's life; pinning the final 4.5-stable tuple is
a one-line lookup against the =py-fsrs= 4.x release tag, deferred to
implementation.)

* Algorithm shape (v4.5)

The four-rating scale FSRS uses internally:

| Rating | Name  | Meaning                |
|--------+-------+------------------------|
|      1 | Again | failed recall          |
|      2 | Hard  | recalled with struggle |
|      3 | Good  | recalled normally      |
|      4 | Easy  | trivial recall         |

org-drill uses a 0–5 quality scale.  The mapping respects
=org-drill-failure-quality= (default 2):

| org-drill quality | FSRS rating |
|-------------------+-------------|
| 0, 1, 2           | Again (1)   |
| 3                 | Hard (2)    |
| 4                 | Good (3)    |
| 5                 | Easy (4)    |

=DECIDE:= mapping table.  Above is the natural reading of
=org-drill-failure-quality=, but org-drill historically lets users
remap session keys, and the mapping should respect that or be its
own defcustom.  Simplest: pin the mapping above for v1 and add
=org-drill-fsrs-quality-mapping= as a future enhancement.

** State model

Per-card persisted state:

| Property              | Type   | Meaning                              |
|-----------------------+--------+--------------------------------------|
| =DRILL_FSRS_STABILITY=  | float  | =S=, the memory-stability estimate     |
| =DRILL_FSRS_DIFFICULTY= | float  | =D=, the difficulty estimate (1..10)   |
| =DRILL_FSRS_REVIEWS=    | int    | review count under FSRS (≥ 0)        |
| =DRILL_FSRS_LAPSES=     | int    | failures (rating=Again) under FSRS   |
| =DRILL_LAST_REVIEWED=   | time   | reused from the existing SM property |

The last-reviewed timestamp is already written by the SM path
(=org-drill-store-item-data= writes it via =org-set-property= elsewhere in
the flow); FSRS reads the same property.

A virgin FSRS card has all four =DRILL_FSRS_*= properties absent; the
scheduler treats absence as "first review."

** Update equations (v4.5 form)

First review (no prior FSRS state) with rating R ∈ {1..4}:

#+begin_src elisp
S0 = w[R-1]                            ; initial stability per rating
D0 = clamp (- w[4] (* w[5] (- R 2))) 1 10
#+end_src

Subsequent review at time =elapsed-days= since last review, with current
stability =S=, difficulty =D=, and rating =R=:

#+begin_src elisp
;; Retrievability when the card is shown
R-retrieval = (expt (1+ (/ elapsed-days (* 9 S))) -1)

;; Difficulty update (linear with mean reversion toward w[4])
D-delta    = (* w[6] (- (- R 3)))      ; positive on Again, neutral on Good
D-new-raw  = (+ D D-delta)
D-new      = (+ (* w[7] w[4]) (* (- 1 w[7]) D-new-raw))   ; mean reversion
D-new      = (clamp D-new 1 10)

;; Stability update — success (R ≥ 2)
S-new = S * (1 +
              (exp w[8]) *
              ((11 - D) / 9) *
              (S ^ (-w[9])) *
              ((exp ((1 - R-retrieval) * w[10])) - 1) *
              (R == Hard ? w[15] : 1) *
              (R == Easy ? w[16] : 1))

;; Stability update — lapse (R = 1, Again)
S-new = w[11] *
        D^(-w[12]) *
        ((S + 1)^w[13] - 1) *
        exp((1 - R-retrieval) * w[14])
#+end_src

Next interval given desired retention =DR= and new stability =S=:

#+begin_src elisp
I = (* 9 S (- (expt DR -1) 1))         ; days, rounded down (floor)
#+end_src

Default desired retention is *0.9* (a common Anki default).  Pinned as
=org-drill-fsrs-desired-retention= defcustom.

=DECIDE:= the exact form of the v4.5 update equations above is paraphrased
from Expertium's algorithm walkthrough (March 2026, v6-oriented) and the
=py-fsrs= reference implementation.  Implementation will cross-check the
exact constants and grouping against a tagged =py-fsrs= v4.x release
before writing the function body.

** Default parameters (org-drill defcustoms)

#+begin_src elisp
(defcustom org-drill-fsrs-parameters
  '(0.4 0.6 2.4 5.8 4.93 0.94 0.86 0.01 1.49 0.14 0.94 2.18 0.05 0.34 1.26 0.29 2.61)
  "17-tuple FSRS-4.5 default parameters.  See =docs/design/fsrs-spec.org=.")

(defcustom org-drill-fsrs-desired-retention 0.9
  "Target retention for FSRS scheduling.  Higher = shorter intervals.")
#+end_src

* Integration points

** Public function

#+begin_src elisp
(defun org-drill-determine-next-interval-fsrs (state quality)
  "Return next-interval (in days) plus updated FSRS state for STATE after
QUALITY (0-5).  STATE here is a 5-tuple (S D reviews lapses last-reviewed),
not an `org-drill-card-state' — FSRS stores its own state and treats the SM
slots as opaque.")
#+end_src

The shape =(state quality)= matches the post-#147 scheduler signature
convention.  =state= for FSRS is a *separate* struct =org-drill-fsrs-state=
(or a plist — =DECIDE:= which), populated from the =DRILL_FSRS_*=
properties at the call site.

=DECIDE:= the return shape.  Options:

1. Same shape as the SM* schedulers (a list with =next-interval=
   first plus updated state).  Consistent but the trailing slots are
   irrelevant to FSRS.
2. A small =org-drill-fsrs-result= struct (=next-interval=,
   =new-stability=, =new-difficulty=, =new-reviews=, =new-lapses=).
   Cleaner for FSRS but introduces a return-shape special case in the
   =cl-case= caller.

Recommended: option 2.  =cl-case= already special-cases =new-ofmatrix=
for SM5; one more is fine and the FSRS state shape is genuinely different.

** Caller integration

In =org-drill-smart-reschedule=:

#+begin_src elisp
;; sketch — exact destructure shape TBD per the return-shape DECIDE
(fsrs
 (let ((fsrs-state (org-drill-get-fsrs-state)))
   (org-drill-fsrs-apply-result
    (org-drill-determine-next-interval-fsrs fsrs-state quality))))
#+end_src

Two new helpers parallel the existing item-data round-trip:

- =org-drill-get-fsrs-state= — reads the =DRILL_FSRS_*= properties, returns
  the state struct or nil for a virgin card.
- =org-drill-store-fsrs-state= (or =org-drill-fsrs-apply-result=) — writes
  the updated state back.  The =SCHEDULED= timestamp is set via
  =org-schedule= using the returned =next-interval=, the same way
  =smart-reschedule= already does for SM*.

Same in =hypothetical-next-review-date= (read-only, no store).

** Defcustom entry

=org-drill-spaced-repetition-algorithm= grows a fourth =:option= line:

#+begin_src elisp
- FSRS :: Free Spaced Repetition Scheduler, the DSR-based algorithm
  used in Anki since 2024.  See `docs/design/fsrs-spec.org` for the algorithm
  and the state model.
#+end_src

* Backward compatibility

A card last reviewed under SM* has =DRILL_LAST_INTERVAL=,
=DRILL_REPEATS_SINCE_FAIL=, etc., but no =DRILL_FSRS_*=.  On the first
FSRS review after a switch:

- The FSRS scheduler sees absent =DRILL_FSRS_*= and treats this as a
  virgin first review (initial =S₀=, =D₀= from the rating).
- The SM-style properties are left in place.  If the user switches back,
  the SM scheduler reads them as before.
- The =DRILL_FSRS_*= properties are written from this point forward.
  Cards drilled under both algorithms accumulate both property sets.

The cold-start tradeoff is a known one-time loss of the SM-derived
estimate of card difficulty.  Anki's migration heuristic estimates
initial =D= and =S= from the SM2 ease factor and interval; pulling that in
is its own ticket (=todo:= "FSRS migration heuristic from SM history").

=DECIDE:= whether to also surface a one-line "first FSRS review on this
card — cold-starting" message during the prompt for the first FSRS review.
Probably not — users don't need to know.

* Test strategy

Three categories per public function, matching the existing scheduler
test files:

1. =tests/test-org-drill-determine-next-interval-fsrs.el= — Normal /
   Boundary / Error coverage of =org-drill-determine-next-interval-fsrs=.
   *Reference-vector tests* check =(state, rating) -> (next-interval,
   new-state)= against pre-computed outputs from =py-fsrs= at a tagged
   v4.x release.  About a dozen vectors covering the four ratings × first
   review / nth review / lapse / long-elapsed-time, plus boundary ones
   (=elapsed-days=0=, =D= and =S= at clamp limits).

2. =tests/test-org-drill-fsrs-state-roundtrip.el= — get-fsrs-state /
   store-fsrs-state round-trip parity, mirroring the SM
   item-data-roundtrip test.

3. =tests/test-org-drill-fsrs-integration.el= — end-to-end through
   =smart-reschedule= with =org-drill-spaced-repetition-algorithm= set to
   =fsrs=, covering: virgin card first review, returning card under
   FSRS, switched-from-SM card cold-starts cleanly, both algorithms can
   coexist on the same buffer.

Mapping helpers (org-drill quality 0–5 → FSRS rating 1–4) and the
defcustoms get their own small tests.

The reference vectors get committed alongside the tests as a
machine-checkable expected-output table, generated once from =py-fsrs=
and pinned.  The generation script lives at =tests/fixtures/fsrs-vectors.py=
so re-generation is reproducible.

* Effort estimate

Multi-day, plausibly spanning sessions:

- Algorithm function + helpers: 1 day.
- State round-trip + integration in =smart-reschedule=/=hypothetical=: 0.5
  day.
- Test scaffolding + reference vectors + Normal/Boundary/Error: 1 day.
- Documentation (manual entry, README option list, defcustom docstrings,
  this spec ratified): 0.5 day.

Realistic: a session to implement-and-test the algorithm + state
round-trip with reference-vector tests; a follow-up session for the
integration + docs.  No optimizer.

* Open decisions index

Pinned in one place for the implementation gate:

- =DECIDE:= version pin (FSRS-4.5 vs newer).  Recommended: 4.5.
- =DECIDE:= the exact FSRS-4.5 default-parameter tuple.  One-line lookup
  against the =py-fsrs= 4.x final release.
- =DECIDE:= quality-mapping table (0–5 → 1–4).  Recommended: above.
- =DECIDE:= scheduler return shape — list (SM-shape) or
  =org-drill-fsrs-result= struct.  Recommended: struct.
- =DECIDE:= whether to surface a "first FSRS review on this card"
  message.  Recommended: no.
- =DECIDE:= the exact form of the update equations after cross-checking
  =py-fsrs= 4.x source.

* References

- [[https://github.com/open-spaced-repetition/py-fsrs][py-fsrs]] — the canonical Python reference implementation.  Tagged
  v4.x releases pin FSRS-4.5; v6.x is the current line.
- [[https://github.com/open-spaced-repetition/fsrs4anki][fsrs4anki]] — the Anki integration; its wiki has user-facing notes on
  algorithm history and migration.
- [[https://expertium.github.io/Algorithm.html][Expertium: A technical explanation of FSRS]] (March 2026, v6-oriented)
  — the most accessible English-language walkthrough of the formulas
  and parameter roles.
- [[https://help.remnote.com/en/articles/9124137-the-fsrs-spaced-repetition-algorithm][RemNote: The FSRS Spaced Repetition Algorithm]] — third-party
  implementer's overview, useful as a sanity check on terminology.