Version 3.11.0 · All versions

All pages

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

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:

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.

Answer scales

Sessions grade with one of two button sets, chosen per instance in the preferences (sm:answerScale, default sm2):

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, 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 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, 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 (applySm2 from the prompt's state, or the initial state for a new prompt): "1 day", "6 days", "15 days". The grade's label stays the button's name; the interval is its description. The states come with the session's queue, as getStudyQueue read them to build it, with no read of their own; the session keeps each answer's new state, so a card put back shows the intervals its first answer left it with.

What a grade costs

A grade writes one state: a 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
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. 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: