haskell-fsrs-7.0.0: reference/fsrs7_reference.py
"""Pure-Python transcription of the FSRS-7 memory model.
Transcribed line-by-line from the reference implementation used by the official
benchmark:
https://github.com/open-spaced-repetition/srs-benchmark
models/fsrs_v7.py (rev 8c11619, 2026-08-04)
models/fsrs_v7_interval_penalty.py (same rev)
The benchmark implementation is written in PyTorch; this file reproduces the
exact same scalar arithmetic with the standard library only, so that it can be
used to generate golden vectors for the Haskell port without pulling in torch.
"""
import math
# w[15] / w[24] (the "easy bonus" of the long-/short-term blocks) are 1.3 here.
# The srs-benchmark README still lists 1.15 for those two weights, but that
# block of the README has not been touched since 2026-03-18 while the code was
# updated to 1.3 on 2026-03-21 (commit e274ac3, "Update FSRS-7"). The code wins.
DEFAULT_PARAMETERS = [
# Initial stability, indexed by rating - 1
0.041, 2.4175, 4.1283, 11.9709,
# Difficulty
5.6385, 0.4468, 3.262,
# Stability, long-term block
2.3054, 0.1688, 1.3325, 0.3524, 0.0049, 0.7503, 0.0896, 0.6625, 1.3,
# Stability, short-term block
0.882, 0.3072, 3.5875, 0.303, 0.0107, 0.2279, 2.6413, 0.5594, 1.3,
# Long/short-term transition function
2.5, 1.0,
# Forgetting curve
0.0723, 0.1634, 0.5, 0.9555, 0.2245, 0.6232, 0.1362, 0.3862,
]
LOWER_BOUNDS = [
0.0001, 0.0001, 0.0001, 0.0001,
1.0, 0.001, 0.1,
0.0, 0.0, 0.3, 0.01, 0.001, 0.1, 0.0, 0.0, 1.0,
0.0, 0.0, 0.5, 0.001, 0.001, 0.001, 0.0, 0.0, 1.0,
2.5, 0.0,
0.01, 0.01, 0.5, 0.5, 0.01, 0.1, 0.0, 0.1,
]
UPPER_BOUNDS = [
50.0, 100.0, 100.0, 100.0,
10.0, 4.0, 4.0,
4.0, 1.2, 3.0, 1.5, 0.9, 1.0, 3.5, 1.0, 7.0,
4.0, 2.0, 6.0, 1.5, 2.0, 1.0, 5.0, 1.0, 7.0,
15.0, 1.0,
0.25, 0.95, 0.85, 0.99, 1.0, 1.0, 0.9, 1.1,
]
STABILITY_MIN = 0.0001 # config.s_min for FSRS-7 (always run with --secs)
STABILITY_MAX = 36500.0 # config.s_max
MIN_DIFFICULTY = 1.0
MAX_DIFFICULTY = 10.0
MIN_INTERVAL = 1.0 / 86400.0 # one second, in days
MAX_INTERVAL = 36500.0 # one hundred years, in days
LONG_TERM_BASE = 7
SHORT_TERM_BASE = 16
def clamp(x, lo, hi):
return min(max(x, lo), hi)
# --------------------------------------------------------------------------
# Forgetting curve: a stability-weighted mixture of two power laws.
# --------------------------------------------------------------------------
def forgetting_curve(w, t, s):
"""Probability of recall after `t` days with stability `s` days."""
decay1 = -w[-8]
decay2 = -w[-7]
base1, base2 = w[-6], w[-5]
base_weight1, base_weight2 = w[-4], w[-3]
swp1, swp2 = w[-2], w[-1]
t_over_s = t / s
def power_law_retention(base, decay):
factor = base ** (1.0 / decay) - 1.0
return (1.0 + factor * t_over_s) ** decay
r1 = power_law_retention(base1, decay1)
r2 = power_law_retention(base2, decay2)
weight1 = base_weight1 * s ** -swp1
weight2 = base_weight2 * s ** swp2
return (weight1 * r1 + weight2 * r2) / (weight1 + weight2)
def forgetting_curve_derivative(w, t, s):
"""dR/dt of `forgetting_curve`; always <= 0 for in-bounds parameters."""
decay1 = -w[-8]
decay2 = -w[-7]
base1, base2 = w[-6], w[-5]
base_weight1, base_weight2 = w[-4], w[-3]
swp1, swp2 = w[-2], w[-1]
c1 = base1 ** (1.0 / decay1) - 1.0
c2 = base2 ** (1.0 / decay2) - 1.0
t_over_s = t / s
i1 = 1.0 + c1 * t_over_s
i2 = 1.0 + c2 * t_over_s
weight1 = base_weight1 * s ** -swp1
weight2 = base_weight2 * s ** swp2
d1 = decay1 * i1 ** (decay1 - 1.0) * (c1 / s)
d2 = decay2 * i2 ** (decay2 - 1.0) * (c2 / s)
return (weight1 * d1 + weight2 * d2) / (weight1 + weight2)
def next_interval(w, desired_retention, s):
"""Invert the forgetting curve: the t with R(t, s) = desired_retention.
The mixture has no closed-form inverse. R is strictly decreasing in t, so a
bisection on [MIN_INTERVAL, MAX_INTERVAL] converges to full double
precision; this is the value the Haskell root-finder is checked against.
"""
lo, hi = MIN_INTERVAL, MAX_INTERVAL
if forgetting_curve(w, lo, s) <= desired_retention:
return lo
if forgetting_curve(w, hi, s) >= desired_retention:
return hi
for _ in range(200):
mid = math.sqrt(lo * hi) # bisect in log space
if mid <= lo or mid >= hi:
break
if forgetting_curve(w, mid, s) > desired_retention:
lo = mid
else:
hi = mid
return math.sqrt(lo * hi)
# --------------------------------------------------------------------------
# Difficulty
# --------------------------------------------------------------------------
def initial_difficulty(w, rating):
"""Unclamped; `step` clamps the result to [1, 10]."""
return w[4] - math.exp(w[5] * (rating - 1)) + 1.0
def linear_damping(delta_difficulty, difficulty):
return delta_difficulty * (10.0 - difficulty) / 9.0
def mean_reversion(init, current):
return 0.01 * init + 0.99 * current
def next_difficulty(w, difficulty, rating):
delta_d = -w[6] * (rating - 3)
new_d = difficulty + linear_damping(delta_d, difficulty)
return mean_reversion(initial_difficulty(w, 4), new_d)
# --------------------------------------------------------------------------
# Stability
# --------------------------------------------------------------------------
def stability_after_review(w, s, d, r, rating, base):
"""One half of the stability update; `base` is 7 (long) or 16 (short)."""
w_sinc_base = w[base]
w_sinc_s_exp = w[base + 1]
w_sinc_r_mult = w[base + 2]
w_fail_mult = w[base + 3]
w_fail_d_exp = w[base + 4]
w_fail_s_exp = w[base + 5]
w_fail_r_mult = w[base + 6]
w_hard = w[base + 7]
w_easy = w[base + 8]
hard_penalty = w_hard if rating == 2 else 1.0
easy_bonus = w_easy if rating == 4 else 1.0
new_s_fail = (
w_fail_mult
* d ** -w_fail_d_exp
* ((s + 1.0) ** w_fail_s_exp - 1.0)
* math.exp((1.0 - r) * w_fail_r_mult)
)
pls = min(s, new_s_fail)
s_inc = 1.0 + (
math.exp(w_sinc_base - 1.5)
* (11.0 - d)
* s ** -w_sinc_s_exp
* (math.exp((1.0 - r) * w_sinc_r_mult) - 1.0)
* hard_penalty
* easy_bonus
)
new_s_success = max(pls, s * s_inc)
return new_s_success if rating > 1 else pls
def transition_function(w, delta_t):
"""1 for a fully long-term review, 0 for a same-instant (short-term) one."""
return 1.0 - w[26] * math.exp(-w[25] * delta_t)
def next_stability(w, s, d, delta_t, rating):
r = forgetting_curve(w, delta_t, s)
s_long = stability_after_review(w, s, d, r, rating, LONG_TERM_BASE)
s_short = stability_after_review(w, s, d, r, rating, SHORT_TERM_BASE)
coefficient = transition_function(w, delta_t)
return coefficient * s_long + (1.0 - coefficient) * s_short
# --------------------------------------------------------------------------
# The state transition
# --------------------------------------------------------------------------
def step(w, state, delta_t, rating, s_min=STABILITY_MIN):
"""`state` is None for the very first review, else an (S, D) pair."""
if state is None:
new_s = w[rating - 1]
new_d = clamp(initial_difficulty(w, rating), MIN_DIFFICULTY, MAX_DIFFICULTY)
else:
s, d = state
new_s = next_stability(w, s, d, delta_t, rating)
new_d = clamp(next_difficulty(w, d, rating), MIN_DIFFICULTY, MAX_DIFFICULTY)
new_s = clamp(new_s, s_min, STABILITY_MAX)
return (new_s, new_d)
def replay(w, reviews, s_min=STABILITY_MIN):
"""Fold `step` over a list of (delta_t, rating) pairs."""
state = None
for delta_t, rating in reviews:
state = step(w, state, delta_t, rating, s_min)
return state