exchangealgebra-0.6.0.0: src/ExchangeAlgebra/Accounting/Equivalence.hs
{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}
-- | Compare transaction maps in the Accounting layer under explicit posting
-- observations. This module uses algebra filtering and netting for callers of
-- the admission API; it does not admit or validate input. Read the policy first,
-- then use 'isEquivalentUpTo' to compare complete per-transaction maps.
--
-- Choose an observation before comparing entries.
--
-- +-----------------------------------+-----------------------+
-- | Task | Function |
-- +===================================+=======================+
-- | Compare a selected observation | 'isEquivalentUpTo' |
-- +-----------------------------------+-----------------------+
-- | Inspect exact closing differences | 'closingDifferences' |
-- +-----------------------------------+-----------------------+
-- | Test exact closing agreement | 'isClosingEquivalent' |
-- +-----------------------------------+-----------------------+
--
-- > import qualified ExchangeAlgebra.Accounting.Equivalence as Equivalence
-- > import qualified ExchangeAlgebra.Accounting.Transaction as Transaction
--
-- Supply maps of t'TxKey' to 'Entry'. Comparison does not establish admission.
module ExchangeAlgebra.Accounting.Equivalence
( -- * Observation policy
Equivalence(..)
-- * Comparison
, isEquivalentUpTo
-- * Closing comparison
, ClosingSource(..)
, ClosingDifference(..)
, closingDifferences
, isClosingEquivalent
) where
import Data.List (sort)
import qualified Data.Map.Strict as Map
import Data.Map.Strict (Map)
import qualified Data.Set as Set
import Data.Set (Set)
import ExchangeAlgebra.Algebra.Base.Representation (HatBase((:<)), Hat(..))
import ExchangeAlgebra.Algebra.Core
( Alg(_hatBase, _val)
, Redundant(bar)
, toList
)
import qualified ExchangeAlgebra.Algebra.Core as Algebra
import ExchangeAlgebra.Accounting.Exchange (ExBaseClass(whichSide), Exchange(diffRL))
import ExchangeAlgebra.Accounting.Account.Title (AccountTitles)
import ExchangeAlgebra.Accounting.Account.Registry (accountSpec)
import ExchangeAlgebra.Accounting.Account.Classification (Side(..))
import ExchangeAlgebra.Accounting.Transaction (Entry, TxKey, isBlankKey)
import ExchangeAlgebra.Algebra.Value (MoneyDecimal)
-- * Observation policy
-- | Select the transaction-level observation used for comparison.
-- 'NetAccountsInTransactions' nets only the named accounts in the named
-- transactions. 'Entry' has only an account-title base axis, so there are no
-- other base axes to retain when an account is selected. Both the transaction
-- keys and account titles use exact set membership; wildcard coordinates are
-- values, not query patterns. Unselected postings remain strict multisets.
data Equivalence
= PostingMultiset -- ^ Compare every original posting as a multiset.
| NetWithinTransaction -- ^ Apply 'bar' within each transaction.
| NetAccountsInTransactions (Set TxKey) (Set AccountTitles)
-- ^ Net only the exact selected keys and account titles.
deriving (Eq, Show)
-- * Canonical observations
-- | Split strict and netted posting multisets so one cannot cancel the other.
data PostingObservation = PostingObservation PostingMultisetValue PostingMultisetValue
deriving (Eq)
-- | Sorted Hat, concrete account-title coordinate, and exact amount triples.
type PostingMultisetValue = [(Hat, AccountTitles, MoneyDecimal)]
-- | Sort individual postings without using the representation-sensitive 'Alg' Eq.
-- 'toList' returns only atomic postings, so both selectors are defined here.
postings :: Entry -> PostingMultisetValue
postings = sort . map coordinate . toList
where
coordinate posting = case _hatBase posting of
hat :< account -> (hat, account, _val posting)
-- | Test the account coordinate without asking the potentially partial side API.
isInAccounts :: Set AccountTitles -> Entry -> Bool
isInAccounts accounts posting = case _hatBase posting of
_ :< account -> Set.member account accounts
-- | Detect a formal wildcard before applying the algebra's netting operation.
hasWildcard :: Entry -> Bool
hasWildcard = any isWildcard . toList
where
isWildcard posting = case _hatBase posting of
HatNot :< _ -> True
_ -> False
-- | Canonical observation for one entry under the selected scope.
observed :: Equivalence -> TxKey -> Entry -> PostingObservation
observed PostingMultiset _ entry =
PostingObservation (postings entry) []
observed NetWithinTransaction _ entry
| hasWildcard entry = PostingObservation (postings entry) []
| otherwise = PostingObservation (postings (bar entry)) []
observed (NetAccountsInTransactions keys accounts) key entry
| Set.notMember key keys = PostingObservation (postings entry) []
| hasWildcard selected = PostingObservation (postings entry) []
| otherwise = PostingObservation
(postings remaining)
(postings (bar selected))
where
selected = Algebra.filter (isInAccounts accounts) entry
remaining = Algebra.filter (not . isInAccounts accounts) entry
-- * Comparison
-- | Compare two maps after requiring identical transaction-key sets, including
-- keys whose entries have no postings. This is an observation, not admission:
-- callers must separately establish valid provenance, roles, and balances.
--
-- Law (equivalence relation): For finite maps with total 'Entry' values, each
-- policy is reflexive, symmetric, and transitive. The observed value is a
-- canonical sorted posting multiset per transaction, with 'bar' applied only
-- in the policy's stated scope. Comparing the resulting t'MoneyDecimal' values
-- uses exact equality. 'bar' nets each account exactly over the postings and
-- drops a residual only when it is exactly zero, so net policies are exact
-- arithmetic netting. A single atomic posting is unchanged by 'bar', including
-- tiny amounts. These laws apply to this 'Entry' instance. PostingMultiset does
-- not perform any normalization.
-- A selected entry with a wildcard Hat uses strict posting comparison because
-- the algebra's netting operation is not defined for wildcard postings.
isEquivalentUpTo :: Equivalence -> Map TxKey Entry -> Map TxKey Entry -> Bool
isEquivalentUpTo policy left right =
Map.keysSet left == Map.keysSet right
&& all sameEntry (Map.toList left)
where
sameEntry (key, entry) = case Map.lookup key right of
Just other -> observed policy key entry == observed policy key other
Nothing -> False
-- * Closing comparison
-- | The input containing an invalid closing posting or transaction key.
data ClosingSource
= Candidate
| Reference
deriving (Eq, Ord, Show)
-- | One difference between a candidate and a reference closing entry.
-- Amount pairs are always in candidate, reference order. t'Side' values in
-- totals are debit or credit sides, not Hat or Not labels.
data ClosingDifference
= TransactionOnlyInCandidate TxKey
| TransactionOnlyInReference TxKey
| BlankTransactionKey ClosingSource TxKey
| WildcardPosting ClosingSource TxKey AccountTitles
| UnclassifiedAccount ClosingSource TxKey AccountTitles
| NegativeAmount ClosingSource TxKey AccountTitles
| SideTotalDifference TxKey AccountTitles Side MoneyDecimal MoneyDecimal
| RetainedEarningsDifference TxKey (Side, MoneyDecimal) (Side, MoneyDecimal)
deriving (Eq, Show)
-- | Diagnostic kinds in their public reporting order.
data ClosingIssue
= IssueWildcard
| IssueUnclassified
| IssueNegative
deriving (Eq, Ord)
-- | Validate every posting before calling the partial 'whichSide' classifier.
-- Duplicate diagnostics for the same source, kind, and account are collapsed.
entryDiagnostics :: ClosingSource -> TxKey -> Entry -> [ClosingDifference]
entryDiagnostics source key entry =
(if isBlankKey key then [BlankTransactionKey source key] else [])
++ map toDifference (Set.toAscList (Set.fromList postingErrors))
where
postingErrors = concatMap check (toList entry)
check posting = case _hatBase posting of
hat :< account ->
[(IssueWildcard, account) | hat == HatNot]
++ [(IssueUnclassified, account) | accountSpec account == Nothing]
++ [(IssueNegative, account) | _val posting < 0]
toDifference (issue, account) = case issue of
IssueWildcard -> WildcardPosting source key account
IssueUnclassified -> UnclassifiedAccount source key account
IssueNegative -> NegativeAmount source key account
-- | Sum exact posting amounts by account and accounting side.
sideTotals :: Entry -> Map (AccountTitles, Side) MoneyDecimal
sideTotals entry = Map.fromListWith (+)
[ ((account, whichSide base), _val posting)
| posting <- toList entry
, let base@(_ :< account) = _hatBase posting
]
-- | Return the exact retained-earnings credit-minus-debit amount as side and
-- magnitude. Zero always has the credit side, including an absent account.
retainedNet :: AccountTitles -> Map (AccountTitles, Side) MoneyDecimal
-> (Side, MoneyDecimal)
retainedNet account totals
| credit >= debit = (Credit, credit - debit)
| otherwise = (Debit, debit - credit)
where
debit = Map.findWithDefault 0 (account, Debit) totals
credit = Map.findWithDefault 0 (account, Credit) totals
-- | Compare valid entries without applying 'bar', 'diffRL', or a tolerance.
entryDifferences :: AccountTitles -> TxKey -> Entry -> Entry -> [ClosingDifference]
entryDifferences retained key candidate reference =
sideDifferences ++ retainedDifference
where
candidateTotals = sideTotals candidate
referenceTotals = sideTotals reference
coordinates = Set.toAscList
(Map.keysSet candidateTotals `Set.union` Map.keysSet referenceTotals)
sideDifferences =
[ SideTotalDifference key account side candidateAmount referenceAmount
| (account, side) <- coordinates
, account /= retained
, let candidateAmount = Map.findWithDefault 0 (account, side) candidateTotals
, let referenceAmount = Map.findWithDefault 0 (account, side) referenceTotals
, candidateAmount /= referenceAmount
]
candidateNet = retainedNet retained candidateTotals
referenceNet = retainedNet retained referenceTotals
retainedDifference =
[ RetainedEarningsDifference key candidateNet referenceNet
| candidateNet /= referenceNet
]
-- | Compare closings by transaction key. Every input posting is checked first:
-- blank keys, wildcard Hat labels, unclassified accounts, and negative amounts
-- produce diagnostics, and their keys are not compared. Valid keys present in
-- only one map produce a presence difference, even when the entry is
-- 'ExchangeAlgebra.Algebra.Core.Zero'.
-- Other accounts compare exact sums per account and side. Retained earnings
-- compares only its exact credit-minus-debit net, with credit for zero.
-- Results follow ascending keys. Within a key, diagnostics follow candidate
-- then reference, and blank key, wildcard, unclassified, negative order, with
-- accounts ascending within each kind. Comparison differences follow account
-- and t'Side' order (Credit before Debit), then retained earnings.
--
-- Laws (for finite, fully valid input): comparison is reflexive; splitting a
-- posting into same-side amounts with the same sum preserves the result; and
-- retained-earnings gross and net postings with equal net amounts agree.
-- All sums use exact t'MoneyDecimal' arithmetic. Complexity: O(p log p), where
-- p is the total number of postings and transaction keys.
closingDifferences :: AccountTitles -> Map TxKey Entry -> Map TxKey Entry
-> [ClosingDifference]
closingDifferences retained candidate reference = concatMap compareKey keys
where
keys = Set.toAscList (Map.keysSet candidate `Set.union` Map.keysSet reference)
compareKey key =
let candidateEntry = Map.lookup key candidate
referenceEntry = Map.lookup key reference
diagnostics = maybe [] (entryDiagnostics Candidate key) candidateEntry
++ maybe [] (entryDiagnostics Reference key) referenceEntry
in if not (null diagnostics) then diagnostics else case (candidateEntry, referenceEntry) of
(Just left, Just right) -> entryDifferences retained key left right
(Just _, Nothing) -> [TransactionOnlyInCandidate key]
(Nothing, Just _) -> [TransactionOnlyInReference key]
(Nothing, Nothing) -> []
-- | Return whether two valid closing maps have the same exact observation.
-- Invalid input is never equivalent, including when compared with itself.
isClosingEquivalent :: AccountTitles -> Map TxKey Entry -> Map TxKey Entry -> Bool
isClosingEquivalent retained candidate reference =
null (closingDifferences retained candidate reference)