packages feed

haskell-fsrs-7.1.0: src/FSRS/Algorithm.hs

{-# LANGUAGE BangPatterns #-}

-- | The FSRS-7 memory model.
--
-- This module is a direct transcription of the finished FSRS-7, whose
-- reference implementation is the Rust model its author signed off on:
-- <https://github.com/Expertium/fsrs-rs-speed-autoresearch> (see
-- @fsrs-rs\/src\/model.rs@), upstreamed in
-- <https://github.com/open-spaced-repetition/fsrs-rs/pull/426 fsrs-rs#426>.
-- Every function here is pure and total.
--
-- What changed in FSRS-7 relative to FSRS-6:
--
-- * A card carries /two/ memory traces instead of one — see t'MemoryState'.
--   The forgetting curve is a mixture of a fast and a slow component, one per
--   trace, and each trace is updated by its own block of weights. This is what
--   makes same-day reviews behave differently from spaced ones; FSRS-6
--   switched between two formulas on a same-day\/not-same-day flag.
-- * The forgetting curve depends on /difficulty/, which it never did before:
--   a hard card experiences time faster, and counts the slow trace for more.
-- * Post-lapse stability no longer depends on difficulty.
-- * A lapse's difficulty step is weighted by how surprising the lapse was —
--   see 'nextDifficulty'.
-- * Retrievability is squeezed into @[1e-5, 1 - 1e-5]@, so a card reviewed a
--   moment ago has a retrievability just short of @1@.
-- * Intervals are genuinely continuous. Ten minutes is @10 \/ 1440@ days and
--   the model is meant to be evaluated at such values, not at whole days.
module FSRS.Algorithm
  ( -- * Forgetting curve
    retrievability
  , retrievabilityDerivative
  , fastTraceRetrievability
  , nextIntervalDays

    -- * Difficulty
  , initialDifficulty
  , nextDifficulty

    -- * Stability
  , initialStability
  , stabilityAfterReview
  , nextStability

    -- * State transition
  , initialMemoryState
  , nextMemoryState
  , replayReviews

    -- * Bounds
  , stabilityMin
  , stabilityMax
  , difficultyMin
  , difficultyMax
  , minimumIntervalDays
  , maximumIntervalDays
  , retrievabilityFloor
  , fastTraceRatio
  ) where

import FSRS.Parameters
  ( CurveWeights (..)
  , Parameters
  , StabilityWeights (..)
  , curveWeights
  , difficultyDelta
  , fastTraceWeights
  , initialDifficultyBase
  , initialDifficultyRate
  , initialStabilityWeight
  , slowTraceWeights
  )
import FSRS.Types
  ( Days
  , Difficulty
  , MemoryState (..)
  , Rating (..)
  , Retrievability
  , Stability
  , ratingToInt
  )

-- | The smallest stability the model will report, @1e-4@ days (about nine
-- seconds). FSRS-7 is always run with second-precision intervals.
stabilityMin :: Stability
stabilityMin = 1.0e-4

-- | The largest stability the model will report: @36500@ days, a century.
stabilityMax :: Stability
stabilityMax = 36500.0

-- | @1@.
difficultyMin :: Difficulty
difficultyMin = 1.0

-- | @10@.
difficultyMax :: Difficulty
difficultyMax = 10.0

-- | One second, expressed in days — the shortest interval 'nextIntervalDays'
-- will return.
minimumIntervalDays :: Days
minimumIntervalDays = 1.0 / 86400.0

-- | A century in days — the longest interval 'nextIntervalDays' will return.
maximumIntervalDays :: Days
maximumIntervalDays = 36500.0

-- | @1e-5@. 'retrievability' is rescaled into
-- @['retrievabilityFloor', 1 - 'retrievabilityFloor']@ so that neither it nor
-- its logarithm can saturate.
retrievabilityFloor :: Retrievability
retrievabilityFloor = 1.0e-5

-- | @0.8@. The fast trace starts at this fraction of the slow one, and after a
-- lapse is pulled back down to at most this fraction of it.
fastTraceRatio :: Double
fastTraceRatio = 0.8

-- | The magnitude of either decay is held inside @[0.01, 0.95]@.
decayMin, decayMax :: Double
decayMin = 0.01
decayMax = 0.95

-- | The exponent that builds the fast component's shape factor is capped here,
-- in log space, so that both the value and its gradient stay finite.
logFactorCap :: Double
logFactorCap = 60.0

clampTo :: Double -> Double -> Double -> Double
clampTo lo hi x = min hi (max lo x)

clampStability :: Stability -> Stability
clampStability = clampTo stabilityMin stabilityMax

clampDifficulty :: Difficulty -> Difficulty
clampDifficulty = clampTo difficultyMin difficultyMax

-- ---------------------------------------------------------------------------
-- Forgetting curve
-- ---------------------------------------------------------------------------

-- | The pieces of the two-component mixture, shared by 'retrievability' and
-- 'retrievabilityDerivative' so that the two cannot drift apart.
--
-- Each component is @inner ** decay@ where @inner = 1 + rate * t@, which makes
-- both the value and its slope one-liners.
data CurveParts = CurveParts
  { cpDecay1 :: !Double
  , cpRate1 :: !Double
  , cpInner1 :: !Double
  , cpDecay2 :: !Double
  , cpRate2 :: !Double
  , cpInner2 :: !Double
  , cpWeight1 :: !Double
  , cpWeight2 :: !Double
  }

curveParts :: Parameters -> Days -> MemoryState -> CurveParts
curveParts params t0 state =
  CurveParts
    { cpDecay1 = decay1
    , cpRate1 = rate1
    , cpInner1 = 1 + rate1 * t
    , cpDecay2 = decay2
    , cpRate2 = rate2
    , cpInner2 = 1 + rate2 * t
    , cpWeight1 = cwWeight1 cw * sFast ** negate (cwStabilityPower1 cw)
    , cpWeight2 =
        cwWeight2 cw
          * s ** cwStabilityPower2 cw
          * exp ((d - 5) * (cwDifficultyWeight cw - 0.5))
    }
  where
    cw = curveWeights params
    t = max 0 t0
    s = clampStability (memoryStability state)
    d = clampDifficulty (memoryDifficulty state)
    sFast = clampStability (memoryStabilityFast state)

    -- Fast component: the decay is scaled by the fast stability itself.
    decay1 = fastDecay cw sFast
    rate1 = shapeFactor1 cw decay1 / sFast

    -- Slow component: difficulty rescales *time* rather than the decay, so a
    -- hard card experiences time faster but decays with the same slope.
    decay2 = negate (clampTo decayMin decayMax (cwDecay2 cw))
    factor2 = cwBase2 cw ** (1 / decay2) - 1
    timescale = exp ((d - 5) * (cwDifficultyDecay cw - 0.3))
    rate2 = factor2 * timescale / s

fastDecay :: CurveWeights -> Stability -> Double
fastDecay cw sFast =
  negate . clampTo decayMin decayMax $
    cwDecayBase1 cw * sFast ** (cwStabilityDecay1 cw - 0.3)

-- | @base1 ** (1 \/ decay1) - 1@, built in log space and capped so that a
-- decay near zero cannot overflow it.
shapeFactor1 :: CurveWeights -> Double -> Double
shapeFactor1 cw decay1 = exp (min logFactorCap (log (cwBase1 cw) / decay1)) - 1

-- | Rescale a raw mixture value into
-- @['retrievabilityFloor', 1 - 'retrievabilityFloor']@.
rescale :: Double -> Double
rescale x = x * (1 - 2 * retrievabilityFloor) + retrievabilityFloor

-- | The probability of recalling a card @t@ days after the last review.
--
-- FSRS-7 mixes a fast-trace and a slow-trace power law, so this needs the
-- whole memory state — both stabilities /and/ the difficulty. The weight of
-- each component depends on its own stability, so — unlike every earlier
-- version — the retrievability at @t == s@ is not a fixed @0.9@.
--
-- @t@ is treated as @0@ if negative; the state is clamped into range, so any
-- finite state is safe.
retrievability :: Parameters -> Days -> MemoryState -> Retrievability
retrievability params t state = rescale ((w1 * r1 + w2 * r2) / (w1 + w2))
  where
    cp = curveParts params t state
    r1 = cpInner1 cp ** cpDecay1 cp
    r2 = cpInner2 cp ** cpDecay2 cp
    w1 = cpWeight1 cp
    w2 = cpWeight2 cp

-- | @d\/dt@ of 'retrievability'. Never positive for in-bounds parameters.
retrievabilityDerivative :: Parameters -> Days -> MemoryState -> Double
retrievabilityDerivative params t state =
  (w1 * d1 + w2 * d2) / (w1 + w2) * (1 - 2 * retrievabilityFloor)
  where
    cp = curveParts params t state
    d1 = cpDecay1 cp * cpInner1 cp ** (cpDecay1 cp - 1) * cpRate1 cp
    d2 = cpDecay2 cp * cpInner2 cp ** (cpDecay2 cp - 1) * cpRate2 cp
    w1 = cpWeight1 cp
    w2 = cpWeight2 cp

-- | The fast trace's /own/ recall probability — the mixture's fast component
-- on its own, unrescaled and with no contribution from the slow trace.
--
-- This is what drives the fast trace's stability update in 'nextStability':
-- the fast trace responds to how well /it/ was doing, not to the mixture.
fastTraceRetrievability :: Parameters -> Days -> Stability -> Retrievability
fastTraceRetrievability params t0 sFast0 = (1 + rate * t) ** decay
  where
    cw = curveWeights params
    t = max 0 t0
    sFast = clampStability sFast0
    decay = fastDecay cw sFast
    rate = shapeFactor1 cw decay / sFast

-- | The interval, in days, after which a card in the given state will have
-- decayed to exactly the desired retention.
--
-- The FSRS-7 forgetting curve has no closed-form inverse, so this is a
-- root-find: Newton's method in @log t@ (which is what makes the problem
-- well-conditioned), safeguarded by a bracketing bisection so that it cannot
-- diverge.
--
-- The result is always within @['minimumIntervalDays', 'maximumIntervalDays']@;
-- a desired retention that is unreachable within that window saturates at the
-- nearer end. Since retrievability tops out just below @1@, a target of @1@
-- saturates rather than diverging.
nextIntervalDays :: Parameters -> Retrievability -> MemoryState -> Days
nextIntervalDays params target state
  | not (target > 0) = maximumIntervalDays -- also catches NaN
  | target >= 1 = minimumIntervalDays
  | curve lo <= target = lo
  | curve hi >= target = hi
  | otherwise = exp (search (0 :: Int) (log lo) (log hi) u0)
  where
    curve t = retrievability params t state
    lo = minimumIntervalDays
    hi = maximumIntervalDays
    -- R around the slow stability is near the interesting range, so start
    -- there.
    u0 = clampTo (log lo) (log hi) (log (clampStability (memoryStability state)))

    maxIterations = 200 :: Int
    -- Full double precision in log space.
    tolerance = 1.0e-15

    search !n !a !b !u
      | n >= maxIterations = u
      | fu == 0 = u
      | converged = next
      | otherwise = search (n + 1) a' b' next
      where
        t = exp u
        fu = curve t - target
        -- R is strictly decreasing in t, so the sign of `fu` says which side
        -- of the root `u` is on.
        (a', b') = if fu > 0 then (u, b) else (a, u)
        slope = retrievabilityDerivative params t state * t
        newton = u - fu / slope
        next
          | isNaN newton || isInfinite newton || newton <= a' || newton >= b' =
              0.5 * (a' + b')
          | otherwise = newton
        converged = abs (next - u) <= tolerance * max 1 (abs u)

-- ---------------------------------------------------------------------------
-- Difficulty
-- ---------------------------------------------------------------------------

-- | Difficulty before clamping. The mean reversion in 'nextDifficulty' pulls
-- towards the /unclamped/ value for 'Easy', which is why this is kept
-- separate.
rawInitialDifficulty :: Parameters -> Rating -> Double
rawInitialDifficulty params rating =
  initialDifficultyBase params
    - exp (initialDifficultyRate params * fromIntegral (ratingToInt rating - 1))
    + 1

-- | The difficulty a card is born with, given the rating of its first review.
initialDifficulty :: Parameters -> Rating -> Difficulty
initialDifficulty params = clampDifficulty . rawInitialDifficulty params

-- | Difficulty after a review: a rating-driven step, damped so that it slows
-- down as difficulty approaches its maximum, then reverted 1% of the way
-- towards the difficulty an 'Easy' first review would have produced.
--
-- A lapse's step is weighted by how surprising the lapse was, which is why
-- this takes the retrievability at review time: forgetting a card the model
-- expected you to recall says more about the card than forgetting one that was
-- long overdue.
nextDifficulty
  :: Parameters
  -> Difficulty
  -> Retrievability
  -- ^ The retrievability at review time, from 'retrievability'.
  -> Rating
  -> Difficulty
nextDifficulty params d r rating = clampDifficulty (meanReversion damped)
  where
    step = negate (difficultyDelta params) * fromIntegral (ratingToInt rating - 3)
    delta = if rating == Again then step * (r + 0.1) else step
    damped = d + delta * (10 - d) / 9
    meanReversion current = 0.01 * rawInitialDifficulty params Easy + 0.99 * current

-- ---------------------------------------------------------------------------
-- Stability
-- ---------------------------------------------------------------------------

-- | The stability a card is born with, given the rating of its first review.
initialStability :: Parameters -> Rating -> Stability
initialStability = initialStabilityWeight

-- | One trace's stability update.
--
-- Called twice per review — once with 'FSRS.Parameters.slowTraceWeights' and
-- once with 'FSRS.Parameters.fastTraceWeights' — on that trace's own stability
-- and its own retrievability.
--
-- On a lapse the new stability is the post-lapse stability, which can never
-- exceed the old one and, in the finished FSRS-7, does not depend on
-- difficulty. On a success it is the old stability scaled by a factor that
-- grows with how overdue the card was, shrinks as the card gets easier and
-- more stable, and is scaled again by the hard penalty or the easy bonus.
stabilityAfterReview
  :: StabilityWeights
  -> Stability
  -- ^ The stability of the trace being updated.
  -> Difficulty
  -> Retrievability
  -- ^ That trace's retrievability at review time.
  -> Rating
  -> Stability
stabilityAfterReview w s d r rating
  | rating == Again = clampStability postLapse
  | otherwise = clampStability (max postLapse (s * increase))
  where
    postLapse = min s failureStability
    failureStability =
      swFailureFactor w
        * ((s + 1) ** swFailureStabilityExponent w - 1)
        * exp ((1 - r) * swFailureRetrievabilityFactor w)
    hardPenalty = if rating == Hard then swHardPenalty w else 1
    easyBonus = if rating == Easy then swEasyBonus w else 1
    increase =
      1
        + exp (swIncreaseBase w - 1.5)
          * (11 - d)
          * s ** negate (swIncreaseStabilityExponent w)
          * (exp ((1 - r) * swIncreaseRetrievabilityFactor w) - 1)
          * hardPenalty
          * easyBonus

-- | Both stabilities after a review, as @(slow, fast)@.
--
-- The slow trace is updated from the mixed 'retrievability'; the fast trace
-- from its own 'fastTraceRetrievability'. On a lapse the fast trace is pulled
-- down to at most 'fastTraceRatio' of the post-lapse slow stability, so that
-- forgetting a card cannot leave it with a large short-term boost.
nextStability
  :: Parameters
  -> MemoryState
  -> Days
  -- ^ Days since the previous review.
  -> Rating
  -> (Stability, Stability)
nextStability params state deltaT rating = (slow, fast)
  where
    (slow, fast, _) = advance params state deltaT rating

-- | The whole update in one pass: both stabilities and the mixed
-- retrievability that the difficulty step also needs, so that the forgetting
-- curve is evaluated exactly once per review.
advance
  :: Parameters
  -> MemoryState
  -> Days
  -> Rating
  -> (Stability, Stability, Retrievability)
advance params state deltaT rating = (slow, fast, r)
  where
    t = max 0 deltaT
    d = clampDifficulty (memoryDifficulty state)
    r = retrievability params t state
    slow =
      stabilityAfterReview
        (slowTraceWeights params)
        (clampStability (memoryStability state))
        d
        r
        rating
    rFast = fastTraceRetrievability params t (memoryStabilityFast state)
    raw =
      stabilityAfterReview
        (fastTraceWeights params)
        (clampStability (memoryStabilityFast state))
        d
        rFast
        rating
    fast
      | rating == Again = min raw (fastTraceRatio * slow)
      | otherwise = raw

-- ---------------------------------------------------------------------------
-- State transition
-- ---------------------------------------------------------------------------

-- | The state a card is born with, given the rating of its first review. The
-- fast trace starts at 'fastTraceRatio' of the slow one.
initialMemoryState :: Parameters -> Rating -> MemoryState
initialMemoryState params rating =
  MemoryState
    { memoryStability = clampStability s
    , memoryDifficulty = initialDifficulty params rating
    , memoryStabilityFast = clampStability (fastTraceRatio * s)
    }
  where
    s = initialStability params rating

-- | Advance a card's memory state by one review.
--
-- Pass 'Nothing' for a card that has never been reviewed; the elapsed time is
-- then ignored and the state comes from 'initialMemoryState'.
nextMemoryState
  :: Parameters
  -> Maybe MemoryState
  -- ^ The state before the review, or 'Nothing' for a brand-new card.
  -> Days
  -- ^ Days since the previous review.
  -> Rating
  -> MemoryState
nextMemoryState params before deltaT rating = case before of
  Nothing -> initialMemoryState params rating
  Just state ->
    MemoryState
      { memoryStability = clampStability slow
      , memoryDifficulty =
          nextDifficulty params (clampDifficulty (memoryDifficulty state)) r rating
      , memoryStabilityFast = clampStability fast
      }
    where
      (slow, fast, r) = advance params state deltaT rating

-- | Fold a whole review history into a memory state.
--
-- Each element is @(days since the previous review, rating)@; the elapsed time
-- of the first review is ignored. 'Nothing' for an empty history.
replayReviews :: Parameters -> [(Days, Rating)] -> Maybe MemoryState
replayReviews params = go Nothing
  where
    go before [] = before
    go before ((deltaT, rating) : rest) =
      let next = nextMemoryState params before deltaT rating
       in next `seq` go (Just next) rest