packages feed

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 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