Vendoring FSRS spaced repetition as a pure Swift function
6 min read
My audiobook study app lets you highlight a passage and turn it into a flashcard. Which raises the real question of any study tool: when do you show the card again? Too soon and you waste the user’s time; too late and they’ve forgotten it. That scheduling decision is the whole product, and the modern answer to it is FSRS — the Free Spaced Repetition Scheduler, the algorithm Anki now ships.
I wanted FSRS, but I didn’t want a dependency for it. So I vendored the math as a pure function:
no Date, no global state, the caller supplies “days since last review” and gets back the next
interval. That one decision — purity — is what makes the trickiest logic in the app the easiest part
to trust. Here’s the implementation and why it’s shaped this way.
The model: two numbers per card
FSRS represents your memory of a card with two latent variables:
- Stability (S) — how many days until your probability of recall drops to ~90%. Big stability = you’ll remember it for a long time.
- Difficulty (D) — how inherently hard the item is, clamped to 1…10.
From stability you get the forgetting curve: the probability you still recall the card after
t days. FSRS-5 uses a power law, and the constants are chosen so that recall is exactly 90% when
t equals the stability:
// Memory-decay constants. R(t) = (1 + FACTOR * t / S)^DECAY.
private let DECAY: Double = -0.5
// FACTOR = 0.9^(1/DECAY) - 1, so that R == 0.9 exactly when t == S.
private let FACTOR: Double = pow(0.9, 1.0 / DECAY) - 1.0 // ≈ 0.2345679
public func retrievability(elapsedDays: Double, stability: Double) -> Double {
guard stability > 0 else { return 0 }
let t = max(0, elapsedDays)
return pow(1.0 + FACTOR * t / stability, DECAY)
}
That’s the core: give it elapsed days and stability, get back “how likely are they to remember this right now.”
Reviewing a card
When the user rates a card (again/hard/good/easy), the scheduler updates both variables and
returns the next interval. A brand-new card seeds its values from the rating; an existing card
evolves difficulty, then evolves stability differently depending on whether the user recalled or
forgot:
public func review(_ card: SchedulerCard, rating: Rating) -> (card: SchedulerCard, intervalDays: Double) {
var next = card
if card.state == .new {
next.difficulty = initialDifficulty(rating)
next.stability = initialStability(rating)
next.state = (rating == .again) ? .learning : .review
} else {
let retrievability = self.retrievability(elapsedDays: card.elapsedDays, stability: card.stability)
next.difficulty = nextDifficulty(card.difficulty, rating: rating)
if rating == .again {
next.stability = nextForgetStability(difficulty: next.difficulty,
stability: card.stability, retrievability: retrievability)
next.lapses += 1
next.state = .relearning
} else {
next.stability = nextRecallStability(difficulty: next.difficulty, stability: card.stability,
retrievability: retrievability, rating: rating)
next.state = .review
}
}
// (reps++ / lapses bookkeeping omitted here for brevity)
next.difficulty = clampDifficulty(next.difficulty)
next.stability = max(0.01, next.stability)
next.elapsedDays = 0
return (next, nextInterval(stability: next.stability))
}
The meaty bit is how stability grows on a successful recall. Note the shape: harder cards
(11 - difficulty is small) grow less; already-stable cards grow less (stability^(-w9)); and a
card recalled when you were about to forget it (low retrievability) grows more, because a
hard-won recall teaches your brain more. hard/easy apply a penalty/bonus:
private func nextRecallStability(difficulty: Double, stability: Double,
retrievability: Double, rating: Rating) -> Double {
let hardPenalty = (rating == .hard) ? w[15] : 1.0
let easyBonus = (rating == .easy) ? w[16] : 1.0
let growth = exp(w[8])
* (11 - difficulty)
* pow(stability, -w[9])
* (exp(w[10] * (1 - retrievability)) - 1)
* hardPenalty
* easyBonus
return max(0.01, stability * (1 + growth))
}
Finally, the interval is just the forgetting curve run backwards: how many days until recall decays to your target retention (default 0.9)?
private func nextInterval(stability: Double) -> Double {
let raw = (stability / FACTOR) * (pow(requestRetention, 1.0 / DECAY) - 1.0)
return max(1, raw.rounded())
}
The 19-weight parameter vector (w[0]…w[18]) is the published FSRS-5 default, copied from the
open-source reference implementations. You could fit per-user weights from review history, but the
defaults are strong, and for a study app the defaults are the right place to start.
Why “pure” is the whole point
Look again at review: it takes elapsedDays as an input. It never calls Date(). It reads no
global state. That’s deliberate, and it buys three things:
- It’s deterministic, so it’s testable. I can assert “a new card rated good, then good again after 3 days, produces this stability and this interval” as a plain unit test — no clock to freeze, no database, no mocks. Because the algorithm is where bugs would be catastrophic (a scheduling bug silently makes the product worse), it’s the thing I most want under test.
- It runs anywhere. This lives in a platform-free Swift package with zero dependencies, so the tests run on macOS in a fraction of a second in CI — no simulator, no device.
- The impurity lives at the edge. The app’s store computes
elapsedDaysfrom the real review dates, callsreview, and persists the returned card plus the next due date. The messy parts — “what time is it,” “write to disk” — stay in one thin layer around a pure core. That separation is the difference between “I think the scheduler is right” and “the scheduler is proven right, and the only thing left to get wrong is the date arithmetic around it.”
Vendor or depend?
Adding a package would have been less code to write. I vendored anyway, and I’d make the same call again: FSRS-5 is a stable, well-specified, well-documented algorithm, so the usual reasons to take a dependency (it changes often, it’s big, you won’t maintain it as well) don’t apply. In exchange I get a zero-dependency package that resolves instantly, compiles everywhere, and has no upstream that can break my build. The one cost — manually pulling in upstream changes — is negligible for math that essentially doesn’t move. Vendoring gets a bad rap; for a small, stable, correctness-critical algorithm it’s often the senior choice.
The takeaway
When a piece of logic is both important and hard to get right, make it a pure function. FSRS is the
brain of a study app, so it’s implemented as math that takes its inputs explicitly and returns a
result — no clock, no I/O, no globals — which makes it deterministic, testable on any platform, and
easy to reason about. Push the Date() and the persistence to a thin layer at the edge. The reward
is that the scariest part of the codebase becomes the part you trust most.
This is the scheduler behind the flashcards in Recall, my offline-first audiobook study app — live on the App Store, with a web version in progress. If you’re building anything with spaced repetition, I’m happy to talk it through. Related: low-latency on-device captions with SpeechAnalyzer.