packages feed

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

{-# LANGUAGE BangPatterns #-}

-- | The FSRS-7 memory model.
--
-- This module is a direct transcription of the reference implementation in
-- <https://github.com/open-spaced-repetition/srs-benchmark srs-benchmark>
-- (@models\/fsrs_v7.py@ and @models\/fsrs_v7_interval_penalty.py@). Every
-- function here is pure and total.
--
-- What changed in FSRS-7 relative to FSRS-6:
--
-- * The forgetting curve is a stability-weighted mixture of /two/ power laws
--   instead of one, which is why it has eight weights and no closed-form
--   inverse (see 'nextIntervalDays').
-- * The stability update is computed twice — once with a long-term weight
--   block and once with a short-term one — and the two are blended by
--   'transitionCoefficient', a smooth function of the elapsed time. FSRS-6
--   switched between two separate formulas on a same-day\/not-same-day flag.
-- * 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
  , nextIntervalDays

    -- * Difficulty
  , initialDifficulty
  , nextDifficulty

    -- * Stability
  , initialStability
  , stabilityAfterReview
  , transitionCoefficient
  , nextStability

    -- * State transition
  , nextMemoryState
  , replayReviews

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

import FSRS.Parameters
  ( CurveWeights (..)
  , Parameters
  , StabilityWeights (..)
  , curveWeights
  , difficultyDelta
  , initialDifficultyBase
  , initialDifficultyRate
  , initialStabilityWeight
  , longTermWeights
  , shortTermWeights
  , transitionAmplitude
  , transitionRate
  )
import FSRS.Types
  ( Days
  , Difficulty
  , MemoryState (..)
  , Rating (..)
  , Retrievability
  , Stability
  , ratingToInt
  )

-- | The smallest stability the model will report, @1e-4@ days (about nine
-- seconds). This is the @s_min@ the upstream configuration uses for FSRS-7,
-- which 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

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

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

-- | The probability of recalling a card @t@ days after the last review, given
-- its stability.
--
-- FSRS-7 mixes two power laws whose relative weight depends on the stability
-- itself, so — unlike every earlier version — the retrievability at @t == s@
-- is not a fixed @0.9@ but drifts with @s@.
--
-- @t@ must be non-negative and @s@ strictly positive.
retrievability :: Parameters -> Days -> Stability -> Retrievability
retrievability params t s = (weight1 * r1 + weight2 * r2) / (weight1 + weight2)
  where
    cw = curveWeights params
    tOverS = t / s
    powerLaw base decay = (1 + factor * tOverS) ** decay
      where
        factor = base ** (1 / decay) - 1
    r1 = powerLaw (cwBase1 cw) (cwDecay1 cw)
    r2 = powerLaw (cwBase2 cw) (cwDecay2 cw)
    weight1 = cwWeight1 cw * s ** negate (cwStabilityPower1 cw)
    weight2 = cwWeight2 cw * s ** cwStabilityPower2 cw

-- | @d\/dt@ of 'retrievability'. Never positive for in-bounds parameters.
retrievabilityDerivative :: Parameters -> Days -> Stability -> Double
retrievabilityDerivative params t s =
  (weight1 * d1 + weight2 * d2) / (weight1 + weight2)
  where
    cw = curveWeights params
    tOverS = t / s
    slope base decay = decay * inner ** (decay - 1) * (factor / s)
      where
        factor = base ** (1 / decay) - 1
        inner = 1 + factor * tOverS
    d1 = slope (cwBase1 cw) (cwDecay1 cw)
    d2 = slope (cwBase2 cw) (cwDecay2 cw)
    weight1 = cwWeight1 cw * s ** negate (cwStabilityPower1 cw)
    weight2 = cwWeight2 cw * s ** cwStabilityPower2 cw

-- | The interval, in days, after which a card of the given stability 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 — see the upstream @fsrs_v7_interval_penalty@ module),
-- 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.
nextIntervalDays :: Parameters -> Retrievability -> Stability -> Days
nextIntervalDays params target s
  | not (target > 0) = maximumIntervalDays -- also catches NaN
  | target >= 1 = minimumIntervalDays
  | retrievability params lo s <= target = lo
  | retrievability params hi s >= target = hi
  | otherwise = exp (search (0 :: Int) (log lo) (log hi) u0)
  where
    lo = minimumIntervalDays
    hi = maximumIntervalDays
    -- R(s, s) is near the interesting range, so start there.
    u0 = clampTo (log lo) (log hi) (log s)

    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 = retrievability params t s - 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 s * 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 =
  clampTo difficultyMin difficultyMax . 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.
nextDifficulty :: Parameters -> Difficulty -> Rating -> Difficulty
nextDifficulty params d rating =
  clampTo difficultyMin difficultyMax (meanReversion damped)
  where
    delta = negate (difficultyDelta params) * fromIntegral (ratingToInt rating - 3)
    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 half of the stability update.
--
-- Called twice per review — once with 'FSRS.Parameters.longTermWeights' and
-- once with 'FSRS.Parameters.shortTermWeights' — and the two results are
-- blended by 'nextStability'.
--
-- On a lapse the new stability is the post-lapse stability, which can never
-- exceed the old one. 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
  -> MemoryState
  -> Retrievability
  -- ^ The retrievability at review time, from 'retrievability'.
  -> Rating
  -> Stability
stabilityAfterReview w (MemoryState s d) r rating
  | rating == Again = postLapse
  | otherwise = max postLapse (s * increase)
  where
    postLapse = min s failureStability
    failureStability =
      swFailureFactor w
        * d ** negate (swFailureDifficultyExponent 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

-- | How much of a long-term review this is: @0@ for a review that happens at
-- the same instant as the previous one, tending to @1@ as the gap grows.
--
-- This is the piece that replaces FSRS-6's hard same-day\/not-same-day split.
-- The elapsed time is expected to be non-negative.
transitionCoefficient :: Parameters -> Days -> Double
transitionCoefficient params deltaT =
  1 - transitionAmplitude params * exp (negate (transitionRate params) * deltaT)

-- | Stability after a review, blending the long- and short-term updates.
nextStability
  :: Parameters
  -> MemoryState
  -> Days
  -- ^ Days since the previous review.
  -> Rating
  -> Stability
nextStability params state deltaT rating =
  coefficient * longTerm + (1 - coefficient) * shortTerm
  where
    r = retrievability params deltaT (memoryStability state)
    longTerm = stabilityAfterReview (longTermWeights params) state r rating
    shortTerm = stabilityAfterReview (shortTermWeights params) state r rating
    coefficient = transitionCoefficient params deltaT

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

-- | 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 is read straight off the initial-stability and
-- initial-difficulty weights.
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 =
  MemoryState
    { memoryStability = clampTo stabilityMin stabilityMax s
    , memoryDifficulty = clampTo difficultyMin difficultyMax d
    }
  where
    (s, d) = case before of
      Nothing ->
        ( initialStability params rating
        , initialDifficulty params rating
        )
      Just state ->
        ( nextStability params state deltaT rating
        , nextDifficulty params (memoryDifficulty state) 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