Table of Contents
- The problem
- Where 2.0.0 comes from
- Design decisions and alternatives
- A real bug: resuming a saved quiz in quadratic time
- Usage
- Limits
The problem
A flashcard app has one hard question at its core: when should this card come back? Too early wastes the learner's time; too late and the card is forgotten.
For years the usual answer was SM-2: multiply the previous interval by an easiness factor. It is simple, but it has no notion of how likely the learner is to remember, so there is no single setting for "I want to remember 90 % of my cards". FSRS (the Free Spaced Repetition Scheduler) models memory with three numbers instead: stability, difficulty and retrievability. The next review goes on the day the predicted recall falls to the desired retention. FSRS-6 is the version behind Anki's FSRS scheduler, and its reference implementation is py-fsrs.
"Implements FSRS" is easy to write in a README and hard to check. EducationAI 2.0.0 is a Swift package that implements FSRS-6 and shows, in its test suite, that it gives the same results as py-fsrs 6.3.2. Around the scheduler it has the parts a study app needs: a study queue, SM-2 and its migration to FSRS, typed-answer grading, quizzes, a difficulty adjuster, a forecast, statistics and streaks.
Despite the name, there is no neural network: FSRS is a statistical memory model with 21 weights.
Where 2.0.0 comes from
The repository is older than the package it holds now. It was opened in August 2025 and tagged 1.0.0 as an EducationAI class that depended on Alamofire, RxSwift, SnapKit and SDWebImage. In February 2026 it was rewritten without dependencies as SM-2, quiz, progress, recommendation and gamification engines built on lock-guarded classes. In June 2026 a dependency on a local folder, .package(path: "../SwiftAI"), and placeholder "neural tutor" and "enterprise" types were added, so the package no longer resolved for anyone else.
The 2.0.0 CHANGELOG names the problems of that code: the engines read the clock themselves, converted intervals with Int(days * 86_400), which traps on NaN, and treated a "hard" answer as a lapse. On 15 October 2026 the June code was removed (7aff8be), the engines were replaced (6bc2b0f), and 2.0.0 shipped through PR #31 as a new major version.
Design decisions and alternatives
FSRS-6 as the main scheduler, SM-2 kept. FSRS lets an app change one number, desiredRetention, to trade review load against recall, and trained FSRS weights can be passed in as they are. Staying with SM-2 would have been simpler, and a home-made heuristic would have had no evidence behind it. SM-2 did not go away: SM2Scheduler implements it as published (qualities 0–5; 1 day, 6 days, then the interval times the easiness factor), and ReviewState(migrating:scheduler:sm2Retention:) converts SM-2 cards with the method fsrs-rs uses, so existing progress is not lost.
Match the reference operation by operation. The formulas in FSRSScheduler+Formulas.swift follow py-fsrs 6.3.2 operation by operation. They use pow(M_E, x) instead of exp(x), because that is what Python's math.e ** x computes, and the source comment says why: results agree bit for bit and intervals that land on .5 days round the same way. Elapsed days round down like Python's timedelta.days. The price is code that reads closer to Python than to idiomatic Swift. The reward is a claim that can be tested: scripts/generate-fsrs-fixtures.py produces 2,400 reviews (eight configurations, 25 cases each, 12 reviews per case) from py-fsrs, and FSRSReferenceTests compares phase, step, stability, difficulty, due date and retrievability after every one of them, with fuzz off.
Values, injected dates, injected randomness. FSRSScheduler, ReviewState, QuizSession and DifficultyAdjuster are Sendable, Codable structs. Every function takes the date it works with, and interval fuzz draws from a RandomNumberGenerator that can be a SeededRandomNumberGenerator. The alternative, a lock-guarded service class, is what 1.x had, and it made "what happens to this card next Tuesday" untestable.
Do not trust stored data. A ReviewState comes back from disk, from a sync service or from an older app version. Before any arithmetic, the scheduler checks it: NaN or infinite dates, negative counters, a step that does not fit the phase, a difficulty outside 1–10. A bad state throws SchedulingError.invalidState instead of crashing. Integer conversions go through SafeNumerics.roundedInt(_:clampedTo:), and counters stop at Int.max. A precondition would have been shorter, but one corrupt card would then take down the whole app.
Grading that does not depend on the phone's language. AnswerMatcher folds case, accents, width and punctuation with locale: nil, so the same answer is graded the same way on every device. Typos are counted as insertions, deletions, substitutions and adjacent swaps, computed only in a band around the diagonal, and answers over 500 characters are not compared. A full edit-distance table would be simpler but slower on hostile input; the tests compare the banded result with the full table on 3,000 random pairs.
A real bug: resuming a saved quiz in quadratic time
QuizSession is Codable, and its decoder does not trust the saved results: it replays every saved answer through answer(_:to:), so an edited file cannot turn a wrong answer into a right one. The cost of that choice showed up in the lookup. answer found the question with questions.first(where:), so decoding a quiz took quadratic time. Commit b334965 records the numbers: about 2 s for 25,000 answers and about 22 s for 100,000. The security policy of the package says no decoded value makes it use time out of proportion to its size, so this was a broken promise, not only a slow path.
The fix builds a dictionary from question identifier to position while the initializer checks for duplicate identifiers, and currentQuestion reads a position that moves forward instead of scanning the list on every call. Both are derived values, so they are not encoded and the saved format did not change.
The first test for it said "100,000 questions resume within 5 s". That was the second lesson. On the iOS simulator in CI the whole run is slower, and the check failed on the merge commit of PR #33 and on the first run of that pull request, then passed on a rerun of the same code. PR #34 (4f88ede) replaced the fixed limit with a ratio: decode 25,000 and 100,000 questions and require the larger case to take less than ten times as long as the smaller. Linear resume gives a ratio near 4, the old scan near 16, and the ratio does not depend on how fast the machine is. A performance test with an absolute time limit measures the runner as much as the code; a test of how the time grows measures the code.
A related fix, 7bf9e7e, capped stability at FSRSWeights.maximumStability (10^10 days): a stored stability of 1e308 passed validation, grew to infinity in a review, and the next review of that card threw. Accepting an input and keeping the output valid are two separate checks.
Usage
Add the package in Xcode with https://github.com/muhittincamdali/educationai.git, or in Package.swift:
1dependencies: [2 .package(url: "https://github.com/muhittincamdali/educationai.git", from: "2.0.0")3],4targets: [5 .target(name: "YourApp", dependencies: [6 .product(name: "EducationAI", package: "educationai")7 ])8]The samples below come from ReadmeSamples.swift, which the tests compile and run. Labelling the answer buttons with the next interval uses a preview of all four ratings:
1func buttonLabels(for card: Flashcard, scheduler: FSRSScheduler) throws -> [String] {2 let preview = try scheduler.preview(card.review)3 let format = Duration.UnitsFormatStyle(allowedUnits: [.days, .hours, .minutes], width: .narrow, maximumUnitCount: 1)4 return Rating.allCases.map { rating in5 let interval = Duration.seconds(preview[rating].interval)6 return "\(String(describing: rating).capitalized) · \(interval.formatted(format))"7 }8}For a new card this gives Again · 1m, Hard · 6m and Good · 10m, and about a week for Easy. A quiz feeds back into the scheduler through QuizResult.suggestedRating: a wrong answer is scheduled as Again, an answer with typos as Hard, a right one as Good:
1func quizAndReschedule(deck: inout [Flashcard], answers: [String: String], at date: Date) throws -> Double {2 var quiz = try QuizSession(questions: QuizBuilder.typedAnswer(from: deck))3 while let question = quiz.currentQuestion {4 let result = try quiz.answer(.text(answers[question.id] ?? ""), to: question.id)5 if let index = deck.firstIndex(where: { $0.id == result.questionID }) {6 deck[index].review = try FSRSScheduler.default.review(deck[index].review, rating: result.suggestedRating, at: date).state7 }8 }9 return quiz.score10}The StudyDeck example app (iOS and macOS) puts these pieces together, and the DocC reference has four guides. The suite has 124 tests in 11 suites; they passed with swift test at tag v2.0.0 on 15 October 2026.
Limits
- "Matches the reference" is the claim, not "best for learners". There is no measurement with real learners' data.
- There is no optimizer. Weights are the defaults or come from elsewhere; training them from a learner's review logs is on the roadmap, along with FSRS-7 once the reference implementations adopt it, Anki log import and load balancing of due dates.
- Fuzz is not compared with
py-fsrs, which draws it from Python's own random generator; it is only checked for range and reproducibility. - There is no storage, sync or UI library. The package returns
Codablevalues; saving them and resolving conflicts is the app's job. - The limits are explicit and can be hit: answers over 500 characters are not compared, forecasts cover at most 3,650 days, and adjuster windows hold at most 1,000 answers.
- Locale-free folding is tested on a few Unicode cases, such as "İstanbul"; there is no separate test for every writing system.
- It requires Swift 6.2 (Xcode 26) and iOS 17, macOS 14, tvOS 17, watchOS 10 or visionOS 1.
The code, the fixtures and the script that generates them are on GitHub under the MIT license.
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

