packages feed

dtmc-0.2.0.0: test/Dtmc/DynamicsSpec.hs

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

module Dtmc.DynamicsSpec (
    spec,
) where

import Data.Finite (
    Finite,
    finites,
 )
import Dtmc.Analysis.FiniteTime (
    stepProbability,
 )
import Dtmc.Distribution (
    distributionWeights,
    probabilityAt,
 )
import Dtmc.Distribution.Map qualified as DistributionMap
import Dtmc.Distribution.Vector (
    DistributionVector,
 )
import Dtmc.Distribution.Vector qualified as Vector
import Dtmc.Dynamics (
    evolveN,
    evolveVector,
    evolveVectorN,
 )
import Dtmc.State (
    FiniteState,
 )
import Dtmc.TestSupport (
    approxDistributionEq,
    approxEq,
    chunksOf,
    genSimplexPoint,
    genTransitionRows,
    testTolerance,
 )
import Dtmc.Transition.Kernel qualified as Kernel
import Dtmc.Transition.Matrix (
    TransitionMatrix,
    fromRows,
 )
import GHC.Generics (
    Generic,
 )
import Test.Hspec (
    Spec,
    describe,
    it,
    shouldBe,
    shouldSatisfy,
 )
import Test.Hspec.QuickCheck (
    prop,
 )
import Test.QuickCheck (
    choose,
    counterexample,
    forAll,
    property,
 )

data NamedPosition = LowerPosition | UpperPosition
    deriving (Eq, Ord, Show, Generic)

instance FiniteState NamedPosition

checked :: (Show error) => Either error value -> value
checked = either (error . show) id

finiteChain :: TransitionMatrix (Finite 3)
finiteChain =
    checked $
        fromRows
            ( chunksOf
                3
                [ 0.5
                , 0.5
                , 0
                , 0
                , 0.2
                , 0.8
                , 1
                , 0
                , 0
                ]
            )

finiteInitial :: DistributionVector (Finite 3)
finiteInitial =
    checked (Vector.fromList [0.6, 0.3, 0.1])

kernelChain :: Kernel.TransitionKernel (Finite 3)
kernelChain =
    Kernel.fromLaws $ \source ->
        checked $
            DistributionMap.fromList
                [ (destination, stepProbability finiteChain source destination)
                | destination <- finites
                ]

mapInitial :: DistributionMap.DistributionMap (Finite 3)
mapInitial =
    checked $
        DistributionMap.fromList
            [(state, probabilityAt finiteInitial state) | state <- finites]

simpleRandomWalk :: Kernel.TransitionKernel Integer
simpleRandomWalk =
    Kernel.fromLaws $ \state ->
        checked
            (DistributionMap.fromList [(state - 1, 0.5), (state + 1, 0.5)])

closeTo :: Double -> Double -> Bool
closeTo = approxEq testTolerance

twoState :: TransitionMatrix (Finite 2)
twoState =
    either (error . show) id $
        fromRows
            (chunksOf 2 [0.9, 0.1, 0.4, 0.6])

namedInitial :: DistributionVector NamedPosition
namedInitial =
    either (error . show) id $
        Vector.fromList @NamedPosition [1, 0]

namedTwoState :: TransitionMatrix NamedPosition
namedTwoState =
    either (error . show) id $
        fromRows @NamedPosition
            (chunksOf 2 [0.9, 0.1, 0.4, 0.6])

spec :: Spec
spec = do
    describe "evolveVector" $ do
        prop "keeps the distribution on the simplex" $
            forAll ((,) <$> genSimplexPoint 3 <*> genTransitionRows 3) $
                \(vector, matrix) ->
                    case (Vector.fromList @(Finite 3) vector, fromRows matrix) of
                        (Right mu, Right p) ->
                            case Vector.fromList @(Finite 3)
                                (Vector.toList (evolveVector mu p)) of
                                Right _ ->
                                    property True
                                Left err ->
                                    counterexample
                                        ("evolved distribution left the simplex: " <> show err)
                                        False
                        result ->
                            counterexample
                                ("generated input was rejected: " <> show result)
                                False

        it "matches a hand-computed two-state step" $ do
            let mu =
                    either (error . show) id $
                        Vector.fromList @(Finite 2) [1, 0]

            Vector.toList (evolveVector mu twoState)
                `shouldBe` [0.9, 0.1]

        it "preserves named states while evolving the dense vector" $ do
            probabilityAt (evolveVector namedInitial namedTwoState) LowerPosition
                `shouldBe` 0.9
            probabilityAt (evolveVector namedInitial namedTwoState) UpperPosition
                `shouldBe` 0.1

    describe "evolveVectorN" $ do
        it "leaves a distribution unchanged after zero steps" $ do
            let mu =
                    either (error . show) id $
                        Vector.fromList @(Finite 2) [0.25, 0.75]

            approxDistributionEq
                1e-12
                (evolveVectorN 0 mu twoState)
                mu
                `shouldBe` True

        prop "agrees with iterating evolveVector"
            $ forAll
                ( (,,)
                    <$> choose (0, 6 :: Int)
                    <*> genSimplexPoint 3
                    <*> genTransitionRows 3
                )
            $ \(k, vector, matrix) ->
                case (Vector.fromList @(Finite 3) vector, fromRows matrix) of
                    (Right mu, Right p) ->
                        let iterated =
                                iterate (`evolveVector` p) mu !! k
                         in property $
                                approxDistributionEq
                                    1e-9
                                    (evolveVectorN (fromIntegral k) mu p)
                                    iterated
                    result ->
                        counterexample
                            ("generated input was rejected: " <> show result)
                            False

        prop "composes m steps then n steps"
            $ forAll
                ( (,,,)
                    <$> choose (0, 4 :: Int)
                    <*> choose (0, 4 :: Int)
                    <*> genSimplexPoint 3
                    <*> genTransitionRows 3
                )
            $ \(m, n, vector, matrix) ->
                case (Vector.fromList @(Finite 3) vector, fromRows matrix) of
                    (Right mu, Right p) ->
                        property $
                            approxDistributionEq
                                1e-9
                                (evolveVectorN (fromIntegral (m + n)) mu p)
                                ( evolveVectorN
                                    (fromIntegral n)
                                    (evolveVectorN (fromIntegral m) mu p)
                                    p
                                )
                    result ->
                        counterexample
                            ("generated input was rejected: " <> show result)
                            False

    describe "evolve/evolveN" $ do
        it "evolves an infinite-state random walk without enumerating its state space" $
            distributionWeights
                (evolveN 2 (DistributionMap.pointMass 0) simpleRandomWalk)
                `shouldBe` [(-2, 0.25), (0, 0.5), (2, 0.25)]

        it "agrees across equivalent matrix and kernel representations" $
            sequence_
                [ probabilityAt (evolveN time mapInitial kernelChain) state
                    `shouldSatisfy` closeTo
                        (probabilityAt (evolveVectorN time finiteInitial finiteChain) state)
                | time <- [0 .. 4]
                , state <- finites :: [Finite 3]
                ]