SiriusStarr / elm-spaced-repetition / SpacedRepetition.Leitner

This package provides everything necessary to create spaced repetition software using a variant of the Leitner system. The Leitner system was proposed by Sebastian Leitner in the early 1970s and was originally intended for use with physical (paper) flashcards.

For the basics about this algorithm, please refer to the following description on Wikipedia.

Settings

This algorithm requires certain settings be provided when functions are called to specify the behavior of the (very general) system.


type alias LeitnerSettings =
{ onIncorrect : OnIncorrect
, boxSpacing : SpacingFunction
, numBoxes : NumberOfBoxes 
}

LeitnerSettings customizes the behavior of this algorithm. Three parameters must be defined: the behavior of the system upon an incorrect answer, the spacing (interval) between "boxes", and the total number of boxes before a card "graduates." No builder functions are provided, as only three settings exist and the Leitner system doesn't have "defaults" to speak of. Additionally, no JSON encoder/decoder is provided because serializing functions (for SpacingFunction) is non-trivial.


type OnIncorrect
    = BackOneBox
    | BackToStart

OnIncorrect specifies what should happen to a card when an incorrect answer is given. In the "traditional" Leitner system, the card goes all the way back to the first box (BackToStart), however many variants simply move the card back one box (BackOneBox). This behavior may be overridden for a specific answer as discussed with the Answer type.


type alias SpacingFunction =
Basics.Int -> Basics.Int

SpacingFunction takes an integer argument, representing the box number, and returns an integer representing the interval in days that cards in that box should go between reviews. Note that box numbering starts at zero (0), and intervals will always be at least 1 day (regardless of the output of a SpacingFunction). A custom function may be provided, or the pre-made functions doubleSpacing or fibonacciSpacing may be used. For obvious reasons, care should be taken that the complexity/recursive depth/etc. does not become excessive within the number of boxes your system will use.

doubleSpacing : Basics.Int -> Basics.Int

doubleSpacing is a SpacingFunction with which the interval between each box simply doubles, e.g. Box 0 has an interval of 1, Box 1 of 2, Box 2 of 4, Box 3 of 8, etc.

fibonacciSpacing : Basics.Int -> Basics.Int

fibonacciSpacing is a SpacingFunction with which the interval between each box follows the Fibonacci sequence, e.g. Box 0 has an interval of 1, Box 1 of 1, Box 2 of 2, Box 3 of 3, Box 4 of 5, Box 5 of 8, Box 6 of 13, etc.

numberOfBoxes : Basics.Int -> NumberOfBoxes

numberOfBoxes may be used to specify the total number of boxes before a card "graduates" (i.e. is no longer reviewed). It takes an integer as a parameter, specifying a system with that integer number of boxes. There must, of course, be at least 1 box in the system (and there should almost certainly be more), so values < 1 will be ignored and result in a system with only one box.


type alias NumberOfBoxes =
SpacedRepetition.Internal.Leitner.NumberOfBoxes

The maximum number of boxes in the Leitner system, beyond which cards will be graduated, as created by numberOfBoxes.

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 that 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 are required for a use case may simply be included in the record, e.g.:

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

A Card by default 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 b =
{ a | cards : Array (Card b)
, settings : LeitnerSettings 
}

A Deck represents a list of cards to be studied (this might be called a "collection" in other software). It is a record with field cards holding an Array of Card and field settings holding LeitnerSettings. 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.Leitner.Box

SRSData contains all data necessary for the Leitner system 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 Leitner system depends only on answers being "correct" or "incorrect." Nevertheless, answers are additionally provided for "manually" moving cards, e.g. if you wish to implement a system in which the user decides how to move the card (this is usually a bad idea, since the point of an SRS system is to schedule cards better than people are able to estimate themselves).


type Answer
    = Correct
    | Incorrect
    | Pass
    | MoveBoxes Basics.Int
    | BackToFirstBox

Answer must be passed to answerCard/answerCardInDeck when the user answers a card. It is usually best to simply use Correct and Incorrect (which follow the behavior specified in LeitnerSettings), but one could potentially provide the user with more options.

answerCardInDeck : Time.Posix -> Answer -> Basics.Int -> Deck a b -> Deck a b

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 the current time (in the Time.Posix format returned by the now task of the core Time module), an Answer, the index of the card in the Deck (e.g. what is returned by the getDueCardIndices function), 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.

answerCard : Time.Posix -> Answer -> LeitnerSettings -> Card a -> Card a

When a card is presented to the user and answered, answerCard should be called with the current time (in the Time.Posix format returned by the now task of the core Time module), an Answer, and LeitnerSettings. 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, which is much more convenient. Handling the presentation of a card is the responsibility of the implementing program, as various behaviors might be desirable in different cases.

Due Cards

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

getDueCardIndices : Time.Posix -> Deck a b -> 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 12 hours, e.g. if a card is scheduled to be studied the next day, it will come due after 12 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 b -> 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 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 12 hours, e.g. if a card is scheduled to be studied the next day, it will come due after 12 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
    | InBox ({ boxNumber : Basics.Int, lastSeen : Time.Posix })
    | GraduatedCard

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.