haskell-fsrs-7.0.0: src/FSRS/Scheduler.hs
{-# LANGUAGE DerivingStrategies #-}
-- | A card scheduler built on top of the FSRS-7 memory model.
--
-- The memory model in "FSRS.Algorithm" is fully specified by upstream; the
-- scheduling policy around it is not, so this module follows the widely used
-- reference scheduler from
-- <https://github.com/open-spaced-repetition/py-fsrs py-fsrs>: a card walks
-- through a list of learning steps, graduates into the review queue, and drops
-- into relearning steps when it lapses.
--
-- Two things differ from py-fsrs, both because FSRS-7 is built for continuous
-- intervals where FSRS-6 was built for whole days:
--
-- * Elapsed time is measured in fractional days rather than truncated to whole
-- days, and it is fed to the model directly. There is no same-day special
-- case, because 'FSRS.Algorithm.transitionCoefficient' already is one.
-- * Scheduled intervals are not rounded to whole days. Set
-- 'schedulerMinimumInterval' to @1@ (the default) to keep review-queue
-- intervals at a day or more anyway, or lower it to let FSRS-7 schedule
-- sub-day reviews.
--
-- Fuzzing is explicit rather than ambient: 'reviewCard' is deterministic, and
-- 'reviewCardFuzzed' takes the random sample as an argument, so scheduling
-- stays a pure function of its inputs.
module FSRS.Scheduler
( -- * Cards
CardState (..)
, Card (..)
, newCard
, cardRetrievability
-- * Scheduler
, Scheduler (..)
, defaultScheduler
, nextReviewInterval
-- * Reviewing
, ReviewLog (..)
, reviewCard
, reviewCardFuzzed
, previewIntervals
-- * Fuzz
, fuzzRanges
, fuzzBounds
, fuzzInterval
) where
import Data.Maybe (fromMaybe)
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime)
import FSRS.Algorithm
( nextIntervalDays
, nextMemoryState
, retrievability
)
import FSRS.Parameters (Parameters, defaultParameters)
import FSRS.Types
( Days
, MemoryState (..)
, Rating (..)
, Retrievability
, Stability
, allRatings
)
-- | Where a card sits in the learn \/ review \/ relearn cycle.
data CardState
= -- | Working through 'schedulerLearningSteps'.
Learning
| -- | Graduated; intervals come from the memory model.
Review
| -- | Lapsed, working through 'schedulerRelearningSteps'.
Relearning
deriving stock (Eq, Ord, Show, Read, Enum, Bounded)
-- | A card's scheduling state.
data Card = Card
{ cardState :: !CardState
, cardStep :: !(Maybe Int)
-- ^ Index into the current step list; 'Nothing' in the 'Review' state.
, cardMemory :: !(Maybe MemoryState)
-- ^ 'Nothing' until the card has been reviewed once.
, cardDue :: !UTCTime
, cardLastReview :: !(Maybe UTCTime)
}
deriving stock (Eq, Show)
-- | A card that has never been reviewed, due at the given time.
newCard :: UTCTime -> Card
newCard due =
Card
{ cardState = Learning
, cardStep = Just 0
, cardMemory = Nothing
, cardDue = due
, cardLastReview = Nothing
}
-- | Scheduling policy.
--
-- 'schedulerMinimumInterval' must not exceed 'schedulerMaximumInterval'.
data Scheduler = Scheduler
{ schedulerParameters :: !Parameters
, schedulerDesiredRetention :: !Retrievability
-- ^ The recall probability a scheduled review aims for.
, schedulerLearningSteps :: ![NominalDiffTime]
, schedulerRelearningSteps :: ![NominalDiffTime]
, schedulerMinimumInterval :: !Days
-- ^ Floor for review-queue intervals.
, schedulerMaximumInterval :: !Days
-- ^ Ceiling for review-queue intervals.
}
deriving stock (Eq, Show)
-- | The FSRS-7 defaults: 90% desired retention, one-minute and ten-minute
-- learning steps, a ten-minute relearning step, and intervals between one day
-- and a century.
defaultScheduler :: Scheduler
defaultScheduler =
Scheduler
{ schedulerParameters = defaultParameters
, schedulerDesiredRetention = 0.9
, schedulerLearningSteps = [minutes 1, minutes 10]
, schedulerRelearningSteps = [minutes 10]
, schedulerMinimumInterval = 1
, schedulerMaximumInterval = 36500
}
where
minutes :: Integer -> NominalDiffTime
minutes n = fromInteger (n * 60)
-- | The interval a graduated card of the given stability earns, clamped to the
-- scheduler's interval bounds.
nextReviewInterval :: Scheduler -> Stability -> Days
nextReviewInterval sched stability =
clampTo (schedulerMinimumInterval sched) (schedulerMaximumInterval sched) $
nextIntervalDays
(schedulerParameters sched)
(schedulerDesiredRetention sched)
stability
-- | What happened in a single review.
data ReviewLog = ReviewLog
{ logRating :: !Rating
, logReviewTime :: !UTCTime
, logElapsedDays :: !Days
-- ^ Days since the previous review; @0@ for a card's first review.
, logStateBefore :: !CardState
, logMemoryBefore :: !(Maybe MemoryState)
, logMemoryAfter :: !MemoryState
, logInterval :: !NominalDiffTime
-- ^ The interval that was scheduled, after any fuzz.
}
deriving stock (Eq, Show)
-- | Review a card. Deterministic: no fuzz is applied.
reviewCard :: Scheduler -> Card -> Rating -> UTCTime -> (Card, ReviewLog)
reviewCard = reviewCardWith Nothing
-- | Review a card, fuzzing the scheduled interval.
--
-- The first argument is a uniform sample from @[0, 1]@, which the caller draws
-- however it likes; values outside that range are clamped. Fuzz only ever
-- applies to a card that ends up in the 'Review' state with an interval of at
-- least 2.5 days — see 'fuzzInterval'.
reviewCardFuzzed :: Double -> Scheduler -> Card -> Rating -> UTCTime -> (Card, ReviewLog)
reviewCardFuzzed = reviewCardWith . Just
reviewCardWith
:: Maybe Double -> Scheduler -> Card -> Rating -> UTCTime -> (Card, ReviewLog)
reviewCardWith sample sched card rating now = (card', logEntry)
where
elapsed = case cardLastReview card of
Nothing -> 0
Just previous -> max 0 (realToFrac (diffUTCTime now previous) / 86400)
memoryAfter = nextMemoryState (schedulerParameters sched) (cardMemory card) elapsed rating
(state', step', interval) = schedule sample sched card rating memoryAfter
card' =
card
{ cardState = state'
, cardStep = step'
, cardMemory = Just memoryAfter
, cardDue = addUTCTime interval now
, cardLastReview = Just now
}
logEntry =
ReviewLog
{ logRating = rating
, logReviewTime = now
, logElapsedDays = elapsed
, logStateBefore = cardState card
, logMemoryBefore = cardMemory card
, logMemoryAfter = memoryAfter
, logInterval = interval
}
-- | The state machine: which state the card moves to, where it lands in the
-- step list, and how long until it is due again.
schedule
:: Maybe Double
-> Scheduler
-> Card
-> Rating
-> MemoryState
-> (CardState, Maybe Int, NominalDiffTime)
schedule sample sched card rating memory = case cardState card of
Learning -> stepped Learning (schedulerLearningSteps sched)
Relearning -> stepped Relearning (schedulerRelearningSteps sched)
Review -> case rating of
Again -> case schedulerRelearningSteps sched of
[] -> graduate
(firstStep : _) -> (Relearning, Just 0, firstStep)
_ -> graduate
where
graduate = (Review, Nothing, daysToDiffTime fuzzed)
where
plain = nextReviewInterval sched (memoryStability memory)
fuzzed = maybe plain (\u -> fuzzInterval sched u plain) sample
current = max 0 (fromMaybe 0 (cardStep card))
stepped _ [] = graduate
stepped state steps@(firstStep : rest)
-- The card was scheduled by a scheduler with more steps than this one.
| current >= length steps && rating /= Again = graduate
| otherwise = case rating of
Again -> (state, Just 0, firstStep)
Hard -> (state, Just current, hardInterval)
Good -> case stepAt (current + 1) of
Nothing -> graduate
Just interval -> (state, Just (current + 1), interval)
Easy -> graduate
where
stepAt i = case drop i steps of
(interval : _) -> Just interval
[] -> Nothing
-- Hard repeats the current step. On the very first step there is
-- nothing to repeat yet, so py-fsrs splits the difference with the
-- next step, or stretches the only step by half.
hardInterval = case (current, rest) of
(0, []) -> firstStep * 1.5
(0, second : _) -> (firstStep + second) / 2
-- `current` is in range here: the guard above sent the rest to
-- `graduate`, so the fallback is unreachable.
_ -> fromMaybe firstStep (stepAt current)
-- | A card's retrievability at the given time, or 'Nothing' if it has never
-- been reviewed.
cardRetrievability :: Scheduler -> Card -> UTCTime -> Maybe Retrievability
cardRetrievability sched card now = do
memory <- cardMemory card
previous <- cardLastReview card
let elapsed = max 0 (realToFrac (diffUTCTime now previous) / 86400)
pure (retrievability (schedulerParameters sched) elapsed (memoryStability memory))
-- | The interval each of the four ratings would earn, without reviewing.
-- Handy for showing the four buttons' answers in a UI.
previewIntervals :: Scheduler -> Card -> UTCTime -> [(Rating, NominalDiffTime)]
previewIntervals sched card now =
[ (rating, logInterval (snd (reviewCard sched card rating now)))
| rating <- allRatings
]
-- ---------------------------------------------------------------------------
-- Fuzz
-- ---------------------------------------------------------------------------
-- | @(start, end, factor)@ triples describing how much an interval may be
-- nudged: every day of the interval that falls inside a range contributes
-- @factor@ days of slack. Taken from py-fsrs.
fuzzRanges :: [(Days, Days, Double)]
fuzzRanges =
[ (2.5, 7.0, 0.15)
, (7.0, 20.0, 0.1)
, (20.0, 1 / 0, 0.05)
]
-- | The window an interval may be fuzzed into, or 'Nothing' for intervals
-- shorter than 2.5 days, which are left alone.
fuzzBounds :: Scheduler -> Days -> Maybe (Days, Days)
fuzzBounds sched interval
| not (interval >= 2.5) = Nothing
| otherwise = Just (min low high, high)
where
delta =
1
+ sum
[ factor * max 0 (min interval end - start)
| (start, end, factor) <- fuzzRanges
]
low = max 2 (interval - delta)
high = min (schedulerMaximumInterval sched) (interval + delta)
-- | Nudge an interval by a random amount within 'fuzzBounds'.
--
-- The first argument is a uniform sample from @[0, 1]@; it is clamped, so any
-- finite value is safe. Intervals below 2.5 days are returned unchanged.
fuzzInterval :: Scheduler -> Double -> Days -> Days
fuzzInterval sched sample interval = case fuzzBounds sched interval of
Nothing -> interval
Just (low, high) -> low + clampTo 0 1 sample * (high - low)
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
clampTo :: Double -> Double -> Double -> Double
clampTo lo hi x = min hi (max lo x)
daysToDiffTime :: Days -> NominalDiffTime
daysToDiffTime d = realToFrac (d * 86400)