haskell-fsrs-7.1.0: test/Test/FSRS/Gen.hs
-- | Generators and comparison helpers shared by the test modules.
--
-- Everything here produces /valid/ inputs: parameter vectors inside the box
-- the upstream optimiser clips to, stabilities inside
-- @['stabilityMin', 'stabilityMax']@, difficulties inside @[1, 10]@ and
-- non-negative elapsed times. Properties that should hold for nonsense inputs
-- too say so explicitly.
module Test.FSRS.Gen
( -- * Generators
genParameters
, genRating
, genStability
, genDifficulty
, genMemoryState
, genNaturalMemoryState
, genGrowableState
, genElapsedDays
, genRetrievability
, genDesiredRetention
, genReviewHistory
, genUTCTime
, genScheduler
-- * Approximate comparison
, approxEqual
, relativeError
) where
import Data.Time.Calendar (addDays, fromGregorian)
import Data.Time.Clock (UTCTime (..), secondsToDiffTime)
import Test.QuickCheck
import FSRS
-- | A parameter vector inside the valid box, biased towards the defaults.
genParameters :: Gen Parameters
genParameters =
frequency
[ (1, pure defaultParameters)
, (3, genRandomParameters)
]
genRandomParameters :: Gen Parameters
genRandomParameters = do
ws <- traverse choose parameterBounds
-- `clampParameters` only re-establishes the ordering constraints; the
-- weights are already inside their individual bounds.
case clampParameters ws of
Right p -> pure p
Left errs -> error ("genRandomParameters: " <> show errs)
genRating :: Gen Rating
genRating = elements allRatings
-- | Log-uniform across the whole legal range, with the endpoints thrown in.
genStability :: Gen Stability
genStability =
frequency
[ (1, pure stabilityMin)
, (1, pure stabilityMax)
, (1, pure 1)
, (9, exp <$> choose (log stabilityMin, log stabilityMax))
]
genDifficulty :: Gen Difficulty
genDifficulty =
frequency
[ (1, pure difficultyMin)
, (1, pure difficultyMax)
, (8, choose (difficultyMin, difficultyMax))
]
-- | A whole memory state. The fast trace is usually a modest multiple of the
-- slow one, as it is in practice, but sometimes wildly different so that
-- nothing may assume a relationship between them.
genMemoryState :: Gen MemoryState
genMemoryState = do
s <- genStability
d <- genDifficulty
sFast <-
frequency
[ (6, pure (clampStability (s * fastTraceRatio)))
, (2, (\k -> clampStability (s * k)) <$> choose (0.05, 20))
, (2, genStability)
]
pure (MemoryState s d sFast)
where
clampStability = min stabilityMax . max stabilityMin
-- | A state whose fast trace sits at exactly the ratio to the slow one that
-- the model itself establishes for a new card.
--
-- Properties about what /more stability/ or /more difficulty/ does need this:
-- the FSRS-7 curve weights its two components by their own stabilities, so for
-- a wildly lopsided pair of traces those questions have no monotone answer —
-- raising difficulty slows the slow component but also shifts mixture weight
-- onto the fast one, and which effect wins depends on the ratio.
genNaturalMemoryState :: Gen MemoryState
genNaturalMemoryState = do
s <- choose (log naturalStabilityMin, log stabilityMax)
d <- genDifficulty
pure (naturalState (exp s) d)
-- | A 'genNaturalMemoryState' together with a factor by which both of its
-- traces can be multiplied without either leaving its range — so that scaling
-- really does scale, instead of saturating one trace and skewing the ratio.
genGrowableState :: Gen (MemoryState, Double)
genGrowableState = do
growth <- exp <$> choose (1.0e-6, 5)
s <- choose (log naturalStabilityMin, log (stabilityMax / growth))
d <- genDifficulty
pure (naturalState (exp s) d, growth)
-- | The smallest slow stability whose fast trace is still above the floor, so
-- that the ratio is exact rather than clamped.
naturalStabilityMin :: Stability
naturalStabilityMin = stabilityMin / fastTraceRatio
naturalState :: Stability -> Difficulty -> MemoryState
naturalState s d = MemoryState s d (fastTraceRatio * s)
-- | Anything from "the same instant" to ten years, log-uniform in between.
genElapsedDays :: Gen Days
genElapsedDays =
frequency
[ (2, pure 0)
, (8, exp <$> choose (log (1 / 86400), log 3650))
]
-- | Anything the forgetting curve could plausibly have returned, including the
-- extremes it is rescaled into.
genRetrievability :: Gen Retrievability
genRetrievability =
frequency
[ (1, pure retrievabilityFloor)
, (1, pure (1 - retrievabilityFloor))
, (8, choose (retrievabilityFloor, 1 - retrievabilityFloor))
]
genDesiredRetention :: Gen Retrievability
genDesiredRetention =
frequency
[ (1, choose (0.5, 0.7))
, (8, choose (0.7, 0.98))
, (1, choose (0.98, 0.999))
]
genReviewHistory :: Gen [(Days, Rating)]
genReviewHistory = sized $ \n -> do
k <- choose (0, min n 30)
vectorOf k ((,) <$> genElapsedDays <*> genRating)
genUTCTime :: Gen UTCTime
genUTCTime = do
day <- choose (0, 3650)
seconds <- choose (0, 86399)
pure (UTCTime (addDays day (fromGregorian 2026 1 1)) (secondsToDiffTime seconds))
genScheduler :: Gen Scheduler
genScheduler = do
params <- genParameters
retention <- genDesiredRetention
learning <- genSteps
relearning <- genSteps
maxIvl <- choose (1, 36500)
minIvl <- choose (1 / 86400, maxIvl)
pure
Scheduler
{ schedulerParameters = params
, schedulerDesiredRetention = retention
, schedulerLearningSteps = learning
, schedulerRelearningSteps = relearning
, schedulerMinimumInterval = minIvl
, schedulerMaximumInterval = maxIvl
}
where
genSteps = do
k <- choose (0 :: Int, 4)
vectorOf k (fromInteger <$> choose (60, 86400))
-- | @abs (a - b) <= atol + rtol * abs b@.
approxEqual
:: Double
-- ^ Absolute tolerance.
-> Double
-- ^ Relative tolerance.
-> Double
-- ^ Actual.
-> Double
-- ^ Expected.
-> Bool
approxEqual atol rtol actual expected =
abs (actual - expected) <= atol + rtol * abs expected
relativeError :: Double -> Double -> Double
relativeError actual expected
| expected == 0 = abs actual
| otherwise = abs (actual - expected) / abs expected