Building a spaced repetition engine in Swift

Math Hero has over 700 lessons. A student might nail polynomial long division on Tuesday and forget how it works by the following week. I needed a review system that tracked what each student knew, what was slipping, and when to surface a problem again. That's spaced repetition -- showing you something right before you're about to forget it.

Why I rolled my own

There are open source SM-2 implementations out there. I looked at a few. The algorithm itself is about forty lines of code, so that wasn't the issue. The issue was integrating someone else's scheduling logic with SwiftData, with my quality rating system, and with the specific ways math practice differs from flashcard vocab. Every library I found assumed a generic card model. I needed per-problem tracking that plugged directly into my persistence layer. Writing it from scratch took an afternoon. Adapting a library would have taken longer and left me with an abstraction I didn't control.

How SM-2 works

The algorithm tracks two values per problem: an ease factor (EF) and an interval. The ease factor is a multiplier, starting at 2.5, that represents how easy a problem is for a given student. The interval is how many days until the next review.

When a student answers, I rate the quality on a 0-5 scale. Fast, correct, no hints gets a 5. Correct but slow is a 4. Needed a hint is a 3. Below 3 counts as a failure. The ease factor updates after every review:

// EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
var newEF = reviewData.easinessFactor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
newEF = max(1.3, newEF)

A perfect response nudges EF up by 0.1. A quality-3 response pulls it down by 0.14. The floor is 1.3, so no problem ever gets scheduled so aggressively that the student sees it every day forever.

For intervals: first successful review means come back tomorrow. Second success means come back in six days. After that, the interval multiplies by the ease factor each time. Fail a problem and the whole sequence resets to day one.

Tuning for math

Vanilla SM-2 was designed for language flashcards, where recall is fairly binary. You remember the word or you don't. Math has two distinct modes. Procedural skills like factoring trinomials or computing derivatives are mechanical. You either remember the steps or you've lost them, and they decay fast. Conceptual topics like why the chain rule works tend to stick longer once understood, but they're harder to assess with a simple right/wrong signal.

I handle this through the quality rating. For procedural problems, response time weighs heavily -- if a student gets it right but takes three times the expected duration, that's a quality 3, not a 5, and the ease factor drops. For conceptual questions, I'm more generous with time but stricter about hint usage. A correct answer that required two hints still schedules a near-term review.

The data model

Each problem-user pair gets a ProblemReviewData record in SwiftData:

  • easinessFactor (Double, starts at 2.5)
  • intervalDays (Double, current gap between reviews)
  • nextReviewDate (Date, when to show it again)
  • repetitionCount (Int, consecutive correct answers, resets to zero on failure)
  • totalReviews and correctResponses for accuracy tracking
  • recentResponseTimesSeconds for the last ten attempts, stored as JSON-encoded data
  • needsReinforcement flag for problems failed recently

A computed priorityScore combines overdue days, low ease factor, and reinforcement status to rank which problems to surface first in a session.

The clustering bug

About a month in, I noticed students were getting huge review piles on certain days and nothing on others. Reviews were clustering instead of spreading out. Traced it to floating point drift in the ease factor. After many reviews, EF values converge toward a few specific numbers -- 2.36, 2.5, 2.6 -- because the SM-2 formula has a small set of fixed points depending on quality. When multiple problems share an EF and get reviewed on the same day, their next intervals land on the same future date. Repeat a few cycles and you get 40 problems due Monday, 3 on Tuesday.

Fix was adding a small random jitter to the calculated interval -- up to plus or minus 5% -- before computing the next review date. Spreads reviews across adjacent days without meaningfully changing the spacing. I also round nextReviewDate to start of day using Calendar.current.startOfDay(for:) so comparisons stay clean.

Did it work

I tracked 7-day retention rates (percentage of problems answered correctly on first attempt after a gap of at least 7 days) before and after enabling spaced repetition. Before, with problems shown in fixed lesson order, 7-day retention sat around 58%. After three months of SM-2 scheduling, it climbed to 81%. Students who completed at least 80% of their scheduled reviews did even better, around 89%. It wasn't instant -- took about six weeks of consistent use before the retention curves clearly separated.

The ease factor distribution was useful too. Problems with EF stuck near 1.3 after many reviews were genuinely hard for most students, which helped me identify lessons that needed better explanations. Problems that quickly climbed to 2.5+ were candidates for removal from the review queue entirely.


-- Daniel