exchangealgebra 0.4.0.0 → 0.4.1.0
raw patch · 7 files changed
+207/−24 lines, 7 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
+ ExchangeAlgebra.Algebra: nearlyEqScaled :: HatVal n => n -> n -> Bool
Files
- ChangeLog.md +35/−0
- README.md +47/−3
- exchangealgebra.cabal +2/−2
- src/ExchangeAlgebra/Algebra.hs +38/−12
- src/ExchangeAlgebra/Journal.hs +24/−7
- src/ExchangeAlgebra/Simulate.hs +11/−0
- test/Spec.hs +50/−0
ChangeLog.md view
@@ -1,5 +1,40 @@ # Changelog for ExchangeAlgebra +## 0.4.1.0 - 2026-06-06++### Added+- `nearlyEqScaled` — scale-aware approximate equality+ (`|x - y| <= atol + rtol * max |x| |y|`, with `atol = 1e-13`, `rtol = 1e-12`).++### Fixed+- `bases` ignored the `_notSide` Seq and iterated `_hatSide` twice+ (`src/ExchangeAlgebra/Algebra.hs`, regression existed since the introduction of the+ HashMap-backed `Liner` representation). The previous implementation produced+ `length (bases x) != length (vals x)` whenever the Hat-side and Not-side Seqs of any+ base had different lengths, dropped entries whose Hat-side Seq was empty, and+ duplicated Hat-side entries with the wrong label. A 1-character fix+ (`hs` → `ns` in the outer fold) restores the intended behaviour, covered by a+ new `testBasesNotSideRegression` unit test.++### Changed+- Reconciliation comparators (`bar` / `(.-)`, `balance`, `diffRL`, `barNormPair`) now use a+ scale-aware tolerance instead of a fixed `1e-13` absolute tolerance. **Behaviour change:**+ near-balanced values at large magnitudes no longer retain floating-point rounding noise as a+ spurious residual, and `balance` / `diffRL` no longer use exact `==` / `>` comparisons.+- `isNearlyNum` returns `False` (instead of raising `error`) when a NaN makes every ordered+ comparison fail, so non-finite inputs can no longer crash the check.++### Internal+- `Journal.toAlg` avoids materializing an intermediate `Map.elems base` list.+- Removed an unused `Control.Parallel.Strategies` import from `ExchangeAlgebra.Algebra`.++### Documentation+- Documented the spill-to-disk path (`runSimulationWithSpill` / `runScenariosWithSpill` with+ `SpillDeletePolicy`) as the recommended approach for constant-memory large-scale simulations,+ in the README and the `ExchangeAlgebra.Simulate` module header (example: `sim2`).+- Added the original axiomatic source (Deguchi & Nakano, *Axiomatic Foundations of Vector+ Accounting*, Systems Research 3(1):31–39, 1986) to the README References section.+ ## 0.4.0.0 - 2026-05-18 First release prepared for Hackage publication.
README.md view
@@ -83,9 +83,9 @@ # then edit freely as a starting point ``` -A Git sparse-checkout with a standalone `examples/stack.yaml` is planned so that-`cd examples && stack build` works after a sparse-clone; this will land together-with the first Hackage release.+A standalone `examples/stack.yaml` (pinned to the Hackage release) is checked in,+so `cd examples && stack build` works after a sparse-clone or `degit` without+needing the rest of the repository. ## Module Overview @@ -154,6 +154,37 @@ type or `EA.proj`, for example). **Using Journal as the unqualified umbrella and pulling the Algebra layer in as `EA` qualified is the idiomatic style for this library.** +## Large-scale simulations (constant memory)++`runSimulation` keeps the entire world state in memory for the whole run, so peak memory+grows with the number of terms. For long horizons or large agent populations, use the+**spill-to-disk** variants instead — they periodically write ledger chunks to disk and evict+old terms, so peak memory becomes **independent of the number of terms**:++```haskell+import qualified ExchangeAlgebra.Simulate as ES++opts :: ES.SpillOptions Term World Transaction+opts = (ES.mkBinarySpillOptions everyNTerms spillPath extractPayload)+ -- keep only the most recent N terms resident; older terms live on disk+ { ES.spillDeletePolicy = ES.KeepRecentTerms 2 }++main = do+ _world <- ES.runSimulationWithSpill opts gen env+ -- restore spilled chunks later with ES.readBinarySpillFile / restoreJournalFromBinarySpill+ pure ()+```++- `ES.runSimulationWithSpill` / `ES.runScenariosWithSpill` are drop-in replacements for+ `runSimulation` / `runScenarios` that add periodic spilling.+- `ES.SpillDeletePolicy` bounds resident memory: `KeepRecentTerms n` keeps a sliding window,+ `DeleteSpilledChunk` evicts each chunk right after it is written, `NoDelete` keeps everything.+- Restore spilled data with `ES.readBinarySpillFile` (binary format) or the+ `restoreJournalFromBinarySpill` helper.++A runnable end-to-end example (multi-scenario run with binary spill, `KeepRecentTerms`, and+restore) is `examples/basic/simulateEx2.hs` (the `sim2` executable).+ ## A note on visualization `ExchangeAlgebra.Simulate.Visualize` provides Chart-based PNG rendering, but **we recommend@@ -217,6 +248,7 @@ stack build stack exec -- ebex1 # Introductory bookkeeping example stack exec -- sim1 # 100-term simulation (+ Python visualization)+stack exec -- sim2 # spill-to-disk simulation (constant memory, binary spill + restore) stack exec -- ripple # 10-agent ripple-effect simulation stack exec -- cge # CGE model ```@@ -247,6 +279,18 @@ plain-text forms via GitHub's "Cite this repository" button. ## References++- Hiroshi Deguchi and Bunpei Nakano.+ *Axiomatic Foundations of Vector Accounting.*+ Systems Research, Vol. 3, No. 1, pp. 31–39, 1986.+ Pergamon Press.+ DOI: [10.1002/sres.3850030105](https://doi.org/10.1002/sres.3850030105)++ The axiomatic origin of Exchange Algebra. This paper formalises double-entry+ bookkeeping as an accounting vector space over the extended basis+ `Γ = Λ ∪ Λ̂` (account titles and their dual hats), introduces the five+ transaction axioms, and derives the debit/credit partition and the balance+ principle (`|y_L| = |y_R|`) purely algebraically. - Hiroshi Deguchi. *Economics as an Agent-Based Complex System: Toward Agent-Based Social Systems Sciences.* Springer, 2004.
exchangealgebra.cabal view
@@ -4,10 +4,10 @@ -- -- see: https://github.com/sol/hpack ----- hash: b82362d388c7bd4bf8ed038200e10484428d995a6bab1ebd91c900e9f8c1acbe+-- hash: 7d4bb7802873f6ce2b4888ce4717621e5ccc4400b73ebc12b9d4ea69f0e994b5 name: exchangealgebra-version: 0.4.0.0+version: 0.4.1.0 synopsis: Exchange Algebra for bookkeeping and economic simulation description: Please see the README on GitHub at <https://github.com/yakagika/ExchangeAlgebra#readme> category: Accounting, Finance, Math
src/ExchangeAlgebra/Algebra.hs view
@@ -42,6 +42,7 @@ ( module ExchangeAlgebra.Algebra.Base , Nearly(..) , isNearlyNum+ , nearlyEqScaled , Redundant(..) , Exchange(..) , HatVal(..)@@ -99,7 +100,6 @@ import Algebra.Additive (C) import qualified Data.Scientific as D (Scientific, fromFloatDigits, formatScientific, FPFormat(..)) import Control.DeepSeq-import Control.Parallel.Strategies (rpar, rseq, runEval, using, Strategy, rdeepseq) import GHC.Stack (HasCallStack, callStack, prettyCallStack) import Data.Hashable import qualified Data.Binary as Binary@@ -138,13 +138,40 @@ {-# INLINE isNearlyNum #-} -- | Complexity: O(1) -- Assumes primitive numeric operations and comparisons are constant time.+--+-- NOTE: this is an /absolute/-tolerance test (@|x - y| <= |t|@); it does not+-- scale with magnitude. For large values, rounding error easily exceeds a small+-- fixed @t@, while for small values it can swallow a real residual. Internal+-- accounting reconciliation uses 'nearlyEqScaled' instead. The final guard+-- returns 'False' (was: 'error') when a NaN makes every ordered comparison fail,+-- so a non-finite input can no longer crash the check. isNearlyNum :: (Show a, Num a, Ord a) => a -> a -> a -> Bool isNearlyNum x y t | x == y = True | x > y = abs (x - y) <= abs t | x < y = abs (y - x) <= abs t- | otherwise = error $ "on isNearlyNum: " ++ show x ++ ", " ++ show y+ | otherwise = False -- NaN: not nearly-equal to anything +{-# INLINE nearlyEqScaled #-}+-- | Scale-aware approximate equality for accounting reconciliation:+--+-- @|x - y| <= atol + rtol * max |x| |y|@, with @atol = 1e-13@, @rtol = 1e-12@.+--+-- The absolute floor @atol@ handles values near zero; the relative term @rtol@+-- lets the threshold track magnitude, so the test stays meaningful for large+-- balances (where a fixed @1e-13@ was far too strict and retained pure rounding+-- noise as a spurious residual). Returns 'False' if either argument is a+-- non-finite error value (NaN/Inf), so error values never read as nearly equal.+--+-- Complexity: O(1)+nearlyEqScaled :: (HatVal n) => n -> n -> Bool+nearlyEqScaled x y+ | isErrorValue x || isErrorValue y = False+ | otherwise = abs (x - y) <= atol + rtol * max (abs x) (abs y)+ where+ atol = 1e-13+ rtol = 1e-12+ ------------------------------------------------------------ -- * Algebra ------------------------------------------------------------@@ -752,7 +779,7 @@ f p@(Pair hs ns) = let !h = Foldable.foldl' (+) 0 hs !n = Foldable.foldl' (+) 0 ns- in case isNearlyNum h n 1e-13 of -- precision 13 digits+ in case nearlyEqScaled h n of -- scale-aware tolerance (WI-11) True -> Nothing False -> case (Seq.length hs, Seq.length ns) of -- Already in canonical form: singleton on winning side, empty on other@@ -795,14 +822,13 @@ -- | filter Minus Stock decM xs = filter (\x -> x /= Zero && (not. isHat. _hatBase) x) xs - -- | check Credit Debit balance- balance xs | (norm . decR) xs == (norm . decL) xs = True- | otherwise = False+ -- | check Credit Debit balance (scale-aware tolerance, WI-12)+ balance xs = nearlyEqScaled ((norm . decR) xs) ((norm . decL) xs) - -- |- diffRL xs | r > l = (Credit, r - l)- | l > r = (Debit, l -r)- | otherwise = (Side,0)+ -- | (scale-aware tolerance, WI-12); near-equal sides report (Side, 0)+ diffRL xs | nearlyEqScaled r l = (Side, 0)+ | r > l = (Credit, r - l)+ | otherwise = (Debit, l - r) where r = (norm . decR) xs l = (norm . decL) xs@@ -839,7 +865,7 @@ where f :: (HatVal v, HatBaseClass b) => [b] -> BasePart b -> Pair v -> [b] f xs b (Pair {_hatSide = hs, _notSide = ns})- = Foldable.foldl' (g Not b) (Foldable.foldl' (g Hat b) xs hs) hs+ = Foldable.foldl' (g Not b) (Foldable.foldl' (g Hat b) xs hs) ns g :: (HatVal v, HatBaseClass b) => Hat -> BasePart b -> [b] -> v -> [b] g h b ys v = (merge h b):ys@@ -1287,7 +1313,7 @@ barNormPair (Pair hs ns) = let !h = Foldable.foldl' (+) 0 hs !n = Foldable.foldl' (+) 0 ns- in if isNearlyNum h n 1e-13+ in if nearlyEqScaled h n then 0 else if h > n then h - n else n - h
src/ExchangeAlgebra/Journal.hs view
@@ -415,14 +415,13 @@ decP xs = map (EA.filter (\x -> x /= EA.Zero && (isHat . EA._hatBase) x)) xs decM xs = map (EA.filter (\x -> x /= EA.Zero && (not . isHat . EA._hatBase) x)) xs - balance xs- | (norm . decR) xs == (norm . decL) xs = True- | otherwise = False+ -- scale-aware tolerance (WI-12), consistent with Alg's Exchange instance+ balance xs = EA.nearlyEqScaled ((norm . decR) xs) ((norm . decL) xs) diffRL xs- | r > l = (Credit, r - l)- | l > r = (Debit, l - r)- | otherwise = (Side, 0)+ | EA.nearlyEqScaled r l = (Side, 0)+ | r > l = (Credit, r - l)+ | otherwise = (Debit, l - r) where r = (norm . decR) xs l = (norm . decL) xs@@ -434,6 +433,14 @@ -- >>> x = [(1.00:@Hat:<Cash .| z) | z <- ["Loan Payment","Purchace Apple"]] :: [Test] -- >>> fromList x -- 1.00:@Hat:<Cash.|"Purchace Apple" .+ 1.00:@Hat:<Cash.|"Loan Payment"+--+-- NOTE: kept as @foldr (.+) mempty@ deliberately. A strict @foldl'@ merge was+-- tried (plan WI-1) but it changes the accumulation order. This is a redundant+-- algebra that preserves same-base postings as an ordered sequence (audit trail),+-- and 'Double' addition is non-associative, so reordering shifts the last-ULP+-- result of 'norm' and breaks exact-value tests (doctest here + sim1). Reordering+-- is an observable behaviour change, not a transparent optimization.+-- See plans/in-progress/LAZY_EVAL_AUDIT.md (WI-1) for the safe redesign. fromList :: (HatVal v, HatBaseClass b, Note n) => [Journal n v b] -> Journal n v b fromList = foldr (.+) mempty@@ -527,6 +534,13 @@ -- | Summation in a monadic context. Applies a monadic function to each element and mconcats the results. -- -- Complexity: O(|xs| * cost(f))+--+-- NOTE: kept as @mconcat <$> forM xs f@ deliberately. A strict @foldM@ left fold+-- was tried (plan WI-3) but it changes the '<>' association order, which for+-- 'Alg'/'Journal' reorders the audit-trail sequence and (via non-associative+-- 'Double' addition) shifts 'norm' results. Although the 'Monoid' laws make the+-- value equal in exact arithmetic, it is observably different under floating point.+-- See plans/in-progress/LAZY_EVAL_AUDIT.md (WI-3). sigmaM :: (Monoid m, Monad m0) => [a] -> (a -> m0 m) -> m0 m sigmaM xs f = mconcat <$> CM.forM xs f @@ -537,7 +551,10 @@ toAlg :: (HatVal v, HatBaseClass b, Note n) => Journal n v b -> Alg v b toAlg (Journal base delta _ _ _) =- EA.unionsMerge (Map.elems base ++ Map.elems delta)+ -- Fold base's elements directly onto delta's element list instead of+ -- @Map.elems base ++ Map.elems delta@, which avoids materializing the+ -- separate @Map.elems base@ list and the @(++)@ traversal.+ EA.unionsMerge (Map.foldr (:) (Map.elems delta) base) ------------------------------------------------------------------ -- | Apply function f to the entry of each Note in the Journal.
src/ExchangeAlgebra/Simulate.hs view
@@ -19,6 +19,17 @@ <https://repository.kulib.kyoto-u.ac.jp/dspace/bitstream/2433/82987/1/0809-7.pdf> + == Large-scale runs (constant memory)++ 'runSimulation' keeps the whole world state resident for the entire run, so+ peak memory grows with the number of terms. For long horizons or large agent+ populations, prefer the spill-to-disk variants 'runSimulationWithSpill' /+ 'runScenariosWithSpill': they periodically write ledger chunks to disk and,+ with a 'SpillDeletePolicy' (@'KeepRecentTerms' n@ for a sliding window, or+ 'DeleteSpilledChunk'), evict old terms so peak memory is independent of the+ term count. Spilled data is restored with 'readBinarySpillFile'. A runnable+ example is @examples\/basic\/simulateEx2.hs@ (the @sim2@ executable).+ == Application note The design of this module — the accounting state space as the minimal
test/Spec.hs view
@@ -117,6 +117,33 @@ assertNear "Journal.projWithNoteNorm (selected notes)" expected1 actual1 assertNear "Journal.projWithNoteNorm (plank wildcard)" expected2 actual2 +-- | Regression test for the `bases` typo bug.+--+-- Before the fix at Algebra.hs:868, `bases` ignored the `_notSide` Seq and+-- iterated `_hatSide` twice (with `Hat` and `Not` labels). As a result,+-- `length (bases x) != length (vals x)` whenever Hat/Not Seq lengths differed.+--+-- This test constructs an Alg where the Hat Seq for `Yen` has length 1 and+-- the Not Seq has length 2, plus a separate basis whose Hat Seq is empty.+-- That makes the divergence detectable in both directions.+testBasesNotSideRegression :: IO ()+testBasesNotSideRegression = do+ let alg :: TestAlg+ alg = (100 :@ (Hat :< Yen)) -- Yen: hatSide = [100]+ .+ (50 :@ (Not :< Yen)) -- Yen: notSide = [50]+ .+ (30 :@ (Not :< Yen)) -- Yen: notSide = [50, 30]+ .+ (20 :@ (Not :< Amount)) -- Amount: notSide = [20], hatSide = []+ vs = EA.vals alg+ bs = EA.bases alg+ hatCount = length (L.filter isHat bs)+ notCount = length (L.filter (not . isHat) bs)+ -- vals and bases must agree on total count (one label per scalar entry)+ assertEqual "bases/vals same length (regression for hs/ns typo)"+ (length vs) (length bs)+ -- Expected: 1 Hat label (Hat:<Yen) and 3 Not labels (50:<Yen, 30:<Yen, 20:<Amount)+ assertEqual "bases Hat label count" 1 hatCount+ assertEqual "bases Not label count" 3 notCount+ testSigmaMergePath :: IO () testSigmaMergePath = do let xs = [1 .. 5 :: Int]@@ -705,6 +732,27 @@ assertEqual "CSV writeCSV empty cell" "\"\",\"x\"" (lns !! 0) removeFile path +-- | Regression tests for scale-aware numeric tolerance (WI-11/12/14).+-- These exercise large magnitudes that the previous fixed @1e-13@ absolute+-- tolerance handled incorrectly (retaining pure rounding noise as a residual);+-- small-scale behavior is unchanged. See plans LAZY_EVAL_AUDIT.md s4.6.+testNumericToleranceScaleAware :: IO ()+testNumericToleranceScaleAware = do+ assertEqual "nearlyEqScaled: large-scale rounding treated as equal"+ True (EA.nearlyEqScaled (1e10 + 0.1 + 0.2) (1e10 + 0.3 :: Double))+ assertEqual "isNearlyNum 1e-13: large-scale rounding rejected (documents old flaw)"+ False (EA.isNearlyNum (1e10 + 0.1 + 0.2) (1e10 + 0.3) (1e-13 :: Double))+ assertEqual "nearlyEqScaled: small-scale noise treated as equal"+ True (EA.nearlyEqScaled (0.1 + 0.2) (0.3 :: Double))+ assertEqual "nearlyEqScaled: genuine residual kept (not swallowed)"+ False (EA.nearlyEqScaled (1e10 + 5.0) (1e10 :: Double))+ assertEqual "nearlyEqScaled: NaN guarded (no crash, not equal)"+ False (EA.nearlyEqScaled (0/0) (1.0 :: Double))+ let big = (1e10 :@ (Hat :< Yen)) .+ (0.1 :@ (Hat :< Yen)) .+ (0.2 :@ (Hat :< Yen))+ .+ (1e10 :@ (Not :< Yen)) .+ (0.3 :@ (Not :< Yen)) :: TestAlg+ assertEqual "bar cancels balanced large-scale element to Zero"+ True (EA.isZero ((.-) big))+ -- | Strict file read helper for tests readFileStrict :: FilePath -> IO String readFileStrict p = do@@ -721,6 +769,8 @@ testProjNormFastPath testProjWithBaseNorm testProjWithNoteNorm+ testBasesNotSideRegression+ testNumericToleranceScaleAware testSigmaMergePath testSigma2When testSigmaFromMap