exchangealgebra-0.6.0.0: test/Journal/NormalFormSpec.hs
{-# LANGUAGE ScopedTypeVariables #-}
-- | Journal normal-form laws for the Journal layer, using public Algebra inputs.
-- N1 checks both layers; N2 compares with the old map operations; N3 reads old bytes.
-- All observations use exact equality on small integral values, for Double and
-- MoneyDecimal with String and tuple notes. Start with 'runTests'.
module Journal.NormalFormSpec (runTests) where
import Control.Monad (forM_)
import qualified Data.Binary as Binary
import Data.Binary.Put (runPut)
import qualified Data.HashMap.Strict as Map
import qualified Data.List as List
import qualified ExchangeAlgebra.Algebra as EA
import ExchangeAlgebra.Algebra
( HatBase(..)
, Hat(..)
, CountUnit(..)
, Redundant(..)
, (.@)
)
import ExchangeAlgebra.Algebra.Value (MoneyDecimal)
import qualified ExchangeAlgebra.Journal.Core as Journal
import Support (assertEqual, quickProp)
import Test.QuickCheck
-- | Recognize the structural Zero pattern, independently of Journal equality.
isEmpty :: (Journal.Note n, EA.HatVal v, EA.HatBaseClass b)
=> Journal.Journal n v b -> Bool
isEmpty Journal.Zero = True
isEmpty _ = False
-- | N1: merged entries and each layer contain no zero algebra; empty means Zero.
isNormal :: (Journal.Note n, EA.HatVal v)
=> Journal.Journal n v (HatBase CountUnit) -> Bool
isNormal journal = all (not . EA.isZero) (Map.elems entries)
&& isEmpty (Journal.filterWithNote (\_ algebra -> EA.isZero algebra) journal)
&& (not (Map.null entries) || isEmpty journal)
where
entries = Journal.toMap journal
-- | N1-N3 over positive integral amounts, arbitrary layer placement, and API choices.
-- N2 observes the posting multiset, norm, and net readout without numeric tolerance.
normalFormLaws :: forall n v. (Journal.Note n, Binary.Binary n, EA.HatVal v, Binary.Binary v)
=> n -> n -> n -> v -> Property
normalFormLaws first second emptyNote witness =
forAll (chooseInt (1, 1000)) $ \amount ->
forAll (chooseInt (0, 2)) $ \placement ->
forAll (chooseInt (0, 2)) $ \operation ->
let value = fromIntegral amount + 0 * witness
positive = value .@ (Not :< Yen)
balanced = positive .+ value .@ (Hat :< Yen)
base = Map.fromList [(first, positive), (emptyNote, EA.Zero)]
delta = Map.fromList [(first, positive), (second, balanced), (emptyNote, EA.Zero)]
input = case placement of
0 -> Journal.mkJournal base Map.empty
1 -> Journal.mkJournal Map.empty delta
_ -> Journal.mkJournal base delta
transform :: EA.Alg v (HatBase CountUnit) -> EA.Alg v (HatBase CountUnit)
transform = case operation of
0 -> const EA.Zero
1 -> bar
_ -> (0 .*)
query = [Not :< Dollar | odd amount] ++ [Not :< Yen | even amount]
notes = [first, second]
oldMap = Journal.toMap input
oldResults =
[ Map.map transform oldMap
, Map.map (EA.proj query) oldMap
, Map.map (EA.proj query) (Map.filterWithKey (\n _ -> n == first) oldMap)
, Map.map (EA.proj query) (Map.filterWithKey (\n _ -> n `elem` notes) oldMap)
]
newResults =
[ Journal.map transform input
, Journal.projWithBase query input
, Journal.projWithNoteBase [first] query input
, Journal.projWithNoteBase notes query input
]
bytes = runPut $ do
Binary.put (2 :: Int)
Binary.put emptyNote
Binary.put (EA.Zero :: EA.Alg v (HatBase CountUnit))
Binary.put first
Binary.put positive
decoded = Binary.decode bytes :: Journal.Journal n v (HatBase CountUnit)
candidates = newResults ++
[ input
, Journal.fromMap base
, decoded
, 0 .* input
, bar (balanced Journal..| first)
, (.^) input
, (.-) input
, EA.compress input
, Journal.projWithNote notes input
, Journal.filterWithNote (\n _ -> n == first) input
, Journal.filterByAxis 0 (Journal.NoteAxisKey first) input
, Journal.replaceNotes input (positive Journal..| second)
, Journal.fromList [input, input]
, Journal.sigma [input, input] id
, Journal.sigma2When [input] [input] (\_ _ -> True) (.+)
, Journal.decTo (const (Just first)) balanced
, Journal.gather first input
, mempty
, EA.Zero Journal..| first
]
unchanged :: (Map.HashMap n (EA.Alg v (HatBase CountUnit)),
Journal.Journal n v (HatBase CountUnit)) -> Bool
unchanged (old, new) =
Journal.toMap new == Map.filter (not . EA.isZero) old
&& List.sort (EA.toASCList (Journal.toAlg new))
== List.sort (concatMap EA.toASCList (Map.elems old))
&& norm new == sum (Prelude.map norm (Map.elems old))
&& Journal.projWithBaseNetNorm [HatNot :< Yen] new
== sum (Prelude.map (EA.projNetNorm [HatNot :< Yen]) (Map.elems old))
in conjoin
[ counterexample "N1 layers / merged map / Zero pattern"
(all isNormal candidates)
, counterexample "N2 content" (all unchanged (zip oldResults newResults))
, counterexample "N3 legacy decode"
(isNormal decoded && Journal.toMap decoded == Map.singleton first positive)
]
-- | A0: underflow drops zero postings and retains the sequence and projection indices.
-- Finite nonnegative Double products include tiny values and zero; nonzero sums
-- remain nonzero. Exact equality observes posting values, not a rounded norm.
underflowLaw :: Property
underflowLaw = forAll (elements [0, 1e-200, 1e-100, 1]) $ \scalar ->
forAll (elements [1e-200, 1e-100, 1, 2]) $ \value ->
let single = value .@ (Not :< Yen) :: EA.Alg Double (HatBase CountUnit)
liner = single .+ 1 .@ (Hat :< Dollar) .+ single
entries :: EA.Alg Double (HatBase CountUnit) -> [(Double, HatBase CountUnit)]
entries = List.sort . EA.foldEntries (\rows v b -> (v, b) : rows) []
tinyLiner = single .+ value .@ (Hat :< Dollar)
products = Prelude.map (scalar .*) [single, liner, tinyLiner]
expected = Prelude.map
(Prelude.filter ((/= 0) . fst) . Prelude.map (\(v, b) -> (scalar * v, b))
. entries) [single, liner, tinyLiner]
in conjoin
[ Prelude.map entries products === Prelude.map List.sort expected
, property (all (all (not . EA.isZeroValue) . EA.vals) products)
, property (and [not (null rows) || EA.isZero a
| (a, rows) <- zip products expected])
, property (and [EA.isZero a || EA.isZero b || not (EA.isZero (a .+ b))
| a <- products, b <- products])
, property (all (\a -> entries (EA.proj [HatNot :< wildcard] a)
== entries a) products)
]
where
wildcard :: CountUnit
wildcard = EA.wildcard
-- | Empty exact and wildcard projections are Zero for either side.
-- For finite positive values, the selected posting multiset is preserved exactly.
-- The opposite-side Journal regression is the former empty "dog" note.
projectionLaw :: forall v. EA.HatVal v => v -> Property
projectionLaw witness = forAll (chooseInt (1, 1000)) $ \amount ->
let value = fromIntegral amount + 0 * witness
inputs =
[ value .@ (side :< Yen) .+ value .@ (side :< unit)
| side <- [Hat, Not], unit <- [Yen, Dollar] ]
queries = [[side :< unit] | side <- [Hat, Not], unit <- [Yen, EA.wildcard]]
++ [[side :< Yen, side :< Dollar] | side <- [Hat, Not]]
postings :: EA.Alg v (HatBase CountUnit) -> [(v, HatBase CountUnit)]
postings = List.sort . EA.foldEntries (\rows v b -> (v, b) : rows) []
dog = value .@ (Not :< Yen) .+ value .@ (Hat :< Amount)
regression = Journal.projWithBase [Not :< Amount] (dog Journal..| "dog")
in conjoin
[ counterexample "A0 projection emptiness / retained postings" $ conjoin
[ let projected = EA.proj query input
expected = Prelude.filter
(\(_, side :< unit) -> any
(\(querySide :< queryUnit) -> querySide == side
&& (queryUnit == EA.wildcard || queryUnit == unit)) query)
(postings input)
in property (EA.isZero projected == null expected
&& postings projected == expected)
| input <- inputs, query <- queries ]
, counterexample "N1 opposite-side projection drops the dog note"
(isEmpty regression && isNormal regression)
]
-- | Run N1-N3 for both value types and note shapes, then the A0 regression.
runTests :: IO ()
runTests = do
quickProp "N1-N3 Double / String" (normalFormLaws "a" "b" "zero" (0 :: Double))
quickProp "N1-N3 MoneyDecimal / String"
(normalFormLaws "a" "b" "zero" (0 :: MoneyDecimal))
quickProp "N1-N3 Double / tuple"
(normalFormLaws ("a", 1 :: Int) ("b", 2) ("zero", 3) (0 :: Double))
quickProp "N1-N3 MoneyDecimal / tuple"
(normalFormLaws ("a", 1 :: Int) ("b", 2) ("zero", 3) (0 :: MoneyDecimal))
quickProp "A0 underflow / nonzero addition" underflowLaw
quickProp "A0 projection / Double" (projectionLaw (0 :: Double))
quickProp "A0 projection / MoneyDecimal" (projectionLaw (0 :: MoneyDecimal))
let tiny = 1e-200 .* (1e-200 .@ (Not :< Yen)) :: EA.Alg Double (HatBase CountUnit)
journal = Journal.sigma [tiny, tiny] (Journal..| "same")
assertEqual "N1 underflow sigma same note" True (isNormal journal && isEmpty journal)
forM_ [(Yen, 0), (Dollar, 1e-200)] $ \(unit, expected) ->
assertEqual "A0 surviving projection after underflow"
expected
(norm $ EA.proj [HatNot :< unit]
$ 1e-200 .* (1e-200 .@ (Not :< Yen) .+ 1 .@ (Hat :< Dollar)
:: EA.Alg Double (HatBase CountUnit)))