Implementing FSRS in Elixir
I’m trying to improve my memory.
I’m learning Japanese. Reading more. Building more. But the more I learn, the more I realize how quickly things fade.
So instead of just using a flashcard app — I decided to build one.
And if I’m going to build one, I want a scheduler backed by a real model of how memory changes over time. That led me to FSRS — the algorithm set to replace SM-2 in Anki, trained on real review data and built around a three-parameter model of memory.
There’s a concise Rust implementation in ~100 lines. I wanted to truly understand it — so I ported it to Elixir.
This post walks through that implementation step by step.
If you’re not familiar with FSRS, the short version: it’s the algorithm that will replace SM-2 (used by Anki) for scheduling flashcard reviews. The pitch is 30% less review time for the same retention. It was trained on a large dataset of Anki reviews rather than designed by hand, which makes its equations feel a bit arcane — but the implementation is compact and the math is approachable.
I’ll follow the same structure as the original post, going through each piece of the algorithm before putting it all together.
The DSR Model
FSRS is based on the three-component model of memory, where the state of a memory is described by three values:
- Retrievability (
r) — probability of recalling the card right now, in[0.0, 1.0] - Stability (
s) — time in days forrto fall from 1.0 to 0.9 - Difficulty (
d) — how hard the card is to remember, in[1.0, 10.0]
In Elixir, we can represent a card’s state as a simple struct:
defmodule FSRS.Card do
defstruct [:stability, :difficulty]
end
Retrievability is computed dynamically (it depends on how much time has passed), while stability and difficulty are stored per card.
Grade
After each review, the user rates their recall. There are four possible grades:
defmodule FSRS.Grade do
@type t :: :forgot | :hard | :good | :easy
def to_float(:forgot), do: 1.0
def to_float(:hard), do: 2.0
def to_float(:good), do: 3.0
def to_float(:easy), do: 4.0
end
Pattern matching on atoms is much cleaner than numeric enums, and we only convert to float when the math requires it.
Parameters
FSRS has 19 learned parameters with well-established defaults:
defmodule FSRS.Params do
@w {
0.40255, 1.18385, 3.173, 15.69105,
7.1949, 0.5345, 1.4604, 0.0046,
1.54575, 0.1192, 1.01925, 1.9395,
0.11, 0.29605, 2.2698, 0.2315,
2.9898, 0.51655, 0.6621
}
def w(i), do: elem(@w, i)
end
Using a tuple and elem/2 gives us O(1) access and keeps the indexing consistent with every other FSRS reference you’ll find online.
Retrievability
Retrievability decays over time according to this formula:
R(t) = (1 + F * t/S)^C
where F = 19/81 and C = -0.5.
defmodule FSRS.Scheduler do
@f 19.0 / 81.0
@c -0.5
def retrievability(t, s) do
(1.0 + @f * (t / s)) |> :math.pow(@c)
end
end
The pipe makes the intent clear: compute the inner expression, then raise it to the power C. At t=0 this simplifies to R=1.0, meaning you’ll always recall something you just reviewed.
Review Interval
We want to review a card when its retrievability hits a desired retention threshold (default: 0.9). Rearranging the retrievability formula to solve for t:
I(Rd) = (S / F) * (Rd^(1/C) - 1)
def interval(r_d, s) do
(s / @f) * (:math.pow(r_d, 1.0 / @c) - 1.0)
end
Initial Stability and Difficulty
The first time a card is reviewed, stability and difficulty are bootstrapped from the grade alone:
def initial_stability(:forgot), do: Params.w(0)
def initial_stability(:hard), do: Params.w(1)
def initial_stability(:good), do: Params.w(2)
def initial_stability(:easy), do: Params.w(3)
def initial_difficulty(grade) do
g = Grade.to_float(grade)
(Params.w(4) - :math.exp(Params.w(5) * (g - 1.0)) + 1.0)
|> clamp_difficulty()
end
defp clamp_difficulty(d), do: max(1.0, min(10.0, d))
Multi-clause functions with pattern matching replace the match expression from Rust, reading almost like a lookup table.
Updating Stability
On success (hard, good, easy)
When the user remembers a card, stability is scaled by a factor α:
S' = S * α
α = 1 + t_d * t_s * t_r * h * b * e^w8
defp stability_on_success(d, s, r, grade) do
t_d = 11.0 - d
t_s = :math.pow(s, -Params.w(9))
t_r = :math.exp(Params.w(10) * (1.0 - r)) - 1.0
h = if grade == :hard, do: Params.w(15), else: 1.0
b = if grade == :easy, do: Params.w(16), else: 1.0
c = :math.exp(Params.w(8))
alpha = 1.0 + t_d * t_s * t_r * h * b * c
s * alpha
end
A few things to note about the formula’s intuition:
t_d = 11 - D: harder cards (high D) gain stability more slowlyt_s = S^(-w9): very stable memories are harder to make more stablet_r: the lower your recall probability, the more room there is to grow — the optimal review time is when you’ve almost forgotten something
On failure (forgot)
defp stability_on_failure(d, s, r) do
d_f = :math.pow(d, -Params.w(12))
s_f = :math.pow(s + 1.0, Params.w(13)) - 1.0
r_f = :math.exp(Params.w(14) * (1.0 - r))
c_f = Params.w(11)
min(d_f * s_f * r_f * c_f, s)
end
The min(…, s) ensures stability can only decrease on failure, never increase.
Combined
def stability(d, s, r, :forgot), do: stability_on_failure(d, s, r)
def stability(d, s, r, grade), do: stability_on_success(d, s, r, grade)
Pattern matching on the grade as the last argument makes this read like a natural rule: “forgot → failure path, anything else → success path.”
Updating Difficulty
After the first review, difficulty is updated using a mean-reverting formula that anchors toward D₀(easy):
ΔD(G) = -w6 * (G - 3)
D'(D,G) = D + ΔD(G) * ((10 - D) / 9)
D'' = w7 * D₀(easy) + (1 - w7) * D'
def difficulty(d, grade) do
g = Grade.to_float(grade)
delta = -Params.w(6) * (g - 3.0)
d_prime = d + delta * ((10.0 - d) / 9.0)
(Params.w(7) * initial_difficulty(:easy) + (1.0 - Params.w(7)) * d_prime)
|> clamp_difficulty()
end
The mean reversion (w7 * D₀(:easy) term) prevents difficulty from drifting to extremes over many reviews. Grading :good leaves difficulty unchanged; :easy decreases it; :hard and :forgot increase it.
The Full Scheduler
Putting it all together into a public API:
defmodule FSRS.Scheduler do
alias FSRS.{Card, Grade, Params}
@f 19.0 / 81.0
@c -0.5
@desired_retention 0.9
# --- Public API ---
@doc """
Schedule a card review. Returns `{updated_card, interval_in_days}`.
Pass `nil` as the card for the very first review of a new card.
"""
def schedule(nil, grade) do
s = initial_stability(grade)
d = initial_difficulty(grade)
i = next_interval(s)
{%Card{stability: s, difficulty: d}, i}
end
def schedule(%Card{stability: s, difficulty: d} = _card, grade) do
r = retrievability(s)
s2 = stability(d, s, r, grade)
d2 = difficulty(d, grade)
i = next_interval(s2)
{%Card{stability: s2, difficulty: d2}, i}
end
# --- Core computations ---
def retrievability(t \\ nil, s) do
t = t || interval(@desired_retention, s)
(1.0 + @f * (t / s)) |> :math.pow(@c)
end
def interval(r_d, s) do
(s / @f) * (:math.pow(r_d, 1.0 / @c) - 1.0)
end
defp next_interval(s) do
interval(@desired_retention, s) |> round() |> max(1)
end
# --- Stability ---
defp stability(d, s, r, :forgot), do: stability_on_failure(d, s, r)
defp stability(d, s, r, grade), do: stability_on_success(d, s, r, grade)
defp stability_on_success(d, s, r, grade) do
t_d = 11.0 - d
t_s = :math.pow(s, -Params.w(9))
t_r = :math.exp(Params.w(10) * (1.0 - r)) - 1.0
h = if grade == :hard, do: Params.w(15), else: 1.0
b = if grade == :easy, do: Params.w(16), else: 1.0
c = :math.exp(Params.w(8))
s * (1.0 + t_d * t_s * t_r * h * b * c)
end
defp stability_on_failure(d, s, r) do
d_f = :math.pow(d, -Params.w(12))
s_f = :math.pow(s + 1.0, Params.w(13)) - 1.0
r_f = :math.exp(Params.w(14) * (1.0 - r))
min(d_f * s_f * r_f * Params.w(11), s)
end
# --- Difficulty ---
defp difficulty(d, grade) do
g = Grade.to_float(grade)
delta = -Params.w(6) * (g - 3.0)
d_prime = d + delta * ((10.0 - d) / 9.0)
(Params.w(7) * initial_difficulty(:easy) + (1.0 - Params.w(7)) * d_prime)
|> clamp_difficulty()
end
defp initial_stability(:forgot), do: Params.w(0)
defp initial_stability(:hard), do: Params.w(1)
defp initial_stability(:good), do: Params.w(2)
defp initial_stability(:easy), do: Params.w(3)
defp initial_difficulty(grade) do
g = Grade.to_float(grade)
(Params.w(4) - :math.exp(Params.w(5) * (g - 1.0)) + 1.0)
|> clamp_difficulty()
end
defp clamp_difficulty(d), do: max(1.0, min(10.0, d))
end
Usage Example
alias FSRS.Scheduler
# First review of a new card
{card, interval} = Scheduler.schedule(nil, :good)
IO.puts("First review done. Next in #{interval} day(s).")
IO.inspect(card)
# => %FSRS.Card{stability: 3.173, difficulty: 6.37...}
# Second review (user remembered it easily)
{card, interval} = Scheduler.schedule(card, :easy)
IO.puts("Next review in #{interval} day(s).")
# Simulate a streak of reviews
grades = [:good, :good, :hard, :good, :easy, :easy]
Enum.reduce(grades, nil, fn grade, card ->
{card, interval} = Scheduler.schedule(card, grade)
IO.puts("Grade: #{grade} → interval: #{interval} days, stability: #{Float.round(card.stability, 2)}")
card
end)
What’s Different from the Rust Version
Beyond the obvious syntax differences, a few Elixir idioms made the port nicer:
Pattern matching replaces conditionals. The stability/4 function dispatches to failure or success paths via clause matching on :forgot, rather than an if/else. The same applies to initial_stability/1, which is essentially a compile-time lookup table.
Atoms are more expressive than enums. :forgot | :hard | :good | :easy reads naturally, and Elixir’s pattern matching on atoms is fast and idiomatic.
Pipes clarify formula structure. Multi-step expressions like the difficulty update read well with |>, making it clear what’s being transformed at each step.
Structs are enough. Elixir’s %Card{} struct with a simple API works cleanly here. For a production app, you’d likely persist cards to Postgres and use Ecto changesets — but the core scheduling logic stays pure and side-effect-free, which makes it easy to test.
What’s Next
This is the algorithmic core — it takes a card and a grade and returns an updated card and the next interval. In my flashcard app, I’m plugging this into:
- An Oban job that queries cards due today and queues them for the user
- A Phoenix LiveView interface where users rate cards and see immediate scheduling feedback
- An AI layer that generates card content and explanations on the fly
I’ll be writing about each of those pieces as I build them out. The full code for this module will be in the project repo once I have it published.
References
- Implementing FSRS in 100 Lines — Fernando Borretti’s original Rust implementation that inspired this post
- FSRS Algorithm Description
- FSRS Visualizer
~Norman Argueta