@monkey-dev-vibes/spaced-repetition
v0.1.0
Published
Modern TypeScript implementation of the SM-2 spaced repetition algorithm. Pure function, zero runtime dependencies, full test coverage.
Maintainers
Readme
spaced-repetition
A modern, audit-friendly TypeScript implementation of the SM-2 spaced repetition algorithm. One pure function. Zero runtime dependencies. Ships exactly what you need to schedule reviews — and nothing else.
import { createNewCard, processReview } from '@monkey-dev-vibes/spaced-repetition';
let card = createNewCard(); // immediately due
card = processReview(card, 2); // Good → due in 1 day
card = processReview(card, 2); // Good → due in 6 days
card = processReview(card, 3); // Easy → due in ~16 days
card.interval; // 16
card.easeFactor; // 2.7
card.nextReviewDate; // ISO 8601 timestampThat's the whole API for the common case. No setup, no class hierarchy, no global state.
Why this one?
Searching npm for sm2 returns half-a-dozen packages, most of which haven't shipped a release in years. The ones that are maintained tend to bundle storage adapters, React hooks, or framework assumptions. None offer the combination of:
| Feature | This package | Most others |
| --------------------------------------------- | :----------: | -------------------------- |
| TypeScript types | ✓ | partial / @types only |
| Pure function — (card, rating) → card | ✓ | mixed with storage |
| Zero runtime dependencies | ✓ | usually bundles a DB / ORM |
| Dual ESM + CJS output | ✓ | one or the other |
| Non-mutation purity test | ✓ | rare |
| Configurable max interval | ✓ | usually hardcoded |
| Honest FSRS comparison | ✓ | rarely mentioned |
If you want the scheduling math and nothing else, this is the package.
At a glance
- Pure function.
processReview(card, rating)returns a brand-new card. No mutation, no IO, no side effects, no randomness. Deterministic given the same inputs. - Zero runtime dependencies. The whole package is ~95 lines of TypeScript. Audit it in a single sitting.
- Dual ESM + CJS. Works in modern bundlers, legacy CommonJS, edge runtimes, and Node ≥ 18.
- Configurable interval cap. Default is
Infinity(classical SM-2). PassmaxInterval: 180for exam-style time-bounded study. - Documented policy choices. Where this differs from the 1990 paper (4-grade scale, optional cap) is called out openly so you can decide for yourself.
- Type-safe rating scale.
Ratingis the literal union0 | 1 | 2 | 3— invalid grades fail at compile time.
Install
npm install @monkey-dev-vibes/spaced-repetitionNode 18+. No peer dependencies. No native modules. Works in browsers, Workers, Deno, and Bun.
Usage
Schedule a review
import { createNewCard, processReview } from '@monkey-dev-vibes/spaced-repetition';
let card = createNewCard();
// User answered: 0=Again, 1=Hard, 2=Good, 3=Easy
card = processReview(card, 2);Cap the maximum interval
Classical SM-2 has no upper bound — after dozens of consecutive successful reviews, intervals balloon into years. For time-bounded study (an exam, a certification window, a six-month course), cap the interval:
card = processReview(card, 3, new Date(), { maxInterval: 180 });Check whether a card is due
import { isDue } from '@monkey-dev-vibes/spaced-repetition';
if (isDue(card)) {
// surface the card to the user
}Persist your cards however you like
The package is storage-agnostic on purpose. A CardState is a plain serialisable object — you can stash it in localStorage, IndexedDB, SQLite, Postgres, a JSON file, or sync it to a cloud KV store. Whatever fits your stack.
// localStorage example
localStorage.setItem(cardId, JSON.stringify(card));
const restored: CardState = JSON.parse(localStorage.getItem(cardId)!);API
createNewCard(now?: Date): CardState
Returns a fresh card with default ease factor 2.5, interval 0, repetitions 0, and nextReviewDate equal to now. The card is immediately due.
processReview(card, rating, now?, options?): CardState
Runs one SM-2 review. Returns a new CardState; the input is not mutated.
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| card | CardState | required | Current state. |
| rating | Rating | required | 0 Again, 1 Hard, 2 Good, 3 Easy. |
| now | Date | new Date() | Review timestamp. |
| options.maxInterval | number | Infinity | Upper bound on the computed interval, in days. |
isDue(card, now?): boolean
Convenience helper: now.toISOString() >= card.nextReviewDate.
MASTERED_INTERVAL_DAYS
Constant 21. Cards with interval > MASTERED_INTERVAL_DAYS are commonly displayed as "mastered" in progress UIs. Not part of the SM-2 specification — ignore or override as you wish.
Types
interface CardState {
easeFactor: number; // starts at 2.5, floored at 1.3
interval: number; // days until next review
repetitions: number; // consecutive successful reviews (Good/Easy)
nextReviewDate: string; // ISO 8601
}
type Rating = 0 | 1 | 2 | 3;
interface ReviewOptions {
maxInterval?: number;
}The algorithm in plain English
- Grade the recall on the 4-level scale. Again / Hard / Good / Easy map to SM-2 qualities
1 / 2 / 4 / 5. - On a pass (Good or Easy), increment the repetition count and compute the next interval:
- Rep 1 → 1 day
- Rep 2 → 6 days
- Rep ≥ 3 →
round(previous_interval × ease_factor), optionally capped.
- On a lapse (Again or Hard), reset repetitions to 0 and interval to 1 day.
- Update the ease factor on every review (pass or lapse):
EF ← EF + (0.1 − (5 − q)(0.08 + (5 − q)·0.02)), floored at1.3.- Easy →
+0.10 - Good →
±0.00 - Hard →
−0.32 - Again →
−0.54
- Easy →
That's it. The whole algorithm is a closed-form arithmetic update over four numbers.
Policy decisions
These are deliberate deviations from the canonical 1990 specification. They are documented openly so you can decide whether they fit your use case.
Four-grade scale, not six
Canonical SM-2 grades recall on 0–5. This implementation exposes four user-visible grades and maps them to qualities {1, 2, 4, 5}. Qualities 0 ("total blackout", EF delta −0.80) and 3 ("hesitation but correct, no reset", EF delta −0.14) are not surfaced.
Practical effect: the harshest single-review penalty is −0.54, not −0.80. Hard always triggers a streak reset. This matches the simplified Anki-style four-button layout most modern flashcard apps present, where forcing users to distinguish six levels of recall difficulty hurts more than it helps.
Optional interval cap
Canonical SM-2 has no upper bound. This implementation defaults to Infinity (matching the paper) but accepts an explicit maxInterval option for time-bounded use cases. Pass maxInterval: 180 for a six-month review horizon.
Should you use SM-2 or FSRS?
SM-2 is a 1990 algorithm. It's widely understood, predictable, requires no training data, and is trivial to audit. It also has known weaknesses: the ease factor is a global estimate that doesn't adapt to forgetting patterns over time, and the algorithm cannot predict the probability of recall at a given moment.
If you want state-of-the-art scheduling, use ts-fsrs (FSRS v4/v5). FSRS uses a neural-derived forgetting model that significantly outperforms SM-2 on recall accuracy benchmarks.
Use this package when:
- You need a dependency-free, auditable algorithm you can hold in your head.
- You're building for a constrained environment (embedded, edge, offline-first, low-end devices).
- Your team understands SM-2 and wants predictable, explainable scheduling.
- You want minimal surface area and explicit policy boundaries.
- You're translating an existing SM-2 implementation to TypeScript and need an algorithmic peer.
Use FSRS instead when:
- You have meaningful review-history data and want to optimise recall accuracy.
- You're willing to take on a slightly more complex API for measurable scheduling gains.
Testing
npm test21 tests across the rating scale, interval boundaries, ease factor floor, purity (non-mutation), the maxInterval option, and isDue. Runs in ~1 second. Spends zero external API tokens.
Contributing
PRs welcome. See CONTRIBUTING.md for the three load-bearing rules — processReview stays pure, behaviour changes update both JSDoc and the Policy decisions section, new defaults match the canonical SM-2 paper.
