packages feed

dtmc-0.2.0.0: test/Dtmc/Analysis/AbsorptionSpec.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeApplications #-}

module Dtmc.Analysis.AbsorptionSpec (
    spec,
) where

import Data.Finite (
    Finite,
 )
import Dtmc.Analysis.Absorption qualified as Absorption
import Dtmc.Analysis.Classification (
    recurrentStates,
    transientStates,
 )
import Dtmc.Analysis.Expectation qualified as E
import Dtmc.Analysis.HittingTime qualified as Hitting
import Dtmc.State (
    FiniteState,
 )
import Dtmc.TestSupport
import Dtmc.Transition.Matrix (
    TransitionMatrix,
    TransitionMatrixError,
    fromRows,
 )
import GHC.Generics (
    Generic,
 )
import Test.Hspec
import Test.Hspec.QuickCheck (
    prop,
 )
import Test.QuickCheck

-- The chain of the "transient class {A,B}, recurrent class {C,D}" example in
-- section 3.7 of the notes, which states G, eta and the hit-before
-- probabilities in closed form.
data Four = A | B | C | D
    deriving (Eq, Ord, Show, Generic, FiniteState)

chain :: TransitionMatrix Four
chain =
    either (error . show) id $
        fromRows
            ( chunksOf
                4
                [ 0
                , 1 / 3
                , 2 / 3
                , 0
                , 1 / 2
                , 0
                , 1 / 8
                , 3 / 8
                , 0
                , 0
                , 1 / 2
                , 1 / 2
                , 0
                , 0
                , 3 / 4
                , 1 / 4
                ]
            )

-- Two states in one recurrent class: no transient states at all.
twoCycle :: TransitionMatrix Bool
twoCycle =
    either (error . show) id $
        fromRows (chunksOf 2 [0, 1, 1, 0])

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

finiteCloseTo :: Double -> E.Expectation -> Bool
finiteCloseTo expected (E.FiniteExpectation x) = closeTo expected x
finiteCloseTo _ E.InfiniteExpectation = False

rightCloseTo :: Double -> Either err Double -> Bool
rightCloseTo expected (Right actual) = closeTo expected actual
rightCloseTo _ (Left _) = False

spec :: Spec
spec = do
    describe "canonicalOrder" $
        it "splits the example chain into {A,B} and {C,D}" $
            Absorption.canonicalOrder chain `shouldBe` ([A, B], [C, D])

    describe "fundamentalMatrix" $ do
        it "indexes rows and columns by the transient states" $
            fmap fst (Absorption.fundamentalMatrix chain) `shouldBe` Right [A, B]

        it "matches the closed form of the notes" $
            case Absorption.fundamentalMatrix chain of
                Left err -> expectationFailure ("solve failed: " <> show err)
                Right (_, rows) ->
                    concat rows
                        `shouldSatisfy` ( and
                                            . zipWith closeTo [6 / 5, 2 / 5, 3 / 5, 6 / 5]
                                        )

        it "returns an empty block when no state is transient" $
            Absorption.fundamentalMatrix twoCycle `shouldBe` Right ([], [])

    describe "probability" $ do
        it "reproduces the hit-before probabilities of the notes" $ do
            Absorption.probabilityGivenInitialState chain C A `shouldSatisfy` rightCloseTo (17 / 20)
            Absorption.probabilityGivenInitialState chain D A `shouldSatisfy` rightCloseTo (3 / 20)
            Absorption.probabilityGivenInitialState chain C B `shouldSatisfy` rightCloseTo (11 / 20)
            Absorption.probabilityGivenInitialState chain D B `shouldSatisfy` rightCloseTo (9 / 20)

        it "is exact at a recurrent starting state" $ do
            Absorption.probabilityGivenInitialState chain C C `shouldBe` Right 1
            Absorption.probabilityGivenInitialState chain D C `shouldBe` Right 0

        it "is exactly zero for a transient target" $
            Absorption.probabilityGivenInitialState chain A B `shouldBe` Right 0

        it "differs from ever hitting the same state" $ do
            -- The chain reaches C almost surely, but it enters {C,D} at D
            -- with probability 3/20, so B(A,C) is strictly smaller.
            Hitting.eventualProbabilityGivenInitialState chain [C] A `shouldSatisfy` rightCloseTo 1
            Absorption.probabilityGivenInitialState chain C A `shouldSatisfy` rightCloseTo (17 / 20)

        it "agrees with eventual hitting after summing over the class" $
            case (Absorption.probabilityGivenInitialState chain C A, Absorption.probabilityGivenInitialState chain D A) of
                (Right toC, Right toD) ->
                    Hitting.eventualProbabilityGivenInitialState chain [C, D] A
                        `shouldSatisfy` rightCloseTo (toC + toD)
                other -> expectationFailure ("solve failed: " <> show other)

    describe "expectationByState" $ do
        it "matches the closed form of the notes" $
            case absorptionExpectationByState chain of
                Left err -> expectationFailure ("solve failed: " <> show err)
                Right values ->
                    values
                        `shouldSatisfy` ( and
                                            . zipWith finiteCloseTo [8 / 5, 9 / 5, 0, 0]
                                        )

        it "equals the expected hitting time of the recurrent set" $
            absorptionExpectationByState chain
                `shouldBe` hitExpectationByState chain [C, D]

    describe "absorption probabilities" $
        prop "sum to one from every transient state" $
            forAll (genTransitionRows 3) $ \m ->
                case fromRows m ::
                        Either TransitionMatrixError (TransitionMatrix (Finite 3)) of
                    Left err ->
                        counterexample ("generated matrix rejected: " <> show err) False
                    Right p ->
                        conjoin
                            [ case traverse (\k -> Absorption.probabilityGivenInitialState p k i) (recurrentStates p) of
                                -- A refused solve is a documented outcome, not a
                                -- violated law.
                                Left _ -> property True
                                Right ps ->
                                    counterexample
                                        ("from " <> show i <> ": " <> show ps)
                                        (approxEq 1e-9 1 (sum ps))
                            | i <- transientStates p
                            ]