SiriusStarr / elm-spaced-repetition / SpacedRepetition.SMTwoPlus

This package provides everything necessary to create spaced repetition software using the SM2+ algorithm. The SM2+ algorithm was proposed by "BlueRaja" as an improvement of the SM-2 algorithm, which has been released for free public use when accompanied by the following notice:

Algorithm SM-2, (C) Copyright SuperMemo World, 1991.

For details about the SM2+ algorithm and its purported advantages over the SM-2 algorithm, please refer to the following blog post.

It should be noted that this algorithm produces seemingly illogical behavior, namely that more incorrect answers result in longer intervals than less incorrect answers. Do not use it if this behavior is objectionable to you (as it probably should be). A custom scheduling function for incorrect answers may be provided if one still wishes to use it (with a different incorrect scheduler).

Most notably (versus SM-2), the SM2+ algorithm "rewards" correctly answering more-overdue cards and sorts due items based on the proportion they are overdue, not the absolute amount they are overdue by. Additionally, the initial intervals are 1 day -> 3 days, rather than 1 day -> 6 days.

Cards and Decks

The building blocks of this package are Cards and Decks. In simple terms, a Card may be thought of as a single flashcard and a Deck as a list or collection of Cards.


type alias Card a =
{ a | srsData : SRSData }

A Card represents a single question or unit of knowledge the user will review. In general terms, each would represent a single flashcard. Card is defined as an extensible record; as such, whatever necessary custom fields for a use case may simply be included in the record, e.g.:

type alias MyFlashcard =
    Card { prompt : String, answer : String }

A Card contains only the information necessary for scheduling and nothing else; all other information should be added as in the above example.


type alias Deck a =
Array (Card a)

A Deck represents a list of cards to be studied (this might be called a "collection" in other software). It is simply an Array of Card and requires no special creation or manipulation. Maintaining the state of a Deck may be handled by the user of the module or by this module itself. In general, it is probably best not to add a massive quantity of new (unstudied) cards to a deck at once.

Card Data

A Card may be created by use of the newSRSData function, as in the following example:

type alias MyFlashcard =
    Card { prompt : String, answer : String }

myFlashcard : MyFlashcard
myFlashcard =
    { prompt = "SYN"
    , answer = "SYN-ACK"
    , srsData = newSRSData
    }


type alias SRSData =
SpacedRepetition.Internal.SMTwoPlus.ReviewHistory

SRSData contains all data necessary for the SM2+ scheduling algorithm and may be created with the newSRSData function. It may additionally be saved/loaded using the Json encoder/decoder in this package

newSRSData : SRSData

newSRSData creates a new SRSData for inclusion in a Card.

Json Encoders/Decoders

Since Card data must necessarily be preserved between sessions, a Json encoder/decoder is provided for SRSData. It may be utilized as follows:

import Json.Decode as Decode
import Json.Encode as Encode

type alias MyFlashcard =
    Card { prompt : String, answer : String }

myFlashcardConstructor : SRSData -> String -> String -> MyFlashcard
myFlashcardConstructor srsData prompt answer =
    { prompt = prompt
    , answer = answer
    , srsData = srsData
    }

myFlashcardToJson : MyFlashcard -> String
myFlashcardToJson myCard =
    Encode.encode 0 <|
        Encode.object
            [ ( "srsData", encoderSRSData myCard.srsData )
            , ( "prompt", Encode.string myCard.prompt )
            , ( "answer", Encode.string myCard.answer )
            ]

myFlashcardDecoder : Decode.Decoder MyFlashcard
myFlashcardDecoder =
    Decode.map3 myFlashcardConstructor
        (Decode.field "srsData" decoderSRSData)
        (Decode.field "prompt" Decode.string)
        (Decode.field "answer" Decode.string)

jsonToMyFlashcard : String -> Result Decode.Error MyFlashcard
jsonToMyFlashcard str =
    Decode.decodeString myFlashcardDecoder str

encoderSRSData : SRSData -> Json.Encode.Value

encoderSRSData provides a Json encoder for encoding SRSData from a Card.

decoderSRSData : Json.Decode.Decoder SRSData

decoderSRSData provides a Json decoder for decoding SRSData for a Card.

Answering Cards

The SM2+ algorithm depends on grading answers on a scale from 0.0 to 1.0, with answers of 0.6 and above being correct (and incorrect below that) responses. For some use cases, it may be able to programmatically determine the PerformanceRating of a user's response. In other cases, however, the user may need to self-report.


type alias PerformanceRating =
SpacedRepetition.Internal.SMTwoPlus.PerformanceRating

The PerformanceRating type represents how accurate/certain a user's response was to a card and must be passed to answerCard whenever a Card is answered. PerformanceRating is quantitative and must be between 0.0 and 1.0, with values of 0.6 and greater representing a "correct" answer. A PerformanceRating may be created with the performanceRating function.

performanceRating : Basics.Float -> PerformanceRating

The performanceRating function creates a PerformanceRating. PerformanceRating is quantitative and must be between 0.0 and 1.0, with values of 0.6 and greater representing a "correct" answer.

answerCardInDeck : Maybe IncorrectSchedulingFunction -> Time.Posix -> PerformanceRating -> Basics.Int -> Deck a -> Deck a

answerCardInDeck functions analogously to answerCard but handles maintenance of the Deck, which is typically what one would desire. When a card is presented to the user and answered, answerCardInDeck should be called with a Maybe IncorrectSchedulingFunction, the current time (in the Time.Posix format returned by the now task of the core Time module), a PerformanceRating, the index of the card in the Deck, and the Deck itself. It returns the updated Deck. Use this function if you simply want to store a Deck and not worry about updating it manually (which is most likely what you want). Otherwise, use answerCard to handle updating the Deck manually. Handling the presentation of a card is the responsibility of the implementing program, as various behaviors might be desirable in different cases. Note that if an invalid (out of bounds) index is passed, the Deck is returned unaltered. IncorrectSchedulingFunction may be provided to fix the issue with scheduling incorrect cards inherent in the algorithm. If Nothing is provided, the algorithm-specified 1 / diffWeight ^ 2 scaling is used that results in questionable behavior.

answerCard : Maybe IncorrectSchedulingFunction -> Time.Posix -> PerformanceRating -> Card a -> Card a

When a card is presented to the user and answered, answerCard should be called with a Maybe IncorrectSchedulingFunction, the current time (in the Time.Posix format returned by the now task of the core Time module) and an PerformanceRating. It returns the updated card, which should replace the card in the Deck. Use this function if you want to handle updating the Deck manually; otherwise, use answerCardInDeck. Handling the presentation of a card is the responsibility of the implementing program, as various behaviors might be desirable in different cases. IncorrectSchedulingFunction may be provided to fix the issue with scheduling incorrect cards inherent in the algorithm. If Nothing is provided, the algorithm-specified 1 / diffWeight ^ 2 scaling is used that results in questionable behavior.


type alias IncorrectSchedulingFunction =
Basics.Float -> Basics.Float

IncorrectSchedulingFunction must take a float as an argument, representing "difficultyWeight" (which is in the interval [1.3, 3.0]), and return a float representing the factor by which the interval should be scaled (which should probably be in the interval [0.0, 1.0]). This function will only be called with incorrect answers, not correct ones. Note that an incorrect interval cannot be less than 1 day, so any factor resulting in a shorter interval will simply result in an interval of 1 day. A custom function may be provided, or the pre-made function oneMinusReciprocalDiffWeightSquared may be used, which seems a likely correction.

oneMinusReciprocalDiffWeightSquared : Basics.Float -> Basics.Float

oneMinusReciprocalDiffWeightSquared represents an attempt to "fix" the SM2+ algorithm, scheduling incorrect cards with the more logical behavior of more difficult cards receiving shorter intervals, with the assumption that the original formula 1 / diffWeight ^ 2 was intended to be 1 - 1 / diffWeight ^ 2. It maps "difficultyWeight" ([1.3, 3.0]) to the interval [0.408, 0.889].

Due Cards

Besides answering cards, this package handles determining which cards in a Deck are due and require answering.

getDueCardIndices : Time.Posix -> Deck a -> List Basics.Int

getDueCardIndices takes the current time (in the Time.Posix format returned by the now task of the core Time module) and a Deck and returns the indices of the subset of the Deck that is due for review (as List Int). The returned indices will be sorted in the following order:

  1. Cards overdue for review
    1. Cards more overdue (by proportion of interval)
    2. Cards less overdue (by proportion of interval)
  2. Any new cards in the deck (never having been studied before)

Equally due cards are presented in random order.

getDueCardIndices assumes that a new day begins after 8 hours, e.g. if a card is scheduled to be studied the next day, it will become due after 8 hours of elapsed time. This can of course create edge cases where cards are reviewed too "early" if one studies very early in the morning and again late at night. Still, only very "new" cards would be affected, in which case the adverse effect is presumably minimal.

getDueCardIndicesWithDetails : Time.Posix -> Deck a -> List { index : Basics.Int, queueDetails : QueueDetails }

getDueCardIndicesWithDetails takes the current time (in the Time.Posix format returned by the now task of the core Time module) and a Deck and returns the the subset of the Deck that is due for review (as a list of records), providing their index and which queue they are currently in, with any relevant queue details. The returned indices will be sorted in the following order:

  1. Cards overdue for review
    1. Cards more overdue (by proportion of interval)
    2. Cards less overdue (by proportion of interval)
  2. Any new cards in the deck (never having been studied before)

Equally due cards are presented in random order.

getDueCardIndicesWithDetails assumes that a new day begins after 8 hours, e.g. if a card is scheduled to be studied the next day, it will become due after 8 hours of elapsed time. This can of course create edge cases where cards are reviewed too "early" if one studies very early in the morning and again late at night. Still, only very "new" cards would be affected, in which case the adverse effect is presumably minimal.

Card Details

If you require specific details for a single card, you may use the provided functionality here. If you need details for all due cards, just use getDueCardIndicesWithDetails.


type QueueDetails
    = NewCard
    | ReviewQueue ({ intervalInDays : Basics.Float, lastSeen : Time.Posix })

QueueDetails represents the current status of a card.

getCardDetails : Card a -> { queueDetails : QueueDetails }

getCardDetails returns the current queue status for a given card. If you require this for every due card, simply use getDueCardIndicesWithDetails.