Implementing FSRS in Elixir

Feb 24, 2026 10 min

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 for r to 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 slowly
  • t_s = S^(-w9): very stable memories are harder to make more stable
  • t_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

~Norman Argueta