Spaced repetition (SM-2)
Spaced repetition shows a card again just before you would forget it: soon after you learn it, then at longer and longer intervals. Solid Memo uses the SM-2 method to pick each card's next day from how well you remembered it. This page says how that schedule is worked out, how a day's study session is built, and where the cards stand.
How Solid Memo schedules cards. Implemented entirely in the domain layer (sm2.ts, scheduling.ts) as pure functions.
Reference. SM-2 is the algorithm of SuperMemo 2, first described in Woźniak, P. A. (1990). Optimization of learning. Master's thesis, University of Technology in Poznań. Its description, with the formulas used here, is published by SuperMemo as Application of a computer to improve the results obtained in working with the SuperMemo method. Solid Memo departs from it where The SM-2 transition says so. policy.md lists it with the other rules the code follows.
Prompts and directions
A session asks prompts: a card seen from one side (Prompt in
deck.ts). A deck's direction decides which
prompts its cards make — front→back, back→front, or both (a
bidirectional deck, two prompts per card). Each prompt is scheduled on
its own: knowing "Sweden → Stockholm" says nothing about knowing
"Stockholm → Sweden". Everything below — review state, budgets, the
queue, the session — is per prompt.
Review state
Each prompt (card + direction) carries one ReviewState:
| Field | Meaning |
|---|---|
easeFactor | SM-2 easiness (starts 2.5, never below 1.3) |
intervalDays | Days until the next review |
repetitions | Consecutive correct answers |
due | Study day ("YYYY-MM-DD") the card is next due |
firstReviewedAt | Introduction timestamp (counts against the new-card budget) |
lastReviewedAt | Latest review timestamp (counts against the review budget) |
A prompt with no ReviewState is new. Changing a deck's direction
keeps every state: a card's front→back state waits, unused, while the
deck is studied back→front.
The SM-2 transition
The user grades each answer with a quality q from 0 (blackout) to 5
(perfect). applySm2 transitions (easeFactor, intervalDays, repetitions):
The diagram's source
flowchart TD
accTitle: One SM-2 step
accDescr: A grade below 3 resets the repetitions and sets a one-day interval, and a grade of 3 or more updates the ease factor and sets the interval by the repetition count.
A[answer graded q] --> B{q at least 3?}
B -- no (lapse) --> C[repetitions = 0<br/>interval = 1 day<br/>ease factor kept]
B -- yes --> D["update ease factor<br/>EF' = EF + 0.1 − (5−q)(0.08 + (5−q)·0.02)<br/>floor 1.3"]
D --> E{repetition #}
E -- 1st --> F[interval = 1 day]
E -- 2nd --> G[interval = 6 days]
E -- 3rd+ --> H["interval = round(previous × EF')"]Decisions fixed in code (and tests):
- The updated ease factor drives interval growth (computed before the interval, per Woźniak's original description).
- Lapses (
q < 3) keep the ease factor; only repetitions and interval reset. The card returns the next study day (interval 1). - A grade of 0 or 1 additionally sends the card round again within the
session (
repeatsInSession); see Study sessions. Each pass is a full SM-2 review, so the state stored after the repeat is the one that counts. - The original repeats every card graded below 4 after the session, until each scores 4 or more. Solid Memo repeats only grades 0 and 1, so a lapse graded 2 (reset, but not repeated) and a hard but correct answer (3) end the card's day.
- Intervals are rounded to whole days.
- Every grade below 3 resets the card, but the statistics and the
Studio count a lapse only where it says something about memory: a
scored review graded below 3, that is an answer to a prompt already
introduced, its first that study day, recalled and graded rather than
chosen among a course's options (
isLapsein answerChain.ts). A failed first answer, the answer to a card back in the session the same day and a course answer are not counted (statistics.md).
Worked examples
Each row is one answer: the state before it, the grade, and the state after it. sm2.test.ts reads this table and checks that the code gives every row, so a change to the rules, or to a row, that leaves the two apart fails the tests. Keep its header and its three number columns as they are.
| Before: ease, interval, repetitions | Grade | After: ease, interval, repetitions | Why |
|---|---|---|---|
| 2.5, 0, 0 (new) | 5 | 2.6, 1, 1 | The first repetition: 1 day. |
| 2.5, 0, 0 (new) | 3 | 2.36, 1, 1 | A pass lowers the ease; the interval is still 1 day. |
| 2.6, 1, 1 | 5 | 2.7, 6, 2 | The second repetition: 6 days. |
| 2.5, 6, 2 | 3 | 2.36, 14, 3 | 6 × 2.36 = 14.16, rounded: the updated ease sets the interval. |
| 2.5, 6, 2 | 4 | 2.5, 15, 3 | Grade 4 keeps the ease: 6 × 2.5 = 15. |
| 2.5, 6, 2 | 5 | 2.6, 16, 3 | 6 × 2.6 = 15.6, rounded. |
| 2.0, 10, 3 | 3 | 1.86, 19, 4 | 10 × 1.86 = 18.6, rounded. |
| 1.3, 10, 3 | 3 | 1.3, 13, 4 | The ease would be 1.16, so it stays at the floor, 1.3. |
| 2.2, 30, 5 | 2 | 2.2, 1, 0 | A lapse: back to 1 day, the ease kept. |
| 2.2, 30, 5 | 0 | 2.2, 1, 0 | Grades 0, 1 and 2 schedule alike. |
Answer scales
Sessions grade with one of two button sets, chosen per instance in the
preferences (sm:answerScale, default sm2):
| Scale | Buttons | Records |
|---|---|---|
sm2 | 0 — Blackout … 5 — Easy | that grade |
minimal | Again · Hard · Good · Easy | 1 · 3 · 4 · 5 |
The minimal scale is a view over SM-2, not a second algorithm. Again stands for 0–1 and Hard for 2–3, but each button must record one value: Again records 1 (0 and 1 schedule identically) and Hard records 3, the lowest passing grade — recording 2 would make Hard a lapse, indistinguishable from Again. So on the minimal scale only Again repeats in the session (answerScale.ts).
Multiple-choice answers in a course
A course asks a card as a multiple-choice question: its
back among its distractors. The answer is right or wrong, not graded by
the learner, and gradeOfChoice in
course.ts maps it to SM-2:
| Choice | Grade | Why |
|---|---|---|
| right | 3 | The lowest passing grade: a choice says that the answer was known, not how well. |
| wrong | 1 | A lapse. |
That function is the only place a choice becomes a grade, so another
scheduler (FSRS) replaces this map alone. The grade then goes through
the same transition as a study review (applyGrade in
useCases.ts, which
recordReview uses too). Course answers are front→back.
Whether an answer is graded at all depends on the card's state
(courseAnswerEffect):
| The card | Right | Wrong |
|---|---|---|
| no state: answered for the first time, in a step | introduced, grade 3: 1 repetition, ease 2.36, due the next study day | introduced, grade 1: 0 repetitions, due the next study day |
| graded earlier the same study day, as in the final review after its step | nothing written: the day's grade stands | grade 1 |
| due this study day or earlier, not graded today | grade 3 | grade 1 |
| due on a later study day: a step revisited or a chapter retaken | nothing written: practice | nothing written: practice |
- A step introduces each card the first time its question is answered. The card is written into the deck first, then graded. A retry the same day writes nothing when it is right, and is another lapse when it is wrong.
- The final review asks every question of the chapter, usually the same day. A right answer leaves the day's grade as it is, so the card is not pushed further out by an answer it was just shown. A wrong one is a lapse. A question answered wrongly comes back until it is answered right.
- Revisiting writes nothing while the card is not due, since grading a card early would cut its interval short. A card that is due is reviewed, as in study.
- No daily limit applies: the learner reached the card in the course. A card introduced there still counts as introduced that day, so it uses up the deck's new-card budget in study (below).
- Afterward the cards are studied by flip-and-grade, with the instance's answer scale.
Study days and the queue
Scheduling works in study days, not calendar days. studyDayOf shifts an
instant back by dayBoundaryHour (default 4) before taking the local date —
reviewing at 03:00 still counts as yesterday. Timezone caveat: study days are
device-local; traveling shifts due times by a few hours (accepted for v1).
buildStudyQueue produces the day's session from cards + the deck's
direction + review states + preferences — the instance's, with the
deck's own daily limits in their place where it sets them
(deckPreferences in domain/deckPace.ts) (now is always passed in, never
read from a clock — that keeps it deterministic and testable). Retired
cards (owl:deprecated true) make no prompts: their review states are
kept, but never due and never new:
- due: prompts whose
due <= today, oldest due first, capped atmaxReviewsPerDayminus reviews already done today. - new: prompts without review state, drawn at random (Fisher–Yates
over an injected
randomsource, so tests stay deterministic), capped atnewCardsPerDayminus prompts introduced today. A long deck is therefore not introduced front to back, and a bidirectional deck's two prompts of one card need not arrive together — or even the same day.
The budgets count prompts, so a bidirectional deck spends two of the day's new-card budget on a card introduced both ways.
Both budgets clamp at zero.
New instances start at 5 new cards per day: every new card brings reviews in the days after, so a high number now piles them up later. The preferences say so beside the field. (A format-1 preferences document without the field still means the 20 of its day; see migrations.)
The deck list needs only the counts, which getStudyCounts takes from
the deck's schedule in the instance's digest
while neither of its documents has changed (studyCountsOf in
domain/studyDigest.ts, tested against buildStudyQueue for every
direction and cap). A schedule holds its counts by study day, so it
stays right as days pass: any review since it was computed would have
changed the reviews document, so on a later day nothing was reviewed or
introduced yet.
The queue is a snapshot taken at session start; the session then owns its own order (below) and a refetch never reshuffles it.
Study sessions
A deck offers one session, Study (StudyContainer):
today's due prompts plus new ones up to the daily new-card budget, the
new ones spread evenly among the due (interleave) rather than queued
after them, so a deck with a backlog still introduces something new
early on. The session opens with a due prompt when there is one. (There
used to be a separate due-only mode; one button with everything for the
day proved simpler.)
A session walks its queue one prompt at a time: the side asked → reveal
the other side → grade. A back→front prompt shows the card's back first.
A card graded 0 or 1 is put back into the remainder of the session at a
random position (requeueCard) — never as the very next card, unless it is
the only card left, in which case it simply repeats until it passes. The
"Card x of y" counter grows with each repeat. Each answer runs the
recordReview use case
(useCases.ts): load the card's stored
state (or start from the initial SM-2 state), apply the transition, compute
the next due day, persist to reviews/<deckId>.ttl, and return the new
state. A failed save keeps the card in place with an error; the queue only
advances on success. Ending the session invalidates the review caches so
other screens see fresh state.
Storage note: sm:due is stored as a plain "YYYY-MM-DD" string literal,
not xsd:date — a study day is a calendar label, and date round-trips
through Date objects risk timezone off-by-one shifts.
Suggesting a session
The app only suggests a session that has something in it, so nobody starts one to learn there was nothing to study. Both the deck page and each deck-list row read the deck's queue for today:
| Today's queue | Deck page | Deck-list row |
|---|---|---|
| prompts due and/or new within budget | Study, with "N due today, and M new to introduce" | Study with "N due · M new" |
| nothing | "All cards have been studied" | "Nothing to study today" |
| unknown (loading / unreadable) | loading or error | no suggestion |
Rows load independently and share the ["studyQueue", deckUrl] cache entry
with the deck page. Concurrent reads of the instance's preferences are
shared (one request for all rows) but never cached beyond the request.
Where cards stand
The statistics page, and each deck's page for that deck, show how far
along the cards are now and what falls due next
(cardProgress.ts). Both come
from the decks' cards and review states, read afresh each time they are
shown (getCardProgress, two GETs a deck), not from the answer log.
- New, relearning, young and mature. A prompt with no review state
is new. A reviewed one with
repetitions0 is relearning: it was forgotten last time and not remembered since, as SM-2 resets the repetitions on a lapse and any pass sets them to at least 1 (the Studio's "learning" state). Of the others, withrepetitionsabove 0, one whoseintervalDaysis under 21 (MATURE_INTERVAL_DAYS) is young, and one at 21 days or more is mature. The recall tiles and the recall-by-month chart draw the same line, but on the interval before each review (the answer's prior interval), so the review that makes a prompt mature still counts as a young one. - Prompts, not cards. The counts are of prompts in the deck's
direction, as the queue's are (
cardProgressOfcountspromptsOf(activeCards(cards), direction)and the statesscheduledStateskeeps of them), so a bidirectional deck counts each card twice, and the page says so. Retired cards, and states of a direction the deck is not studied in, count for nothing. - The forecast is the Studio's (
forecastOf, scheduleInsight.ts), worked out for each deck from its schedule (the digest's while it is of the documents just read, elsescheduleOfafresh, then kept in the digest), then added up day by day across the decks (mergeForecasts): the reviews due on each of the next 30 study days, today first. Today's bar also holds every prompt already overdue. Each day pairs what falls due with what the deck'smaxReviewsPerDaylets through (the rest carried over to the next day), so the reviews held back show. - Overdue and caught up. Overdue counts the prompts due before
today (
overdueOf), apart from those due today. The day the decks are caught up is worked out per deck from its own forecast (daysToClearOf): the study days its cap takes to let through every review it holds back, or more than the 30 days. Across decks the latest is taken, as each deck's cap is its own. - Introduced is not learned. "Cards introduced over time" is a running total of first answers in the answer log, over the months it reads (the last 12). It counts prompts of removed decks and retired cards too, and none introduced before those months, so it need not match the new, young and mature counts, which say only where today's prompts stand.
Resetting the day
The deck page offers Reset today's study once something has been studied today. It undoes the current study day for that deck, as if the day's sessions had not happened.
A review overwrites a card's state, so undoing needs a record of what was
overwritten. recordReview therefore stores a snapshot (ReviewState.previous,
the sm:previous* triples) of the state from before the study day's first
review — a second review the same day keeps that snapshot instead of
replacing it (snapshotBeforeReview).
resetStudyDay (pure, in scheduling.ts) then
decides, per card reviewed today:
| Card | Reset does |
|---|---|
introduced today (firstReviewedAt is today) | removes its state — it is a new card again |
| reviewed today, has a snapshot | restores the snapshot (ease, interval, repetitions, due, last review) |
| reviewed today, no snapshot (state written before snapshots existed, or given a due day in the Studio) | can't be restored: made due today, so it can at least be studied again |
| not reviewed today | untouched |
The diagram's source
flowchart LR
accTitle: Resetting today's study
accDescr: The first review of the day keeps a snapshot, and a reset removes a card introduced today, restores a snapshot, or leaves the card due today.
R[review today] -->|first of the day| S[snapshot previous state]
R -->|again today| K[keep the morning's snapshot]
X[Reset today's study] --> Q{card reviewed today?}
Q -->|introduced today| N[remove the state: new again]
Q -->|has snapshot| B[restore snapshot]
Q -->|no snapshot| D[due today]Because the daily budgets are derived from the same timestamps
(lastReviewedAt / firstReviewedAt falling in today), restoring them also
frees today's review and new-card budget. All changes go out in one save of
the reviews document (applyReviewChanges), so a reset is never half-applied.
The day's answers of that deck then leave the
answer log as well, so the statistics
match the cards: answers still on their way to the log are added first,
then removed with the rest.
"Today" honors the instance's dayBoundaryHour, like the queue.
The Studio changes a state outside a review.
Setting a card's due day (rescheduleState, in
reviewStateEdits.ts) drops
its snapshot. Otherwise a reset of the day would bring back a state from
before the new due day, and the due day would be lost. Forgetting a card
(resetStates) removes its state, so a reset of the day has nothing of
it to restore. Neither touches the answer log.
In a course's deck, a reset treats the day's course answers like study answers:
- A card introduced today loses its state but stays in the deck, with its distractors. Study then shows it as new, and the course counts its step as not done again. Answering its question in the course introduces it again.
- A card reviewed today gets its snapshot back.
- The day's multiple-choice answers leave the answer log.
- A chapter completed today stays completed:
sm:completedChapteris on the catalog entry, which a reset does not touch.