packages feed

exchangealgebra-0.5.3.2: test/Algebra/ProjWildcardSpec.hs

{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings #-}

-- | The @proj@ family matches wildcards one way: only the query's wildcards
-- act as a pattern, and a wildcard stored in a ledger base is an ordinary
-- value. The result must not depend on the shape of the ledger (a single
-- element or a 'Liner').
module Algebra.ProjWildcardSpec (runTests) where

import           Control.Monad (unless)
import qualified Data.List as List
import qualified Data.Map.Strict as Map
import           Data.Text (Text)
import           System.Exit (exitFailure)
import           Test.QuickCheck hiding (label)
import           ExchangeAlgebra.Algebra hiding (map, filter)
import qualified ExchangeAlgebra.Algebra as Algebra
import           ExchangeAlgebra.Algebra.Transfer.Rule (collapseEntries)

-- | Account and unit coordinates; either axis may hold a ledger wildcard.
type TestBase = HatBase (AccountTitles, CountUnit)

type TestAlg = Alg Double TestBase

-- | Generated postings (ledger wildcards allowed) and a query set. Values are
-- small whole numbers, so every 'Double' sum below is exact.
data ProjCase = ProjCase [(Double, TestBase)] [TestBase]
    deriving (Show)

genTitle :: Gen AccountTitles
genTitle = frequency [(1, pure wildcard), (4, elements [Cash, Products, Sales])]

genUnit :: Gen CountUnit
genUnit = frequency [(1, pure wildcard), (4, elements [Yen, Amount, Dollar])]

genEntryBase :: Gen TestBase
genEntryBase = (:<) <$> elements [Hat, Not] <*> ((,) <$> genTitle <*> genUnit)

genQueryBase :: Gen TestBase
genQueryBase = (:<) <$> elements [Hat, Not, HatNot] <*> ((,) <$> genTitle <*> genUnit)

genProjCase :: Gen ProjCase
genProjCase = do
    count <- chooseInt (0, 12)
    entries <- vectorOf count ((,) <$> (fromIntegral <$> chooseInt (1, 1000)) <*> genEntryBase)
    queryCount <- chooseInt (1, 4)
    ProjCase entries <$> vectorOf queryCount genQueryBase

-- | The same generator restricted to concrete ledger bases.
genConcreteCase :: Gen ProjCase
genConcreteCase = do
    ProjCase entries queries <- genProjCase
    pure (ProjCase (filter (not . haveWildcard . snd) entries) queries)

-- | Reference predicate, written per axis and independent of the library's
-- 'ignoreWildcard': a query axis matches when it is a wildcard or equals the
-- entry axis. A ledger wildcard is matched only by a query wildcard.
oneWay :: TestBase -> TestBase -> Bool
oneWay (queryHat :< (queryTitle, queryUnit)) (entryHat :< (entryTitle, entryUnit)) =
    axis queryHat entryHat && axis queryTitle entryTitle && axis queryUnit entryUnit
  where
    axis :: Element a => a -> a -> Bool
    axis query entry = isWildcard query || query == entry

-- | The old symmetric predicate, for the concrete-ledger regression.
symmetric :: TestBase -> TestBase -> Bool
symmetric = (.==)

build :: [(Double, TestBase)] -> TestAlg
build entries = fromList [value :@ postingBase | (value, postingBase) <- entries]

-- | Raw postings keyed by the rendered base. 'HatBase' orders wildcards as
-- equal to anything, so the base itself cannot be the key here.
observe :: TestAlg -> Map.Map String [Double]
observe = Map.map List.sort
        . foldEntries (\result value postingBase ->
              Map.insertWith (++) (show postingBase) [value] result) Map.empty

selected :: (TestBase -> TestBase -> Bool) -> ProjCase -> [(Double, TestBase)]
selected match (ProjCase entries queries) =
    [entry | entry@(_, postingBase) <- entries, any (`match` postingBase) queries]

-- | Bar-netted norm of the selected entries, per base.
netNorm :: [(Double, TestBase)] -> Double
netNorm entries = sum (Map.elems (Map.map abs perBase))
  where
    perBase = Map.fromListWith (+)
        [ (show (base postingBase), if hat postingBase == Hat then negate value else value)
        | (value, postingBase) <- entries ]

propLedger :: ProjCase -> Bool
propLedger testCase@(ProjCase entries queries) =
    observe (proj queries (build entries)) == observe (build (selected oneWay testCase))

propSingle :: ProjCase -> Bool
propSingle (ProjCase entries queries) = all agrees entries
  where
    agrees entry@(value, postingBase) =
        observe (proj queries (value :@ postingBase))
            == observe (build (selected oneWay (ProjCase [entry] queries)))

propNetNorm :: ProjCase -> Bool
propNetNorm testCase@(ProjCase entries queries) =
    projNetNorm queries (build entries) == netNorm (selected oneWay testCase)
        && all single entries
  where
    single entry@(value, postingBase) =
        projNetNorm queries (value :@ postingBase)
            == netNorm (selected oneWay (ProjCase [entry] queries))

propConcreteRegression :: ProjCase -> Bool
propConcreteRegression testCase =
    selected oneWay testCase == selected symmetric testCase && propLedger testCase

assertTest :: String -> Bool -> IO ()
assertTest label success = do
    unless success $ do
        putStrLn ("[FAIL] " ++ label)
        exitFailure
    putStrLn ("[PASS] " ++ label)

quickProperty :: Testable property => String -> property -> IO ()
quickProperty label proposition = do
    result <- quickCheckWithResult stdArgs { maxSuccess = 500, chatty = False } proposition
    unless (isSuccess result) $ do
        putStrLn ("[FAIL] " ++ label ++ ": " ++ output result)
        exitFailure
    putStrLn ("[PASS] " ++ label)

-- | The reported table: cash carries no unit axis in the ledger.
testReportedTable :: IO ()
testReportedTable = do
    let cash = 10 :@ Not :< (Cash, wildcard) :: TestAlg
        both = cash .+ 3 :@ Not :< (Products, Yen)
        anyTitleYen = [Not :< (wildcard, Yen)]
        cashYen = [Not :< (Cash, Yen)]
        cashAny = [Not :< (Cash, wildcard)]
        products = 3 :@ Not :< (Products, Yen) :: TestAlg
    assertTest "proj: wildcard-title query skips ledger-wildcard unit (single)"
        (Algebra.isZero (proj anyTitleYen cash))
    assertTest "proj: wildcard-title query skips ledger-wildcard unit (Liner)"
        (observe (proj anyTitleYen both) == observe products)
    assertTest "proj: concrete query does not match a ledger wildcard (single)"
        (Algebra.isZero (proj cashYen cash))
    assertTest "proj: concrete query does not match a ledger wildcard (Liner)"
        (Algebra.isZero (proj cashYen both))
    assertTest "proj: query wildcard picks up the ledger wildcard (single)"
        (observe (proj cashAny cash) == observe cash)
    assertTest "proj: query wildcard picks up the ledger wildcard (Liner)"
        (observe (proj cashAny both) == observe cash)
    assertTest "projNetNorm: single element and Liner agree on a ledger wildcard"
        (projNetNorm cashYen cash == 0 && projNetNorm cashYen both == 0
            && projNetNorm cashAny cash == 10 && projNetNorm cashAny both == 10)
    assertTest "proj: projecting a projection is stable"
        (observe (proj cashYen (proj cashAny both)) == Map.empty)

-- | 'projByAccountTitle' follows the same one-way rule on the title axis.
testProjByAccountTitle :: IO ()
testProjByAccountTitle = do
    let noTitle = 7 :@ Not :< wildcard :: Alg Double (HatBase AccountTitles)
        cash = 10 :@ Not :< Cash
        ledger = noTitle .+ cash
        postings = List.sort . foldEntries (\result value postingBase ->
            (show postingBase, value) : result) []
    assertTest "projByAccountTitle: concrete title skips a ledger wildcard (single)"
        (Algebra.isZero (projByAccountTitle Cash noTitle))
    assertTest "projByAccountTitle: concrete title skips a ledger wildcard (Liner)"
        (postings (projByAccountTitle Cash ledger) == postings cash)
    assertTest "projByAccountTitle: wildcard title selects every entry"
        (postings (projByAccountTitle wildcard ledger) == postings ledger)

-- | 'Algebra.map' keeps a rewrite between a wildcard and a concrete coordinate
-- in both directions and in both ledger shapes.
testMapRewritesWildcardAxis :: IO ()
testMapRewritesWildcardAxis = do
    let cash = 10 :@ Not :< (Cash, wildcard) :: TestAlg
        cashYen = 10 :@ Not :< (Cash, Yen) :: TestAlg
        products = 3 :@ Not :< (Products, Amount) :: TestAlg
        setCashUnit unit entry@(value :@ (h :< (title, _)))
            | title == Cash = value :@ (h :< (title, unit))
            | otherwise     = entry
        setCashUnit _ entry = entry
    assertTest "map: wildcard axis rewritten to a concrete value (single)"
        (observe (Algebra.map (setCashUnit Yen) cash) == observe cashYen)
    assertTest "map: wildcard axis rewritten to a concrete value (Liner)"
        (observe (Algebra.map (setCashUnit Yen) (cash .+ products))
            == observe (cashYen .+ products))
    assertTest "map: concrete axis rewritten to the wildcard (Liner)"
        (observe (Algebra.map (setCashUnit wildcard) (cashYen .+ products))
            == observe (cash .+ products))

-- | Flattening Text coordinates preserves projection norms, net norms,
-- collapse postings, and symmetric matching. Small non-negative whole values
-- make the Double observations exact; either inner query axis may be a wildcard.
propNestedFlattening :: (Text, Text, Text) -> (Double, Double, Double) -> Bool
propNestedFlattening (queryFirst, querySecond, queryThird) (notValue, hatValue, otherValue) =
    norm (proj [nestedQuery] nestedLedger) == norm (proj [flatQuery] flatLedger)
        && projNetNorm [nestedQuery] nestedLedger == projNetNorm [flatQuery] flatLedger
        && List.sort nestedCollapsed == List.sort flatCollapsed
        && (nestedStored .== nestedCoordinates) == (flatStored .== flatCoordinates)
        && ((Not :< nestedStored) .== (Not :< nestedCoordinates))
            == ((Not :< flatStored) .== (Not :< flatCoordinates))
  where
    nestedStored      = (("a", "b"), "c") :: ((Text, Text), Text)
    flatStored        = ("a", "b", "c") :: (Text, Text, Text)
    nestedCoordinates = ((queryFirst, querySecond), queryThird)
    flatCoordinates   = (queryFirst, querySecond, queryThird)
    nestedQuery       = HatNot :< nestedCoordinates
    flatQuery         = HatNot :< flatCoordinates
    nestedLedger :: Alg Double (HatBase ((Text, Text), Text))
    nestedLedger = notValue   .@ Not :< nestedStored
                .+ hatValue   .@ Hat :< nestedStored
                .+ otherValue .@ Not :< (("x", "y"), "z")
    flatLedger :: Alg Double (HatBase (Text, Text, Text))
    flatLedger = notValue   .@ Not :< flatStored
              .+ hatValue   .@ Hat :< flatStored
              .+ otherValue .@ Not :< ("x", "y", "z")
    nestedCollapsed :: [(Double, Hat, (Text, Text, Text))]
    nestedCollapsed = foldEntries
        (\result value (side :< ((first, second), third)) ->
            (value, side, (first, second, third)) : result) []
        (collapseEntries [nestedQuery] (const (("a", "b"), "d")) nestedLedger)
    flatCollapsed :: [(Double, Hat, (Text, Text, Text))]
    flatCollapsed = foldEntries
        (\result value (side :< coordinates) -> (value, side, coordinates) : result) []
        (collapseEntries [flatQuery] (const ("a", "b", "d")) flatLedger)

-- | The reported inner-only wildcard selects gross 13 and net 7, and
-- collapseEntries adds reversal and replacement postings with total norm 26.
testNestedWildcard :: IO ()
testNestedWildcard = do
    let ledger = 10 .@ Not :< (("a", "b"), "c")
              .+  3 .@ Hat :< (("a", "b"), "c")
              .+  2 .@ Not :< (("x", "y"), "z")
              :: Alg Double (HatBase ((Text, Text), Text))
        query = HatNot :< (("a", ""), "c")
    assertTest "nested wildcard: reported projection and collapse values"
        (norm (proj [query] ledger) == 13
            && projNetNorm [query] ledger == 7
            && norm (collapseEntries [query] (const (("a", "b"), "d")) ledger) == 26)
    assertTest "nested wildcard: reported case agrees with flat coordinates"
        (propNestedFlattening ("a", "", "c") (10, 3, 2))

runTests :: IO ()
runTests = do
    testReportedTable
    testProjByAccountTitle
    testMapRewritesWildcardAxis
    testNestedWildcard
    quickProperty "nested wildcard: flattening preserves projection, collapse, and matching"
        (forAll ((,,) <$> elements ["a", ""] <*> elements ["b", ""]
                      <*> elements ["c", ""]) $ \coordinates ->
            forAll ((,,) <$> (fromIntegral <$> chooseInt (0, 1000))
                         <*> (fromIntegral <$> chooseInt (0, 1000))
                         <*> (fromIntegral <$> chooseInt (0, 1000)))
                (propNestedFlattening coordinates))
    quickProperty "proj: Liner result equals the one-way reference (ledger wildcards)"
        (forAll genProjCase propLedger)
    quickProperty "proj: single-element result equals the one-way reference"
        (forAll genProjCase propSingle)
    quickProperty "projNetNorm: equals the one-way reference for both shapes"
        (forAll genProjCase propNetNorm)
    quickProperty "proj: concrete ledgers keep the old symmetric result"
        (forAll genConcreteCase propConcreteRegression)