packages feed

exchangealgebra-0.5.3.0: test/Journal/CarrySpec.hs

-- | Acceptance properties for explicit journal carryover.
module Journal.CarrySpec (runTests) where

import Control.Monad (unless)
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Map.Strict as Map
import System.Exit (exitFailure)
import Test.QuickCheck

import ExchangeAlgebra.Algebra ((.@), foldEntries)
import ExchangeAlgebra.Algebra.Base
    ( AccountTitles(..)
    , CountUnit(..)
    , Hat(..)
    , HatBase(..)
    )
import ExchangeAlgebra.Journal (Journal, (.|))
import qualified ExchangeAlgebra.Journal as Journal
import ExchangeAlgebra.Journal.Transfer.Rule (carryBefore, carryEntries)
import ExchangeAlgebra.Journal.Exact (ExactSumError(..))

-- | Test postings use two base axes and integer note labels.
type Part = (CountUnit, AccountTitles)

-- | One scalar posting with its complete base coordinates.
type Row = (Int, Part, Hat, Double)

-- | The generated journal's concrete base has unit and account coordinates.
type TestJournal = Journal Int Double (HatBase Part)

-- | Include decimal values and the binary64 rounding boundary.
valueGen :: Gen Double
valueGen = elements [0.1, 0.2, 0.3, 1, 2, 2 ^ (53 :: Int), 2 ^ (53 :: Int) + 2]

-- | Vary selected notes, retained notes, sides, and complete bases.
rowsGen :: Gen [Row]
rowsGen = do
    count <- chooseInt (0, 18)
    vectorOf count $ do
        note <- elements [0, 1, 2, 3, 4, 9]
        unit <- elements [Yen, Dollar]
        account <- elements [Cash, Deposits, Products]
        side <- elements [Not, Hat]
        value <- valueGen
        pure (note, (unit, account), side, value)

-- | Retain the redundancy of each original scalar posting.
build :: [Row] -> TestJournal
build = foldMap (\(note, coordinates, side, value) ->
    (value .@ (side :< coordinates)) .| note)

-- | Extract individual scalar entries, including their original note.
rows :: TestJournal -> [Row]
rows journal = concatMap entries (HashMap.toList (Journal.toMap journal))
  where
    entries (note, algebra) = foldEntries
        (\previous value (side :< coordinates) ->
            (note, coordinates, side, value) : previous)
        []
        algebra

-- | Count repeated entries without relying on their storage order.
multiset :: Ord a => [a] -> Map.Map a Int
multiset = Map.fromListWith (+) . fmap (\entry -> (entry, 1))

-- | Compute exact signed balances directly from the scalar entries.
balances :: [Row] -> Map.Map Part Rational
balances = Map.fromListWith (+) . fmap contribution
  where
    contribution (_, coordinates, side, value) =
        (coordinates, case side of
            Not    -> toRational value
            Hat    -> negate (toRational value)
            HatNot -> error "CarrySpec: concrete-side invariant violated")

-- | Carry preserves retained scalars and rounds only each selected net.
propCarry :: Property
propCarry = forAll rowsGen $ \original ->
    case carryBefore (< 3) 9 (build original) of
      Left failure -> counterexample (show failure) False
      Right after  -> checkCarry original (rows after)

-- | Forgetting selected notes after adding carry entries matches replacement.
-- The carry note 9 is outside the selected notes 0, 1, and 2.
propCarryEntriesDecomposition :: Property
propCarryEntriesDecomposition = forAll rowsGen $ \original ->
    let journal = build original
        observed = do
            additions <- carryEntries (< 3) 9 journal
            pure (Journal.filterWithNote (\note _ -> note >= 3) (journal <> additions))
    in fmap (multiset . filter nonzero . rows) observed
        === fmap (multiset . filter nonzero . rows) (carryBefore (< 3) 9 journal)
  where
    nonzero (_, _, _, value) = value /= 0

-- | Each base's exact balance changes by the rounding of its selected net.
propCarryEntriesBalance :: Property
propCarryEntriesBalance = forAll rowsGen $ \original ->
    let journal = build original
        selectedBalances = balances
            (filter (\(note, _, _, _) -> note < 3) original)
        originalBalances = balances original
        observed = do
            additions <- carryEntries (< 3) 9 journal
            pure (balances (rows (journal <> additions)))
        check result = all (matches result) allCoordinates
        allCoordinates = Map.keys (Map.union originalBalances selectedBalances)
        matches result coordinates =
            let before = Map.findWithDefault 0 coordinates originalBalances
                after = Map.findWithDefault 0 coordinates result
                selected = Map.findWithDefault 0 coordinates selectedBalances
                rounded = toRational (fromRational selected :: Double)
            in after - before == rounded - selected
    in fmap check observed === Right True

-- | Compare exact balances and entry multisets after a successful carry.
checkCarry :: [Row] -> [Row] -> Property
checkCarry original observed =
    let retained = filter (\(note, _, _, _) -> note >= 3) original
        retainedCounts = multiset retained
        observedCounts = multiset observed
        originalBalances = balances original
        observedBalances = balances observed
        selectedBalances = balances
            (filter (\(note, _, _, _) -> note < 3) original)
        expectedCarried =
            [ (9, coordinates, netSide amount, fromRational (abs amount))
            | (coordinates, amount) <- Map.toAscList selectedBalances
            , amount /= 0
            ]
        allCoordinates = Map.keys (Map.unions
            [originalBalances, observedBalances, selectedBalances])
        retainedPresent = all (\(entry, count) ->
            Map.findWithDefault 0 entry observedCounts >= count)
            (Map.toList retainedCounts)
        otherRetained = filter (\(note, _, _, _) -> note /= 9) retained
        otherObserved = filter (\(note, _, _, _) -> note /= 9) observed
        roundedExactlyOnce coordinates =
            let beforeBalance = Map.findWithDefault 0 coordinates originalBalances
                afterBalance = Map.findWithDefault 0 coordinates observedBalances
                selectedBalance = Map.findWithDefault 0 coordinates selectedBalances
                roundedSelected = toRational (fromRational selectedBalance :: Double)
            in afterBalance - beforeBalance == roundedSelected - selectedBalance
        valid (_, _, side, value) =
            side /= HatNot && value > 0 && not (isNaN value) && not (isInfinite value)
    in conjoin
        [ multiset observed === multiset (retained ++ expectedCarried)
        , counterexample "retained scalar multiset" (property retainedPresent)
        , multiset otherObserved === multiset otherRetained
        , counterexample "carry difference is not the selected net's one rounding"
            (property (all roundedExactlyOnce allCoordinates))
        , counterexample "carry generated an invalid magnitude or side"
            (property (all valid observed))
        ]
  where
    netSide amount
        | amount > 0 = Not
        | otherwise  = Hat

-- | An exact cancellation adds nothing, even with an existing carry note.
propZeroAndExistingNote :: Property
propZeroAndExistingNote =
    let original =
            [ (0, (Yen, Cash), Not, 0.1)
            , (1, (Yen, Cash), Hat, 0.1)
            , (9, (Yen, Deposits), Not, 3)
            ]
        after = fmap rows (carryBefore (< 3) 9 (build original))
    in fmap multiset after === Right (multiset [(9, (Yen, Deposits), Not, 3)])

-- | An existing entry on the carry note and complete base is appended to.
propCarryNoteCollision :: Property
propCarryNoteCollision =
    let original =
            [ (0, (Yen, Cash), Not, 2)
            , (9, (Yen, Cash), Not, 2)
            , (0, (Dollar, Cash), Hat, 3)
            ]
        expected =
            [ (9, (Yen, Cash), Not, 2)
            , (9, (Yen, Cash), Not, 2)
            , (9, (Dollar, Cash), Hat, 3)
            ]
    in fmap (multiset . rows) (carryBefore (< 3) 9 (build original))
        === Right (multiset expected)

-- | Carry entries keep the existing carry-note entry and add a separate net.
propCarryEntriesCollision :: Property
propCarryEntriesCollision =
    let original =
            [ (0, (Yen, Cash), Not, 2)
            , (9, (Yen, Cash), Not, 2)
            ]
        expected =
            [ (0, (Yen, Cash), Hat, 2)
            , (9, (Yen, Cash), Not, 2)
            ]
    in fmap (multiset . rows) (carryEntries (< 3) 9 (build original))
        === Right (multiset expected)

-- | Both carry operations report the same checked aggregation failure.
propCarryEntriesFailureParity :: Property
propCarryEntriesFailureParity =
    let maximumFinite = encodeFloat (2 ^ (53 :: Int) - 1) 971 :: Double
        original = build
            [ (0, (Yen, Cash), Not, maximumFinite)
            , (1, (Yen, Cash), Not, maximumFinite)
            ]
        before = fmap (const ()) (carryBefore (< 3) 9 original)
        entries = fmap (const ()) (carryEntries (< 3) 9 original)
    in conjoin
        [ before === Left SumOutOfRange
        , entries === before
        ]

-- | Selection of every or no note follows the same exact balance contract.
propSelectionExtremes :: Property
propSelectionExtremes =
    let original =
            [ (0, (Yen, Cash), Not, 0.1)
            , (1, (Yen, Cash), Not, 0.2)
            , (9, (Dollar, Cash), Hat, 4)
            ]
        none = fmap (multiset . rows) (carryBefore (const False) 12 (build original))
        allEntries = fmap (multiset . rows) (carryBefore (const True) 12 (build original))
        expectedAll =
            [ (12, (Yen, Cash), Not, fromRational
                (toRational (0.1 :: Double) + toRational (0.2 :: Double)))
            , (12, (Dollar, Cash), Hat, 4)
            ]
    in conjoin
        [ none === Right (multiset original)
        , allEntries === Right (multiset expectedAll)
        ]

-- | A selected complete base whose exact side sum exceeds Double fails.
testOverflow :: IO ()
testOverflow = do
    let maximumFinite = encodeFloat (2 ^ (53 :: Int) - 1) 971 :: Double
        original =
            [ (0, (Yen, Cash), Not, maximumFinite)
            , (1, (Yen, Cash), Not, maximumFinite)
            ]
        carried = carryBefore (< 3) 9 (build original)
        additions = carryEntries (< 3) 9 (build original)
    unless (fmap (const ()) carried == fmap (const ()) additions) $
        failTest "carryEntries and carryBefore disagree on overflow"
    case carried of
        Left SumOutOfRange -> pure ()
        Left failure     -> failTest ("unexpected overflow error: " ++ show failure)
        Right _          -> failTest "overflowing selected side sum succeeded"

-- | Report a deterministic acceptance failure and stop the suite.
failTest :: String -> IO ()
failTest message = do
    putStrLn ("[FAIL] journal carry: " ++ message)
    exitFailure

-- | Run journal carry properties as part of the package test suite.
runTests :: IO ()
runTests = do
    quickProperty 500 "carry" propCarry
    quickProperty 500 "carry decomposition" propCarryEntriesDecomposition
    quickProperty 500 "carry balance" propCarryEntriesBalance
    quickProperty 1 "carry zero" propZeroAndExistingNote
    quickProperty 1 "carry collision" propCarryNoteCollision
    quickProperty 1 "carry entries collision" propCarryEntriesCollision
    quickProperty 1 "carry error parity" propCarryEntriesFailureParity
    quickProperty 1 "carry selection" propSelectionExtremes
    testOverflow
    putStrLn "[PASS] journal carry (retention, rounding, zero, and carry note)"

-- | Run one named QuickCheck property and fail on a counterexample.
quickProperty :: Testable property => Int -> String -> property -> IO ()
quickProperty count label proposition = do
    result <- quickCheckWithResult stdArgs { maxSuccess = count, chatty = False } proposition
    unless (isSuccess result) $ failTest (label ++ ": " ++ output result)