Version 4.0.0 · All versions

All pages

These are the docs as released with Solid Memo 4.0.0. Read the latest docs.

Spaced repetition (SM-2 and FSRS-6)

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 picks each card's next day from how well you remembered it, by the SM-2 method or, when the preferences name it, by FSRS-6 (FSRS-6). 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, fsrs.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:

FieldMeaning
easeFactorSM-2 easiness (starts 2.5, never below 1.3)
intervalDaysDays until the next review
repetitionsConsecutive correct answers
dueStudy day ("YYYY-MM-DD") the card is next due
firstReviewedAtIntroduction timestamp (counts against the new-card budget)
lastReviewedAtLatest 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):

One SM-2 step
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.
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):

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, repetitionsGradeAfter: ease, interval, repetitionsWhy
2.5, 0, 0 (new)52.6, 1, 1The first repetition: 1 day.
2.5, 0, 0 (new)32.36, 1, 1A pass lowers the ease; the interval is still 1 day.
2.6, 1, 152.7, 6, 2The second repetition: 6 days.
2.5, 6, 232.36, 14, 36 × 2.36 = 14.16, rounded: the updated ease sets the interval.
2.5, 6, 242.5, 15, 3Grade 4 keeps the ease: 6 × 2.5 = 15.
2.5, 6, 252.6, 16, 36 × 2.6 = 15.6, rounded.
2.0, 10, 331.86, 19, 410 × 1.86 = 18.6, rounded.
1.3, 10, 331.3, 13, 4The ease would be 1.16, so it stays at the floor, 1.3.
2.2, 30, 522.2, 1, 0A lapse: back to 1 day, the ease kept.
2.2, 30, 502.2, 1, 0Grades 0, 1 and 2 schedule alike.

FSRS-6

FSRS-6, the Free Spaced Repetition Scheduler, version 6, is a second scheduler beside SM-2. It keeps two numbers for each prompt, how long its memory lasts (stability) and how hard it is (difficulty), and picks the due day on which the chance of remembering the card falls to the desired retention, 90% by default. It is implemented in the domain layer too, as pure functions: fsrs.ts has the formulas, and gradePrompt in grading.ts applies a grade to both schedulers. The preferences name the scheduler that sets the due days (sm:studyScheduler, preferences format 5), and SM-2's state is updated on every review either way. FSRS-6 is the recommended one: its intervals differ clearly by the answer, and a late review counts for how late it is. A new instance starts on it (Which scheduler an instance is on). The learner switches in the preferences, or from the deck list's invitation (Switching the scheduler).

Reference. FSRS is by Jarrett Ye and the Open Spaced Repetition project, described in The Algorithm. Solid Memo reimplements FSRS-6's formulas and default parameters as the reference implementation fsrs-rs v6.6.2 (BSD-3-Clause) gives them; no code is copied from it. Where Solid Memo fixes what FSRS leaves to the app, Decisions fixed in code says so.

FSRS-6 state

A prompt scheduled with FSRS-6 has an FsrsState (review.ts) beside its SM-2 ReviewState, a subject of its own in the reviews document:

FieldMeaning
stabilityDays until the chance of recall falls to 90% (above 0)
difficultyFrom 1 (easiest) to 10 (hardest)
dueStudy day ("YYYY-MM-DD") the card is next due
firstReviewedAtIntroduction timestamp, the SM-2 state's
lastReviewedAtLatest review timestamp
previousThe snapshot a reset of the day restores: stability, difficulty, due, last review

SM-2 is applied on every review, whichever scheduler sets the due days, so switching back finds its states current. An FSRS-6 state counts only while it is fresh: while the SM-2 state of its prompt has the same last review (isFresh). A review the FSRS-6 state did not see (an older app graded SM-2 only, the day was reset, or the card forgotten) leaves it stale. A stale or missing state counts for nothing, and the next review estimates one from the SM-2 state. What sets a prompt's due day, whichever scheduler does, is a DueState: its due day, the interval that led there and its reviews. Under FSRS-6, effectiveStates takes each prompt's fresh FSRS-6 due day, with the study days from its last review to that day as its interval, and the SM-2 state's where there is none.

The FSRS-6 transition

A rating G is 1 (Again), 2 (Hard), 3 (Good) or 4 (Easy), and t is the whole study days since the prompt's last review. With the default parameters w0 to w20 and the forgetting curve's decay w20:

factor = 0.9^(−1/w20) − 1
R(t, S) = (1 + factor · t / S)^(−w20)            chance of recall; R(S, S) = 0.9
I(S, r) = S / factor · (r^(−1/w20) − 1)          days until R falls to r; I(S, 0.9) = S

first rating:  S = w[G−1]
               D = clamp(w4 − e^(w5·(G−1)) + 1, 1, 10)

a later one:   S, D kept to 0.001–36,500 and 1–10 first; R = R(t, S)
  Again:       S' = min(w11 · D^(−w12) · ((S+1)^w13 − 1) · e^((1−R)·w14),  S / e^(w17·w18))
  Hard..Easy:  S' = S · (1 + e^w8 · (11 − D) · S^(−w9) · (e^((1−R)·w10) − 1) · h · b)
               h = w15 for Hard, else 1;  b = w16 for Easy, else 1
  same day:    S' = S · e^(w17·(G − 3 + w18)) · S^(−w19), at least S unless Again
               (t = 0 replaces both above)
  difficulty:  D' = D − w6·(G − 3) · (10 − D) / 9
               D'' = clamp(w7 · D0(4) + (1 − w7) · D', 1, 10),  D0(4) = w4 − e^(3·w5) + 1
  S' is kept from 0.001 to 36,500.

interval = clamp(round(I(S', desired retention)), 1, 36500)
due = today + interval
ParametersDefaultUsed for
w0–w30.212, 1.2931, 2.3065, 8.2956first stability, by rating
w4–w76.4133, 0.8334, 3.0194, 0.001first difficulty, its step, mean reversion
w8–w101.8722, 0.1666, 0.796stability after a recall
w11–w141.4835, 0.0614, 0.2629, 1.6483stability after a lapse
w15, w160.6014, 1.8729Hard penalty, Easy bonus
w17–w190.5425, 0.0912, 0.0658same-day reviews
w200.1542the forgetting curve's decay

Estimate from SM-2. A prompt reviewed with SM-2 only gets its FSRS-6 memory from its SM-2 state (memoryFromSm2, as fsrs-rs's memory_state_from_sm2 at a retention of 90%): the stability is the SM-2 interval, and the difficulty the one at which a Good review on the due day multiplies the stability by the ease factor:

S = max(interval, 0.001)
D = clamp(11 − (ease − 1) / (e^w8 · S^(−w9) · (e^(0.1·w10) − 1)), 1, 10)

Decisions fixed in code (FSRS-6)

Where the FSRS-6 states are kept

Each prompt's FSRS-6 state is a subject of the deck's reviews document beside its SM-2 state (data-model.md), read in the same read of the document.

Which scheduler an instance is on

The scheduler is the preferences' own, never a default's, so a change of default never changes anyone's scheduler behind their back:

The invitation to switch

A learner whose instance is on SM-2 is invited above the deck list to switch to FSRS-6 (SchedulerInvitationContainer, SchedulerInvitation.tsx; invitesToFsrs), a guest among them: what FSRS-6 gives, a button that switches and "Not now". The switch is switchScheduler's, with the preferences as they are but for the scheduler, its progress and its result where the invitation was, as on the preferences screen. "Not now" is kept in the preferences (sm:schedulerInvitationDismissed, dismissSchedulerInvitation), so the invitation does not come back, on any device, and a save of the preferences keeps it. It is not shown while FSRS-6 sets the due days. A learner who switches back to SM-2 in the preferences and has not answered "Not now" is invited once more.

Switching the scheduler

The preferences screen, where FSRS-6 is listed first and marked Recommended, and the deck list's invitation save a change of scheduler through switchScheduler (useCases.ts); a plain save of the preferences (savePreferences) keeps the scheduler stored, so FSRS-6 is never turned on without its states.

FSRS-6 worked examples

Each row is one answer: the memory before it (a new prompt, a stability and difficulty, or an SM-2 state's ease and interval to estimate from), the rating, the study days since the last review, the memory after it and the interval at a desired retention of 90%. fsrs.test.ts reads this table and checks that the code gives every row to within 0.0001 of each value, as the table rounds them to 4 decimals, so a change to the formulas, or to a row, that leaves the two apart fails the tests. Keep its header and its five number columns as they are.

Before: stability, difficultyRatingDaysAfter: stability, difficultyIntervalWhy
(new)1 Again00.212, 6.41331A first Again: the stability is w0, and the interval at least 1 day.
(new)2 Hard01.2931, 5.11221A first Hard: w1.
(new)3 Good02.3065, 2.11812A first Good: w2.
(new)4 Easy08.2956, 18A first Easy: w3; the difficulty would be below 1, so it is 1.
2.3065, 2.11813 Good210.9643, 2.111211Good on the due day: the stability grows almost fivefold.
10.9643, 2.11123 Good1146.2802, 2.104346And again on the due day.
46.2802, 2.10433 Good46162.8626, 2.0974163An easy card's stability grows by less each time.
46.2802, 2.10433 Good20107.1892, 2.0974107Reviewed early: the chance of recall was still high, so the stability grows less.
10.9643, 2.11121 Again111.5383, 7.39222A lapse: the stability falls, and the difficulty rises.
1.5383, 7.39223 Good24.8763, 7.385Good after a lapse: a harder card grows more slowly.
2.3065, 2.11813 Good02.3065, 2.11122Good again the same day: the same-day formula never lowers the stability.
0.212, 6.41333 Good00.2467, 6.40211Good the same day after an Again, as when a failed card comes back in the session.
5, 51 Again01.5968, 8.34182Again the same day lowers the stability by the same-day formula.
0.212, 6.41331 Again10.1009, 8.80631Again the next day.
8.2956, 14 Easy865.6242, 166The Easy bonus, w16.
1.2931, 5.11222 Hard13.2494, 6.74053The Hard penalty, w15.
SM-2: 2.5, 103 Good1025, 6.902425Estimated from SM-2 (stability 10, difficulty 6.9141): Good on the due day multiplies the stability by the ease, 2.5.
SM-2: 1.3, 203 Good2026.5415, 9.985227The lowest ease, 1.3, would need a difficulty above 10, so it is 10 and the stability grows a little more.
SM-2: 2.5, 101 Again101.3646, 8.97091A lapse after an estimate.

Answer scales

Sessions grade with one of two button sets, chosen per instance in the preferences (sm:answerScale, default sm2). Under FSRS-6 the minimal scale is always used, as FSRS-6 knows the four ratings alone (effectiveAnswerScale in preferences.ts); the stored scale stays, for switching back to SM-2:

ScaleButtonsRecords
sm20 — Blackout … 5 — Easythat grade
minimalAgain · Hard · Good · Easy1 · 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:

ChoiceGradeWhy
right3The lowest passing grade: a choice says that the answer was known, not how well.
wrong1A lapse.

That function is the only place a choice becomes a grade; FSRS-6 rates the same answer with ratingOfChoice, Good when right and Again when wrong (FSRS-6), so under FSRS-6 the "grade 3" below is SM-2's, and FSRS-6's rating of it Good. The grade then goes through the same transition as a study review (gradePrompt in grading.ts, which applyGrade in useCases.ts applies, as recordReview does). Course answers are front→back.

Whether an answer is graded at all depends on the card's state (courseAnswerEffect), its due day the one the scheduler in use set (under FSRS-6, the fresh FSRS-6 state's):

The cardRightWrong
no state: answered for the first time, in a stepintroduced, grade 3: 1 repetition, ease 2.36, due the next study dayintroduced, grade 1: 0 repetitions, due the next study day
graded earlier the same study day, as in the final review after its stepnothing written: the day's grade standsgrade 1
due this study day or earlier, not graded todaygrade 3grade 1
due on a later study day: a step revisited or a chapter retakennothing written: practicenothing written: practice

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:

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): take the card's state as reviews/<deckId>.ttl holds it when written (or start from the initial SM-2 state), apply the transition (gradePrompt: SM-2's, and FSRS-6's too while it sets the due days), compute the next due day, persist it there, and return the new state. The preferences are not read for it: the queue comes with the instance's (StudySessionQueue), which the session grades with (GradeBasis, recordReview's known). 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.

Under each grade button is the interval that grade would give (previewIntervals in grading.ts, the grade applied to the prompt's states as gradePrompt would apply it, a new prompt's from nothing): "1 day", "6 days", "15 days". Under SM-2 it is SM-2's interval; under FSRS-6 the FSRS-6 interval at the desired retention, which depends on the days since the last review, so on the time the card is shown. The screen is handed the numbers. The grade's label stays the button's name; the interval is its description. The states, SM-2's and FSRS-6's, come with the session's queue, as getStudyQueue read them to build it, with no read of their own, and so do the preferences, which pick the scheduler and the buttons; the session keeps each answer's new states, so a card put back shows the intervals its first answer left it with.

What a grade costs

A grade writes one state, or two under FSRS-6 (its SM-2 and its FSRS-6 state, FSRS-6), in one PATCH of the reviews document, If-Match the version the document was last read or written at (data-model.md), the grade applied to the card's state as that document holds it (gradeReviewState). The session's first grade edits the document as its queue read it, with no read first. A later one can do the same only when the pod answered the last write with the document's new ETag. None of the servers the tests measure does: Community Solid Server 6, 7 and 8, with ACP too, and Pivot answer a PATCH with 205 and no ETag, node-solid-server gives no ETag at all, and Nextcloud none on a write (JavaScript Solid Server could not be measured). There the document is read again, whole, before each later grade. When it changed elsewhere since (another device's grade: 412), it is read again and the grade applied to the state it holds then, once, so a grade of the same card made meanwhile is built on, not undone.

MEASURE_GRADES=1 measures the requests, bytes and time of a session's grades on a deck of 2,000 states against each server the tests run (testing.md).

Undoing the last answer

After an answer the screen offers Undo last answer (U, Ctrl+Z or Cmd+Z, the last two checked apart from the card's own keys, which skip any key pressed with Ctrl or Meta). It takes back the session's most recent answer and shows that card again, unrevealed:

Undo goes back one step only, the last answer, and only while the session is open; after an undo there is nothing more to undo until the next answer. The answer that ends the session can be undone from the end of it.

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 queueDeck pageDeck-list row
prompts due and/or new within budgetStudy, 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 errorno 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.

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 single answer is undone in the session itself: Undoing the last answer.)

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:

CardReset does
introduced today (firstReviewedAt is today)removes its state — it is a new card again
reviewed today, has a snapshotrestores 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 todayuntouched

The FSRS-6 states go back in the same save (resetFsrsStudyDay): one reviewed today is restored to its snapshot (stability, difficulty, due day, last review); one introduced today goes, as its SM-2 state does, and so does one without a snapshot, first written today from an estimate, which the next review estimates again from the SM-2 state restored.

Resetting today's study
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.
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. It sets an FSRS-6 state's due day the same way. Forgetting a card (resetStates) removes its states, SM-2's and FSRS-6's, 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: