packages feed

dtmc-0.2.0.0: test/Dtmc/SimulationSpec.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeApplications #-}

module Dtmc.SimulationSpec (
    spec,
) where

import Control.Monad (
    replicateM,
 )
import Control.Monad.ST (
    runST,
 )
import Data.Finite (
    Finite,
 )
import Dtmc.Distribution (
    Distribution (..),
 )
import Dtmc.Distribution.Map (
    pointMass,
    fromDistribution,
 )
import Dtmc.Distribution.Vector qualified as Vector
import Dtmc.Simulation (
    SimulationError (..),
    sample,
    simulate,
    step,
 )
import Dtmc.State (FiniteState)
import Dtmc.TestSupport (
    chunksOf,
 )
import Dtmc.Transition.Kernel qualified as Kernel
import Dtmc.Transition.Matrix (
    TransitionMatrix,
    fromRows,
 )
import GHC.Generics (Generic)
import Numeric.Natural (
    Natural,
 )
import System.Random.MWC qualified as MWC
import Test.Hspec (
    Spec,
    describe,
    it,
    shouldBe,
 )

data NamedSample = FirstSample | SecondSample | ThirdSample
    deriving (Eq, Ord, Show, Generic)

instance FiniteState NamedSample

newtype UncheckedDistribution
    = UncheckedDistribution [(Int, Double)]

instance Distribution UncheckedDistribution where
    type DistributionState UncheckedDistribution = Int

    probabilityAt (UncheckedDistribution entries) state =
        sum
            [ weight
            | (storedState, weight) <- entries
            , storedState == state
            ]

    distributionWeights (UncheckedDistribution entries) = entries

checkedSimulation :: (Monad m) => m (Either SimulationError value) -> m value
checkedSimulation action = do
    result <- action
    pure (either (error . show) id result)

cyclicThree :: TransitionMatrix (Finite 3)
cyclicThree =
    either (error . show) id $
        fromRows
            ( chunksOf
                3
                [ 0
                , 1
                , 0
                , 0
                , 0
                , 1
                , 1
                , 0
                , 0
                ]
            )

namedCyclicThree :: TransitionMatrix NamedSample
namedCyclicThree =
    either (error . show) id $
        fromRows @NamedSample
            (chunksOf 3 [0, 1, 0, 0, 0, 1, 1, 0, 0])

absorbingTwo :: TransitionMatrix (Finite 2)
absorbingTwo =
    either (error . show) id $
        fromRows
            ( chunksOf
                2
                [ 1
                , 0
                , 0.3
                , 0.7
                ]
            )

threeCycleOrbit :: [Finite 3]
threeCycleOrbit = runST $ do
    generator <- MWC.create
    first <- checkedSimulation (step cyclicThree 0 generator)
    second <- checkedSimulation (step cyclicThree first generator)
    third <- checkedSimulation (step cyclicThree second generator)
    pure [first, second, third]

namedThreeCycleOrbit :: [NamedSample]
namedThreeCycleOrbit = runST $ do
    generator <- MWC.create
    first <- checkedSimulation (step namedCyclicThree FirstSample generator)
    second <- checkedSimulation (step namedCyclicThree first generator)
    third <- checkedSimulation (step namedCyclicThree second generator)
    pure [first, second, third]

absorbingSamples :: [Finite 2]
absorbingSamples = runST $ do
    generator <- MWC.create
    replicateM 50 (checkedSimulation (step absorbingTwo 0 generator))

pointMassSamples :: [Finite 3]
pointMassSamples = runST $ do
    generator <- MWC.create
    let distribution =
            either (error . show) id $
                Vector.fromList @(Finite 3) [0, 1, 0]
    replicateM 20 (checkedSimulation (sample distribution generator))

namedPointMassSamples :: [NamedSample]
namedPointMassSamples = runST $ do
    generator <- MWC.create
    let distribution =
            either (error . show) id $
                Vector.fromList @NamedSample [0, 1, 0]
    replicateM 20 (checkedSimulation (sample distribution generator))

mapPointMassSamples :: [Natural]
mapPointMassSamples = runST $ do
    generator <- MWC.create
    replicateM 20 (checkedSimulation (sample (pointMass 7) generator))

sampleUnchecked :: [(Int, Double)] -> Either SimulationError Int
sampleUnchecked entries = runST $ do
    generator <- MWC.create
    sample (UncheckedDistribution entries) generator

invalidSampleAndGeneratorState :: (Either SimulationError Int, Bool)
invalidSampleAndGeneratorState = runST $ do
    generator <- MWC.create
    before <- MWC.save generator
    result <- sample (UncheckedDistribution []) generator
    after <- MWC.save generator
    pure (result, before == after)

zeroStepAndGeneratorState :: (Either SimulationError [Finite 3], Bool)
zeroStepAndGeneratorState = runST $ do
    generator <- MWC.create
    before <- MWC.save generator
    result <-
        simulate
            0
            ( error "zero-step simulation evaluated its kernel" ::
                TransitionMatrix (Finite 3)
            )
            0
            generator
    after <- MWC.save generator
    pure (result, before == after)

emptyKernel :: Kernel.TransitionKernel Int
emptyKernel =
    Kernel.fromLaws
        (const (fromDistribution (UncheckedDistribution [])))

spec :: Spec
spec = do
    describe "sample" $ do
        it "samples a dense point mass" $
            pointMassSamples `shouldBe` replicate 20 1

        it "samples a dense point mass as a named state" $
            namedPointMassSamples `shouldBe` replicate 20 SecondSample

        it "samples a map-backed point mass through the same function" $
            mapPointMassSamples `shouldBe` replicate 20 7

        it "repairs a tolerated negative weight" $
            sampleUnchecked [(1, -1e-12), (2, 1)]
                `shouldBe` Right 2

        it "rejects empty stored support" $
            sampleUnchecked []
                `shouldBe` Left EmptySupport

        it "rejects a non-finite weight by index" $ do
            sampleUnchecked [(1, 0 / 0), (2, 1)]
                `shouldBe` Left (NonFiniteWeight 0)
            sampleUnchecked [(1, 1), (2, 1 / 0)]
                `shouldBe` Left (NonFiniteWeight 1)

        it "rejects a weight below the repair tolerance" $
            sampleUnchecked [(1, -1e-6), (2, 1)]
                `shouldBe` Left (NegativeWeight 0 (-1e-6))

        it "rejects a non-positive repaired total" $
            sampleUnchecked [(1, 0), (2, -1e-12)]
                `shouldBe` Left (NonPositiveTotal 0)

        it "rejects overflow in the total" $
            sampleUnchecked
                [(1, 1.7976931348623157e308), (2, 1.7976931348623157e308)]
                `shouldBe` Left NonFiniteTotal

        it "does not advance the generator when validation fails" $ do
            let (result, unchanged) = invalidSampleAndGeneratorState
            result `shouldBe` Left EmptySupport
            unchanged `shouldBe` True

    describe "step" $ do
        it "follows a deterministic three-cycle" $
            threeCycleOrbit `shouldBe` [1, 2, 0]

        it "follows a deterministic cycle over named states" $
            namedThreeCycleOrbit
                `shouldBe` [SecondSample, ThirdSample, FirstSample]

        it "never leaves an absorbing state" $
            absorbingSamples `shouldBe` replicate 50 0

        it "runs in ST through PrimMonad" $
            length threeCycleOrbit `shouldBe` 3

        it "returns a transition-law validation failure" $
            runST
                ( do
                    generator <- MWC.create
                    step emptyKernel 0 generator
                )
                `shouldBe` Left EmptySupport

    describe "simulate" $ do
        it "simulates a finite matrix through the shared interface" $
            let trajectory = runST $ do
                    generator <- MWC.create
                    checkedSimulation (simulate 4 cyclicThree 0 generator)
             in trajectory `shouldBe` [0, 1, 2, 0, 1]

        it "returns the initial state plus the requested kernel transitions" $
            let trajectory = runST $ do
                    generator <- MWC.create
                    checkedSimulation
                        ( simulate
                            4
                            ( Kernel.fromLaws
                                (pointMass . (\state -> (state + 1) `mod` (3 :: Int)))
                            )
                            0
                            generator
                        )
             in trajectory `shouldBe` [0, 1, 2, 0, 1]

        it "does not inspect the kernel or advance the generator at zero steps" $ do
            let (result, unchanged) = zeroStepAndGeneratorState
            result `shouldBe` Right [0]
            unchanged `shouldBe` True

        it "stops at the first invalid transition law" $
            runST
                ( do
                    generator <- MWC.create
                    simulate 3 emptyKernel 0 generator
                )
                `shouldBe` Left EmptySupport