exchangealgebra 0.4.1.2 → 0.5.0.0
raw patch · 41 files changed
+24208/−3266 lines, 41 filesdep +Decimaldep +QuickCheck
Dependencies added: Decimal, QuickCheck
Files
- ChangeLog.md +1100/−32
- README.md +198/−26
- docs/migration-0.5-derived-metrics.md +72/−0
- exchangealgebra.cabal +51/−15
- src/ExchangeAlgebra.hs +52/−3
- src/ExchangeAlgebra/Accounting/PostingPolicy.hs +77/−0
- src/ExchangeAlgebra/Algebra.hs +78/−1431
- src/ExchangeAlgebra/Algebra/Base.hs +153/−208
- src/ExchangeAlgebra/Algebra/Base/Account/JcciAliases.hs +216/−0
- src/ExchangeAlgebra/Algebra/Base/Account/Registry.hs +3940/−0
- src/ExchangeAlgebra/Algebra/Base/Account/Types.hs +102/−0
- src/ExchangeAlgebra/Algebra/Base/Element.hs +406/−206
- src/ExchangeAlgebra/Algebra/Internal.hs +2062/−0
- src/ExchangeAlgebra/Algebra/Transfer.hs +107/−125
- src/ExchangeAlgebra/Assist.hs +289/−0
- src/ExchangeAlgebra/Assist/Descriptions.hs +9/−0
- src/ExchangeAlgebra/Bookkeeping.hs +609/−0
- src/ExchangeAlgebra/Consolidation/Worksheet.hs +489/−0
- src/ExchangeAlgebra/Convert.hs +227/−0
- src/ExchangeAlgebra/Convert/Checked.hs +570/−0
- src/ExchangeAlgebra/Convert/Csv.hs +175/−0
- src/ExchangeAlgebra/Journal.hs +258/−83
- src/ExchangeAlgebra/Journal/Transfer.hs +30/−34
- src/ExchangeAlgebra/Optimize.hs +101/−0
- src/ExchangeAlgebra/Optimize/Annealing.hs +154/−0
- src/ExchangeAlgebra/Optimize/GA.hs +293/−0
- src/ExchangeAlgebra/Reporting/Group.hs +409/−0
- src/ExchangeAlgebra/Reporting/Metric.hs +182/−0
- src/ExchangeAlgebra/Reporting/Presentation.hs +788/−0
- src/ExchangeAlgebra/Simulate.hs +25/−118
- src/ExchangeAlgebra/Simulate/Lite.hs +742/−0
- src/ExchangeAlgebra/Simulate/Network.hs +1229/−0
- src/ExchangeAlgebra/Simulate/Policy.hs +278/−0
- src/ExchangeAlgebra/Simulate/Spill.hs +324/−0
- src/ExchangeAlgebra/Simulate/Visualize.hs +19/−21
- src/ExchangeAlgebra/TrialBalance/Balance.hs +96/−0
- src/ExchangeAlgebra/TrialBalance/Validation.hs +432/−0
- src/ExchangeAlgebra/Value.hs +184/−0
- src/ExchangeAlgebra/Write.hs +850/−130
- test/Spec.hs +6813/−833
- test/doctests.hs +19/−1
ChangeLog.md view
@@ -1,37 +1,1105 @@ # Changelog for ExchangeAlgebra -## 0.4.1.2 - 2026-06-11--### Fixed-- `incomeSummaryAccount` (both `ExchangeAlgebra.Algebra.Transfer` and- `ExchangeAlgebra.Journal.Transfer`) crashed with `Non-exhaustive patterns in- case` on a **balanced ledger** (credit == debit, i.e. zero net income). In that- case `diffRL` reports the wildcard `Side` constructor, which the- `case dc of { Credit -> …; Debit -> … }` did not handle. The fix adds a `Side`- branch that returns the input ledger unchanged (no `NetIncome` / `NetLoss`- posting is appended when net income is zero). Note that appending a `Zero`- posting is **not** a correct alternative for the Journal version, since it is- not an identity there. Covered by the new `testIncomeSummaryBalancedAlg` and- `testIncomeSummaryBalancedJournal` regression tests.--## 0.4.1.1 - 2026-06-07--### Fixed-- `union` (and therefore `(.+)` / `mappend` / `fromList`) misassociated a value- with the wrong base when one operand was a **zero-valued singleton**. For- `(v1:@b1) .+ (v2:@b2)` with `isZeroValue v1`, the result was `v2:@b1` — the- surviving nonzero value relabeled onto the *zero posting's* base (symmetrically- `v1:@b2` when `v2` was zero). A zero contributes nothing, so the result must be- `v2:@b2` / `v1:@b1` (the nonzero value on its **own** base).- The bug preserved `norm` (the total was unchanged) but corrupted **per-base- projection** (`proj` / `projWithBase` / `balanceBy` / stock & profit queries):- a value silently moved to a neighboring base. It was construction-order- sensitive — ledgers that build explicit `0:@base` singletons via the raw `(:@)`- constructor (e.g. sparsified input coefficients in agent-based simulations)- would, depending on accumulation order, invent a phantom posting on an adjacent- base. In the bundled simulation example this shifted a company's reported stock- by up to ~30% over 100 terms. One-line fix in `Algebra.hs union`, covered by the- new `testUnionZeroSingletonBase` unit test. (Independently confirmed root cause.)+## 0.5.0.0 - 2026-09-04++The 0.5 line was first prepared on 2026-06-07 around a selectable value type, with recovery tag `recovery/0.5.0.0-dev-2026-06-08`. Before publication, it absorbed the account registry and semantics, the Definition 7 contra amendment with netting presentation, the JCCI/EDINET vocabulary, checked-conversion posting capability, categorical phase-1 laws, the CL-SBM industrial network generator, and the audit-eval tooling.++Selectable value type: `Double` (default, fast) / `MoneyDouble` (typed fast FP)+vs an exact non-negative `Decimal` (`MoneyDecimal`) for determinism/auditability.+**Breaking** (PVP major):+`HatVal` lost its `RealFloat` superclass and gained `showValue`. See the README+"Choosing a value type" and "Migrating to 0.5.0.0" sections.++### Highlights+- Add selectable value types with `MoneyDecimal` and `MoneyDouble`, while removing the `RealFloat` superclass from `HatVal`.+- Add an exhaustive account registry and `AccountSemantics` covering roles, posting capability, and JCCI/EDINET presentation names.+- Amend Definition 7 with `isContra`, contra-aware projections, and netting presentation in `bsRows` / `plRows`.+- Make closing entries cover every Cost and Revenue account.+- Enforce posting capability by processing context in checked conversion.+- Encode `AccountTitles` Binary tags as big-endian `Word16`.+- Document the categorical phase-1 laws for `mapBasePart`, `foldEntries`, and `postFromNetBy`, pinned by property tests.+- Add trial-balance validation, consolidation-worksheet validation, JGAAP reporting transformation, and derived period-result metrics.+- Add the ordered CL-SBM `industrialNetwork` / `industrialFlows` generator and `industrialEx1`.+- Add audit-eval compatibility, scoring, checkpoint, Track S checked-loader, and generated-suite tooling in the examples.+- Improve performance with strict `Journal.fromList`, an exact-projection fast path, faster journal append, and projection-sharing trial-balance rows.+- Tighten module boundaries: `Alg`, `Journal` and `TransTable` are abstract (`Algebra.Internal` holds the representation); posting authority lives in `Accounting.PostingPolicy`, account balances in `TrialBalance.Balance`, spill codec and restore in `Simulate.Spill`; `Write` no longer depends on `Simulate`; Chart plotting sits behind the `visualize` flag; typed `mapPosting`, `replaceNotes` and `mkJournal` replace implicit contracts.++### Breaking+- **BREAKING: `Alg`, `Journal` and `TransTable` are abstract.** `Alg` exports+ only `Zero`, `(:@)`, `_val` and `_hatBase`; the multi-posting `Liner`+ representation, `Pair` and the cache fields live in the new+ `ExchangeAlgebra.Algebra.Internal` (outside the PVP contract). `Journal` is+ built with `mkJournal`, `(.|)` or `fromList`; `TransTable` with `table`,+ `(.->)` or `(|%)`. Hand-built values could desynchronise the cached axis+ indices and make wildcard projections answer wrongly without an error.+- **BREAKING: binary spill files are validated on read.** `readBinarySpillFile`+ now raises an error at the first undecodable chunk instead of silently+ truncating the remainder, and `restoreJournalFromBinarySpill` rejects+ out-of-order, overlapping, gapped, or empty chunk ranges (previously a+ partially readable or stale file was merged as if complete, dropping every+ term up to the last readable chunk). Its constraints gain `Enum t` and+ `Show t`, both already implied by `StateTime`. `Simulate.Lite` now truncates+ the spill file when a run opens it (`WriteMode` instead of `AppendMode`), so+ re-running with the same path no longer accumulates stale chunks.++- **BREAKING: checked conversion now enforces posting capability by processing+ context.** `checkedEntryIn`, `checkedEntryTextIn`, `checkedJournalIn`, and+ `certifyJournalTextIn` admit ordinary postings plus exactly the capability+ owned by `ClosingProcess`, `ConsolidationWorksheet`, or+ `EngineComputation`. The existing context-free functions now delegate to+ `OrdinaryJournal`, so they reject closing-only, consolidation-only,+ engine-generated, and non-postable coordinates with `PostingNotAllowed`.+ Unknown account text remains a vocabulary failure, while a resolved but+ context-disallowed title is a structural rejection. Algebra construction,+ balance rules, and unchecked conversion (including `Convert.Csv`) are+ unchanged.++- **BREAKING: LLM-facing account metadata now separates bookkeeping and+ reporting semantics.** `AccountSemantics` records account roles, posting+ capability, the meaning of the legacy five-way division, home-side+ semantics, and reporting eligibility for all 235 concrete titles;+ `AccountTitle` remains explicitly outside the domain. `AccountInfo` exposes+ these fields instead of the ambiguous `aiDivision` / `aiHomeSide` pair, so+ direction encodings such as `IncomeSummary = Assets` and+ `NetIncome = Cost` are no longer presented as statement classifications.+ The exchange-algebra basis, legacy division/side/PIMO behaviour, Binary+ encoding, closing, projections, and financial-statement rows are unchanged.++- **BREAKING: closing entries now cover every Cost and Revenue account.**+ `finalStockTransfer` derives its policy from the exhaustive account registry;+ previously it closed only 17 SNA-era accounts. `NetIncome` and `NetLoss`+ are permanent explicit `NoClose` overrides: their division encodes the+ P/L presentation side, so the division-derived rule would invert their+ transfer sign; the dedicated net-income transfer owns their closing.++- **BREAKING: `AccountTitles` binary tags now use Word16 big-endian encoding**+ instead of Word8. This removes the 256-constructor ceiling and rejects+ out-of-range tags through the `Get` failure channel. Journals and other+ values persisted with the old format cannot be read by 0.5.0.0.++- **BREAKING: account division semantics for contra accounts** (Definition 7+ amendment, Land 2). `AllowanceForDoubtfulAccounts` and+ `AccumulatedDepreciation` are now classified `Assets` with the new+ `ExBaseClass` method `isContra = True` (previously `Liability`). Home side+ and PIMO are both derived from `(whatDiv, isContra)`: home side =+ `defaultSide` of the division, reversed for contra; PIMO =+ `pimoFromDivision`, flipped by `pimoFlip` (PS↔MS, IN↔OUT) for contra.+ Observable invariants: `whichSide`, `whatPIMO` and `fixedCurrent` are+ unchanged for every account (the two contra accounts keep the Credit home+ side and MS); only `whatDiv` — and, in the pre-Land1 assistance API,+ `aiDivision` and descriptions — changed, for exactly these two accounts.++- **BREAKING: `(<=>)` on `AccountDivision` is now derived via+ `pimoFromDivision`**, matching Proposition 5.3.8 (Deguchi 2004; PS⇔IN,+ PS⇔MS, OUT⇔IN, OUT⇔MS). Migration table (ordered cases; every other pair+ is unchanged): `(Assets, Revenue)` False→True, `(Revenue, Assets)`+ False→True, `(Cost, Revenue)` False→True, `(Revenue, Cost)` False→True.+ Exchange checks on bases should use `whatPIMO` (contra-aware), not bare+ divisions.++- **BREAKING: real contra deduction/netting presentation (Definition 7,+ Land 3).** `bsRows` and `plRows` now render active presentation groups as+ gross rows, deduction rows, and a net row instead of placing contra assets+ in the Liability column or dropping P/L contra accounts. The shared+ `ExchangeAlgebra.Reporting.Group` module defines the five registry-backed+ groups and keeps stored and rendered magnitudes non-negative; a leading+ minus sign is introduced only when rows are rendered. Statements without a+ contra posting keep their ordinary rows, while formerly hidden abnormal+ balances are no longer included in column totals. Contra amounts exceeding their gross+ parent, multiple contra rows, absent parents, and nested groups have explicit+ test coverage. Column totals are calculated from displayed net amounts.+ The six division projections now exclude contra accounts entirely;+ select them with `projContraAssets` (Assets division) or the generic+ `projContra` (attribute-based: keeps both Hat and Not postings).++- **BREAKING: Add shared JCCI B-column names.** The 6 labels accepted by+ `parseAccountTitle` (`未払金`, `借入金`, `仮払金`, `仮受金`, `有価証券`,+ `投資有価証券`) now produce `AmbiguousAccount` with a list of candidates+ instead of resolving uniquely. Use canonical constructor names or explicitly+ select a candidate based on context.++- `whichSide` now rejects a `HatNot` (wildcard) base with an error instead of+ silently treating it as `Hat` (design-review C5): stored postings are always+ `Hat`/`Not` (same policy as `isHat`), so a wildcard reaching `whichSide`+ means a query-side value leaked into a posting-side computation. __Breaking__+ only for code that relied on the silent-`Hat` behaviour.++- The universal `instance (HatVal n) => Show (n -> n)`+ (`ExchangeAlgebra.Algebra.Transfer`) was removed (design-review C5):+ __breaking__ for code that `show`ed raw rule-list tuples. `TransTable`'s own+ `Show` still prints `<function>` without it; one doctest was adjusted.++- `ExchangeAlgebra.Simulate.Lite` export hygiene (design-review C5):+ __breaking__. `Stage` is now exported name-only (build with+ `stageFor`/`stage`/`stageOf`, read the name via `stageName`); the `GLite*`+ Generic-plumbing classes are exported name-only (their primed methods are+ internal Rep wiring — user code only names the classes in constraints).++- The `ExchangeAlgebra` umbrella no longer re-exports `ExchangeAlgebra.Simulate`:+ __breaking__ (design-review C1). The simulation engine exports very generic+ names (`copy`, `modify`, `update`, `initialize`, `normal`, `initAll`, …) that+ polluted the recommended bookkeeping entry point. Migration: add+ `import ExchangeAlgebra.Simulate` where those names are used — the module now+ follows the same "import directly" policy as `Bookkeeping`/`Simulate.Lite`/+ `Simulate.Network`/`Simulate.Policy` (documented in the umbrella Haddock).+ The bundled examples were migrated mechanically (import line only).++- `ExchangeAlgebra.Algebra.Base.Element` no longer re-exports the whole+ `Data.Hashable` and `GHC.Generics` modules: __breaking__ (design-review C1).+ Only the two names needed to define an `Element` instance remain re-exported+ (`Hashable(..)` and `Generic`); the previous module-level re-exports leaked+ their entire namespaces through `Base` → `Algebra` → the umbrella. Migration:+ import `Data.Hashable`/`GHC.Generics` directly for any other names.++- `Element` class wildcard methods (`ExchangeAlgebra.Algebra.Base.Element`):+ __breaking__. The misspelt `wiledcard` method family is renamed to the correct+ spelling, with no compatibility aliases (audit R2). Migration (旧名 → 新名):+ `wiledcard` → `wildcard`, `haveWiledcard` → `haveWildcard`,+ `isWiledcard` → `isWildcard`, `ignoreWiledcard` → `ignoreWildcard`. Any+ `instance Element` defining `wiledcard` (and overriding `haveWiledcard` /+ `isWiledcard` / `ignoreWiledcard`) must rename those method definitions; all+ call sites use the new names. The `(.#)` wildcard shorthand is unchanged.+ The bundled SICE-frozen examples were updated by mechanical identifier rename+ only (the rename is compile-following and preserves their semantics/values).++- `(.*)` (scalar product) now **rejects a negative / non-finite scalar** with an+ `error`, instead of silently producing out-of-domain (negative) postings (the+ algebra is over non-negative values; audit divergence C). The check is on the+ scalar only — `0 .* x = Zero` and non-negative scalars are unchanged, and the+ fast internal value map is preserved. Covered by `testScalarRejectsNegative`;+ the bundled `ripple`/`CGE` Double examples are unaffected (their production+ amounts stay non-negative).++- `HatVal` no longer has `RealFloat` as a superclass; it gains a `showValue ::+ n -> String` method. This lets non-floating-point value types (e.g. an exact+ `Decimal`) become `HatVal` instances. The `Double` / `NN.Double` instances render+ byte-for-byte identically to before (the old internal `showV` was inlined into+ each instance's `showValue`). `Fractional` is kept, so numeric literals still+ work for value types. Downstream code that relied on `HatVal n => RealFloat n`,+ or defined its own `HatVal` instance, must adapt (add `showValue`).++- `Journal.fromList` is now a strict left fold (`L.foldl' (.+) mempty`) instead of+ the lazy right fold (`foldr (.+) mempty`). It is `O(N)` and ~15× faster at+ N=10000 / ~40× at N=20000 in the core benchmark (the lazy right fold built a deep+ thunk that was expensive to force). The posting **multiset is preserved**; the+ only change is the accumulation order of same-note/same-base postings within one+ `Alg` sequence. That `Seq` order is observable through `Eq` / `Show` / `toAlg` /+ `Binary`, and for `Double` through the last-ULP of `norm` / `bar`. For the exact+ `MoneyDecimal` value type the order never affects `norm` / `bar` / balance. (The+ interim `fromListFast`, added during staging, was folded back into `fromList`.)++- Removed: `Journal` `_jVersion` field (`ExchangeAlgebra.Journal`): __breaking__. This+ write-only counter was never observed by any read path (audit R3/F2). The+ `Journal(..)` constructor now has one fewer field and the `_jVersion` record+ accessor is gone; the internal `mkJournal` no longer takes a version argument.+ The `Binary` instance is unaffected (it serialises via `toMap`/`fromMap` and+ never touched `_jVersion`), so the on-disk/spill format is unchanged.++- Removed: `forceBalance` (`ExchangeAlgebra.Algebra`): removed unused, unexported,+ untyped `undefined` placeholder (audit R3).++### Added+- Add `ExchangeAlgebra.TrialBalance.Balance`: one `AccountBalance` type+ (`NoBalance` / `DebitBalance` / `CreditBalance`) and one set of netting+ primitives (`balancePair` in debit-then-credit order, `netPair`,+ `combineBalances`, `balanceFor`, `balanceSide`, `balanceAmount`,+ `accountBalances`) shared by trial-balance validation, JGAAP presentation+ and the consolidation worksheet. The worksheet's separate `BalancePosition`+ type, whose pair order was credit-then-debit, is gone; its constructors are+ the shared ones re-exported, so `CW.CreditBalance` keeps working.+- Add `ExchangeAlgebra.Simulate.Spill`: spill options, chunk writers, the+ checked readers and journal restoration now live in one module that depends+ only on `Algebra` and `Journal`. `Simulate` and `Write` re-export the+ historical names unchanged; `Write` no longer imports `Simulate` and+ `Simulate.Policy` no longer imports `Write`. `Write.writeTermIO` drops its+ unused `StateTime` constraint (`Ix t` suffices).+- Add `ExchangeAlgebra.Accounting.PostingPolicy`: `ProcessingContext`,+ `postingAllowedIn` and `postingCapabilityFor` now live in the accounting+ layer; `Convert.Checked` re-exports them and `Consolidation.Worksheet` no+ longer depends on the input adapter. `Convert.Csv.splitTrim` is exported and+ shared with the `Simulate.Network` CSV readers, and+ `Convert.concreteAccountTitles` is the registry's definition re-exported.+- Add typed `mapPosting` / `mapMaybePosting` (one posting to exactly one, or+ to zero or one, with the same zero normalisation as `(.@)`), the explicit+ `Journal.replaceNotes` (left-biased whole-note replacement), and+ `Journal.Transfer.finalStockTransferAggregated`, the name that makes visible+ that the Journal-level closing folds the note axis onto the plank via `(.-)`.+ `Journal.mkJournal` is now exported as the safe constructor.+- Add `SpillReadError` / `SpillRangeIssue`, `readBinarySpillFileChecked`,+ `restoreJournalFromBinarySpillChecked` and `renderSpillReadError`, the+ `Either`-returning forms of the spill readers.++- `examples/audit-eval` second-experiment harness (audit-harness T3 / T5): the+ generator now covers five task categories (`closing`, `statements` and+ `consolidation` join the existing kinds in `gen/kinds.py`) with dual pandas /+ EA oracles, parameter-only closing adjustments (both oracles compute the+ amounts independently), voucher-id contracts in `given.transactions`, and a+ per-cell manifest with a task-bundle digest (`gen/make_manifest.py`,+ `TASK-FORMAT.md`). The runner adds a no-code-execution arm C with tool-event+ counting, `--chart-of-accounts`, `--skill v3`, a full V gate (voucher+ reconciliation plus a canonicalisation dictionary), manifest-hash checks on+ fresh runs and byte-pinned frozen v1 prompts; the scorer reports+ `posting_complete` and a three-valued outcome with explicit infra-missing+ handling. Examples-only; no library code touched.+- `examples/audit-eval/harness` documentation (audit-harness T4c): a transfer+ catalog (`CATALOG.md`, 19 of 27 library operations adopted plus two loader+ recipes), `SKILL-ea-v3.md`, the A′ named-call contract (`APRIME-CALLS.md`+ with `aprime-calls.schema.json`) and the `LoadChecked` bypass-guard design+ note. Examples-only; no library code touched.+- Add `industrialNetwork` / `industrialFlows` for deterministic ordered CL-SBM+ trade networks and exact demand-driven flows, plus the `industrialEx1`+ accounting example.++- Add a read-only `audit-eval` compatibility replay tool that pins frozen+ confirmatory inputs and compares historical and current checked-loader+ verdicts without regenerating model outputs.++- Add an opt-in `side` scoring contract to `audit-eval`. Ledger and+ trial-balance balances are compared as an actual debit/credit/zero side plus+ a non-negative amount, while the frozen signed-value `v1` contract remains+ the default for confirmatory-result reproducibility.++- Add dedicated `ConsumptionTaxRefundReceivable`, `PropertyTaxPayable`, and+ `DepositsReceivedFromOfficers` constructors for the corresponding JCCI+ level-2 A-column names. Add `AccountSpec.asLabelJa` for all 235 concrete+ titles and a complete JCCI level-2 A-column presentation-label sweep.++- Add typed, read-only reporting metrics. `PeriodResultMetric` represents one+ identity whose value is structurally `PeriodProfit`, `PeriodLoss`, or+ `PeriodBreakEven`; `GrossProfitMetric`, `OrdinaryProfitMetric`, and custom+ metric IDs are likewise separate from account-basis coordinates. Reporting+ subtotal definitions now carry this identity, profile-resolved labels, an+ explicit absent-title policy, and duplicate-identity validation.++- Add `periodResultOfAlg` / `periodResultOf`, which derive period profit or loss+ from genuine statement-classified revenue and cost coordinates without+ inserting a balancing account. After-closing trial-balance validation now+ reports residual period-result or reporting-subtotal coordinates explicitly.++- Add a JGAAP reporting transformation from validated trial balances with+ standalone/combined scope, reciprocal elimination, maturity allocation,+ materiality and contra policy, profile labels, auditable subtotals, and final+ debit-credit reconciliation.++- Add a trial-balance validation boundary with explicit reciprocal, temporary,+ closing-residual, abnormal-side, reclassification, and maturity-evidence+ findings, plus an opaque policy-controlled boundary for downstream reporting.++- Add a consolidation-worksheet validation boundary that preserves source and+ adjustment provenance, rejects imbalanced adjustments individually before+ aggregation, and checks net-income attribution, retained-earnings, and+ non-controlling-interest linkage across P/L, S/S, and B/S columns.++- Add pre-change goldens and a regeneration tool ahead of the 0.5.0.0+ separation of account-title semantics from the presentation layer. They+ freeze Binary bytes, registry/closing/side/PIMO, Assist metadata, projection+ membership, and legacy BS/P&L output for 232 concrete titles. Also add closed+ inventory checks for classification-related consumers to detect unintended+ differences and omitted consumers in subsequent lands.++- Add a checkpoint mechanism that splits and resumes long-running confirmatory+ runs of `audit-eval` at cell boundaries. It rejects drift in the+ task/model/backend/git surface/hash, corrupted or duplicate JSONL, and resume+ forks before execution, and supports lineage audits with independent+ verification and merge CLIs.++- Add the JCCI 2022 bookkeeping level 2 / level 3 A-column / B-column lists as+ frozen fixtures, with a coverage gate for 316 distinct normalized queries.+ 295 queries resolve uniquely, while 21 queries for permitted shared names or+ generic profit-and-loss names are rejected as `AmbiguousAccount` with frozen+ candidate sets. Also add post-vocabulary ordinal, semantics, account-info,+ and suggestion fixtures and a regeneration tool, plus synchronization tests+ for 232 titles / 5 contra accounts in the Python account mirror.++- Add 116 account titles from JCCI bookkeeping level 2 commercial bookkeeping,+ with English display names conforming to the EDINET 2026 'general commercial+ and industrial' taxonomy, to `AccountTitles` and the exhaustive account+ registry. Limit the EDINET English labels to external display names while+ preserving the compatibility and uniqueness of internal constructor IDs and+ Japanese aliases. Also explicitly mark 3 contra accounts and 3 accounts+ excluded from closing transfers as registry attributes.++- Definition 7 amendment support: `ExBaseClass.isContra` (registry-delegated+ default), `defaultSide`, `pimoFromDivision`, `pimoFlip`,+ `projContraAssets`, and `projContra`.++- `JournalCert` and `certifyJournalText` in+ `ExchangeAlgebra.Convert.Checked` add staged certification for text-originated+ journal batches. Duplicate txids and structural errors are rejected first,+ then debit/credit balance is checked from sides and amounts independently of+ account-title resolution. Balanced batches with vocabulary-only failures are+ returned as `BalancedUnresolved`, including resolved postings, unresolved+ account text/errors with 0-origin indices, and exact debit/credit totals;+ fully resolved batches produce the same `Journal` as `checkedJournal`.++- `ExchangeAlgebra.Optimize` (new subsystem): a pluggable optimization+ solver interface — the `Solver` class fixes+ `optimize :: Monad m => strategy -> Config strategy -> (Candidate strategy+ -> m Double) -> Candidate strategy -> m (Candidate strategy, Double)`, so+ objectives can run stateful simulations (`ST s`) while each solver threads+ its own seeded pure RNG (reproducible runs, no random-monad constraint on+ the objective). Ships two strategies: `ExchangeAlgebra.Optimize.Annealing`+ (simulated annealing over an arbitrary candidate type; configurable cooling+ schedule / neighbor move / acceptance rule, with `geometricCooling` and+ `metropolis` provided) and `ExchangeAlgebra.Optimize.GA` (real-coded genetic+ algorithm over `Vector Double` chromosomes; tournament selection, uniform+ crossover, per-gene Gaussian mutation, elitism, optional per-gene bounds,+ `defaultGAConfig`). Further strategies (DE/PSO/CMA-ES) are added by giving+ a new strategy type a `Solver` instance — no interface change. Objective+ values must be finite (fail-fast on NaN/Infinity) and configurations are+ validated up front; solvers never re-evaluate an already-scored candidate.+ This subsystem is a generic numeric layer independent of the+ redundant-algebra core.++- `circulant` (`ExchangeAlgebra.Simulate.Network`): a deterministic circulant+ (ring-lattice) generator where each buyer draws its `min k (N-1)` suppliers+ from the `k` nodes that follow it cyclically. Needs no `StdGen` and is built+ in `O(kN)` (never scans the `O(N²)` ordered pairs), so it stays usable at the+ `N` a market-scale run needs — unlike `kRegular` / `erdosRenyi`, whose+ generation cost is `O(kN²)` / `O(N²)`. `|E| = min k (N-1) · N` exactly, with+ no duplicate or self edges. Doctested.++- `ExchangeAlgebra.Assist` (new module): deterministic assistance layer for+ LLM-facing workflows. `describeAccount` / `allAccountInfos` expose per-account+ metadata (division and home side derived from `classifyAccountDivision` /+ `whichSide`; English/Japanese names and description machine-generated from+ the `AccountTitles` Haddock in `Element.hs` via+ `tools/gen-assist-descriptions.py`, checked in as+ `ExchangeAlgebra.Assist.Descriptions` and guarded by a drift test);+ `suggestAccounts` gives deterministic keyword lookup (no LLM); and+ `explainEntryError` / `explainJournalErrors` / `explainSourceErrors` render+ `Convert.Checked` rejections as structured one-line feedback for retry loops.++- `ExchangeAlgebra.Convert.Checked` (new module): checked construction for+ externally generated (LLM/runner) journal entries. `checkedEntry` /+ `checkedEntryText` reject empty entries, wildcard `Side`/`AccountTitle`,+ non-positive amounts and debit/credit imbalance at construction time+ (errors accumulate as `NonEmpty EntryError` with 0-origin posting indices);+ `checkedJournal` additionally pre-scans txid duplicates (`DuplicateTxId`)+ before notes are merged into the `Journal`; `reconcileSources` performs+ source-coverage reconciliation (missing / unknown txid / amount mismatch)+ between input transactions and the note-indexed journal. Balance uses the+ new `exactBalanced` (strict `==` over `norm . decL` / `norm . decR` — the+ exact-equality predicate is closed under `(.+)`, unlike the tolerance-based+ `balance`). Accepted values are built with `journalFromSides`, so the+ unchecked path's semantics are unchanged. Property tests (accept-iff,+ equivalence, submonoid closure, duplicate rejection, reconciliation) and+ doctests included.++- `stepBackWith` / `spillDeleteDecision` (`ExchangeAlgebra.Simulate`): the+ eviction-window arithmetic and the per-chunk delete decision are now pure,+ exported, unit-tested functions — the __single source__ of "which term range+ is evicted" (design-review C4). Previously the same logic lived inline in+ three places: the classic `runSimulationWithSpill` loop (`backBy` ++ `deleteRangeForChunk`), `Simulate.Lite`'s retention loop (`backByTerms`),+ and implicitly behind the `policySpillOptions` bridge. Both engines now call+ the shared functions (classic passes `prevTerm`, Lite passes `pred` — each+ engine keeps its own notion of "previous term"); behaviour is unchanged and+ the decision table is pinned by tests.++- Pure row builders for the legacy CSV writers (design-review C7): `bsRows`,+ `plRows`, `journalRows`, `accountLedgerRowsJournal` and+ `compoundTrialBalanceRows` are the pure counterparts of `writeBS` /+ `writePL` / `writeJournal` / `writeAccountOfJournal` /+ `writeCompoundTrialBalance`, which are now thin `writeCSV` wrappers around+ them — same "pure `*Rows` + IO wrapper" shape as `worksheetRows` /+ `postClosingTrialBalanceRows` / `accountLedgerRows`. Output is bit-for-bit+ unchanged (pinned regression tests were added before the refactor and pass+ unmodified after it); each new builder carries Haddock + doctests.++- `BaseClass` instance for 7-tuples (design-review C5): `Element` and+ `AxisDecompose` already had 7-tuple instances, so every Element tuple arity+ is now also usable as a base.++- `examples/audit-eval`: full 23-task pilot suite (representative tasks #4–#23+ converted to `tasks/*.json` with per-task `ea_account_map`) and a v2 task/output+ contract (`TASK-FORMAT.md`) extending the runner beyond journal-posting arrays:+ `derived` (statement figures), `findings` (audit defect detection with a fixed+ type taxonomy), `decision` (classification calls) and judgment escape-hatch+ scoring. Scoring is collision-aware (many-to-one `ea_account_map` entries are+ matched via candidate sets disambiguated by amount) and the finding-type+ vocabulary is normalized — both fixes remove name-translation measurement bias+ from arm comparisons (same rationale as the P1 account-map fix). Runner gains+ multi-seed runs (`--seed 0-4`) and an append-mode `metrics/summary.csv`.+ Follow-up (seed-0 forensics): the exact `derived` key vocabulary is now+ injected into every arm's output contract (keys are the output schema, not+ the answer — third P1-family fairness fix) and the codex timeout is raised+ to 360s (largest task returned empty at 240s).+ Contract v2.1: canonical printing is now harness-owned —+ `harness/EmitCanonical.hs` projects the postings JSON from the EA algebra+ value itself (same `whichSide` convention as the oracle, roundtrip-closed)+ and the arm-A/D minimal instruction forbids hand-assembled JSON. This pins+ the printing seam observed in pilot seed 0 (a correctly-built journal+ misprinted by model-written string code) to the measurement layer; the+ versioned SKILL treatment artifact is untouched.+ Examples-only change; no library code touched.++- `examples/audit-eval` Track S Land 3: arm Aprime now uses the+ `LoadChecked.hs` checked-loader gate with raw/rich retry feedback, arm A can+ select `SKILL-ea-v2`, and runner metadata records effective model / CLI+ versions automatically. Examples-only change; no library code touched.++- `examples/audit-eval` Track S Land 4: `gen/` now has a deterministic generator+ fairness pass, independent pandas/EA double-oracle adoption via+ `DeriveEA.hs`, explicit defect injection checks, and `make_suite.py` for+ generated suites. Examples-only change; no library code touched.++- `ExchangeAlgebra.Simulate`: the `StateSpace` methods `initT` / `lastT` are now+ exported. Their Haddock has always described them as customizable (they let an+ instance override the simulation start/end term, and `runSimulationWithSpill`+ consults them), but the export list only exposed+ `StateSpace(event, randomSeeds)`, so external instances could not actually+ override — or even name — them. Purely additive.++- `CumulativeTranslationAdjustment` `AccountTitles` constructor (為替換算調整勘定,+ classified as `Equity`) — the equity/OCI account that absorbs the foreign-currency+ translation adjustment. This is the only library primitive that foreign-currency+ translation requires: the translation itself (relabel a `CountUnit` currency axis+ and rescale the value at the exchange rate) is expressed with the existing+ `ExchangeAlgebra.Algebra.Transfer` machinery (`createTransfer`/`.->`/`|%`), and the+ CTA residual is posted from the caller, so no new translation operator is added to+ the library. Classification and exhaustiveness tests updated.++- `ExchangeAlgebra.Bookkeeping.priorPeriodErrorCorrection` — prior-period error+ correction builder (前期修正/誤謬訂正): the current-period portion is charged to+ an expense account while the prior-period portion is routed, by construction, to+ `RetainedEarnings` (IAS 8 / ASC 250-10 retrospective restatement), so the caller+ cannot misbook the prior-period amount to the income statement. Adds the+ `AmortizationExpense` `AccountTitles` constructor (無形固定資産償却費, Cost;+ distinct from tangible-asset `Depreciation`); classification and exhaustiveness+ tests updated. Balanced by construction (`norm (decL x) == norm (decR x)`).++- `ExchangeAlgebra.Bookkeeping` — equity-method closing builders+ (`equityMethodEarningsEntry`, `equityMethodDividendEntry`, `equityMethodEntries`)+ plus `equityMethodBalance`, the engine-recomputed carrying amount+ (`norm . bar . projByAccountTitle InvestmentInAssociate`), so the+ `cost + Σ(share of NI) − Σ(dividends)` roll-forward of an equity-method+ investment is correct-by-construction rather than hand-written. Adds two+ `AccountTitles`: `InvestmentInAssociate` (関係会社株式, Asset/Fixed) and+ `EquityInEarningsOfInvestee` (持分法による投資利益, Revenue). All builders are+ debit-credit balanced (`norm (decL x) == norm (decR x)`); the exhaustiveness+ and classification unit tests cover the two new titles.++- `ExchangeAlgebra.Algebra.netPairMapBy` — the pair read-out of the paper's+ class-net operator ν_κ (`def:class-net`, notes Def 2.7). For each bucket key it+ returns a non-negative `(notTotal, hatTotal)` pair, built by netting each base's+ two sides (bar-like cancellation) and summing the residual on the winning side+ — `(Σ_{n>h}(n−h), Σ_{h>n}(h−n))`. `balanceMapBy` is its signed-difference+ version: `balanceMapBy kf == fmap (\(n,h) -> n − h) . netPairMapBy kf`. Both+ components are non-negative, so `netPairMapBy` is well-behaved on non-negative+ value types (the `n − h` identity with `balanceMapBy` only holds on a signed+ type such as `Double`/`MoneyDecimal`). Single pass; redundancy is intentionally+ reduced (named, not an implicit `bar`).++- `ExchangeAlgebra.Simulate.Lite` — `stageOf` / `StageTagged`: a note-tagged BSP+ stage whose note type is fixed to `(tag, t)` by construction. Each agent emits+ a bare `Alg v b`; the runner attaches the single note `(stTag, t)` in exactly+ one place (`runStage`), eliminating the write-site `alg .| (Tag, t)`+ duplication. Because the tag and any downstream `projWithNote [(tag, t)]` are+ now checked against the same constructor, a stringly-typed note mismatch is a+ compile error rather than a silently empty projection. `stageFor` / `stage` /+ the existing `StageFor` constructor are unchanged (purely additive: `Stage` is+ now a GADT with both constructors). Multi-note stages (e.g. a closing stage+ posting both `(Closing, t)` and `(Carryover, t+1)`) keep using `stageFor`,+ which still returns a fully general `Journal`. Also adds `stageName`+ (`stName` for `StageFor`, `show stTag` for `StageTagged`) for stages that may+ be either constructor. Determinism is unaffected — the per-agent `StdGen` is+ still derived from `(specSeed, termIx, stageIx, agentIx)` only, and the note+ attachment is a pure post-transform.++- `ExchangeAlgebra.Write` — three closing-document CSV writers (and their pure+ row-builders, for testing/composition). `writeWorksheet` / `worksheetRows`+ render an 8-column worksheet (8 桁精算表): per account title, the+ trial-balance, adjustment, profit-&-loss and balance-sheet debit/credit column+ pairs. The trial-balance and adjustment columns come from the pre-adjustment+ ledger and the adjustment entries respectively; the final balance of+ `pre .+ adj` is routed by `whatDiv` (Cost/Revenue → P/L, Assets/Liability/+ Equity → B/S). The closing row is the profit/loss balancing figure+ (当期純利益/純損失) placed so each statement's debit/credit pair balances; since+ `norm` is a homomorphism the P/L and B/S imbalances coincide — that equality is+ the worksheet's own self-check (it is *not* enforced: an inconsistent input+ still emits both figures so the discrepancy stays visible).+ `writePostClosingTrialBalance` / `postClosingTrialBalanceRows` produce a+ post-closing trial balance (繰越試算表) listing only the real+ (Assets/Liability/Equity) accounts — nominal Cost/Revenue accounts are excluded+ by construction. `writeAccountOf` (previously an unimplemented stub) and the new+ `writeAccountOfJournal` / `accountLedgerRows` render the general ledger+ (総勘定元帳 / T-account): every posting on a title is listed individually in+ date order with **no aggregation**, so the redundant sequence is preserved as+ the audit trail (`writeAccountOfJournal` additionally carries each posting's+ note/摘要). The trial-balance and post-closing balances use an explicit+ `diffRL` netting (the same aggregation as `writeCompoundTrialBalance`); no+ implicit `bar`. All three have Haddock doctests on their pure row-builders and+ unit tests (worksheet self-check P/L diff == B/S diff == net income; post-closing+ TB excludes Cost/Revenue; ledger preserves posting count).++- `ExchangeAlgebra.Bookkeeping` — a new module of *closing-adjustment entry+ builders* (決算整理仕訳) at the 日商簿記 3 級 level. Unlike+ `ExchangeAlgebra.Algebra.Transfer` (which relabels existing ledger balances),+ these record fresh postings whose amounts come from outside the ledger+ (period-end inventory, estimated allowance, depreciation, tax). The base+ polymorphism is absorbed by a caller-supplied injection `type MkBase b = Hat ->+ AccountTitles -> b`. Builders: `cogsAdjustmentEntries` (cost of goods sold under+ the periodic/3-account method, 売上原価算定), `depreciationIndirectEntry` /+ `depreciationDirectEntry` (減価償却, 間接法/直接法), `allowanceReplenishmentEntry`+ (差額補充法) / `allowanceResetEntries` (洗替法) for the allowance for doubtful+ accounts, the four deferral/accrual entries (経過勘定) `prepaidExpenseEntry` /+ `unearnedRevenueEntry` / `accruedRevenueEntry` / `accruedExpenseEntry`,+ `consumptionTaxSettlementEntry` (消費税確定; a tax refund `received < paid` is+ rejected as out of 3-級 scope), and `corporateTaxInterimEntry` /+ `corporateTaxSettlementEntries` (法人税等の中間納付・確定). `reversingEntry` is a+ vocabulary alias for the Hat operation `(.^)`: it expresses the opening+ reversing entry (再振替仕訳) and the correction entry+ (訂正仕訳 = `reversingEntry wrong .+ correct`), whose redundant sequence is+ retained as an audit trail of the correction. Every builder is constructed only+ with the smart constructor `(.@)` (zero amounts normalise to `Zero`,+ negative/non-finite amounts are rejected) and is debit-credit balanced+ (`norm (decL x) == norm (decR x)`), verified as a QuickCheck property for all+ builders plus unit tests on representative lecture figures.++- `AccountTitles` — added `ReversalOfAllowanceForDoubtfulAccounts` (貸倒引当金戻入,+ `Revenue`), the credit counterpart released by the allowance builders when the+ estimate is below the current balance. Appended before the `AccountTitle`+ wildcard (existing ordinals preserved) and added to the classification+ exhaustiveness table.++- `AccountTitles` — added ~49 account titles at the 日商簿記 3 級 (elementary+ Japanese bookkeeping) level, each with an English/Japanese bilingual Haddock+ gloss: assets (e.g. `PettyCash`, `NotesReceivable`, `MerchandiseInventory`,+ `Land`, `Fixtures`, `Patent`, `CashOverShort`), liabilities (e.g.+ `AccountsPayable`, `NotesPayable`, `BankOverdraft`, `AllowanceForDoubtfulAccounts`,+ `AccumulatedDepreciation`), equity (`LegalRetainedEarnings`), costs (e.g.+ `ProvisionForDoubtfulAccounts`, `BadDebtLoss`, `PaymentFees`, `MiscellaneousLoss`,+ `CorporateIncomeTaxes`, `CommunicationExpenses`) and revenues (e.g.+ `GainOnSalesOfFixedAssets`, `RecoveryOfBadDebts`, `MiscellaneousIncome`).+ Valuation accounts (`AllowanceForDoubtfulAccounts`, `AccumulatedDepreciation`)+ are classified under `Liability` (B/S contra-asset presentation deferred to the+ Write side), keeping values non-negative and the Hat/Not structure intact. New+ constructors are appended before the `AccountTitle` wildcard so existing+ `Enum`/`Binary` ordinals are preserved. `AccountTitles` now also derives+ `Bounded`. A new exhaustiveness table test pins `(whatDiv, whichSide,+ fixedCurrent)` for every constructor and fails if a new title is left+ unclassified, guarding against `classifyAccountDivision`'s wildcard silently+ treating a title as `Assets`. This also completed the `fixedCurrent` cases for+ the pre-existing `AccountsReceivable` (now `Current`) and `Sales` (now `Other`),+ which previously had no case and would `error` on a non-exhaustive pattern.++- `ExchangeAlgebra.Simulate.Policy` — a declarative vocabulary for managing the+ size of a long simulation's audit trail, decided once when the ledger is+ built. A `LedgerPolicy` bundles three orthogonal choices: **retention**+ (`Retention` = `RetainAll` | `RetainRecent Int`, the resident-history window),+ **spill** (`spillTo :: Maybe FilePath`, an optional binary backup of evicted+ terms), and **compaction** (`Compaction` = `FullAudit` | `CompressClosedTerms`).+ `CompressClosedTerms` applies `compress` only to *closed* terms — it is+ norm- and balance-preserving (only the within-term posting sequence is+ collapsed) and the in-progress term always keeps its full audit trail; per the+ prohibition on implicit `bar`/`compress` it is reachable only through this+ named policy. The term a `Note` belongs to is fixed by the `HasTermAxis` class+ (type family `TermOf n`, method `termOf`): **the term is the last component of+ the Note**, with shipped `(e, t)` and `(e1, e2, t)` instances and a one-line+ instance for bespoke Notes. Two bridges connect the policy to the existing+ spill engine: `policySpillOptions` builds a binary `SpillOptions` for the+ classic `runSimulationWithSpill`, deriving the per-chunk extraction and the+ eviction range from `termOf` (replacing the ~20 lines of hand-written+ `filterWithNote` plumbing), and `restoreLedger` rebuilds the full ledger from a+ spill file plus the in-memory remainder (lossless with an exact value type).+ `defaultLedgerPolicy` (`RetainAll` / `Nothing` / `FullAudit`) is exactly the+ classic full-audit behaviour. **Data-loss note:** `spillTo = Nothing` together+ with `RetainRecent w` *discards* evicted terms with no backup — documented+ prominently. `Simulate.Lite` gains `runLiteWithPolicy`, an `IO` runner whose+ BSP loop is identical to `runLite` but which, at each term boundary, compresses+ closed terms (under `CompressClosedTerms`) and evicts/spills out-of-window+ terms (under `RetainRecent`); under `defaultLedgerPolicy` it is observationally+ equal to `runLite`. The existing `SpillOptions`, `runSimulationWithSpill` and+ `runLite` are unchanged.++- `ExchangeAlgebra.Simulate.Network` — separates a market's *trade relation*+ from its *technology*. `TradeNetwork k` is a sparse directed "who may supply+ whom" graph (edge `(i, j)` = supplier `i` of buyer `j`); `InputCoefficients+ k v` is the matching sparse, buyer-major coefficient table, with the invariant+ `supp(A) ⊆ edges(G)` enforced by the smart constructors. All three types are+ abstract (no exported constructors) and every read-out (`nodes`, `edges`,+ `suppliersOf`, `buyersOf`, `inputsOf`, `coefficient`, `edgeCount`) is returned+ in ascending order, so results never depend on hash-table iteration. The key+ operation `sigmaEdges g f` runs the familiar Σ notation over a network's+ *edges* (cost `O(E)`) instead of over all ordered pairs (`O(N²)`); with+ `completeNetwork` the two coincide exactly+ (`sigmaEdges (completeNetwork ks) f == sigma2When ks ks (/=) f`), so an+ all-pairs model can adopt a sparse market structure without changing its Σ.+ Includes deterministic network generators driven by an explicit `StdGen`+ (`completeNetwork`, `kRegular`, `erdosRenyi`, `scaleFree` Barabási–Albert,+ `sectorBlock` stochastic-block), random coefficient generation with an+ optional Hawkins–Simon (productivity) column-sum rescale (`randomCoefficients`+ / `CoefOptions` / `defaultCoefOptions`), long-form table and dense-matrix+ ingestion (`networkFromTable`, `coefficientsFromTable`, `fromCoefficientMatrix`),+ and a tiny dependency-free CSV reader for the fixed `from,to[,coef]` schemas+ (`parseEdgeCsv`, `parseCoefCsv`, `readEdgeCsv`, `readCoefCsv`). Smart+ constructors reject self-loops, duplicate edges, out-of-network coefficients,+ negative coefficients, and duplicate coefficients with a `NetworkError`+ (nothing is silently merged or dropped). The network types carry `Show` / `Eq`+ / `NFData`. No `Updatable` instance is provided (the `Updatable` functional+ dependency makes one impossible for the library to supply); the Haddock shows+ a three-line `UpdatableSTRef` wrapper for the classic `Simulate` engine, and+ in `Simulate.Lite` a network is simply a `carry` field.++- `ExchangeAlgebra.Simulate.Lite` — a small, additive front-end for agent-based+ bookkeeping simulations with bulk-synchronous-parallel (BSP) semantics. It+ sits beside the classic `ExchangeAlgebra.Simulate` (unchanged) and removes+ most of its boilerplate: the world is a product-only higher-kinded record+ (`HK` role tags `InitT` / `RefT s` / `SnapT`; only `deriving Generic`+ needed — no per-field `Updatable` instances, no newtype wrappers), term+ boundaries are declared per field (`carry` / `resetEach` / `updateEach`),+ the term range and seed are runtime values (`SimSpec` via `mkSimSpec`), and+ stages are pure functions from a read-only world snapshot to a `Journal`+ "message" (`stage` / `stageFor`). `runLite` drives the BSP loop: per stage+ it freezes the world once, runs every agent against that same snapshot+ (sequentially or with fixed-chunk parallelism, `Par`), merges the messages+ in one pass (via `sigma`) and commits them to the ledger; per-field rules+ fire once per term (regression-tested with a multi-stage model).+ Determinism: per-agent generators derive purely from+ (seed, term, stage, agent); with the exact `MoneyDecimal` value type the+ parallel and sequential runs agree exactly (tested), and with `MoneyDouble`+ a fixed schedule is run-to-run reproducible (tested). Note the BSP semantic+ difference from the classic engine: within a stage, agents cannot observe+ each other's same-stage postings (covered by a sentinel test). A minimal+ model is ~20 lines versus ~90 with the classic instances.++- `instance NFData (Journal n v b)` (shallow-structural, mirroring the `Alg`+ instance): forces the base/delta map spines and each contained `Alg`,+ leaving the lazily built axis indices untouched. Used by `Simulate.Lite`'s+ parallel stage evaluation; generally useful for `parMap rdeepseq` over+ journals.++- `ExchangeAlgebra.Algebra.decBy :: Ord k => (b -> Maybe k) -> Alg v b ->+ Map k (Alg v b)` — quotient decomposition (dec_κ): one-pass partition of an+ algebra along the classes induced by a classifier on the full `HatBase`.+ Each class is the redundancy-preserving restriction of the input (no `bar`,+ no aggregation); the pieces reconstruct the input and `norm` is additive over+ classes. Generalizes the Deguchi-Nakano (1986) decomposition operators+ (`decR`/`decL`/`decP`/`decM` are two-class special cases). Replaces per-class+ projection loops (`O(classes * query)`) with a single `O(entries)` fold.+ `bar` commutes with `decBy` componentwise iff the classifier does not+ distinguish Hat/Not (side-sensitive classifiers encode a semantic choice;+ covered by sentinel tests).++- `ExchangeAlgebra.Algebra.postFromNetBy :: Ord k => (b -> Maybe k) ->+ (k -> v -> Alg v b) -> Alg v b -> Alg v b` — fused classify→net→post:+ `bar` (explicit in the name), classify the netted entries, sum per class, and+ bulk-merge the generated postings. The "shortage detection → purchase+ postings" pattern becomes one call running in a single pass (the naive+ all-pairs formulation costs `O(N^2)` per-pair queries).++- `ExchangeAlgebra.Journal.decTo :: Note n' => (b -> Maybe n') -> Alg v b ->+ Journal n' v b` — quotient decomposition landing on the `Journal` (the+ library's native keyed family of algebras, paper Definition 12), keeping the+ per-key result inside the algebra vocabulary (no external `Map` in the+ result). Same redundancy/norm guarantees as `decBy`; `plank` cannot carry a+ class (such entries join the residual).++- Quotient-decomposition axiom property tests (reconstruction, norm additivity+ over classes, componentwise `bar` commutation for base-part classifiers) plus+ fixed sentinels for the side-sensitive non-commutation cases (`isHat`,+ `whichSide`) and for `mapBasePart`'s coarsen-vs-net order sensitivity.++- `bench-core` gains a `dec/*` group: per-key reporting A/B between the naive+ per-key wildcard `balanceBy` loop, `balanceMapBy`, `decBy`+`norm`, and+ `postFromNetBy` at K=200/1000 keys.++- `projCapitalStock` (`ExchangeAlgebra.Algebra`): now implemented (previously an+ `undefined` placeholder that crashed when called, audit R3). It projects the+ credit-side entries classified under the `Equity` division — the equity+ counterpart of `projCurrentLiability` / `projFixedLiability`. Includes a Haddock+ doctest.++- `ExchangeAlgebra.Convert` — a new dependency-free (Text only) input-conversion+ core that turns external `(side, account-name, amount)` postings into exchange+ algebra `Alg` terms. `parseAccountTitle` matches a name (case-, whitespace- and+ symbol-insensitively via `norm`) against the canonical English constructor names+ plus a Japanese-label / abbreviation alias table built from the+ `ExchangeAlgebra.Algebra.Base.Element` bilingual Haddock; unknown names and the+ wildcard `AccountTitle` are rejected (`UnknownAccount`), and a label shared by+ several accounts (e.g. `準備預金` for both the asset and liability side of reserve+ deposits, or `通信費` for `CommunicationExpenses`/legacy `Commutation`) is+ rejected as `AmbiguousAccount` with the candidates listed — a correct-by-+ construction guard against hallucinated or under-specified accounts. `parseSide`+ parses debit/credit; `markerForSide` derives the `Hat`/`Not` marker from the+ library's own `whichSide` (so the debit/credit rule is never duplicated);+ `postingFromSide` / `journalFromSides` build the algebra terms through the+ non-negative smart constructor `.@`. Serialization glue (JSON/XML) deliberately+ stays out of the core. Haddock doctests assert the Debit/Credit ↔ Hat/Not+ mapping explicitly.++- `ExchangeAlgebra.Convert.Csv` — a fixed-schema, dependency-light+ (Text + scientific) reader for general journal CSV: a header+ `side,account,amount` with an optional trailing `note` column, one posting per+ row. `parseJournalCsv` folds the rows into a single `Alg` term;+ `parseJournalCsvWith` takes a caller-supplied amount parser (keeping the value+ type open); `parseNotedJournalCsv` returns `(side, account, value, note)` rows+ so a caller can key a `Journal` by the note. `scientificAmount` parses a+ non-negative decimal via `Data.Scientific` exactly through `toRational` (so+ exact-decimal value types keep precision). Blank lines and `#` comment lines+ are skipped and fields are trimmed; unknown/ambiguous accounts, bad sides,+ malformed headers/rows and negative or non-numeric amounts are rejected through+ the `ConvError` channel (now extended with `MalformedCsv` and `BadAmount`).+ This is the read counterpart of the `ExchangeAlgebra.Write` ledger/report CSV+ writers (writing is not handled here). The minimal CSV splitter intentionally+ mirrors the one in `ExchangeAlgebra.Simulate.Network` (`parseEdgeCsv` etc.); a+ future refactor could share it, but the two stay independent for now to avoid a+ cross-module dependency. A QuickCheck round-trip property (render → parse is+ exact for `MoneyDecimal`) and structural-rejection unit tests are included.++- `ExchangeAlgebra.Value` with `MoneyDecimal`, an exact non-negative decimal value type+ (wraps `Data.Decimal.Decimal`) usable as the `v` in `Alg v b` / `Journal n v b`.+ Numeric literals work directly (derived `Num`/`Fractional`). Because decimal addition+ is exact and associative, `norm` / `bar` results are independent of construction order+ (unlike `Double`). Ships `bankersRound` (round-half-to-even, the unbiased financial+ default) and `ceilingRound`. New dependency: `Decimal`.++- `ExchangeAlgebra.Value.MoneyDouble`, a zero-cost `newtype` over `Double` for a fast+ IEEE-754 value type that is *typed* as money (distinct from bare `Double`+ coefficients / random draws) yet has identical speed and precision. All its+ instances (`HatVal`/`Nearly`/`Binary`/`Hashable`/`NFData`, plus the numeric+ classes) are derived from the bare-`Double` instances via `deriving newtype`, so —+ like `MoneyDecimal` — there are no orphan instances. Its subtraction is signed, so+ the negative intermediates that arise inside `bar`/`(.-)` are fine (unlike+ `Number.NonNegative.Double`, whose `(-)` errors on a negative result, making it+ unusable as a value type). Measured: `MoneyDouble` matches `MoneyDecimal` exactly+ where a result is exactly representable but diverges in the last ULP at scale, and+ runs ~5–7× faster with ~15% less memory than `MoneyDecimal` in `sim2`.++- `ExchangeAlgebra.Algebra.mapBasePart :: (BasePart b -> BasePart b') -> Alg v b ->+ Alg v b'` — relabel the base part of every element while preserving the Hat/Not+ structure and the redundancy (ordered sequences); colliding targets are+ concatenated, so `norm` is preserved. (Hat is left untouched; the type expresses+ the Hat/Not-preserving intent, per the redundant-algebra design.)++- `ExchangeAlgebra.Algebra.balanceMapBy :: (BasePart b -> Maybe k) -> Alg v b ->+ Map k v` — the bucketed form of `balanceBy`: nets each entry by a key projected+ from its `BasePart` (Not adds, Hat subtracts) in a single fold, replacing one+ wildcard projection per key. For per-key reporting (e.g. per-company stock /+ profit) this turns `O(keys * entries)` into `O(entries)`; the result equals the+ per-key `balanceBy` up to floating-point reassociation. Returns *signed* net+ balances, so use a signed value type (`Double` / `MoneyDouble` / `MoneyDecimal`).++- README gains a "Choosing a value type" section (Double vs MoneyDecimal comparison+ table, the simulation boundary pattern, the large-scale precision×memory+ trade-off, and the `fromList` ordering contract).++### Changed+- Cabal flag `visualize` (manual, default on) now guards+ `ExchangeAlgebra.Simulate.Visualize` and the `Chart` / `Chart-cairo`+ dependencies. `stack build --flag exchangealgebra:-visualize` builds the+ accounting algebra without a cairo toolchain; the default build and the+ bundled examples are unchanged.+- Resolve Japanese presentation and LLM-facing names through the cleaned+ `asLabelJa` registry field, with the JCCI `AdvancesReceived` override kept as+ `契約負債`. Mark `EquityInEarningsOfInvestee` and+ `CumulativeTranslationAdjustment` as consolidation-only and contextual.+ Replace catch-all branches in all five account-semantics classifiers with+ exhaustive constructor cases.++- Clarify that the retained `GrossProfit` and `OrdinaryProfit` constructors and+ transfer functions are historical SNA/simulation coordinates, not complete+ JGAAP subtotal definitions. Their ordinals and Binary tags remain unchanged;+ see `docs/migration-0.5-derived-metrics.md`.++- Correct `NonControllingInterests` metadata to `ConsolidationOnly`,+ `ContextualPresentation`, and `AttributionAccount`, preventing the+ consolidated balance-sheet coordinate from being posted to an individual+ entity's ordinary journal.++- Correct `HeadOfficeCurrentAccount` (`本店`) from `Assets` to `Liability` to+ match its credit balance in branch books.++- The bar-netted projection read-outs are renamed so the netting is visible in+ the name (design-review C2): `projNorm` → `projNetNorm`+ (`ExchangeAlgebra.Algebra`), `projWithBaseNorm` → `projWithBaseNetNorm` and+ `projWithNoteNorm` → `projWithNoteBaseNetNorm` (`ExchangeAlgebra.Journal` —+ the last also gains the missing `Base` in its name: it takes note AND base+ queries). The old names remain as __deprecated aliases__ (removal planned+ for 0.6), so this is warning-only, not immediately breaking.++- `ExchangeAlgebra.Convert`: the account-name normaliser is renamed+ `norm` → `normalizeTitle` (the module has never been released, so no+ migration burden). The old name collided with the core value-domain+ homomorphism `ExchangeAlgebra.Algebra.norm` — a fundamental, entirely+ unrelated operation — and would have made `norm` ambiguous in any module+ importing both unqualified.++- `Liner` (`ExchangeAlgebra.Algebra`) and `Journal` (`ExchangeAlgebra.Journal`):+ added Haddock documenting the constructor invariants (the internal axis/index+ cache fields must stay consistent with `_realg` / `_jBase`/`_jDelta`, or the+ wildcard projection / `filterByAxis` paths return wrong answers silently). Build+ values via the smart constructors (`fromList`/`fromMap`/`(.@)`/`(.|)`), not by+ applying the data constructors directly (audit R11, doc only).++- `Updatable.copy` / `Updatable.modify` default methods+ (`ExchangeAlgebra.Simulate`): the unoverridden default now raises a diagnostic+ `error "Updatable.copy: default method not overridden"` (resp. `modify`) instead+ of bare `undefined` (audit R4). Behaviour-equivalent for any instance that+ overrides them.++- `Liner` `_bpToId` / `_nextBpId` fields (`ExchangeAlgebra.Algebra`): no longer+ built by `linerFromMap` (they were never read; reserved for a dormant+ incremental-id scheme). They are now lazy `error` poison — forcing either throws+ with an explanatory message — guarded by a regression test (audit R3/F6).+ Normal projection (concrete and wildcard) never forces them.++- Examples: `marketEx1`\/`marketEx1d` (`examples/market/MarketModel.hs`): the model note+ is now an ADT `MTag` (`PlankTag | Trade | Production | Report | Closing |+ Carryover`) instead of a `String` tag, so `MNote = (MTag, Int)`. The event tag+ is written (`.| (Trade, t)`) and read (`projWithNote [(Trade, t)]`) by the same+ constructor, so a typo is now a compile error rather than a projection that+ silently matches nothing. Behaviour and all numeric results are unchanged (the+ default N=20 run still reports `norm = 8587.1027`, shortage `200.0000`). The+ test suite's Market mirror gains the same-shape `MktTag`. The `Note` class+ Haddock now documents the "prefer an ADT note over `String`" guidance with the+ `MTag` sketch. No library API change.++- Performance: `Journal` append (`.+` / `addJournal`): two redundancies removed on the hot+ commit path (audit R5 = ROAD_MAP P1b). `toMap` no longer copies when either+ the base or the delta layer is empty, and appending to an *existing* note key+ no longer re-inserts the (unchanged) note-axis index entry. Values, sequence+ order (audit trail) and the public API are unchanged; the whole test suite+ passes unmodified. Measured: append micro-benches alloc -29%/-36%+ (base-only / same-note), end-to-end simulation alloc -4.5% with a small+ wall-clock improvement.++- Performance: `Write` trial-balance documents (`writeCompoundTrialBalance`,+ `worksheetRows` / `writeWorksheet`, `postClosingTrialBalanceRows` /+ `writePostClosingTrialBalance`): aggregation changed from O(a·s) (a full+ `projByAccountTitle` scan per distinct account title) to a single+ `foldEntries` pass O(s) (audit R6). Per-title gross debit/credit totals are+ accumulated **non-negatively** (preserving the value-domain invariant) and+ netted with the same `diffRL`/tolerance rule, so the CSV output is+ byte-identical (verified by the existing doctests/unit self-checks and an+ ebex6/7/9 byte-diff). Measured: trial-balance row build a=50/s=10⁴ wall+ 13.4 ms → 1.3 ms (~90% reduction, ~10× speedup).++- Performance: Concrete (non-wildcard) `proj` / `projNorm` no longer force/build the lazy axis+ index. The module is compiled `Strict`, so passing the index to the shared+ projection helper previously forced its full construction even for an exact+ single-base lookup that only needs a `Map.lookup`. The helper is now split into+ `projExactMap` (index-free) and `projWildMap` (uses the index), and callers+ dispatch on `haveWildcard` with the index fields bound lazily — so an exact+ projection is a plain `Map.lookup` and a wildcard projection still uses the+ index. Measured ~4× faster for repeated concrete projections over a large+ ledger (more for workloads that rebuild the projected algebra per query, e.g.+ per-company stock reporting). Results are unchanged; guarded by a poison-index+ regression test.++- Examples: The bundled bookkeeping and simulation examples (`elementaryBookkeepingEx1–5`,+ `simulateEx1`, `simulateEx2`) and the test suite's simulation now use the exact+ `MoneyDecimal` ledger value type, following the boundary pattern (ABM+ parameters/coefficients/random draws stay `Double` and convert at the ledger+ boundary; reported stocks/profits convert back). The numeric-method examples+ (`ripple/*`, `CGE`) intentionally stay `Double`, demonstrating the Double side of+ the selectable value type.++### Deprecated+- `Journal.insert`: deprecated alias of `replaceNotes`. The name suggested a+ redundant addition, but it replaces whole notes (left-biased) and is not+ `(.+)`.+- `projNorm` / `projWithBaseNorm` / `projWithNoteNorm`: deprecated aliases of+ `projNetNorm` / `projWithBaseNetNorm` / `projWithNoteBaseNetNorm` (see+ Changed — the old names concealed the bar-netting). Removal planned for 0.6.++- The `HatVal NN.Double` instance (`Number.NonNegative.Double`): deprecated+ since 0.5.0.0, removal planned for 0.6 (design-review C3). Its `(-)` errors+ on the negative intermediates that the algebra's netting produces, and+ `MoneyDouble` covers the same use case safely. GHC cannot attach `DEPRECATED`+ to an instance, so the notice lives in the Haddock (class + instance), the+ README value-type section, and here. All library doctests were migrated from+ `NN.Double` to `Double`; the value-type guidance is unified across the+ umbrella Haddock, the `HatVal` class doc and the README (`Double` /+ `MoneyDouble` = fast, `MoneyDecimal` = exact\/audited).++- `rounding` (`ExchangeAlgebra.Algebra`): the `NN.Double`-only whole-unit+ ceiling helper is deprecated in favour of the explicit, value-type-appropriate+ `ExchangeAlgebra.Value.ceilingRound` / `bankersRound` (which take a+ decimal-places argument and work on `MoneyDecimal`). No internal callers; the+ function itself is unchanged.++### Fixed+- `ExchangeAlgebra.Bookkeeping.corporateTaxSettlementEntries` now rejects+ `interim > total` (a corporate-tax refund position, out of 日商簿記 3 級+ scope) with a clear error, mirroring the guard style of its sibling+ `consumptionTaxSettlementEntry`; previously the negative `unpaid` leg hit+ the generic `(.@)` error (design-review C5).++- `classifyAccountDivision` (`ExchangeAlgebra.Algebra.Base`) is now total by+ explicit enumeration: the trailing catch-all `_ = Assets` was replaced with+ explicit `Assets` cases for the 19 legacy SNA/macro asset titles (`Cash`,+ `Deposits`, …, `GovernmentService`). Previously any __future__+ `AccountTitles` constructor added without a classification would have been+ silently classified as `Assets`; now a missing case fails loudly (pattern-+ match error, caught by the Bounded/Enum exhaustiveness test). No behaviour+ change for existing titles.++- `ExchangeAlgebra.Journal`: removed two GHC `RULES` that rewrote+ `norm (projWithBase bs js)` to `projWithBaseNorm bs js` (and the+ note-base analogue). The equation is __false__ whenever a query selects both+ sides of one base (e.g. a `HatNot` wildcard, or a list containing both+ `Hat:<b` and `Not:<b`): the left-hand side is the gross norm (sums both+ sides), the right-hand side is the bar-netted read-out (verified 14.0 vs 6.0+ on a both-sided base). Had the rule fired under `-O`, optimized and+ unoptimized builds would silently disagree. The Haddock of+ `projWithBaseNorm`/`projWithNoteNorm` — which claimed the false equivalence —+ now states the correct identity+ `projWithBaseNorm bs js == norm (map bar (projWithBase bs js))`, and a+ regression test pins both the netted and the gross value.++- `incomeSummaryAccount` (both `ExchangeAlgebra.Algebra.Transfer` and+ `ExchangeAlgebra.Journal.Transfer`): no longer crashes with+ "Non-exhaustive patterns" on a balanced ledger (audit R1). When credit and+ debit totals are equal, `diffRL` reports the wildcard `Side` and net income is+ zero; the function now returns its input unchanged (balanced ledger = identity)+ instead of matching only `Credit`/`Debit`. No `NetIncome`/`NetLoss` posting is+ added in this case (appending a zero posting is not an identity for `Journal`,+ since `(.|)` builds a singleton that drives version/compaction). A+ balanced-ledger regression test now runs every closing transfer.++- `proj` and `projNorm` (`ExchangeAlgebra.Algebra`): the multi-pattern paths now+ use __set semantics__ — a query list is treated as a set, so duplicate queries+ or an exact base overlapping a wildcard query select each posting __at most+ once__ (audit R7). Previously the multi-pattern path merged per-query results+ with sequence concatenation, double counting any posting matched by more than+ one query (the single-pattern path already returned only the first match, so+ the two paths disagreed). __Results of multi-pattern `proj`/`projNorm` can now+ differ__ from prior releases when a query list contains overlapping or+ duplicate patterns. The Haddock now documents the set semantics and the+ `projNorm bs x == norm (bar (proj bs x))` identity (bar-netted norm).++- `Simulate.Lite`: under `ParChunk`, the first stage message is now forced to+ normal form in the calling thread before the remaining messages are sparked.+ Previously all sparks raced to force the shared snapshot's lazily-built index+ structures, which could abort a run with a spurious RTS `<<loop>>`+ (intermittent, scheduling-dependent — the thunk graph is acyclic and+ sequential runs are unaffected). Results are unchanged (pure values;+ determinism tests assert exact equality). The library and example executables+ are also compiled with `-feager-blackholing`, as recommended by GHC for+ programs using sparks.++### Documentation+- Document the precise validity layers of the category-theory phase 1 laws for+ `mapBasePart`, `foldEntries`, and `postFromNetBy`, and pin them with QuickCheck+ properties plus raw-order and `bar` non-commutation counterexamples. This is+ documentation/test coverage only; library behaviour and exports are unchanged.++- `plank` in the note-query lists of `projWithNote` /+ `projWithNoteBase` / `projWithNoteBaseNetNorm` is now documented as a+ note-wildcard (the projection widens to all notes) — previously an+ undocumented behaviour (design-review C5). The underscore-prefixed+ `UpdatableSTRef` methods are documented as instance wiring, not user API.++- Same-base sequence order documented as __construction-path dependent__: the+ pairwise-union path (`fromList`/`mconcat`) and the bulk-merge path+ (`sigma`/`unionsMerge`) arrange the same multiset of postings in different+ orders (verified: `[3,1,2]` vs `[3,2,1]` for three same-base postings), which+ `Eq`/`Binary` observe and `Double` observes through the last ULP of+ `norm`/`bar`. The `Redundant` class, `sigma` and `unionsMerge` Haddocks now+ warn against comparing algebras built by different routes with `==` (compare+ after `compress`/`bar`, or use `MoneyDecimal`), and a characterization test+ pins the current orders so any change to either path is deliberate. Path+ unification is deferred to the 0.5.0.0 cleanup plan.++- `projCredit` / `projDebit` Haddock: documented that for `Alg` they coincide+ with the `Exchange` methods `decR` / `decL` respectively, and removed the+ stale guidance "use this instead of decL/decR when the base contains non-Enum+ elements" (it referred to long-removed `Enum`-based class defaults — and even+ named the wrong counterpart). The dead commented-out `credit`/`debit` class+ members in `ExBaseClass` were removed.++- `Exchange` class Haddock corrected: the class-level docs of `decR`/`decL` were+ __inverted__ relative to both the implementation and the Deguchi & Nakano+ (1986, Definition 2.16) convention. `decR` extracts the __credit__ side+ (R = Right = Credit, 貸方) and `decL` the __debit__ side (L = Left = Debit,+ 借方) — the instance code was always correct; only the class Haddock (and thus+ the rendered Hackage docs) said the opposite. The `decP`/`decM` glosses were+ also reworded neutrally as the Hat-side/Not-side projections. Likewise the+ `HatBaseClass` Haddock no longer equates Hat/Not with credit/debit: the side+ of a posting is determined by the account division together with the Hat/Not+ label (`whichSide`).++- Module-reachability policy made explicit (audit R10): `ExchangeAlgebra.Bookkeeping`,+ `ExchangeAlgebra.Simulate.Lite`, `ExchangeAlgebra.Simulate.Network` and+ `ExchangeAlgebra.Simulate.Policy` are __designed to be imported directly__ and+ are intentionally not re-exported from the `ExchangeAlgebra` umbrella (re-export+ would collide names with the Algebra layer or with each other). This is now+ stated in the umbrella module's Haddock and in the README's import-patterns+ section.++- `Simulate.Lite.specLedger` Haddock now warns that the committed-ledger role is+ a model declaration conferred by the selector alone, not inferred from the+ product type: with more than one `Journal` field a wrong selector type-checks+ and fails silently (commits, eviction and the final projection all hit the+ wrong ledger). Recommends exactly one `Journal` field per world.++- README module overview now lists all 31 modules in seven layers and states+ that `Simulate.Lite` is the canonical simulation front-end while `Simulate`'s+ `Updatable` front-end is kept frozen for published examples; the+ `Simulate.Lite` module header no longer claims spill policies are out of+ scope.++### Internal+- Drop `mtl` from the library dependencies (no module in `src/` imports it);+ the test suite keeps it as a test-only dependency.+- `-Wall` warning cleanup (audit R9): removed unused imports\/bindings, silenced+ unused-match and name-shadowing warnings (mechanical, behaviour-preserving), and+ documented the remaining audited non-exhaustive patterns in place (168 → 27+ `-Wall` warnings; residual are doctest-only imports and out-of-scope categories+ such as orphans\/x-partial\/type-defaults). No public API change beyond exporting+ the previously-unused `balanceOf` (`ExchangeAlgebra.Write`) and `createTransfer`+ (`ExchangeAlgebra.Journal.Transfer`).++- Added a QuickCheck property suite (test dep `QuickCheck`) encoding the Definition 6+ redundant-algebra axioms (Hat involution, scalar on element, scalar distribution,+ norm additivity, norm homogeneity) and derived lemmas (bar idempotent, zero+ identity, associativity), plus two regression generalizations: `union` preserves+ the per-base net even for zero-valued singletons (the 0.4.1.1 bug class), and+ `MoneyDecimal` `fromList` per-base nets are construction-order independent.+ Journal-level properties: `norm` additivity, Hat preserves the note set, and+ per-(note,base) net is construction-order independent.++- Documented the Definition 6 axioms on the `Redundant` class and `norm` (Haddock+ only), cross-referencing the property suite.++- Benchmark/CI scaffolding: `simulateEx2` agent count and horizon are now+ env-configurable (`EA_LASTC`, `EA_LASTTERM`; defaults 200 / 100) for end-to-end+ scale benchmarking; added a GitHub Actions CI workflow (build + test + doctest,+ bench build-only).++## 0.4.1.2 - 2026-06-11++### Fixed+- `incomeSummaryAccount` (both `ExchangeAlgebra.Algebra.Transfer` and+ `ExchangeAlgebra.Journal.Transfer`) crashed with `Non-exhaustive patterns in+ case` on a **balanced ledger** (credit == debit, i.e. zero net income). In that+ case `diffRL` reports the wildcard `Side` constructor, which the+ `case dc of { Credit -> …; Debit -> … }` did not handle. The fix adds a `Side`+ branch that returns the input ledger unchanged (no `NetIncome` / `NetLoss`+ posting is appended when net income is zero). Note that appending a `Zero`+ posting is **not** a correct alternative for the Journal version, since it is+ not an identity there. Covered by the new `testIncomeSummaryBalancedAlg` and+ `testIncomeSummaryBalancedJournal` regression tests.++## 0.4.1.1 - 2026-06-07++### Fixed+- `union` (and therefore `(.+)` / `mappend` / `fromList`) misassociated a value+ with the wrong base when one operand was a **zero-valued singleton**. For+ `(v1:@b1) .+ (v2:@b2)` with `isZeroValue v1`, the result was `v2:@b1` — the+ surviving nonzero value relabeled onto the *zero posting's* base (symmetrically+ `v1:@b2`). A zero contributes nothing, so the result must be `v2:@b2` / `v1:@b1`+ (the nonzero value on its **own** base). The bug preserved `norm` (the total was+ unchanged) but corrupted **per-base projection** (`proj` / `projWithBase` /+ `balanceBy` / stock & profit queries): a value silently moved to a neighboring+ base. It was construction-order sensitive — ledgers that build explicit+ `0:@base` singletons via the raw `(:@)` constructor (e.g. sparsified input+ coefficients in agent-based simulations) would, depending on accumulation order,+ invent a phantom posting on an adjacent base. In the bundled simulation example+ this shifted a company's reported stock by up to ~30% over 100 terms. One-line+ fix in `Algebra.hs union`, covered by the new `testUnionZeroSingletonBase` test. ## 0.4.1.0 - 2026-06-06
README.md view
@@ -21,7 +21,7 @@ ```yaml # stack.yaml extra-deps:- - exchangealgebra-0.4.0.0+ - exchangealgebra-0.4.1.2 ``` ```yaml@@ -43,7 +43,11 @@ - GHC 9.10 (tested with Stackage `lts-24.4`) - Cabal 3.0 or later - `Chart` / `Chart-cairo` transitively require the Cairo / Pango / Freetype system libraries- (on macOS: `brew install cairo pango`)+ (on macOS: `brew install cairo pango`). If you cannot or do not want to+ install them, build without the plotting module: `stack build --flag+ exchangealgebra:-visualize` (Cabal flag `visualize`, default on). This drops+ `ExchangeAlgebra.Simulate.Visualize` and the Chart dependencies; the bundled+ `ripple*`, `sim*` and `cge` examples import that module and need the default ## How to consume this package @@ -89,45 +93,186 @@ ## Module Overview -The public modules are organised into two parallel layers.+The 35 public modules are organised into seven layers. -### Foundation layer (Algebra)+### Umbrella -|Module|Role|-|---|---|-|`ExchangeAlgebra.Algebra`|Core algebra: the `Alg` type, `HatVal` / `BaseClass`, addition `.+` / hat `.^` / bar `.-` / projection `proj`|-|`ExchangeAlgebra.Algebra.Base`|Basis classes (`BaseClass`, `HatBaseClass`, `ExBaseClass`) and basis display helpers|-|`ExchangeAlgebra.Algebra.Base.Element`|The `Element` type class (wildcard-aware basis components)|-|`ExchangeAlgebra.Algebra.Transfer`|Transfer rewriting (`TransTable`, `(.->)`, `transfer`, `finalStockTransfer`)|+The recommended entry point collects the common single-period bookkeeping API. -### Journal layer — metadata-aware basis algebra+- `ExchangeAlgebra` — re-exports `Algebra`, `Algebra.Transfer`, `Value`, and `Write`. -|Module|Role|-|---|---|-|`ExchangeAlgebra.Journal`|`Journal n v b` (journal entries carrying a `Note`), `sigmaOn`, `filterByAxis`, `projWithNote`, …|-|`ExchangeAlgebra.Journal.Transfer`|Transfer API specialised for `Journal` (thin wrappers over `Algebra.Transfer`)|+### Account vocabulary -### Simulation / IO layer+These modules define basis elements and the canonical account metadata vocabulary. -|Module|Role|-|---|---|-|`ExchangeAlgebra.Simulate`|`StateSpace`, `Updatable`, `runSimulation`, spill-to-disk, ripple-effect utilities (`rippleEffect`, `leontiefInverse`)|-|`ExchangeAlgebra.Simulate.Visualize`|Chart/Cairo based PNG rendering (see the caveats below)|-|`ExchangeAlgebra.Write`|CSV output (`writeBS`, `writePL`, `writeIOMatrix`, `writeCSV`) and binary-spill restore helpers|+- `ExchangeAlgebra.Algebra.Base.Element` — defines wildcard-aware basis components and the built-in account-title vocabulary.+- `ExchangeAlgebra.Algebra.Base.Account.Types` — defines account metadata types shared by the basis and registry.+- `ExchangeAlgebra.Algebra.Base.Account.JcciAliases` — supplies the generated JCCI 2022 account-name alias overlay.+- `ExchangeAlgebra.Algebra.Base.Account.Registry` — is the exhaustive source of canonical account classification, semantics, and descriptions.+- `ExchangeAlgebra.Algebra.Base` — defines basis classes, Hat/Not bases, account divisions, and basis display helpers. -### Umbrella entry modules+### Core algebra -|Module|Content|-|---|---|-|`ExchangeAlgebra` (top level)|Umbrella for the Algebra layer: re-exports `Algebra`, `Algebra.Transfer`, `Write`, and `Simulate`|-|`ExchangeAlgebra.Journal`|Umbrella for the Journal layer. Re-exports `Algebra.Base`, so user-defined `Element` instances are available through this import as well|+These modules implement exchange-algebra values, transformations, and selectable numeric representations. +- `ExchangeAlgebra.Algebra` — defines `Alg`, exchange-algebra operations, projections, and aggregation.+- `ExchangeAlgebra.Algebra.Internal` — the representation behind `Alg` (all constructors, cache fields, rebuild helpers); outside the PVP contract.+- `ExchangeAlgebra.Algebra.Transfer` — rewrites existing algebra balances through transfer tables and closing transfers.+- `ExchangeAlgebra.Value` — provides fast typed and exact non-negative value types for `Alg` and `Journal`.++### Journal and simulation++These modules attach notes to postings and run classic, Lite, network, policy-driven, and visual simulation workflows.++- `ExchangeAlgebra.Journal` — defines metadata-bearing `Journal` values, indexed projections, and journal aggregation.+- `ExchangeAlgebra.Journal.Transfer` — specialises the algebra transfer API to `Journal`.+- `ExchangeAlgebra.Simulate` — provides the state-space engine, classic front-end, spill support, ripple utilities, and scenario execution.+- `ExchangeAlgebra.Simulate.Spill` — provides spill chunk writers, checked readers, and ledger restoration shared by simulation and reporting layers.+- `ExchangeAlgebra.Simulate.Policy` — declares retention, spill, and compaction policy for long simulations.+- `ExchangeAlgebra.Simulate.Lite` — provides the product-HKD, BSP front-end with declarative field rules and stages.+- `ExchangeAlgebra.Simulate.Network` — separates sparse trade-network topology from input coefficients and industrial flows.+- `ExchangeAlgebra.Simulate.Visualize` — renders simulation grids and time series with Chart/Cairo.++### Which simulation API to use++`ExchangeAlgebra.Simulate.Lite` is the canonical front-end for new simulations: a `Generic`-derived world record, term-boundary field rules, stages, and `runLite` / `runLiteWithPolicy`. `ExchangeAlgebra.Simulate` is the engine underneath it (`StateTime`, spill and restore, `runSimulation`) and also exposes the older `Updatable` / `UpdatePattern` front-end used by the `simulateEx*`, `ripple*` and `cge` examples. That older front-end is kept for reproducibility of published results and receives no new features; write new models against Lite.++### Bookkeeping and reporting++These modules build adjustments, validate reporting boundaries, and produce statements and output.++- `ExchangeAlgebra.Bookkeeping` — builds period-end adjustment postings from explicit external amounts.+- `ExchangeAlgebra.Write` — formats bookkeeping reports and journals as CSV and restores binary spill files.+- `ExchangeAlgebra.Reporting.Group` — defines presentation groups and contra-account netting policy.+- `ExchangeAlgebra.TrialBalance.Balance` — the shared `AccountBalance` type and debit-then-credit netting primitives.+- `ExchangeAlgebra.TrialBalance.Validation` — reports trial-balance findings and gates reporting with explicit policies.+- `ExchangeAlgebra.Reporting.Metric` — derives typed, read-only metrics without inserting posting coordinates.+- `ExchangeAlgebra.Reporting.Presentation` — transforms validated trial balances into auditable JGAAP presentation.+- `ExchangeAlgebra.Consolidation.Worksheet` — validates consolidation adjustments and preserves worksheet provenance.++### Conversion and assistance++These modules convert external postings safely and expose deterministic account-selection guidance.++- `ExchangeAlgebra.Convert` — converts pure side, account-name, and amount data to and from algebra terms.+- `ExchangeAlgebra.Convert.Csv` — reads a fixed journal CSV schema into normalized external postings.+- `ExchangeAlgebra.Accounting.PostingPolicy` — which coordinates may be posted in which processing context (`ProcessingContext`, `postingAllowedIn`).+- `ExchangeAlgebra.Convert.Checked` — validates externally generated entries before constructing journal values.+- `ExchangeAlgebra.Assist` — provides LLM-facing account metadata, suggestions, and validation explanations.+- `ExchangeAlgebra.Assist.Descriptions` — preserves the compatibility projection of canonical account descriptions.++### Optimisation++These modules define a common solver interface and two concrete stochastic strategies.++- `ExchangeAlgebra.Optimize` — defines the strategy-agnostic, monadic `Solver` interface.+- `ExchangeAlgebra.Optimize.Annealing` — implements simulated annealing over arbitrary candidate types.+- `ExchangeAlgebra.Optimize.GA` — implements a real-coded genetic algorithm over numeric vectors.++Dependencies point downwards in this list; spill writing, checked reading and ledger restoration live in `Simulate.Spill`, which `Simulate`, `Write` and `Simulate.Policy` all import (the historical `Write` → `Simulate` and `Policy` → `Write` edges are gone).+ Importing both `ExchangeAlgebra` and `ExchangeAlgebra.Journal` unqualified causes name collisions on `sigma`, `fromList`, `map`, `filter`, and friends. See the recommended import patterns below. +## Choosing a value type++The value parameter `v` in `Alg v b` / `Journal n v b` is selectable. Pick per+workload. The bare `Double` is the default; `ExchangeAlgebra.Value` adds two+`newtype` money types — `MoneyDouble` (fast, FP) and `MoneyDecimal` (exact).++| | `Double` (default) | `MoneyDouble` (`…Value`) | `MoneyDecimal` (`…Value`) |+|---|---|---|---|+| Representation | IEEE-754 binary float | `newtype` over `Double` (identical bits) | exact non-negative base-10 decimal (wraps `Data.Decimal`) |+| Distinct money type | ✗ — a bare numeric type | ✓ — can't be silently mixed with coefficients / draws | ✓ |+| Decimal prices / ratios | approximate | approximate | **exact** |+| Tax / proration (`*`, `/`) | rounding noise | rounding noise | exact intermediates; round explicitly with `bankersRound` / `ceilingRound` |+| Construction-order independence | ✗ — addition is non-associative, so the order same-base postings are summed shifts the last-ULP of `norm` / `bar` | ✗ — identical to `Double` | ✓ — addition is exact & associative, so `norm` / `bar` / balance are identical regardless of build order |+| Determinism / auditability | not bit-reproducible across reorderings | not bit-reproducible | bit-reproducible |+| Memory per value | ~16 B | ~16 B (zero-cost wrapper) | ~40 B + indirection (~2.5×) |+| Speed | fastest | fastest (= `Double`) | slower (boxed `Integer` mantissa arithmetic) |+| Typical use | numeric methods (Leontief inverse, optimization), quick ABM | ABM where you want a *typed* money value distinct from raw `Double` | audited ledgers, bookkeeping, anywhere a total must be reproducible |++```haskell+import ExchangeAlgebra.Value (MoneyDecimal) -- or MoneyDouble+type Ledger = Journal Term MoneyDecimal (HatBase AccountTitles)+entry = 10.5 :@ Hat:<Cash .+ 2 :@ Not:<Sales -- numeric literals work directly+```++**FP vs exact, made concrete (measured).** In the bundled `sim1`, the same+stock value matches both types where it lands on an exactly-representable result+(`stock(t=1, c=6) = 30.0` under `MoneyDouble` *and* `MoneyDecimal`), but diverges+in the last ULP once FP rounding accumulates over terms+(`stock(t=100, c=6)`: `767.960563480499` under `MoneyDouble` vs+`767.9605634804993` under `MoneyDecimal`). The cost of that exactness is real and+was measured: in `sim2` (all-pairs purchases over a spill-to-disk ledger),+`MoneyDecimal` ran **~5–7× slower** in wall-clock and used **~15% more memory**+than `MoneyDouble` (consistent at `EA_LASTC`/`EA_LASTTERM` of 40/30 and 80/50 —+e.g. 23.3 s → 4.4 s and 1.42 GB → 1.19 GB at 80/50). The boxed `Integer`-mantissa+decimal arithmetic dominates the runtime, so this is the exactness ↔ speed+trade-off in concrete numbers. `MoneyDouble` is exactly as fast as+bare `Double` (a zero-cost wrapper) and runs everywhere `Double` does — its+subtraction is *signed*, so the negative intermediates that arise inside+`bar` / `(.-)` are fine. (`Number.NonNegative.Double` is **not** a usable value+type for this reason: its `(-)` *errors* on a negative result, which the algebra's+netting produces constantly. Its `HatVal` instance is **deprecated since 0.5.0.0+and will be removed in 0.6** — use `MoneyDouble` for a non-negative-by-convention+FP money type instead.)++**Boundary pattern (simulations).** Keep ABM parameters, input coefficients and+random draws as `Double`, and convert (`realToFrac`) only where a value *enters+the ledger*; convert reported stocks/profits back to `Double` for visualization.+The ledger arithmetic in between is then exact. The bundled bookkeeping and+simulation examples follow this pattern; the numeric-method examples+(`ripple/*`, `CGE`) stay `Double` because they are inherently floating-point.++**Large scale: precision × memory trade-off.** Retaining every posting is `O(n)`+regardless of value type; `MoneyDecimal` adds a constant factor (~2.5× per value box)+on top. For very large simulations weigh exactness against that overhead — or use+the spill-to-disk path (below) to keep memory constant.++**`fromList` ordering contract.** `EJ.fromList` is a strict `O(N)` left fold. It+preserves the *multiset* of postings exactly. When two postings collide on the+same note **and** base they land in one ordered sequence; that sequence's order is+observable through `Eq` / `Show` / `toAlg` / `Binary`, and for `Double` through the+last-ULP of `norm` / `bar`. For `MoneyDecimal` the order never affects+`norm` / `bar` / balance. (`Integer` is intentionally not offered — it cannot+represent the fractional relative prices the ABM work depends on.)++### Migrating to 0.5.0.0++`0.5.0.0` is a major (breaking) release. Most users need no changes — `Double`+ledgers keep working and render identically. Four things to know:++- **Custom `HatVal` instances**: `HatVal` dropped its `RealFloat` superclass and+ added `showValue :: n -> String`. If you defined your own `HatVal` instance, add+ a `showValue` (how the value prints inside `Alg`'s `Show`). If you relied on+ `HatVal n => RealFloat n` in a signature, add the `RealFloat` constraint+ explicitly. The built-in `Double` / `NN.Double` instances are unchanged.+- **`fromList` accumulation order**: now a strict left fold. The multiset is+ preserved, but if you compared `Show` / `Eq` / serialized output of a+ `fromList`-built journal byte-for-byte, the same-(note,base) sequence order may+ differ. Switch such ledgers to `MoneyDecimal` for order-independent results, or+ compare via `norm` / `bar` / `balanceBy` rather than raw structure.+- **Abstract `Alg` / `Journal` / `TransTable`**: pattern-match `Alg` on `Zero`+ and `(:@)` only and build values with `(.@)`, `(.+)`, `fromList`, `mkJournal`,+ `(.|)` and `table`. Code that constructed `Liner`, `Journal` or `TransTable`+ directly must import `ExchangeAlgebra.Algebra.Internal` (not covered by PVP).+- **Spill files are validated**: `readBinarySpillFile` and+ `restoreJournalFromBinarySpill` now raise on a truncated, stale or gapped+ file instead of merging a partial ledger; use the `...Checked` variants for an+ `Either`. `Simulate.Lite` truncates its spill file when a run opens it.++(`0.5.0.0` also includes the `union` zero-base correctness fix first shipped in+`0.4.1.1`; see the changelog.)+ ## Recommended import patterns +`Alg`, `Journal` and `TransTable` are abstract in 0.5.0.0: construct them with+`(.@)`, `(.+)`, `fromList`, `mkJournal`, `(.|)` and `table`, and match `Alg` on+`Zero` / `(:@)` only. The multi-posting representation and its cache fields live+in `ExchangeAlgebra.Algebra.Internal`, which is outside the PVP contract.+ ### Simple single-period bookkeeping ```haskell@@ -154,8 +299,25 @@ 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.** +### Modules to import directly (not re-exported by the umbrellas)++`ExchangeAlgebra.Bookkeeping`, `ExchangeAlgebra.Simulate.Lite`,+`ExchangeAlgebra.Simulate.Network`, and `ExchangeAlgebra.Simulate.Policy` are **by design+imported directly** rather than re-exported from the `ExchangeAlgebra` umbrella. They+introduce names that would otherwise collide with the Algebra layer (or with one another),+so the package keeps them off the umbrella and expects an explicit (usually qualified)+import:++```haskell+import ExchangeAlgebra+import qualified ExchangeAlgebra.Bookkeeping as BK+import qualified ExchangeAlgebra.Simulate.Lite as Lite+```+ ## Large-scale simulations (constant memory) +For new Lite simulations, set `spillTo` in a `LedgerPolicy` and run with `runLiteWithPolicy`.+ `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@@ -181,6 +343,16 @@ `DeleteSpilledChunk` evicts each chunk right after it is written, `NoDelete` keeps everything. - Restore spilled data with `ES.readBinarySpillFile` (binary format) or the `restoreJournalFromBinarySpill` helper.++For policy-driven long runs, `ExchangeAlgebra.Simulate.Policy` keeps `FullAudit` as the+default to preserve the full audit trail, but a declared `LedgerPolicy` can opt into+`CompressClosedTerms` so only closed terms are compressed; `norm` / balance stay unchanged,+the in-progress term keeps its full history, and because the choice is explicit it is not+the forbidden implicit `bar` / `compress` shortcut. Pair it with `RetainRecent 2` and+`spillTo = Just ...` to keep recent terms resident while older terms are restorable from+disk; local measurements of that pattern reduced residency by about 15x. See the+`Tuning long simulations` Haddock section in `ExchangeAlgebra.Simulate.Policy` for the+`LedgerPolicy` record-syntax example. A runnable end-to-end example (multi-scenario run with binary spill, `KeepRecentTerms`, and restore) is `examples/basic/simulateEx2.hs` (the `sim2` executable).
+ docs/migration-0.5-derived-metrics.md view
@@ -0,0 +1,72 @@+# Derived metrics in 0.5.0.0++## Summary++`NetIncome`, `NetLoss`, `GrossProfit`, and `OrdinaryProfit` remain+`AccountTitles` constructors in 0.5.0.0. Existing public transfer functions and+simulations actively use them as engine-generated intermediate coordinates,+and their `Enum` ordinals are part of the Word16 `Binary` encoding. Removing or+renaming them would both break source code and shift the tags of later account+titles.++They are not canonical financial-statement lines. New reporting code should+use `ExchangeAlgebra.Reporting.Metric` and+`ExchangeAlgebra.Reporting.Presentation`:++- `PeriodResultMetric` is one identity. Profit, loss, and break-even are value+ directions (`PeriodProfit`, `PeriodLoss`, and `PeriodBreakEven`), not three+ account classifications.+- `GrossProfitMetric` and `OrdinaryProfitMetric` identify presentation+ subtotals without adding an `AccountTitles` coordinate.+- `CustomMetric MetricId` separates a stable identity from profile-specific+ display labels.++`IncomeSummary` is different. It remains a `ClosingOnly` bookkeeping device+that may appear in closing entries and is never a statement line.++## Legacy transfer boundary++The following functions remain for compatibility:++- `incomeSummaryAccount` and `netIncomeTransfer`.+- `grossProfitTransfer`, `ordinaryProfitTransfer`, and+ `retainedEarningTransfer`.+- Their `ExchangeAlgebra.Journal.Transfer` counterparts.++The names predate the JCCI vocabulary. In particular,+`grossProfitTransfer` uses a fixed SNA/simulation list and does not include+`SalesCost` or `MerchandiseInventory`. `ordinaryProfitTransfer` likewise does+not cover the full JCCI chart. They must not be used as JGAAP gross-profit or+ordinary-profit definitions.++The output of `incomeSummaryAccount` contains a `NetIncome` or `NetLoss`+balancing coordinate. It is an intermediate closing state and cannot be sent+directly to financial-statement presentation. Finish the legacy closing+pipeline, or derive the result from the before-closing trial balance with+`periodResultOf`.++## Checked and unchecked input++`EngineGeneratedOnly` is enforced by `ExchangeAlgebra.Convert.Checked`.+Use its `OrdinaryJournal` context for external or LLM-originated postings.++The legacy unchecked paths, including `ExchangeAlgebra.Convert.Csv`,+`journalFromSides`, and direct algebra construction, intentionally remain+unchecked in 0.5.0.0. They can still construct the four legacy coordinates.+This is compatibility behavior, not authorization for ordinary posting.++## Serialization++The retained ordinals and Word16 big-endian encodings are:++| Constructor | Ordinal | Bytes |+|---|---:|---:|+| `NetIncome` | 49 | `0031` |+| `GrossProfit` | 54 | `0036` |+| `OrdinaryProfit` | 55 | `0037` |+| `NetLoss` | 64 | `0040` |+| `IncomeSummary` | 216 | `00d8` |++The test suite pins these values directly. Physical removal of the four legacy+derived coordinates is deferred to a separately planned major-version+migration, no earlier than 0.6.0.0.
exchangealgebra.cabal view
@@ -1,13 +1,11 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.39.1.+-- This file has been generated from package.yaml by hpack version 0.39.6. -- -- see: https://github.com/sol/hpack------ hash: d3a05562cbe70211374afd6c39a40fd7f8ff7e7b23b90f74716875f339171ea4 name: exchangealgebra-version: 0.4.1.2+version: 0.5.0.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@@ -22,30 +20,60 @@ extra-source-files: README.md ChangeLog.md+ docs/migration-0.5-derived-metrics.md source-repository head type: git location: https://github.com/yakagika/ExchangeAlgebra +flag visualize+ description: Build ExchangeAlgebra.Simulate.Visualize, which needs Chart, Chart-cairo and a cairo / pango toolchain. Disable with --flag exchangealgebra:-visualize to use the accounting algebra without plotting.+ manual: True+ default: True+ library exposed-modules: ExchangeAlgebra+ ExchangeAlgebra.Accounting.PostingPolicy ExchangeAlgebra.Algebra ExchangeAlgebra.Algebra.Base+ ExchangeAlgebra.Algebra.Base.Account.JcciAliases+ ExchangeAlgebra.Algebra.Base.Account.Registry+ ExchangeAlgebra.Algebra.Base.Account.Types ExchangeAlgebra.Algebra.Base.Element+ ExchangeAlgebra.Algebra.Internal ExchangeAlgebra.Algebra.Transfer+ ExchangeAlgebra.Assist+ ExchangeAlgebra.Assist.Descriptions+ ExchangeAlgebra.Bookkeeping+ ExchangeAlgebra.Consolidation.Worksheet+ ExchangeAlgebra.Convert+ ExchangeAlgebra.Convert.Checked+ ExchangeAlgebra.Convert.Csv ExchangeAlgebra.Journal ExchangeAlgebra.Journal.Transfer+ ExchangeAlgebra.Optimize+ ExchangeAlgebra.Optimize.Annealing+ ExchangeAlgebra.Optimize.GA+ ExchangeAlgebra.Reporting.Group+ ExchangeAlgebra.Reporting.Metric+ ExchangeAlgebra.Reporting.Presentation ExchangeAlgebra.Simulate- ExchangeAlgebra.Simulate.Visualize+ ExchangeAlgebra.Simulate.Lite+ ExchangeAlgebra.Simulate.Network+ ExchangeAlgebra.Simulate.Policy+ ExchangeAlgebra.Simulate.Spill+ ExchangeAlgebra.TrialBalance.Balance+ ExchangeAlgebra.TrialBalance.Validation+ ExchangeAlgebra.Value ExchangeAlgebra.Write other-modules: Paths_exchangealgebra hs-source-dirs: src+ ghc-options: -feager-blackholing build-depends:- Chart ==1.9.*- , Chart-cairo ==1.9.*+ Decimal ==0.5.* , array >=0.5.5 && <0.6 , async ==2.2.* , base >=4.17 && <5@@ -54,7 +82,6 @@ , containers >=0.6 && <0.8 , deepseq >=1.4 && <1.6 , hashable >=1.4 && <1.6- , mtl ==2.3.* , non-negative >=0.1.2 && <0.2 , numeric-prelude >=0.4.4 && <0.5 , parallel ==3.2.*@@ -66,16 +93,22 @@ , unordered-containers >=0.2.19 && <0.3 , vector ==0.13.* default-language: Haskell2010+ if flag(visualize)+ exposed-modules:+ ExchangeAlgebra.Simulate.Visualize+ build-depends:+ Chart ==1.9.*+ , Chart-cairo ==1.9.*+ else test-suite ExchangeAlgebra-doctest type: exitcode-stdio-1.0 main-is: test/doctests.hs other-modules: Paths_exchangealgebra- ghc-options: -threaded -rtsopts -with-rtsopts=-N -optl-Wl,-no_warn_duplicate_libraries+ ghc-options: -threaded -rtsopts -with-rtsopts=-N build-depends:- Chart ==1.9.*- , Chart-cairo ==1.9.*+ Decimal ==0.5.* , array >=0.5.5 && <0.6 , async ==2.2.* , base >=4.17 && <5@@ -86,7 +119,6 @@ , doctest , exchangealgebra , hashable >=1.4 && <1.6- , mtl ==2.3.* , non-negative >=0.1.2 && <0.2 , numeric-prelude >=0.4.4 && <0.5 , parallel ==3.2.*@@ -98,6 +130,8 @@ , unordered-containers >=0.2.19 && <0.3 , vector ==0.13.* default-language: Haskell2010+ if os(darwin)+ ghc-options: -optl-Wl,-no_warn_duplicate_libraries test-suite ExchangeAlgebra-test type: exitcode-stdio-1.0@@ -106,10 +140,10 @@ Paths_exchangealgebra hs-source-dirs: test- ghc-options: -threaded -rtsopts -with-rtsopts=-N -optl-Wl,-no_warn_duplicate_libraries+ ghc-options: -threaded -rtsopts -with-rtsopts=-N build-depends:- Chart ==1.9.*- , Chart-cairo ==1.9.*+ Decimal ==0.5.*+ , QuickCheck >=2.14 && <3 , array >=0.5.5 && <0.6 , async ==2.2.* , base >=4.17 && <5@@ -132,3 +166,5 @@ , unordered-containers >=0.2.19 && <0.3 , vector ==0.13.* default-language: Haskell2010+ if os(darwin)+ ghc-options: -optl-Wl,-no_warn_duplicate_libraries
src/ExchangeAlgebra.hs view
@@ -20,7 +20,7 @@ This top-level module is the Algebra-layer umbrella: it re-exports "ExchangeAlgebra.Algebra", "ExchangeAlgebra.Algebra.Transfer",- "ExchangeAlgebra.Write", and "ExchangeAlgebra.Simulate". It is the+ "ExchangeAlgebra.Write", and "ExchangeAlgebra.Value". It is the recommended entry point for simple single-period bookkeeping: > import ExchangeAlgebra@@ -29,6 +29,18 @@ > entry :: Alg Double (HatBase AccountTitles) > entry = 100 :@ Hat :< Cash .+ 100 :@ Not :< Sales + == Choosing the value type (@v@ in @Alg v b@)++ * 'Prelude.Double' — fast IEEE-754; fine for simulations and quick work.+ * 'ExchangeAlgebra.Value.MoneyDouble' — same speed, but a dedicated money+ newtype so a ledger value cannot be confused with a raw coefficient.+ * t'ExchangeAlgebra.Value.MoneyDecimal' — exact decimal; construction-order+ independent totals. Use for audited\/deterministic ledgers.+ * @Number.NonNegative.Double@ (@NN.Double@) — __deprecated__ since+ 0.5.0.0: its @(-)@ errors on negative intermediates and the instance+ will be removed in 0.6. Migrate to 'ExchangeAlgebra.Value.MoneyDouble'+ (or bare 'Prelude.Double').+ For multi-period simulation or metadata-aware journals (notes, axes), switch to the Journal layer. The two umbrellas export overlapping names (@sigma@, @fromList@, @map@, @filter@, …), so Journal-centric@@ -39,6 +51,43 @@ > import qualified ExchangeAlgebra.Journal as EJ > import qualified ExchangeAlgebra.Journal.Transfer as EJT + == Modules to import directly (not re-exported here)++ This umbrella intentionally re-exports only the Algebra-layer modules+ listed above. The following modules are __designed to be imported+ directly__ and are deliberately /not/ re-exported from @ExchangeAlgebra@,+ because they introduce names that would collide with the Algebra layer+ (or with each other) and are better used under a qualified import:++ * "ExchangeAlgebra.Bookkeeping" — double-entry adjusting\/closing entries.+ * "ExchangeAlgebra.Consolidation.Worksheet" — atomic consolidation+ adjustments, source provenance, and cross-statement linkage validation.+ * "ExchangeAlgebra.TrialBalance.Validation" — trial-balance findings,+ reclassification instructions, and the policy-controlled reporting gate.+ * "ExchangeAlgebra.Reporting.Metric" — typed, read-only derived metrics+ that do not add account-basis coordinates.+ * "ExchangeAlgebra.Reporting.Group" — reusable gross, deduction, and net+ presentation groups for contra accounts.+ * "ExchangeAlgebra.Reporting.Presentation" — context-sensitive JGAAP+ transformation from validated trial balances to financial statements.+ * "ExchangeAlgebra.Simulate" — the classic simulation engine. /(Removed+ from this umbrella in 0.5.0.0: it exports very generic names —+ @copy@, @modify@, @update@, @initialize@, @normal@, … — that polluted+ the bookkeeping namespace.)/+ * "ExchangeAlgebra.Simulate.Lite" — the lightweight simulation front-end.+ * "ExchangeAlgebra.Simulate.Network" — trade-network generators.+ * "ExchangeAlgebra.Simulate.Policy" — ledger\/spill policy configuration.+ * "ExchangeAlgebra.Optimize" — pluggable optimization solvers+ ("ExchangeAlgebra.Optimize.Annealing" \/ "ExchangeAlgebra.Optimize.GA");+ generic names (@optimize@, @Config@, ...) best used qualified.++ Import them explicitly when you need them, e.g.:++ > import ExchangeAlgebra+ > import qualified ExchangeAlgebra.Bookkeeping as BK+ > import ExchangeAlgebra.Simulate -- simulation engine+ > import qualified ExchangeAlgebra.Simulate.Lite as Lite+ == Full examples Runnable examples (elementary bookkeeping, ripple-effect simulations,@@ -56,9 +105,9 @@ ( module ExchangeAlgebra.Algebra , module ExchangeAlgebra.Algebra.Transfer , module ExchangeAlgebra.Write- , module ExchangeAlgebra.Simulate ) where+ , module ExchangeAlgebra.Value ) where import ExchangeAlgebra.Algebra import ExchangeAlgebra.Algebra.Transfer import ExchangeAlgebra.Write-import ExchangeAlgebra.Simulate+import ExchangeAlgebra.Value -- MoneyDouble / MoneyDecimal value types
+ src/ExchangeAlgebra/Accounting/PostingPolicy.hs view
@@ -0,0 +1,77 @@+{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}++{- |+Module : ExchangeAlgebra.Accounting.PostingPolicy+Description : Accounting-domain posting authority for processing contexts.++Accounting-domain posting authority: which coordinates may be posted in which+processing context. Input adapters ("ExchangeAlgebra.Convert.Checked") and the+consolidation worksheet ("ExchangeAlgebra.Consolidation.Worksheet") consume+this; it does not depend on either.++The policy has two inputs. The 'PostingCapability' of an account title is+canonical registry metadata ('accountSemantics'); the 'ProcessingContext' names+the boundary at which a posting is admitted. 'postingAllowedIn' is the closed+gate that relates the two, and 'postingCapabilityFor' is the total lookup that+maps the wildcard 'AccountTitle' (outside the metadata domain) to 'NotPostable'.+-}+module ExchangeAlgebra.Accounting.PostingPolicy+ ( ProcessingContext(..)+ , postingAllowedIn+ , postingCapabilityFor+ ) where++import ExchangeAlgebra.Algebra.Base (AccountTitles(..))+import ExchangeAlgebra.Algebra.Base.Account.Registry+ ( AccountSemantics(asemPostingCapability)+ , accountSemantics+ )+import ExchangeAlgebra.Algebra.Base.Account.Types (PostingCapability(..))++-- | Processing boundary at which generated postings are admitted.+--+-- Each non-ordinary context adds exactly one capability to+-- 'OrdinaryPosting'. This keeps closing, consolidation, and engine authority+-- separate instead of introducing one privileged "internal" bypass.+data ProcessingContext+ = OrdinaryJournal+ | ClosingProcess+ | ConsolidationWorksheet+ | EngineComputation+ deriving (Show, Eq)++-- | Whether a capability is admitted at a processing boundary.+postingAllowedIn :: ProcessingContext -> PostingCapability -> Bool+postingAllowedIn OrdinaryJournal capability = case capability of+ OrdinaryPosting -> True+ ClosingOnly -> False+ ConsolidationOnly -> False+ EngineGeneratedOnly -> False+ NotPostable -> False+postingAllowedIn ClosingProcess capability = case capability of+ OrdinaryPosting -> True+ ClosingOnly -> True+ ConsolidationOnly -> False+ EngineGeneratedOnly -> False+ NotPostable -> False+postingAllowedIn ConsolidationWorksheet capability = case capability of+ OrdinaryPosting -> True+ ClosingOnly -> False+ ConsolidationOnly -> True+ EngineGeneratedOnly -> False+ NotPostable -> False+postingAllowedIn EngineComputation capability = case capability of+ OrdinaryPosting -> True+ ClosingOnly -> False+ ConsolidationOnly -> False+ EngineGeneratedOnly -> True+ NotPostable -> False++-- | Posting capability of an account title, total over 'AccountTitles'.+--+-- Concrete titles report their registry capability+-- ('asemPostingCapability'); the wildcard 'AccountTitle' has no registry+-- semantics and is therefore 'NotPostable'.+postingCapabilityFor :: AccountTitles -> PostingCapability+postingCapabilityFor title =+ maybe NotPostable asemPostingCapability (accountSemantics title)
src/ExchangeAlgebra/Algebra.hs view
@@ -1,1431 +1,78 @@-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE InstanceSigs #-}-{-# LANGUAGE TypeSynonymInstances #-}-{-# LANGUAGE DeriveDataTypeable #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE TypeOperators #-}-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE PatternGuards #-}-{-# LANGUAGE InstanceSigs #-}-{-# LANGUAGE TypeFamilies #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE GADTs #-}-{-# LANGUAGE UndecidableInstances #-}-{-# LANGUAGE StrictData #-}-{-# LANGUAGE Strict #-}-{-# LANGUAGE PatternSynonyms #-}-{-# LANGUAGE ViewPatterns #-}-{-# LANGUAGE OverloadedStrings #-}--{- |- Module : ExchangeAlgebra.Algebra- Copyright : (c) Kaya Akagi. 2018-2026- Maintainer : yakagika@icloud.com-- Released under the OWL license-- Package for Exchange Algebra defined by Hiroshi Deguchi.-- Exchange Algebra is an algebraic description of bookkeeping system.- Details are below.-- <https://www.springer.com/gp/book/9784431209850>-- <https://repository.kulib.kyoto-u.ac.jp/dspace/bitstream/2433/82987/1/0809-7.pdf>---}---module ExchangeAlgebra.Algebra- ( module ExchangeAlgebra.Algebra.Base- , Nearly(..)- , isNearlyNum- , nearlyEqScaled- , Redundant(..)- , Exchange(..)- , HatVal(..)- , Pair(..)- , Alg(..)- , isZero- , (.@)- , (<@)- , vals- , bases- , fromList- , toList- , foldEntries- , sigma- , sigma2When- , sigmaFromMap- , toASCList- , map- , filter- , proj- , projCredit- , projDebit- , projByAccountTitle- , projNorm- , balanceBy- , foldEntriesToMap- , projCurrentAssets- , projFixedAssets- , projDeferredAssets- , projCurrentLiability- , projFixedLiability- , projCapitalStock- , rounding- , unionsMerge)where--import ExchangeAlgebra.Algebra.Base--import Debug.Trace-import qualified Data.Text as T-import Data.Text (Text)-import qualified Data.List as L (foldl', map, length, elem,sort,sortOn,filter, or, and,any, sum, concat)-import Prelude hiding (map, head, filter,tail, traverse, mapM)-import qualified Data.Time as Time-import Data.Time-import qualified Data.HashMap.Strict as Map-import qualified Data.IntMap.Strict as IntMap-import qualified Data.IntSet as IntSet-import qualified Data.Map.Strict as M-import qualified Data.Foldable as Foldable (foldMap,foldl',foldr,toList)-import qualified Data.Sequence as Seq-import Data.Sequence (Seq)-import qualified Data.Maybe as Maybe-import qualified Number.NonNegative as NN -- Non-negative real numbers-import Numeric.NonNegative.Class (C)-import Algebra.Additive (C)-import qualified Data.Scientific as D (Scientific, fromFloatDigits, formatScientific, FPFormat(..))-import Control.DeepSeq-import GHC.Stack (HasCallStack, callStack, prettyCallStack)-import Data.Hashable-import qualified Data.Binary as Binary----------------------------------------------------------------------- * Approximate equality----------------------------------------------------------------------- | Type class providing approximate equality for numeric values.--- Performs equality comparison with tolerance for floating-point rounding errors.-class (Eq a, Ord a) => Nearly a where- -- | @isNearly x y t@ : Returns True if the difference between x and y is within the tolerance t.- -- Complexity: O(1)- isNearly :: a -> a -> a -> Bool--instance Nearly Int where- {-# INLINE isNearly #-}- isNearly = isNearlyNum--instance Nearly Integer where- {-# INLINE isNearly #-}- isNearly = isNearlyNum--instance Nearly Float where- {-# INLINE isNearly #-}- isNearly = isNearlyNum--instance Nearly Double where- {-# INLINE isNearly #-}- isNearly = isNearlyNum--instance Nearly NN.Double where- {-# INLINE isNearly #-}- isNearly = isNearlyNum--{-# 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 = 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----------------------------------------------------------------------------------------------------------------------------------- ** Definition of Redundancy (subclassing this makes a redundant algebra)----------------------------------------------------------------------- | Type class for Redundant Algebra.--- Provides fundamental exchange algebra operations: hat, bar, norm, scalar product, and compress.------ Redundant ⊃ Exchange------ hat calculation--- >>> (.^) (10:@Not:<Cash .+ 10:@Hat:<Deposits)--- 10.00:@Hat:<Cash .+ 10.00:@Not:<Deposits------ bar calculation--- >>> x = 10:@Not:<Cash .+ 10:@Hat:<Deposits--- >>> y = 5:@Hat:<Cash .+ 5:@Not:<Deposits--- >>> (.-) $ x .+ y--- 5.00:@Not:<Cash .+ 5.00:@Hat:<Deposits------ norm calculation--- >>> norm $ 10:@Not:<Cash .+ 10:@Hat:<Deposits--- 20.0------ (.*) calculation--- >>> (.*) 5 $ 10:@Not:<Cash .+ 10:@Hat:<Deposits--- 50.00:@Not:<Cash .+ 50.00:@Hat:<Deposits------ compress calculation--- >>> compress $ 10:@Not:<Cash .+ 5:@Hat:<Cash .+ 3:@Not:<Cash--- 5.00:@Hat:<Cash .+ 13.00:@Not:<Cash--class (HatVal n, HatBaseClass b, Monoid (a n b)) => Redundant a n b where- -- | Hat operation. Flips Hat/Not on all elements.- -- Complexity: O(1) for singleton, O(n) for Liner (n is the number of base keys)- (.^) :: a n b -> a n b-- -- | Bar operation. Cancels Hat/Not on the same base and retains only the difference.- -- Complexity: O(n) (n is the number of base keys)- (.-) :: a n b -> a n b-- -- | Alias for bar operation. Identical to @(.-)@.- bar :: a n b -> a n b- bar = (.-)-- -- | Aggregates values on the same base. Sums while preserving the Hat/Not distinction.- -- Complexity: O(n) (n is the number of base keys)- compress :: a n b -> a n b-- -- | Addition of algebra elements. Alias for the Monoid @<>@ operation.- -- Complexity: O(union cost)- (.+) :: a n b -> a n b -> a n b-- -- | Scalar product. Multiplies all element values by a scalar.- -- Complexity: O(1) for singleton, O(n) for Liner- (.*) :: n -> a n b -> a n b-- -- | Norm. Returns the sum of all element values.- -- Complexity: O(n) (n is the number of base keys)- norm :: a n b -> n-- -- | Addition in an Applicative context.- -- Complexity: O(union cost)- {-# INLINE (<+) #-}- (<+) :: (Applicative f) => f (a n b) -> f (a n b) -> f (a n b)- (<+) x y = (.+) <$> x <*> y---infixr 7 .^-infixr 2 .--infixr 3 .+-infixr 3 <+----------------------------------------------------------------- ** Definition of Exchange Algebra----------------------------------------------------------------- | Type class for Exchange Algebra. In addition to Redundant Algebra, provides--- debit(R)/credit(L) decomposition, stock increase(P)/decrease(M) decomposition, and balance checking.-class (Redundant a n b ) => Exchange a n b where- -- | Extracts only the debit side elements. Complexity: O(s)- decR :: a n b -> a n b- -- | Extracts only the credit side elements. Complexity: O(s)- decL :: a n b -> a n b- -- | Extracts only the Hat (stock increase) side elements. Complexity: O(s)- decP :: a n b -> a n b- -- | Extracts only the Not (stock decrease) side elements. Complexity: O(s)- decM :: a n b -> a n b- -- | Checks whether the norms of debit and credit sides are equal. Complexity: O(s)- balance :: a n b -> Bool- -- | Returns the debit-credit difference as a (Side, difference) pair. Complexity: O(s)- diffRL :: a n b -> (Side, n)------------------------------------------------------------------------ * Algebra----------------------------------------------------------------------- | Type class for algebra element values.--- Provides zero-value and error-value predicates.--- Instances are defined for @Double@ and @NN.Double@ (non-negative reals).-class ( Show n- , Ord n- , Eq n- , Nearly n- , Fractional n- , RealFloat n- , Num n) => HatVal n where-- -- | Zero value. Complexity: O(1)- zeroValue :: n-- -- | Tests whether the value is zero. Complexity: O(1)- isZeroValue :: n -> Bool- isZeroValue x- | zeroValue == x = True- | otherwise = False-- -- | Tests whether the value is an error value (NaN, Infinity, etc.). Complexity: O(1)- isErrorValue :: n -> Bool---instance RealFloat NN.Double where- floatRadix = floatRadix . NN.toNumber- floatDigits = floatDigits . NN.toNumber- floatRange = floatRange . NN.toNumber- decodeFloat = decodeFloat . NN.toNumber- encodeFloat m e = NN.fromNumber (encodeFloat m e)- exponent = exponent . NN.toNumber- significand = NN.fromNumber . significand . NN.toNumber- scaleFloat n = NN.fromNumber . scaleFloat n . NN.toNumber- isNaN = isNaN . NN.toNumber- isInfinite = isInfinite . NN.toNumber- isDenormalized = isDenormalized . NN.toNumber- isNegativeZero = isNegativeZero . NN.toNumber- isIEEE = isIEEE . NN.toNumber--instance HatVal NN.Double where- {-# INLINE zeroValue #-}- zeroValue = 0- {-# INLINE isErrorValue #-}- isErrorValue x = isNaN (NN.toNumber x)- || isInfinite (NN.toNumber x)--instance HatVal Prelude.Double where- {-# INLINE zeroValue #-}- zeroValue = 0-- {-# INLINE isErrorValue #-}- isErrorValue x = isNaN x- || isInfinite x- || x < 0--data Pair v where- Pair :: {_hatSide :: !(Seq v)- ,_notSide :: !(Seq v)} -> Pair v- deriving (Eq)--instance (Binary.Binary v) => Binary.Binary (Pair v) where- {-# INLINABLE put #-}- {-# INLINABLE get #-}- put (Pair hs ns) = do- Binary.put (Seq.length hs :: Int)- Foldable.foldr (\x k -> Binary.put x >> k) (pure ()) hs- Binary.put (Seq.length ns :: Int)- Foldable.foldr (\x k -> Binary.put x >> k) (pure ()) ns- get = do- hsLen <- Binary.get :: Binary.Get Int- hs <- go hsLen Seq.empty- nsLen <- Binary.get :: Binary.Get Int- ns <- go nsLen Seq.empty- pure (Pair hs ns)- where- go :: Binary.Binary a => Int -> Seq a -> Binary.Get (Seq a)- go n !acc- | n <= 0 = pure acc- | otherwise = do- x <- Binary.get- go (n - 1) (acc Seq.|> x)---instance (HatVal v) => Ord (Pair v) where- {-# INLINE compare #-}- compare (Pair hs1 ns1) (Pair hs2 ns2) = compare ((sum hs1) - (sum ns1)) ((sum hs2) - (sum ns2))-- (<) x y | compare x y == LT = True- | otherwise = False-- (>) x y | compare x y == GT = True- | otherwise = False-- (<=) x y | compare x y == LT = True- | compare x y == EQ = True- | otherwise = False-- (>=) x y | compare x y == GT = True- | compare x y == EQ = True- | otherwise = False-- max x y | x >= y = x- | otherwise = y-- min x y | x <= y = x- | otherwise = y--{-# INLINE nullPair #-}--- | Complexity: O(1)-nullPair :: Pair v-nullPair = Pair Seq.empty Seq.empty--{-# INLINE isNullPair #-}--- | Complexity: O(1)-isNullPair :: Pair v -> Bool-isNullPair (Pair hs ns) = Seq.null hs && Seq.null ns--{-# INLINE pairAppend #-}--- | Complexity: O(log(min(h1,h2)) + log(min(n1,n2)))--- where h1/h2 and n1/n2 are the lengths of the appended 'Seq's on each side.-pairAppend :: Pair v -> Pair v -> Pair v-pairAppend (Pair x1 y1) (Pair x2 y2) =- let !hs = x1 Seq.>< x2- !ns = y1 Seq.>< y2- in Pair hs ns---- | Algebra element. An element of exchange algebra consisting of a value-base pair.--- Zero is the zero element, @(:@)@ is a singleton, and Liner is a HashMap-based multi-element representation.-data Alg v b where- Zero :: Alg v b- (:@) :: {_val :: !v, _hatBase :: !b} -> Alg v b- Liner :: { _realg :: !(Map.HashMap (BasePart b) (Pair v))- , _axisPosting :: ~(IntMap.IntMap (Map.HashMap AxisKey IntSet.IntSet))- , _bpToId :: ~(Map.HashMap (BasePart b) Int)- , _idToBp :: ~(IntMap.IntMap (BasePart b))- , _nextBpId :: ~Int- , _allBpIds :: ~IntSet.IntSet- } -> Alg v b--instance ( HatBaseClass b- , Binary.Binary v- , Binary.Binary b- , Binary.Binary (BasePart b)- ) => Binary.Binary (Alg v b) where- {-# INLINABLE put #-}- {-# INLINABLE get #-}- put Zero = Binary.put (0 :: Int)- put (v :@ b) = do- Binary.put (1 :: Int)- Binary.put v- Binary.put b- put (Liner m _ _ _ _ _) = do- Binary.put (2 :: Int)- Binary.put (Map.size m :: Int)- Map.foldrWithKey- (\bp p k -> Binary.put bp >> Binary.put p >> k)- (pure ())- m-- get = do- tag <- Binary.get- case (tag :: Int) of- 0 -> pure Zero- 1 -> (:@) <$> Binary.get <*> Binary.get- 2 -> do- n <- Binary.get :: Binary.Get Int- linerFromMap <$> go n Map.empty- _ -> fail ("Binary decode failure for Alg: unknown tag " ++ show tag)- where- go n !acc- | n <= 0 = pure acc- | otherwise = do- bp <- Binary.get- p <- Binary.get- go (n - 1) (Map.insert bp p acc)--type AxisPosting = IntMap.IntMap (Map.HashMap AxisKey IntSet.IntSet)--{-# INLINE emptyAxisPosting #-}--- | Complexity: O(1)-emptyAxisPosting :: AxisPosting-emptyAxisPosting = IntMap.empty--{-# INLINE insertAxisPosting #-}--- | Complexity: O(d * (hash-insert + intset-insert))--- In practice this is near O(d), where d is the number of axes in the base part.-insertAxisPosting :: [AxisKey] -> Int -> AxisPosting -> AxisPosting-insertAxisPosting !keys !bpId !idx =- snd $ L.foldl' step (0 :: Int, idx) keys- where- step (!axis, !acc) !k =- let !axisMap = IntMap.findWithDefault Map.empty axis acc- !ids0 = Map.lookupDefault IntSet.empty k axisMap- !ids1 = IntSet.insert bpId ids0- !axisMap' = Map.insert k ids1 axisMap- !acc' = IntMap.insert axis axisMap' acc- in (axis + 1, acc')--{-# INLINE queryAxisPosting #-}--- | Complexity: O(d + intersection cost)--- d is the number of axes; intersections are performed in ascending set-size order.-queryAxisPosting :: [AxisKey] -> AxisPosting -> IntSet.IntSet -> IntSet.IntSet-queryAxisPosting !keys !idx !allIds =- case matchedSets of- Left () -> IntSet.empty- Right [] -> allIds- Right xs ->- let !(x:rest) = L.sortOn IntSet.size xs- in L.foldl' IntSet.intersection x rest- where- matchedSets =- L.foldl' collect (Right []) (zip [0 :: Int ..] keys)-- collect (Left ()) _ = Left ()- collect (Right acc) (!axis, !k)- | axisIsWildcard k = Right acc- | otherwise =- case IntMap.lookup axis idx of- Nothing -> Left ()- Just axisMap -> case Map.lookup k axisMap of- Nothing -> Left ()- Just ids -> Right (ids : acc)--{-# INLINE linerFromMap #-}--- | Complexity: O(n * d * (hash-insert + intset-insert))--- n is the number of distinct base keys in the map.-linerFromMap :: (HatBaseClass b)- => Map.HashMap (BasePart b) (Pair v)- -> Alg v b-linerFromMap m = Liner m idx bpToId idToBp nextBpId allIds- where- ~(idx, bpToId, idToBp, nextBpId, allIds) =- Map.foldlWithKey'- (\(!idxAcc, !bpToIdAcc, !idToBpAcc, !nextId, !allIdsAcc) bp _ ->- let !bpId = nextId- !idx' = insertAxisPosting (toAxisKeys bp) bpId idxAcc- !bpToId' = Map.insert bp bpId bpToIdAcc- !idToBp' = IntMap.insert bpId bp idToBpAcc- !allIds' = IntSet.insert bpId allIdsAcc- in (idx', bpToId', idToBp', bpId + 1, allIds'))- (emptyAxisPosting, Map.empty, IntMap.empty, 0, IntSet.empty)- m---- | Tests whether the algebra element is zero (empty).------ Complexity: O(1)-isZero :: Alg v b -> Bool-isZero Zero = True-isZero _ = False--{-# INLINE singleton #-}--- | Complexity: O(1)-singleton :: (HatVal v, HatBaseClass b) => v -> b -> Alg v b-singleton v b | isZeroValue v = Zero- | isErrorValue v = error $ "errorValue at (.@) val: "- ++ show v- ++ show ":@"- ++ show b- | otherwise = v :@ b--{-# INLINE (.@) #-}--- | Smart constructor that builds an algebra element from a value and a base.--- Returns Zero for zero values, and throws an exception for error values.------ Complexity: O(1)-(.@) :: (HatVal n, HatBaseClass b) => n -> b -> Alg n b-(.@) v b = singleton v b---- | Constructs an algebra element in an Applicative context. Lifted version of @(.@)@.------ Complexity: O(1) + Applicative effects-(<@) :: (HatVal n, Applicative f, HatBaseClass b)- => f n -> b -> f (Alg n b)-(<@) v b = (.@) <$> v <*> (pure b)---infixr 6 :@-infixr 6 .@-infixr 6 <@---- | Complexity: O(digits(v))--- Formatting cost is proportional to the textual precision of the number.-showV :: (HatVal v) => v -> String-showV v = D.formatScientific D.Generic (Just 2) (D.fromFloatDigits v)--instance (HatVal v, HatBaseClass b) => Eq (Alg v b) where- (==) Zero Zero = True- (==) Zero _ = False- (==) _ Zero = False- (==) (v1:@b1) (v2:@b2) = (v1 == v2) && (b1 == b2)- (==) (Liner m1 _ _ _ _ _) (Liner m2 _ _ _ _ _) = m1 == m2- (==) _ _ = False- (/=) x y = not (x == y)--instance (HatVal v, HatBaseClass b) => Ord (Alg v b) where- {-# INLINE compare #-}- compare Zero Zero = EQ- compare Zero _ = LT- compare _ Zero = GT-- compare (v:@b) (Liner _ _ _ _ _ _) = LT- compare (Liner _ _ _ _ _ _) (v:@b) = GT- compare (v1:@b1) (v2:@b2)- | b1 == b2 = compare v1 v2- | b1 > b2 = GT- | b1 < b2 = LT-- compare (Liner m1 _ _ _ _ _) (Liner m2 _ _ _ _ _) = compare m1 m2-- (<) x y | compare x y == LT = True- | otherwise = False-- (>) x y | compare x y == GT = True- | otherwise = False-- (<=) x y | compare x y == LT = True- | compare x y == EQ = True- | otherwise = False-- (>=) x y | compare x y == GT = True- | compare x y == EQ = True- | otherwise = False-- max x y | x >= y = x- | otherwise = y-- min x y | x <= y = x- | otherwise = y--instance (HatVal v, HatBaseClass b) => Show (Alg v b) where- show Zero = "0"- show (v:@b) = (showV v) ++ ":@" ++ show b- show xs = let ls = toASCList xs- in go ls- where- go [] = "0"- go [y] = show y- go (y:ys) = show y ++ " .+ " ++ go ys---instance NFData (Alg v b) where- rnf Zero = Zero `seq` ()- rnf (v:@b) = v `seq` b `seq` ()- rnf (Liner m _ _ _ _ _) = Map.foldrWithKey (\k v acc -> k `seq` v `seq` acc) () m---------------------------------------------------------------------- Semigroup---------------------------------------------------------------------instance (HatVal n, HatBaseClass b) => Semigroup (Alg n b) where- {-# INLINE (<>) #-}- -- | Associative law ;convert to right join- (<>) = union------ | union two trees------ >>> type Test = Alg NN.Double (HatBase CountUnit)--- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test--- >>> y = 2:@Hat:<Yen .+ 2:@Not:<Amount :: Test--- >>> union x y--- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Yen .+ 1.00:@Not:<Amount .+ 2.00:@Not:<Amount-{-# INLINE union #-}--- | Complexity:--- - singleton/singleton and singleton/liner cases: O(n * d * index-build)--- - liner/liner case: O(n + m) for map union plus O((n+m) * d * index-build)--- where n and m are distinct key counts on each side.-union :: (HatVal n, HatBaseClass b) => Alg n b -> Alg n b -> Alg n b-union Zero x = x-union x Zero = x--- singletons--- NOTE: a zero-valued singleton contributes nothing, so the result must keep the--- /nonzero/ value on its OWN base. Earlier code returned @v2:@b1@ / @v1:@b2@,--- relabeling the surviving value onto the zero posting's base — this preserved--- 'norm' but silently moved the value to the wrong base, corrupting per-base--- projection and making construction order observable (raw @(:@)@ on a sparsified--- zero coefficient builds an explicit @0:@base@ singleton). Keep @v2:@b2@ / @v1:@b1@.-union (v1:@b1) (v2:@b2)- | isZeroValue v1 = case isZeroValue v2 of- True -> Zero- False -> v2:@b2- | isZeroValue v2 = v1:@b1- | otherwise = insert b2 v2 (v1:@b1)--- If one side is a singleton-union x (v:@b) = insert b v x-union (v:@b) x = insert b v x---- In the case of multiple elements-union (Liner m1 _ _ _ _ _) (Liner m2 _ _ _ _ _) = linerFromMap (Map.unionWith pairAppend m1 m2)---{-# INLINE insert #-}--- | Complexity:--- - into Zero or singleton: O(1) to O(d * index-build)--- - into Liner: O(n * d * index-build) due to rebuilding 'linerFromMap'--- where n is the number of distinct base keys after insertion.-insert :: (HatVal v,HatBaseClass b) => b -> v -> Alg v b -> Alg v b-insert _ v x | isZeroValue v = x-insert !b !v Zero = v .@ b-insert !b1 !v1 (v2:@b2) = case isHat b1 of- True -> insert b2 v2- $ linerFromMap- $ Map.singleton (base b1)- $ nullPair {_hatSide = Seq.singleton v1}- False -> insert b2 v2- $ linerFromMap- $ Map.singleton (base b1)- $ nullPair {_notSide = Seq.singleton v1}-insert !b !v (Liner m _ _ _ _ _) = case isHat b of- True -> insertLiner (nullPair {_hatSide = Seq.singleton v})- False -> insertLiner (nullPair {_notSide = Seq.singleton v})- where- !bp = base b- insertLiner !pairToInsert =- let !m' = Map.insertWith pairAppend bp pairToInsert m- in linerFromMap m'----------------------------------------------------------------------- Monoid---------------------------------------------------------------------instance (HatVal n, HatBaseClass b) => Monoid (Alg n b) where- -- Identity element- mempty = Zero- mappend = (<>)- mconcat = unions--{-# INLINE unions #-}--- | Complexity: O(sum of HashMap union costs over the fold)--- For a long list this is typically the dominant construction cost.-unions :: (HatVal n, Foldable f, HatBaseClass b) => f (Alg n b) -> Alg n b-unions ts = Foldable.foldl' union Zero ts--{-# INLINE mergeAlgMap #-}-mergeAlgMap :: (HatVal n, HatBaseClass b)- => Map.HashMap (BasePart b) (Pair n)- -> Alg n b- -> Map.HashMap (BasePart b) (Pair n)-mergeAlgMap !acc Zero = acc-mergeAlgMap !acc (v :@ b)- | isZeroValue v = acc- | otherwise =- let !p = if isHat b- then nullPair {_hatSide = Seq.singleton v}- else nullPair {_notSide = Seq.singleton v}- in Map.insertWith pairAppend (base b) p acc-mergeAlgMap !acc (Liner m _ _ _ _ _)- | Map.null m = acc- | otherwise = Map.unionWith pairAppend acc m--{-# INLINE mergeAlgMapIfNonZero #-}-mergeAlgMapIfNonZero :: (HatVal n, HatBaseClass b)- => Map.HashMap (BasePart b) (Pair n)- -> Alg n b- -> Map.HashMap (BasePart b) (Pair n)-mergeAlgMapIfNonZero !acc Zero = acc-mergeAlgMapIfNonZero !acc alg@(v :@ _)- | isZeroValue v = acc- | otherwise = mergeAlgMap acc alg-mergeAlgMapIfNonZero !acc alg = mergeAlgMap acc alg--{-# INLINE unionsMerge #-}--- | Merge multiple Algs by directly combining their internal HashMaps,--- building the AxisPosting index only once at the end.-unionsMerge :: (HatVal n, Foldable f, HatBaseClass b) => f (Alg n b) -> Alg n b-unionsMerge ts =- let !m = Foldable.foldl' mergeAlgMap Map.empty ts- in mkAlgFromMap m----------------------------------------------------------------------- Redundant---------------------------------------------------------------------instance (HatVal n, HatBaseClass b) => Redundant Alg n b where- (.^) Zero = Zero- (.^) (n:@ b) = n :@ (revHat b)- (.^) (Liner ms idx bpToId idToBp nextBpId allIds) = Liner- (Map.map (\ (Pair hs ns) -> Pair ns hs) ms)- idx- bpToId- idToBp- nextBpId- allIds-- (.+) = mappend-- x .* Zero = Zero- 0 .* x = Zero- x .* (v:@b) = (x * v) :@ b- x .* (Liner ms idx bpToId idToBp nextBpId allIds) = Liner- (Map.map (\ (Pair hs ns) -> Pair (fmap (x *) hs) (fmap (x *) ns)) ms)- idx- bpToId- idToBp- nextBpId- allIds-- norm Zero = 0- norm (v:@b) = v- norm (Liner ms _ _ _ _ _) = Map.foldl' (\ !x (Pair hs ns) -> x + Foldable.foldl' (+) 0 hs + Foldable.foldl' (+) 0 ns) 0 ms-- {-# INLINE (.-) #-}- (.-) Zero = Zero- (.-) (v:@b) = v:@b- (.-) (Liner m _ _ _ _ _) = let !res = Map.mapMaybe f m- in case null res of- True -> Zero- False -> linerFromMap res- where- {-# INLINE f #-}- f p@(Pair hs ns) =- let !h = Foldable.foldl' (+) 0 hs- !n = Foldable.foldl' (+) 0 ns- 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- (1, 0) | h > n -> Just p- (0, 1) | n > h -> Just p- _ -> case compare h n of- GT -> Just (Pair (Seq.singleton (h - n)) Seq.empty)- LT -> Just (Pair Seq.empty (Seq.singleton (n - h)))-- {-# INLINE compress #-}- compress Zero = Zero- compress (v:@b) = v:@b- compress (Liner m idx bpToId idToBp nextBpId allIds) = Liner- (Map.map compressPair m)- idx- bpToId- idToBp- nextBpId- allIds- where- {-# INLINE compressPair #-}- compressPair p@(Pair hs ns) = case (Seq.length hs, Seq.length ns) of- (1, 1) -> p -- already singleton on both sides, reuse- (1, 0) -> p -- already singleton + empty, reuse- (0, 1) -> p -- already empty + singleton, reuse- _ -> Pair (Seq.singleton (Foldable.foldl' (+) 0 hs))- (Seq.singleton (Foldable.foldl' (+) 0 ns))---instance (HatVal n, ExBaseClass b) => Exchange Alg n b where- -- | filter Credit side- decR xs = filter (\x -> x /= Zero && (whichSide . _hatBase) x == Credit) xs-- -- | filter Debit side- decL xs = filter (\x -> x /= Zero && (whichSide . _hatBase) x == Debit) xs-- -- | filter Plus Stock- decP xs = filter (\x -> x /= Zero && (isHat . _hatBase ) x) xs-- -- | filter Minus Stock- decM xs = filter (\x -> x /= Zero && (not. isHat. _hatBase) x) xs-- -- | check Credit Debit balance (scale-aware tolerance, WI-12)- balance xs = nearlyEqScaled ((norm . decR) xs) ((norm . decL) xs)-- -- | (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----------------------------------------------------------------------- * Basic functions----------------------------------------------------------------------- | Returns all values contained in the algebra element as a list.------ Complexity: O(s) (s is the total number of scalar entries)-vals :: (HatVal v, HatBaseClass b) => Alg v b -> [v]-vals Zero = []-vals (v:@b) = [v]-vals (Liner m _ _ _ _ _) =- reverse $- Map.foldl'- (\acc (Pair hs ns) ->- Foldable.foldl' (flip (:))- (Foldable.foldl' (flip (:)) acc hs)- ns- )- []- m----- | Returns all bases contained in the algebra element as a list.------ Complexity: O(s) (s is the total number of scalar entries)-bases :: (HatVal v, HatBaseClass b) => Alg v b -> [b]-bases Zero = []-bases (v:@b) = [b]-bases (Liner m _ _ _ _ _) = Map.foldlWithKey' f [] m- 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) ns-- g :: (HatVal v, HatBaseClass b) => Hat -> BasePart b -> [b] -> v -> [b]- g h b ys v = (merge h b):ys--{-# INLINE fromList #-}--- | convert List to Alg n b--- Complexity: O(sum of HashMap union costs), because this is implemented via 'mconcat'.------ >>> type Test = Alg NN.Double (HatBase AccountTitles)--- >>> xs = [1:@Hat:<Cash,1:@Not:<Deposits, 2:@Hat:<Cash, 2:@Not:<Deposits] :: [Test]--- >>> fromList xs--- 1.00:@Hat:<Cash .+ 2.00:@Hat:<Cash .+ 1.00:@Not:<Deposits .+ 2.00:@Not:<Deposits------ >>> type Test = Alg NN.Double (HatBase CountUnit)--- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test--- >>> y = 2:@Hat:<Yen .+ 2:@Not:<Amount :: Test--- >>> fromList [x,y]--- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Yen .+ 1.00:@Not:<Amount .+ 2.00:@Not:<Amount--fromList ::(HatVal v, HatBaseClass b ) => [Alg v b] -> Alg v b-fromList = mconcat------ | Summation function that applies a function to each element of a list and sums the results.--- Complexity: O(sum of HashMap union costs over produced elements).------ >>> type Test = Alg NN.Double (HatBase CountUnit)--- >>> sigma [1,2] (\x -> x:@Hat:<Yen)--- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Yen--{-# INLINE sigma #-}-sigma :: (HatVal v, HatBaseClass b) => [a] -> (a -> Alg v b) -> Alg v b-sigma xs f = mkAlgFromMap $ L.foldl' step Map.empty xs- where- step !acc !x = mergeAlgMapIfNonZero acc (f x)---- | Conditional summation over a double loop. For all combinations of two lists,--- applies the function only to pairs that satisfy the condition and sums the results.------ Complexity: O(|xs| * |ys| * union cost)-{-# INLINE sigma2When #-}-sigma2When :: (HatVal v, HatBaseClass b)- => [a]- -> [c]- -> (a -> c -> Bool)- -> (a -> c -> Alg v b)- -> Alg v b-sigma2When xs ys cond f =- mkAlgFromMap $ L.foldl' outer Map.empty xs- where- outer !acc !x = L.foldl' (inner x) acc ys- inner !x !acc !y- | cond x y = mergeAlgMapIfNonZero acc (f x y)- | otherwise = acc---- | Summation using keys and values from a Map. Skips entries with zero values.------ Complexity: O(|map| * union cost)-{-# INLINE sigmaFromMap #-}-sigmaFromMap :: (HatVal v, HatBaseClass b, Ord k)- => M.Map k v- -> (k -> v -> Alg v b)- -> Alg v b-sigmaFromMap kvs f =- mkAlgFromMap $ M.foldlWithKey' step Map.empty kvs- where- step !acc !k !v- | isZeroValue v = acc- | otherwise = mergeAlgMapIfNonZero acc (f k v)---- | Converts an algebra element to a list.--- Complexity: O(s) (s is the total number of scalar entries)------ >>> toList (10:@Hat:<(Cash) .+ 10:@Hat:<(Deposits) .+ Zero :: Alg NN.Double (HatBase AccountTitles))--- [10.00:@Hat:<Deposits,10.00:@Hat:<Cash]------ you need define type variables to use this for Zero--- >>> toList Zero :: [Alg NN.Double (HatBase AccountTitles)]--- []-toList :: (HatVal v, HatBaseClass b) => Alg v b -> [Alg v b]-toList Zero = []-toList (v:@b) = [v:@b]-toList (Liner m _ _ _ _ _) = Map.foldlWithKey' f [] m- where- f :: (HatVal v, HatBaseClass b) => [Alg v b] -> BasePart b -> Pair v -> [Alg v b]- f xs b Pair {_hatSide = hs, _notSide = ns}- = Foldable.foldl' (g Hat b) (Foldable.foldl' (g Not b) xs ns) hs-- g :: (HatVal v, HatBaseClass b) => Hat -> BasePart b -> [Alg v b] -> v -> [Alg v b]- g h b ys v- | isZeroValue v = ys- | otherwise = (v :@ (merge h b)):ys--{-# INLINE foldEntries #-}--- | Strict left fold over scalar entries without building an intermediate list.-foldEntries :: (HatVal v, HatBaseClass b)- => (acc -> v -> b -> acc)- -> acc- -> Alg v b- -> acc-foldEntries _ !acc Zero = acc-foldEntries f !acc (v :@ b)- | isZeroValue v = acc- | otherwise = f acc v b-foldEntries f !acc (Liner m _ _ _ _ _) =- Map.foldlWithKey' step acc m- where- step !acc0 !bp (Pair hs ns) =- let !hatBase = merge Hat bp- !notBase = merge Not bp- !acc1 = Foldable.foldl' (\a v -> if isZeroValue v then a else f a v hatBase) acc0 hs- in Foldable.foldl' (\a v -> if isZeroValue v then a else f a v notBase) acc1 ns--{-# INLINE toASCList #-}--- | Complexity: O(s log s), dominated by sorting the list representation.-toASCList :: (HatVal v, HatBaseClass b) => Alg v b -> [Alg v b]-toASCList = L.sort . toList----- | map--- Complexity: O(s + c), where s is traversed scalar entries and c is transformed output size.------ >>> type Test = Alg Double (HatBase CountUnit)--- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test--- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test--- >>> map (\ (x:@hb) -> x:@(toHat hb)) $ x .+ y--- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Yen .+ 1.00:@Hat:<Amount .+ 2.00:@Hat:<Amount------ >>> type Test = Alg Double Hat--- >>> x = 1:@Hat .+ 1:@Not :: Test--- >>> y = 2:@Not .+ 2:@Hat :: Test--- >>> map (\ (x:@hb) -> (2 * x):@hb) $ x .+ y--- 2.00:@Hat .+ 4.00:@Hat .+ 2.00:@Not .+ 4.00:@Not--map :: (HasCallStack,HatVal v, HatBaseClass b)- => (Alg v b -> Alg v b) -> Alg v b -> Alg v b-map f Zero = Zero-map f (v:@b) = let v2:@b2 = f (v:@b)- in case isZeroValue v2 of- True -> Zero- False -> (v2 :@ b2)-map f (Liner m _ _ _ _ _) = mkAlgFromMap $ (Map.foldrWithKey (p f) dnilMap m) Map.empty- where- {-# INLINE dnilMap #-}- dnilMap = id- {-# INLINE dappendMap #-}- dappendMap = (.)- {-# INLINE dsingleMap #-}- dsingleMap (bp, p') = Map.insertWith pairAppend bp p'-- {-# INLINE p #-}- p :: (HatVal v, HatBaseClass b)- => (Alg v b -> Alg v b)- -> BasePart b- -> Pair v- -> DMap (BasePart b) (Pair v)- -> DMap (BasePart b) (Pair v)- p f b Pair {_hatSide=hs, _notSide=ns} accDList =- let (dl1, hs2) = q f Hat b hs- (dl2, ns2) = q f Not b ns- prefix = dappendMap dl1 dl2- in case (Seq.null hs2, Seq.null ns2) of- (True,True) -> dappendMap prefix accDList- (True,False) -> dappendMap prefix- . dappendMap (dsingleMap (b, nullPair{_notSide = ns2}))- $ accDList- (False,True) -> dappendMap prefix- . dappendMap (dsingleMap (b, nullPair{_hatSide = hs2}))- $ accDList- (False,False) -> dappendMap prefix- . dappendMap (dsingleMap (b, Pair hs2 ns2))- $ accDList- {-# INLINE q #-}- q :: (HatVal v, HatBaseClass b)- => (Alg v b -> Alg v b)- -> Hat- -> BasePart b- -> Seq v- -> (DMap (BasePart b) (Pair v), Seq v)- q f h b vs = Foldable.foldl' (r f h b) (dnilMap, Seq.empty) vs-- {-# INLINE r #-}- r :: (HatVal v, HatBaseClass b)- => (Alg v b -> Alg v b)- -> Hat- -> BasePart b- -> (DMap (BasePart b) (Pair v), Seq v)- -> v- -> (DMap (BasePart b) (Pair v), Seq v)- r f h b (dlAcc,vsAcc) v = case f (v:@(merge h b)) of- Zero -> (dlAcc, vsAcc)- ------------------------------------------------------------------- v2:@b2- | isZeroValue v2 -> (dlAcc, vsAcc)- | b2 .== (merge h b) -> (dlAcc, v2 Seq.<| vsAcc)- | isHat (hat b2) -> (dappendMap dlAcc (dsingleMap ( base b2- ,nullPair{_hatSide = Seq.singleton v2}))- ,vsAcc )- | otherwise -> (dappendMap dlAcc (dsingleMap ( base b2- ,nullPair{_notSide = Seq.singleton v2} ))- ,vsAcc )---- Difference list definition-type DList a = [a] -> [a]-type DMap k v = Map.HashMap k v -> Map.HashMap k v--{-# INLINE dnil #-}--- | Complexity: O(1)-dnil :: DList a-dnil = id--{-# INLINE dappend #-}--- | Complexity: O(1)-dappend :: DList a -> DList a -> DList a-dappend = (.) -- Function composition--{-# INLINE dsingle #-}--- | Complexity: O(1)-dsingle :: a -> DList a-dsingle x = \rest -> x : rest--{-# INLINE dToList #-}--- | Complexity: O(k), where k is the resulting list length.-dToList :: DList a -> [a]-dToList dl = dl []--{-# INLINE dFromList #-}--- | Complexity: O(k) to capture the prefix list xs.-dFromList :: [a] -> DList a-dFromList xs = (xs ++)---{-# INLINE filter #-}--- | filter--- Complexity: O(s), where s is total number of scalar entries.------ >>> type Test = Alg Double (HatBase CountUnit)--- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test--- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test--- >>> filter (isHat . _hatBase) $ x .+ y--- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Amount------ >>> type Test = Alg Double (HatBase CountUnit)--- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test--- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test--- >>> filter ((1 <). _val) $ x .+ y--- 2.00:@Not:<Yen .+ 2.00:@Hat:<Amount---filter :: (HatVal v, HatBaseClass b) => (Alg v b -> Bool) -> Alg v b -> Alg v b-filter f Zero = Zero-filter f (v:@b) | f (v:@b) = v:@b- | otherwise = Zero--filter f (Liner m _ _ _ _ _) =- -- Build a new Map using mapMaybeWithKey- let m' = Map.mapMaybeWithKey- (\basePart (Pair hs ns) ->- -- Filter each of hs and ns- let hs' = filterSide basePart Hat hs- ns' = filterSide basePart Not ns- in- -- Remove the entry (Nothing) if both become empty- if Seq.null hs' && Seq.null ns'- then Nothing- else Just (Pair hs' ns'))- m- in- -- If the resulting Map is empty, return Zero; otherwise Liner m'- if Map.null m' then Zero else linerFromMap m'- where- ----------------------------------------------------------------- -- Filter function that constructs "v:@(merge h basePart)" from- -- basePart and Hat/Not, and tests whether it satisfies predicate f- ----------------------------------------------------------------- -- filterSide :: BasePart b -> Hat -> Seq v -> Seq v- {-# INLINE filterSide #-}- filterSide bp h = Seq.filter (\val -> f (val :@ merge h bp))----------------------------------------------------------------- | proj--- Complexity:--- exact single-key path: expected O(1)--- wildcard single-key path: O(queryAxisPosting + c * verify)--- multi-pattern path: O(sum pattern costs + union costs)------ where c is candidate count returned by the posting index.--- >>> type Test = Alg NN.Double (HatBase CountUnit)--- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test--- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test--- >>> proj [Hat:<Yen] $ x .+ y--- 1.00:@Hat:<Yen------ >>> type Test = Alg NN.Double (HatBase CountUnit)--- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test--- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test--- >>> proj [HatNot:<Amount] $ x .+ y--- 2.00:@Hat:<Amount .+ 1.00:@Not:<Amount------ >>> type Test = Alg NN.Double (HatBase (AccountTitles, CountUnit))--- >>> x = 1:@Hat:<(Cash,Yen) .+ 1:@Not:<(Products,Amount) :: Test--- >>> y = 2:@Not:<(Cash,Yen) .+ 2:@Hat:<(Deposits,Yen) :: Test--- >>> proj [Hat:<((.#),Yen)] $ x .+ y--- 1.00:@Hat:<(Cash,Yen) .+ 2.00:@Hat:<(Deposits,Yen)------ >>> type Test = HatBase CountUnit--- >>> compareHatBase (Not:<(.#) :: Test) (Not:<Yen :: Test)--- EQ------ >>> type Test = Alg NN.Double (HatBase CountUnit)--- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test--- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test--- >>> proj [Not:<(.#)] $ x .+ y--- 2.00:@Not:<Yen .+ 1.00:@Not:<Amount------------------------------------------------------------------proj :: (HatVal v, HatBaseClass b) => [b] -> Alg v b -> Alg v b-proj [] _ = Zero-proj _ Zero = Zero-proj [b] (v:@b2)- | b .== b2 = v:@b2- | otherwise = Zero-proj [b] (Liner m idx _ idToBp _ allIds) =- mkAlgFromMap $ projSingleMap b m idx idToBp allIds-proj (b:bs) (v:@b2)- | b .== b2 = v:@b2- | otherwise = proj bs (v:@b2)-proj (b:bs) (Liner m idx _ idToBp _ allIds) =- mkAlgFromMap $- L.foldl'- (\acc q -> Map.unionWith pairAppend acc (projSingleMap q m idx idToBp allIds))- Map.empty- (b:bs)--{-# INLINE choosePairByHat #-}--- | Complexity: O(1)-choosePairByHat :: Hat -> Pair v -> Pair v-choosePairByHat h Pair {_hatSide = hs, _notSide = ns} =- case h of- Hat -> nullPair {_hatSide = hs}- Not -> nullPair {_notSide = ns}- HatNot -> Pair {_hatSide = hs, _notSide = ns}--{-# INLINE projSingleMap #-}--- | Complexity:--- - wildcard path: O(queryAxisPosting + c * verify)--- - exact path: expected O(1)-projSingleMap- :: (HatBaseClass b)- => b- -> Map.HashMap (BasePart b) (Pair v)- -> AxisPosting- -> IntMap.IntMap (BasePart b)- -> IntSet.IntSet- -> Map.HashMap (BasePart b) (Pair v)-projSingleMap b m idx idToBp allIds- | haveWiledcard bp =- let !ids = queryAxisPosting (toAxisKeys bp) idx allIds- in IntSet.foldl'- (\acc bpId -> case IntMap.lookup bpId idToBp of- Nothing -> acc- Just bp0 -> case Map.lookup bp0 m of- Nothing -> acc- Just p -> if bp .== bp0- then Map.insert bp0 (choosePairByHat h p) acc- else acc)- Map.empty- ids- | otherwise = case Map.lookup bp m of- Nothing -> Map.empty- Just p -> Map.singleton bp (choosePairByHat h p)- where- !bp = base b- !h = hat b--{-# INLINE mkAlgFromMap #-}--- | Complexity: O(n) to inspect shape and possibly rebuild index.-mkAlgFromMap :: (HatVal v, HatBaseClass b) => Map.HashMap (BasePart b) (Pair v) -> Alg v b-mkAlgFromMap m- | Map.null m = Zero- | otherwise = case Map.toList m of- [(b, p)] -> Maybe.fromMaybe (linerFromMap $ Map.singleton b p) (singlePairToAlg b p)- _ -> linerFromMap m--{-# INLINE singlePairToAlg #-}--- | Complexity: O(1)-singlePairToAlg :: (HatVal v, HatBaseClass b) => BasePart b -> Pair v -> Maybe (Alg v b)-singlePairToAlg b (Pair hs ns) = case (Seq.viewl hs, Seq.viewl ns) of- (Seq.EmptyL, n Seq.:< nsRest) | Seq.null nsRest -> Just (n :@ merge Not b)- (h Seq.:< hsRest, Seq.EmptyL) | Seq.null hsRest -> Just (h :@ merge Hat b)- _ -> Nothing------------------------------------------------------------------------ | Projects only the credit side elements.--- Use this instead of decL when the base contains non-Enum elements such as Text or Int.------ Complexity: O(s) (s is the total number of scalar entries)-projCredit :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b-projCredit = filter (\x -> (whichSide . _hatBase) x == Credit)---- | Projects only the debit side elements.--- Use this instead of decR when the base contains non-Enum elements such as Text or Int.------ Complexity: O(s) (s is the total number of scalar entries)-projDebit :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b-projDebit = filter (\x -> (whichSide . _hatBase) x == Debit)---- | Projects only the elements matching the specified account title.------ Complexity: O(s) (s is the total number of scalar entries)-projByAccountTitle :: (HatVal n, ExBaseClass b) => AccountTitles -> Alg n b -> Alg n b-projByAccountTitle at alg = filter (f at) alg- where- f :: (HatVal n,ExBaseClass b) => AccountTitles -> Alg n b -> Bool- f at Zero = False- f at x = ((getAccountTitle ._hatBase) x) .== at---- | Complexity: O(cost(proj) + cost(bar) + cost(norm)).-projNorm :: (HatVal n, HatBaseClass b) => [b] -> Alg n b -> n-projNorm [] _ = 0-projNorm _ Zero = 0-projNorm bs (v :@ b)- | L.any (.== b) bs = v- | otherwise = 0-projNorm [b] (Liner m idx _ idToBp _ allIds) =- foldProjectedNorm (projSingleMap b m idx idToBp allIds)-projNorm bs (Liner m idx _ idToBp _ allIds) =- foldProjectedNorm $- L.foldl'- (\acc q -> Map.unionWith pairAppend acc (projSingleMap q m idx idToBp allIds))- Map.empty- bs--{-# INLINE foldProjectedNorm #-}--- | Complexity: O(k), where k is the number of projected base keys.-foldProjectedNorm :: (HatVal n) => Map.HashMap k (Pair n) -> n-foldProjectedNorm = Map.foldl' (\acc p -> acc + barNormPair p) 0--{-# INLINE barNormPair #-}--- | Complexity: O(h + n), where h/n are side lengths within the pair.-barNormPair :: (HatVal n) => Pair n -> n-barNormPair (Pair hs ns) =- let !h = Foldable.foldl' (+) 0 hs- !n = Foldable.foldl' (+) 0 ns- in if nearlyEqScaled h n- then 0- else if h > n then h - n else n - h----- | Compute the net balance as the difference of two projections.--- @balanceBy plusBases minusBases alg@ computes--- @projNorm plusBases alg - projNorm minusBases alg@.------ Useful for calculating stock quantities, profits, etc.------ >>> type T = Alg Double (HatBase AccountTitles)--- >>> let alg = 100 :@ Not:<Cash .+ 30 :@ Hat:<Cash :: T--- >>> balanceBy [Not:<Cash] [Hat:<Cash] alg--- 70.0------ >>> balanceBy [Hat:<Cash] [Not:<Cash] alg--- -70.0-balanceBy :: (HatVal n, HatBaseClass b) => [b] -> [b] -> Alg n b -> n-balanceBy plusBases minusBases alg =- projNorm plusBases alg - projNorm minusBases alg---- | Fold algebra entries into a @Map@, combining values with @(+)@.------ The selector function examines each entry @(v, b)@ and optionally returns--- a @(key, value)@ pair. Values for duplicate keys are summed.------ >>> type T = Alg Double (HatBase AccountTitles)--- >>> let alg = 10 :@ Hat:<Cash .+ 20 :@ Hat:<Deposits .+ 5 :@ Hat:<Cash :: T--- >>> let f v (Hat :< a) = Just (a, v); f _ _ = Nothing--- >>> foldEntriesToMap f alg--- fromList [(Cash,15.0),(Deposits,20.0)]-foldEntriesToMap :: (HatVal v, HatBaseClass b, Ord k)- => (v -> b -> Maybe (k, v))- -> Alg v b- -> M.Map k v-foldEntriesToMap f = foldEntries step M.empty- where- step acc v b = case f v b of- Just (k, v') -> M.insertWith (+) k v' acc- Nothing -> acc---- | Projects only current assets.--- Extracts asset items classified as current from the debit side.------ Complexity: O(s) (s is the total number of scalar entries)-projCurrentAssets :: ( HatVal n, ExBaseClass b) => Alg n b -> Alg n b-projCurrentAssets = (filter (\x -> (fixedCurrent . _hatBase) x == Current))- . (filter (\x -> (whatDiv . _hatBase) x == Assets))- . projDebit---- | Projects only fixed assets.--- Extracts asset items classified as fixed from the debit side.------ Complexity: O(s) (s is the total number of scalar entries)-projFixedAssets :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b-projFixedAssets = (filter (\x -> (fixedCurrent . _hatBase) x == Fixed))- . (filter (\x -> (whatDiv . _hatBase) x == Assets))- . projDebit---- | Projects only deferred assets.--- Tax-specific deferred assets are presented under "investments and other assets" with appropriate items such as long-term prepaid expenses.------ Complexity: O(s) (s is the total number of scalar entries)-projDeferredAssets :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b-projDeferredAssets = (filter (\x -> (fixedCurrent . _hatBase) x == Other))- . (filter (\x -> (whatDiv . _hatBase) x == Assets))- . projDebit---- | Projects only current liabilities.--- Extracts liability items classified as current from the credit side.------ Complexity: O(s) (s is the total number of scalar entries)-projCurrentLiability :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b-projCurrentLiability = (filter (\x -> (fixedCurrent . _hatBase) x == Current))- . (filter (\x -> (whatDiv . _hatBase) x == Liability))- . projCredit---- | Projects only fixed liabilities.--- Extracts liability items classified as fixed from the credit side.------ Complexity: O(s) (s is the total number of scalar entries)-projFixedLiability :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b-projFixedLiability = (filter (\x -> (fixedCurrent . _hatBase) x == Fixed))- . (filter (\x -> (whatDiv . _hatBase) x == Liability))- . projCredit---- | Projects only capital stock.------ __Note__: Not yet implemented. Calling this will throw an exception.-projCapitalStock :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b-projCapitalStock = undefined----- * Balance--{- | Handling when the balance does not hold -}--- Complexity: O(1) currently (undefined placeholder).-forceBalance = undefined----- * Rounding---- | Rounding (ceiling).--- Applied to the results of division and multiplication; uses ceiling rounding by default.--- This should be applied to all multiplication and division of account titles.------ Complexity: O(1)-rounding :: NN.Double -> NN.Double-rounding = fromIntegral . ceiling+{-# LANGUAGE TypeOperators #-}++{- |+ Module : ExchangeAlgebra.Algebra+ Copyright : (c) Kaya Akagi. 2018-2026+ Maintainer : yakagika@icloud.com++ Released under the OWL license++ Package for Exchange Algebra defined by Hiroshi Deguchi.++ Exchange Algebra is an algebraic description of bookkeeping system.+ Details are below.++ <https://www.springer.com/gp/book/9784431209850>++ <https://repository.kulib.kyoto-u.ac.jp/dspace/bitstream/2433/82987/1/0809-7.pdf>++ 'Alg' is abstract here: 'Zero' and '(:@)' can be constructed and matched,+ the multi-posting 'ExchangeAlgebra.Algebra.Internal.Liner' representation+ and its cache fields are reachable+ only through "ExchangeAlgebra.Algebra.Internal".++-}++module ExchangeAlgebra.Algebra+ ( module ExchangeAlgebra.Algebra.Base+ , Nearly(..)+ , isNearlyNum+ , nearlyEqScaled+ , Redundant(..)+ , Exchange(..)+ , HatVal(..)+ , Alg(Zero, (:@), _val, _hatBase)+ , isZero+ , (.@)+ , (<@)+ , vals+ , bases+ , fromList+ , toList+ , foldEntries+ , sigma+ , sigma2When+ , sigmaFromMap+ , toASCList+ , map+ , mapPosting+ , mapMaybePosting+ , mapBasePart+ , filter+ , proj+ , projCredit+ , projDebit+ , projByAccountTitle+ , projNetNorm+ , projNorm+ , balanceBy+ , balanceMapBy+ , netPairMapBy+ , foldEntriesToMap+ , decBy+ , postFromNetBy+ , projCurrentAssets+ , projFixedAssets+ , projDeferredAssets+ , projCurrentLiability+ , projFixedLiability+ , projCapitalStock+ , projContraAssets+ , projContra+ , rounding+ , unionsMerge+ ) where++import Prelude hiding (map, filter)+import ExchangeAlgebra.Algebra.Internal+import ExchangeAlgebra.Algebra.Base
src/ExchangeAlgebra/Algebra/Base.hs view
@@ -26,17 +26,18 @@ {-# LANGUAGE ConstrainedClassMethods #-} {-# LANGUAGE DeriveGeneric #-} - module ExchangeAlgebra.Algebra.Base ( module ExchangeAlgebra.Algebra.Base+ , module ExchangeAlgebra.Algebra.Base.Account.Registry+ , module ExchangeAlgebra.Algebra.Base.Account.Types , module ExchangeAlgebra.Algebra.Base.Element) where import ExchangeAlgebra.Algebra.Base.Element+import ExchangeAlgebra.Algebra.Base.Account.Registry+import ExchangeAlgebra.Algebra.Base.Account.Types import Data.Time (Day, TimeOfDay) import GHC.Stack (HasCallStack, callStack, prettyCallStack)-import GHC.Generics (Generic)-import Data.Hashable import qualified Data.Binary as Binary customError :: HasCallStack => String -> a@@ -71,14 +72,22 @@ instance (Element e1, Element e2, Element e3, Element e4, Element e5, Element e6) => BaseClass (e1, e2, e3, e4, e5, e6) where +-- 7-tuple: 'Element'/'AxisDecompose' already provide 7-tuple instances; this+-- closes the gap so every Element tuple arity is also usable as a base.+instance (Element e1, Element e2, Element e3, Element e4, Element e5, Element e6, Element e7)+ => BaseClass (e1, e2, e3, e4, e5, e6, e7) where + ------------------------------------------------------------------ -- ** HatBase ------------------------------------------------------------------ -- | Type class for bases with a Hat component. Provides functionality to decompose and--- compose a base into its Hat part and BasePart. Manages the credit (Hat) / debit (Not)--- distinction at the base level in exchange algebra.+-- compose a base into its Hat part and BasePart. Manages the Hat (decrease) \/+-- Not (increase) label at the base level in exchange algebra. Note that Hat\/Not+-- is __not__ the debit\/credit distinction: the side of a posting is determined+-- by the account division /together with/ this label (see 'whichSide' — an+-- account sits on its home side when 'Not' and on the opposite side when v'Hat'). class (BaseClass a, BaseClass (BasePart a), AxisDecompose (BasePart a)) => HatBaseClass a where -- | The type of the base part excluding the Hat. type BasePart a@@ -116,7 +125,7 @@ instance Binary.Binary Hat instance Element Hat where- wiledcard = HatNot+ wildcard = HatNot {-# INLINE equal #-} equal Hat Hat = True@@ -137,7 +146,7 @@ instance Binary.Binary BaseForSingleHat instance Element BaseForSingleHat where- wiledcard = BaseForSingleHat+ wildcard = BaseForSingleHat equal _ _ = True instance BaseClass BaseForSingleHat where@@ -145,8 +154,15 @@ instance HatBaseClass Hat where type BasePart Hat = BaseForSingleHat hat = id- base x = BaseForSingleHat+ base _ = BaseForSingleHat + -- NB. 'merge'\/'revHat'\/'isHat' below match only @Hat@ and @Not@. The third+ -- v'Hat' constructor @HatNot@ is the formalization-only wildcard state (the+ -- paper convention is the 2-state Hat\/Not; see CLAUDE.md "HatNot wildcard").+ -- These methods are never invoked on a @HatNot@ label by library code, so the+ -- non-exhaustive @-Wincomplete-patterns@ here is by design (audited). Adding a+ -- @HatNot@ case would change behaviour (turn the pattern-match failure into a+ -- different error), so it is intentionally left as-is rather than masked. merge Hat _ = Hat merge Not _ = Not @@ -198,8 +214,8 @@ `hashWithSalt` b -- | Element (HatBase a)--- haveWiledcard--- >>> haveWiledcard (HatNot:<Amount :: HatBase CountUnit)+-- haveWildcard+-- >>> haveWildcard (HatNot:<Amount :: HatBase CountUnit) -- True -- -- (.==)@@ -214,29 +230,29 @@ -- >>> compareHatBase (Not:<Amount :: Test) (Not:<(.#) :: Test) -- EQ ----- ignoreWiledcard--- >>> ignoreWiledcard (Not:<(Products,Yen)) (Hat:<(Products,Amount))+-- ignoreWildcard+-- >>> ignoreWildcard (Not:<(Products,Yen)) (Hat:<(Products,Amount)) -- Hat:<(Products,Amount) ----- >>> ignoreWiledcard (Not:<(Products,Yen)) (Hat:<(Products,(.#)))+-- >>> ignoreWildcard (Not:<(Products,Yen)) (Hat:<(Products,(.#))) -- Hat:<(Products,Yen) ----- >>> ignoreWiledcard (Not:<(Cash,(.#))) (HatNot:<((.#),Amount))+-- >>> ignoreWildcard (Not:<(Cash,(.#))) (HatNot:<((.#),Amount)) -- Not:<(Cash,Amount) instance (BaseClass a) => Element (HatBase a) where- wiledcard = HatNot :<wiledcard+ wildcard = HatNot :<wildcard - haveWiledcard (h:<b)- = isWiledcard h- || haveWiledcard b+ haveWildcard (h:<b)+ = isWildcard h+ || haveWildcard b {-# INLINE equal #-} equal (h1:<b1) (h2:<b2) = h1 .== h2 && b1 .== b2 - ignoreWiledcard (h1:<b1) (h2:<b2)- = (ignoreWiledcard h1 h2) :< (ignoreWiledcard b1 b2)+ ignoreWildcard (h1:<b1) (h2:<b2)+ = (ignoreWildcard h1 h2) :< (ignoreWildcard b1 b2) compareElement (h1:<b1) (h2:<b2)@@ -256,19 +272,19 @@ merge = (:<) {-# INLINE toHat #-}- toHat (h:<b) = Hat:<b+ toHat (_:<b) = Hat:<b {-# INLINE toNot #-}- toNot (h:<b) = Not:<b+ toNot (_:<b) = Not:<b {-# INLINE revHat #-} revHat (Hat :< b) = Not :< b revHat (Not :< b) = Hat :< b {-# INLINE isHat #-}- isHat (Hat :< b) = True- isHat (Not :< b) = False- isHat (HatNot :< b) = customError "called HatNot"+ isHat (Hat :< _) = True+ isHat (Not :< _) = False+ isHat (HatNot :< _) = customError "called HatNot" {-# INLINE isNot #-} isNot = not . isHat@@ -277,13 +293,6 @@ -- * Define ExBase ------------------------------------------------------------ --- | Credit/Debit distinction. Credit is the credit side, Debit is the debit side.--- Side is a wildcard.-data Side = Credit -- ^ Credit side- | Debit -- ^ Debit side- | Side -- ^ Wildcard- deriving (Ord, Show, Eq)- -- | Reverse the credit/debit side. Swaps Credit and Debit. -- The wildcard Side is returned unchanged. --@@ -294,62 +303,30 @@ switchSide Debit = Credit switchSide Side = Side --- | Fixed/Current distinction. Used for classifying account titles as fixed or current.-data FixedCurrent = Fixed -- ^ Fixed- | Current -- ^ Current- | Other -- ^ Other (expenses, revenues, etc.)- deriving (Show, Eq)+-- | Default (home) side of an account division before any contra reversal:+-- Assets\/Cost are debit-normal, Liability\/Equity\/Revenue are credit-normal.+-- The actual home side of a base is this, reversed when 'isContra' holds+-- (contract: @isContra b == (homeSide of b \/= defaultSide (whatDiv b))@).+--+-- Complexity: O(1)+{-# INLINE defaultSide #-}+defaultSide :: AccountDivision -> Side+defaultSide Assets = Debit+defaultSide Cost = Debit+defaultSide Liability = Credit+defaultSide Equity = Credit+defaultSide Revenue = Credit -- | Classify an account title into an account division (Assets/Equity/Liability/Cost/Revenue). -- -- Complexity: O(1) {-# INLINE classifyAccountDivision #-} classifyAccountDivision :: HasCallStack => AccountTitles -> AccountDivision-classifyAccountDivision AccountTitle = customError "this is wiledcard AccountTitle"-classifyAccountDivision CapitalStock = Equity-classifyAccountDivision RetainedEarnings = Equity-classifyAccountDivision LongTermLoansPayable = Liability-classifyAccountDivision ShortTermLoansPayable = Liability-classifyAccountDivision LoansPayable = Liability-classifyAccountDivision ReserveForDepreciation = Liability-classifyAccountDivision DepositPayable = Liability-classifyAccountDivision LongTermNationalBondsPayable = Liability-classifyAccountDivision ShortTermNationalBondsPayable = Liability-classifyAccountDivision ReserveDepositPayable = Liability-classifyAccountDivision CentralBankNotePayable = Liability-classifyAccountDivision Depreciation = Cost-classifyAccountDivision SalesCost = Cost-classifyAccountDivision BusinessTrip = Cost-classifyAccountDivision Commutation = Cost-classifyAccountDivision UtilitiesExpense = Cost-classifyAccountDivision RentExpense = Cost-classifyAccountDivision AdvertisingExpense = Cost-classifyAccountDivision DeliveryExpenses = Cost-classifyAccountDivision SuppliesExpenses = Cost-classifyAccountDivision MiscellaneousExpenses = Cost-classifyAccountDivision WageExpenditure = Cost-classifyAccountDivision InterestExpense = Cost-classifyAccountDivision TaxesExpense = Cost-classifyAccountDivision ConsumptionExpenditure = Cost-classifyAccountDivision SubsidyExpense = Cost-classifyAccountDivision CentralBankPaymentExpense = Cost-classifyAccountDivision Purchases = Cost-classifyAccountDivision NetIncome = Cost-classifyAccountDivision ValueAdded = Revenue-classifyAccountDivision SubsidyIncome = Revenue-classifyAccountDivision NationalBondInterestEarned = Revenue-classifyAccountDivision DepositInterestEarned = Revenue-classifyAccountDivision GrossProfit = Revenue-classifyAccountDivision OrdinaryProfit = Revenue-classifyAccountDivision InterestEarned = Revenue-classifyAccountDivision ReceiptFee = Revenue-classifyAccountDivision RentalIncome = Revenue-classifyAccountDivision WageEarned = Revenue-classifyAccountDivision TaxesRevenue = Revenue-classifyAccountDivision CentralBankPaymentIncome = Revenue-classifyAccountDivision Sales = Revenue-classifyAccountDivision NetLoss = Revenue-classifyAccountDivision _ = Assets+classifyAccountDivision AccountTitle = customError "this is wildcard AccountTitle"+classifyAccountDivision title =+ case accountSpec title of+ Just spec -> asDivision spec+ Nothing -> customError "this is wildcard AccountTitle" -- | BaseClass ⊃ HatBaseClass ⊃ ExBaseClass --@@ -373,37 +350,46 @@ whatDiv :: a -> AccountDivision whatDiv = classifyAccountDivision . getAccountTitle - -- | Retrieve the PIMO classification (PS/IN/MS/OUT). Complexity: O(1)+ -- | Whether the account is a contra account (評価勘定等): its home side+ -- and PIMO direction are the reverse of its division's defaults.+ -- Delegates to the registry ('classifyAccountContra') exactly like+ -- 'whatDiv' delegates to 'classifyAccountDivision' — a constant default+ -- would disconnect the registry flag from every built-in instance.+ -- Contract: @isContra b == (homeSide of b \/= defaultSide (whatDiv b))@.+ -- Complexity: O(1)+ {-# INLINE isContra #-}+ isContra :: a -> Bool+ isContra = classifyAccountContra . getAccountTitle++ -- | Retrieve the PIMO direction (PS/IN/MS/OUT; see 'PIMO' for the+ -- original semantics). Derived from the division via 'pimoFromDivision',+ -- flipped by 'pimoFlip' for contra accounts — e.g. a contra asset is MS+ -- (minus stock), which is what makes the standard allowance entry+ -- OUT ⇔ MS legal under Proposition 5.3.8. Complexity: O(1) {-# INLINE whatPIMO #-} whatPIMO :: a -> PIMO- whatPIMO x =- case whatDiv x of- Assets -> PS- Equity -> MS- Liability -> MS- Cost -> OUT- Revenue -> IN+ whatPIMO x+ | isContra x = pimoFlip (pimoFromDivision (whatDiv x))+ | otherwise = pimoFromDivision (whatDiv x) -- | Determine whether a base belongs to the Credit or Debit side.- -- Takes the Hat/Not reversal into account. Complexity: O(1)+ -- The home side is 'defaultSide' of the division, reversed for contra+ -- accounts ('isContra'). Takes the Hat/Not reversal into account: an+ -- account sits on its home side under 'Not' and on the opposite side+ -- under v'Hat'. A 'HatNot' (wildcard) label is rejected with an error —+ -- same policy as 'isHat': stored postings are always Hat\/Not, so a+ -- wildcard here means a query-side value leaked into a posting-side+ -- computation (this function previously treated 'HatNot' silently as+ -- v'Hat'). Complexity: O(1) {-# INLINE whichSide #-} whichSide :: a -> Side whichSide x =- let side = f (whatDiv x)- in if hat x == Not then side else switchSide side- where- {-# INLINE f #-}- f Assets = Debit- f Cost = Debit- f Liability = Credit- f Equity = Credit- f Revenue = Credit-- -- credit :: [a] -- ^ Use projCredit when Elem contains Text, Int, etc.- -- credit = L.filter (\x -> whichSide x == Credit) [toEnum 0 ..]-- -- debit :: [a] -- ^ Use projDebit when Elem contains Text, Int, etc.- -- debit = L.filter (\x -> whichSide x == Debit) [toEnum 0 ..]+ let side0 = defaultSide (whatDiv x)+ side = if isContra x then switchSide side0 else side0+ in case hat x of+ Not -> side+ Hat -> switchSide side+ HatNot -> customError "whichSide: called on a HatNot (wildcard) base" -- | Retrieve the fixed/current classification. -- Returns Current, Fixed, or Other based on the account title.@@ -411,70 +397,7 @@ -- Complexity: O(1) {-# INLINE fixedCurrent #-} fixedCurrent :: a -> FixedCurrent- fixedCurrent b = f (getAccountTitle b)- where- {-# INLINE f #-}- f Cash = Current- f Deposits = Current- f CurrentDeposits = Current- f Securities = Current- f InvestmentSecurities = Fixed- f LongTermNationalBonds = Fixed- f ShortTermNationalBonds = Current- f Products = Current- f Machinery = Fixed- f Building = Fixed- f Vehicle = Fixed- f StockInvestment = Other -- Note- f EquipmentInvestment = Fixed- f LongTermLoansReceivable = Fixed- f ShortTermLoansReceivable = Current- f ReserveDepositReceivable = Current- f Gold = Fixed- f GovernmentService = Current- f CapitalStock = Other- f RetainedEarnings = Other- f ShortTermLoansPayable = Current- f LoansPayable = Current- f LongTermLoansPayable = Fixed- f ReserveForDepreciation = Current- f DepositPayable = Current- f LongTermNationalBondsPayable = Fixed- f ShortTermNationalBondsPayable = Current- f ReserveDepositPayable = Current- f CentralBankNotePayable = Current- f Depreciation = Other- f SalesCost = Other- f BusinessTrip = Other- f Commutation = Other- f UtilitiesExpense = Other- f RentExpense = Other- f AdvertisingExpense = Other- f DeliveryExpenses = Other- f SuppliesExpenses = Other- f MiscellaneousExpenses = Other- f WageExpenditure = Other- f InterestExpense = Other- f TaxesExpense = Other- f ConsumptionExpenditure = Other- f SubsidyExpense = Other- f CentralBankPaymentExpense = Other- f Purchases = Other- f NetIncome = Other- f ValueAdded = Other- f SubsidyIncome = Other- f NationalBondInterestEarned = Other- f DepositInterestEarned = Other- f GrossProfit = Other- f OrdinaryProfit = Other- f InterestEarned = Other- f ReceiptFee = Other- f RentalIncome = Other- f WageEarned = Other- f TaxesRevenue = Other- f CentralBankPaymentIncome = Other- f NetLoss = Other- f AccountTitle = Other+ fixedCurrent b = maybe Other asFixedCurrent (accountSpec (getAccountTitle b)) -- | Type class for determining correspondences between account divisions.@@ -486,32 +409,54 @@ -- | Test whether two account divisions are in a corresponding relationship. (<=>) :: a -> a -> Bool -data AccountDivision = Assets -- ^ Assets- | Equity -- ^ Equity- | Liability -- ^ Liability- | Cost -- ^ Cost- | Revenue -- ^ Revenue- deriving (Ord, Show, Eq)-+-- | Derived from the PIMO relation via 'pimoFromDivision', matching+-- Proposition 5.3.8 (Deguchi 2004). BREAKING (0.5.0.0): the previous+-- hand-enumerated instance omitted the pairs required by PS ⇔ IN and+-- OUT ⇔ IN — @Assets \<=\> Revenue@ (e.g. a cash sale) and+-- @Cost \<=\> Revenue@ are now 'True'. This division-level relation cannot+-- see contra reversal; exchange checks on bases must go through 'whatPIMO'. instance AccountBase AccountDivision where- Assets <=> Liability = True- Liability <=> Assets = True- Assets <=> Equity = True- Equity <=> Assets = True- Cost <=> Liability = True- Liability <=> Cost = True- Cost <=> Equity = True- Equity <=> Cost = True- _ <=> _ = False+ a <=> b = pimoFromDivision a <=> pimoFromDivision b --- | PIMO classification. Categories in exchange algebra: Product Stock (PS), Income (IN),--- Money Stock (MS), and Outflow (OUT).-data PIMO = PS -- ^ Product Stock (Assets: production stock)- | IN -- ^ Income (Revenue: income flow)- | MS -- ^ Money Stock (Liability/Equity: monetary stock)- | OUT -- ^ Outflow (Cost: expenditure flow)+-- | PIMO direction. In Proposition 5.3.8 (Deguchi 2004, pp.89-91) PS, IN,+-- MS and OUT mean __plus stock, input, minus stock and output__ —+-- directions of exchange, not statement labels. The allowed exchange pairs+-- are exactly PS ⇔ IN, PS ⇔ MS, OUT ⇔ IN, OUT ⇔ MS (the 'AccountBase'+-- instance below). The earlier Haddock glossed these as "Product Stock \/+-- Income \/ Money Stock \/ Outflow"; that was naming drift from the+-- original and is kept only as a mnemonic.+data PIMO = PS -- ^ plus stock (stock increase; non-contra Assets)+ | IN -- ^ input (flow in; Revenue)+ | MS -- ^ minus stock (stock decrease; Liability\/Equity and contra assets)+ | OUT -- ^ output (flow out; Cost) deriving (Ord, Show, Eq) +-- | The division-to-PIMO map of the standard interpretation (the @g@ of+-- Proposition 5.3.8 restricted to non-contra accounts): Assets are plus+-- stock, Liability\/Equity are minus stock, Cost is output, Revenue is+-- input. Contra accounts flip this via 'pimoFlip' (see 'whatPIMO').+--+-- Complexity: O(1)+{-# INLINE pimoFromDivision #-}+pimoFromDivision :: AccountDivision -> PIMO+pimoFromDivision Assets = PS+pimoFromDivision Equity = MS+pimoFromDivision Liability = MS+pimoFromDivision Cost = OUT+pimoFromDivision Revenue = IN++-- | Direction flip used for contra accounts: PS ↔ MS, IN ↔ OUT.+-- Self-inverse, and it preserves the exchange relation:+-- @x \<=\> y@ implies @pimoFlip x \<=\> pimoFlip y@.+--+-- Complexity: O(1)+{-# INLINE pimoFlip #-}+pimoFlip :: PIMO -> PIMO+pimoFlip PS = MS+pimoFlip MS = PS+pimoFlip IN = OUT+pimoFlip OUT = IN+ instance AccountBase PIMO where PS <=> IN = True IN <=> PS = True@@ -536,8 +481,8 @@ instance BaseClass AccountTitles where instance ExBaseClass (HatBase AccountTitles) where- getAccountTitle (h :< a) = a- setAccountTitle (h :< a) b = h :< b+ getAccountTitle (_ :< a) = a+ setAccountTitle (h :< _) b = h :< b -- *** Name only (redundant algebra base) instance BaseClass Name where@@ -559,38 +504,38 @@ -- | Basic BaseClass with 2 elements instance ExBaseClass (HatBase (AccountTitles, Day)) where- getAccountTitle (h:< (a, d)) = a- setAccountTitle (h:< (a, d)) b = h:< (b, d)+ getAccountTitle (_:< (a, _)) = a+ setAccountTitle (h:< (_, d)) b = h:< (b, d) instance ExBaseClass (HatBase (AccountTitles, Name)) where- getAccountTitle (h:< (a, n)) = a- setAccountTitle (h:< (a, n)) b = h:< (b, n)+ getAccountTitle (_:< (a, _)) = a+ setAccountTitle (h:< (_, n)) b = h:< (b, n) instance ExBaseClass (HatBase (CountUnit, AccountTitles)) where- getAccountTitle (h:< (u, a)) = a- setAccountTitle (h:< (u, a)) b = h:< (u, b)+ getAccountTitle (_:< (_, a)) = a+ setAccountTitle (h:< (u, _)) b = h:< (u, b) -- ** 3-element bases -- | Basic BaseClass with 3 elements instance ExBaseClass (HatBase (AccountTitles, Name, CountUnit)) where- getAccountTitle (h:< (a, n, c)) = a- setAccountTitle (h:< (a, n, c)) b = h:< (b, n, c)+ getAccountTitle (_:< (a, _, _)) = a+ setAccountTitle (h:< (_, n, c)) b = h:< (b, n, c) -- ** 4-element bases -- | Basic BaseClass with 4 elements instance ExBaseClass (HatBase (AccountTitles, Name, CountUnit, Subject)) where- getAccountTitle (h:< (a, n, c, s)) = a- setAccountTitle (h:< (a, n, c, s)) b = h:< (b, n, c, s)+ getAccountTitle (_:< (a, _, _, _)) = a+ setAccountTitle (h:< (_, n, c, s)) b = h:< (b, n, c, s) -- ** 5-element bases -- | Basic BaseClass with 5 elements instance ExBaseClass (HatBase (AccountTitles, Name, CountUnit, Subject, Day)) where- getAccountTitle (h:< (a, n, c, s, d)) = a- setAccountTitle (h:< (a, n, c, s, d)) b = h:< (b, n, c, s, d)+ getAccountTitle (_:< (a, _, _, _, _)) = a+ setAccountTitle (h:< (_, n, c, s, d)) b = h:< (b, n, c, s, d) -- ** 6-element bases -- | Basic BaseClass with 6 elements instance ExBaseClass (HatBase (AccountTitles, Name, CountUnit, Subject, Day, TimeOfDay)) where- getAccountTitle (h:< (a, n, c, s, d, t)) = a- setAccountTitle (h:< (a, n, c, s, d, t)) b = h:< (b, n, c, s, d, t)+ getAccountTitle (_:< (a, _, _, _, _, _)) = a+ setAccountTitle (h:< (_, n, c, s, d, t)) b = h:< (b, n, c, s, d, t)
+ src/ExchangeAlgebra/Algebra/Base/Account/JcciAliases.hs view
@@ -0,0 +1,216 @@+{-# LANGUAGE OverloadedStrings #-}+{-# OPTIONS_GHC -Wno-deprecations #-}++-- Generated by tools/gen-jcci-aliases.py. Do not edit by hand.+module ExchangeAlgebra.Algebra.Base.Account.JcciAliases+ ( jcciAliases ) where++import Data.Text (Text)+import ExchangeAlgebra.Algebra.Base.Element (AccountTitles(..))++jcciAliases :: AccountTitles -> [Text]+jcciAliases AccountsPayable = ["〇〇商店", "買掛金"]+jcciAliases AccountsReceivable = ["売掛金", "〇〇商店"]+jcciAliases AccruedConsumptionTax = ["未払金", "未払消費税"]+jcciAliases AccruedCorporateIncomeTaxes = ["未払法人税等"]+jcciAliases AccruedExpenses = ["未払費用", "未払利息"]+jcciAliases AccruedRevenue = ["未収収益", "未収家賃"]+jcciAliases AccumulatedDepreciation = ["建物減価償却累計額", "減価償却累計額", "備品減価償却累計額", "車両運搬具減価償却累計額", "車両減価償却累計額"]+jcciAliases AdditionalIncomeTaxesForPriorPeriods = ["追徴法人税等"]+jcciAliases AdvancesPaid = ["前払金", "前渡金"]+jcciAliases AdvancesReceived = ["前受金", "契約負債"]+jcciAliases AdvertisingExpense = ["広告宣伝費", "広告費", "広告料", "宣伝費"]+jcciAliases AffiliateStocks = ["関係会社株式", "関連会社株式"]+jcciAliases AllowanceForBonuses = ["賞与引当金", "役員賞与引当金"]+jcciAliases AllowanceForDoubtfulAccounts = ["貸倒引当金"]+jcciAliases AllowanceForProductWarranties = ["商品保証引当金", "製品保証引当金"]+jcciAliases AllowanceForRepairs = ["修繕引当金", "特別修繕引当金"]+jcciAliases AllowanceForRetirementBenefits = ["退職給付引当金"]+jcciAliases AmortizationOfGoodwill = ["のれん償却"]+jcciAliases AmortizationOfPatents = ["特許権償却"]+jcciAliases AmortizationOfSoftware = ["ソフトウェア償却"]+jcciAliases AvailableForSaleSecurities = ["投資有価証券", "その他有価証券"]+jcciAliases BadDebtLoss = ["貸倒損失"]+jcciAliases BankOverdraft = ["借入金", "当座借越"]+jcciAliases Bonuses = ["賞与", "役員賞与"]+jcciAliases BonusesPayable = ["未払賞与", "未払役員賞与", "未払賞与金"]+jcciAliases BranchCurrentAccount = ["支店"]+jcciAliases Building = ["建物"]+jcciAliases BusinessCommencementExpenses = ["開業費"]+jcciAliases BusinessTrip = ["旅費交通費", "旅費", "交通費"]+jcciAliases CapitalStock = ["資本金"]+jcciAliases CapitalSurplus = ["資本剰余金"]+jcciAliases Cash = ["現金"]+jcciAliases CashOverShort = ["現金過不足"]+jcciAliases CommunicationExpenses = ["通信費"]+jcciAliases Commutation = ["通信費"]+jcciAliases ConstructionFundReserve = ["新築積立金"]+jcciAliases ConstructionInProgress = ["建設仮勘定", "建設仮", "建設前渡金", "建設仮払金"]+jcciAliases ConsumptionTaxPaid = ["仮払金", "仮払消費税"]+jcciAliases ConsumptionTaxReceived = ["仮受金", "仮受消費税"]+jcciAliases ConsumptionTaxRefundReceivable = ["未収還付消費税", "未収消費税"]+jcciAliases ContraAccountForGuaranteeObligations = ["保証債務見返"]+jcciAliases ContractAssets = ["契約資産"]+jcciAliases CorporateIncomeTaxes = ["法人税、住民税及び事業税", "法人税等"]+jcciAliases CostOfServices = ["役務原価", "営業費用"]+jcciAliases CreditCardReceivable = ["クレジット売掛金"]+jcciAliases CurrentDeposits = ["当座預金", "当座預金○○銀行"]+jcciAliases DeferredTaxAssets = ["繰延税金資産"]+jcciAliases DeferredTaxLiabilities = ["繰延税金負債"]+jcciAliases DeliveryExpenses = ["発送費", "支払運賃", "発送運賃"]+jcciAliases Deposits = ["普通預金", "銀行預金", "普通預金○○銀行"]+jcciAliases DepositsInSpecialAccounts = ["銀行預金", "別段預金"]+jcciAliases DepositsReceived = ["預り金", "従業員預り金", "所得税預り金", "住民税預り金", "社会保険料預り金"]+jcciAliases DepositsReceivedFromOfficers = ["役員預り金"]+jcciAliases Depreciation = ["減価償却費", "建物減価償却費", "備品減価償却費"]+jcciAliases DevelopmentExpenses = ["開発費"]+jcciAliases DishonoredNotesReceivable = ["不渡手形"]+jcciAliases DividendEqualizationReserve = ["配当平均積立金"]+jcciAliases DividendsReceived = ["受取配当金"]+jcciAliases EarnedSurplus = ["利益剰余金"]+jcciAliases ElectronicallyRecordedObligations = ["電子記録債務"]+jcciAliases ElectronicallyRecordedReceivable = ["電子記録債権"]+jcciAliases Fixtures = ["備品"]+jcciAliases ForeignExchangeGains = ["為替差損益", "為替差益"]+jcciAliases ForeignExchangeLosses = ["為替差損益", "為替差損"]+jcciAliases GainOnBargainPurchase = ["負ののれん発生益"]+jcciAliases GainOnConstructionGrants = ["工事負担金受贈益"]+jcciAliases GainOnDonationOfFixedAssets = ["固定資産受贈益"]+jcciAliases GainOnNationalSubsidies = ["国庫補助金受贈益"]+jcciAliases GainOnSalesOfFixedAssets = ["固定資産売却益", "備品売却益", "土地売却益", "建物売却益"]+jcciAliases GainOnSalesOfInvestmentSecurities = ["投資有価証券売却益", "その他有価証券売却益"]+jcciAliases GainOnSalesOfSecurities = ["有価証券売却益", "有価証券売買益", "有価証券運用益"]+jcciAliases GainOnValuationOfSecurities = ["有価証券運用益", "有価証券評価益", "有価証券評価損益", "有価証券運用損益"]+jcciAliases GeneralReserve = ["別途積立金"]+jcciAliases GiftCertificatesReceived = ["受取商品券"]+jcciAliases Goodwill = ["のれん"]+jcciAliases GuaranteeDepositsReceived = ["預り保証金", "受入保証金"]+jcciAliases GuaranteeObligations = ["保証債務"]+jcciAliases HeadOfficeCurrentAccount = ["本店"]+jcciAliases HeldToMaturityBonds = ["満期保有目的債券", "投資有価証券"]+jcciAliases IncomeSummary = ["損益"]+jcciAliases IncomeTaxesAdjustment = ["法人税等調整額"]+jcciAliases IncomeTaxesRefundReceivable = ["未収還付法人税等"]+jcciAliases IncorporationExpenses = ["創立費"]+jcciAliases InsuranceExpense = ["保険料", "支払保険料", "火災保険料"]+jcciAliases InsuranceGain = ["保険差益"]+jcciAliases InterestEarned = ["受取利息"]+jcciAliases InterestExpense = ["支払利息"]+jcciAliases InterestOnSecurities = ["有価証券利息", "受取社債利息"]+jcciAliases InventoryShrinkageLoss = ["棚卸減耗損", "棚卸減耗費"]+jcciAliases InvestmentSecurities = ["投資有価証券"]+jcciAliases Land = ["土地"]+jcciAliases LandRentPaid = ["地代家賃", "支払賃借料", "支払不動産賃借料", "支払地代"]+jcciAliases LandRentReceived = ["受取地代"]+jcciAliases LeaseExpenses = ["支払リース料"]+jcciAliases LeaseObligations = ["リース債務"]+jcciAliases LeasedAssets = ["リース資産"]+jcciAliases LeaseholdRights = ["借地権"]+jcciAliases LegalCapitalSurplus = ["資本準備金", "株式払込剰余金"]+jcciAliases LegalRetainedEarnings = ["利益準備金"]+jcciAliases LoansPayable = ["借入金", "銀行借入金", "役員借入金"]+jcciAliases LoansReceivable = ["貸付金", "従業員貸付金", "役員貸付金"]+jcciAliases LongTermLoansPayable = ["長期借入金"]+jcciAliases LongTermLoansReceivable = ["長期貸付金"]+jcciAliases LongTermOtherPayables = ["長期未払金"]+jcciAliases LongTermPrepaidExpenses = ["長期前払費用"]+jcciAliases LossOnFire = ["火災損失", "災害損失"]+jcciAliases LossOnReductionOfFixedAssets = ["固定資産圧縮損", "備品圧縮損", "建物圧縮損", "機械装置圧縮損"]+jcciAliases LossOnRetirementOfFixedAssets = ["固定資産除却損", "固定資産廃棄損", "備品除却損", "建物除却損", "除却損"]+jcciAliases LossOnSalesOfElectronicallyRecordedReceivables = ["電子記録債権売却損"]+jcciAliases LossOnSalesOfFixedAssets = ["固定資産売却損", "備品売却損", "建物売却損", "土地売却損"]+jcciAliases LossOnSalesOfInvestmentSecurities = ["投資有価証券売却損", "その他有価証券売却損"]+jcciAliases LossOnSalesOfNotesReceivable = ["手形売却損"]+jcciAliases LossOnSalesOfReceivables = ["債権売却損"]+jcciAliases LossOnSalesOfSecurities = ["有価証券売却損", "有価証券売買損", "有価証券運用損"]+jcciAliases LossOnValuationOfMerchandise = ["商品評価損", "棚卸評価損"]+jcciAliases LossOnValuationOfSecurities = ["有価証券運用損", "有価証券評価損", "有価証券評価損益", "有価証券運用損益"]+jcciAliases Machinery = ["機械装置", "機械"]+jcciAliases MaintenanceExpenses = ["保守費", "維持費", "支払メンテナンス料"]+jcciAliases MembershipFees = ["諸会費"]+jcciAliases MerchandiseInventory = ["繰越商品"]+jcciAliases MiscellaneousExpenses = ["雑費"]+jcciAliases MiscellaneousIncome = ["雑益", "雑収入", "雑収益"]+jcciAliases MiscellaneousLoss = ["雑損", "雑損失"]+jcciAliases NetDefinedBenefitAsset = ["退職給付に係る資産"]+jcciAliases NetDefinedBenefitLiability = ["退職給付に係る負債"]+jcciAliases NetIncomeAttributableToNCI = ["非支配株主に帰属する当期純利益"]+jcciAliases NetLossAttributableToNCI = ["非支配株主に帰属する当期純損失"]+jcciAliases NonControllingInterests = ["非支配株主持分"]+jcciAliases NonOperatingElectronicallyRecordedObligations = ["営業外電子記録債務"]+jcciAliases NonOperatingElectronicallyRecordedReceivable = ["営業外電子記録債権"]+jcciAliases NonOperatingNotesPayable = ["営業外支払手形"]+jcciAliases NonOperatingNotesReceivable = ["営業外受取手形"]+jcciAliases NotesLoansPayable = ["借入金", "手形借入金"]+jcciAliases NotesLoansReceivable = ["貸付金", "手形貸付金"]+jcciAliases NotesPayable = ["支払手形"]+jcciAliases NotesReceivable = ["受取手形"]+jcciAliases OperatingExpenses = ["営業費用"]+jcciAliases OperatingRevenue = ["営業収益"]+jcciAliases OtherCapitalSurplus = ["その他資本剰余金"]+jcciAliases OtherPayables = ["未払金"]+jcciAliases OtherReceivables = ["未収入金", "未収金"]+jcciAliases Patent = ["特許権"]+jcciAliases PaymentFees = ["支払手数料", "販売手数料"]+jcciAliases PaymentsOnBehalf = ["立替金", "従業員立替金"]+jcciAliases PettyCash = ["小口現金"]+jcciAliases PrepaidCorporateIncomeTaxes = ["仮払金", "仮払法人税等"]+jcciAliases PrepaidExpenses = ["前払費用", "前払保険料"]+jcciAliases PrepaidPensionCost = ["前払年金費用"]+jcciAliases Products = ["商品"]+jcciAliases PropertyTaxPayable = ["未払固定資産税"]+jcciAliases ProvisionForBonuses = ["賞与引当金繰入", "役員賞与引当金繰入"]+jcciAliases ProvisionForDoubtfulAccounts = ["貸倒引当金繰入", "貸倒引当金繰入額"]+jcciAliases ProvisionForProductWarranties = ["商品保証引当金繰入", "製品保証引当金繰入"]+jcciAliases ProvisionForRepairs = ["修繕引当金繰入", "特別修繕引当金繰入"]+jcciAliases PurchaseRebates = ["仕入割戻"]+jcciAliases Purchases = ["仕入"]+jcciAliases ReceiptFee = ["受取手数料"]+jcciAliases RecoveryOfBadDebts = ["償却債権取立益"]+jcciAliases RefundLiabilities = ["返金負債"]+jcciAliases RefundOfIncomeTaxes = ["還付法人税等"]+jcciAliases RentExpense = ["支払家賃", "地代家賃", "支払賃借料", "支払不動産賃借料"]+jcciAliases RentalIncome = ["受取家賃"]+jcciAliases RepairFundReserve = ["修繕積立金"]+jcciAliases RepairsExpense = ["修繕費", "支払修繕料", "修理費"]+jcciAliases ResearchAndDevelopmentExpenses = ["研究開発費"]+jcciAliases RetainedEarnings = ["繰越利益剰余金"]+jcciAliases RetirementBenefitExpenses = ["退職給付費用", "退職給付引当金繰入", "退職給付引当損"]+jcciAliases ReversalOfAllowanceForDoubtfulAccounts = ["貸倒引当金戻入", "貸倒引当金戻入益"]+jcciAliases ReversalOfAllowanceForProductWarranties = ["商品保証引当金戻入", "製品保証引当金戻入"]+jcciAliases ReversalOfAllowanceForRepairs = ["修繕引当金戻入"]+jcciAliases Sales = ["売上"]+jcciAliases SalesCost = ["売上原価"]+jcciAliases SalesRebates = ["売上割戻"]+jcciAliases Securities = ["有価証券"]+jcciAliases SecurityDepositsPaid = ["差入保証金"]+jcciAliases ServiceRevenue = ["役務収益", "営業収益"]+jcciAliases ShortTermLoansPayable = ["短期借入金"]+jcciAliases ShortTermLoansReceivable = ["短期貸付金"]+jcciAliases Software = ["ソフトウェア"]+jcciAliases SoftwareInProgress = ["ソフトウェア仮勘定"]+jcciAliases StatutoryWelfareExpenses = ["法定福利費", "社会保険料"]+jcciAliases StockIssuanceCosts = ["株式交付費"]+jcciAliases StockSubscriptionDeposits = ["株式申込証拠金", "申込証拠金", "新株式申込証拠金"]+jcciAliases StorageExpenses = ["保管費", "保管料", "倉庫料"]+jcciAliases Structures = ["構築物"]+jcciAliases SubsidiaryStocks = ["子会社株式", "関係会社株式"]+jcciAliases SuppliesExpenses = ["消耗品費", "事務用消耗品費"]+jcciAliases SuppliesOnHand = ["貯蔵品"]+jcciAliases SuspenseAccount = ["未決算", "火災未決算", "保険未決算"]+jcciAliases SuspensePayments = ["仮払金"]+jcciAliases SuspenseReceipts = ["仮受金"]+jcciAliases TaxesExpense = ["租税公課", "公租公課", "固定資産税", "印紙税"]+jcciAliases TimeDeposits = ["銀行預金", "定期預金", "定期預金○○銀行"]+jcciAliases ToolsAndInstruments = ["工具器具"]+jcciAliases Trademark = ["商標権"]+jcciAliases TradingSecurities = ["売買目的有価証券", "有価証券"]+jcciAliases UnearnedRevenue = ["前受収益", "前受地代"]+jcciAliases UnpaidDividends = ["未払配当金", "未払株主配当金"]+jcciAliases UtilitiesExpense = ["水道光熱費", "光熱水費"]+jcciAliases ValuationDifferenceOnOtherSecurities = ["その他有価証券評価差額金"]+jcciAliases Vehicle = ["車両運搬具", "車両", "運搬具"]+jcciAliases WageExpenditure = ["給料", "給料手当", "賃金給料"]+jcciAliases WelfareExpenses = ["福利厚生費"]+jcciAliases WorkInProcess = ["仕掛品"]+jcciAliases _ = []
+ src/ExchangeAlgebra/Algebra/Base/Account/Registry.hs view
@@ -0,0 +1,3940 @@+{-# LANGUAGE OverloadedStrings #-}+{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}+-- The exhaustive registry intentionally retains the deprecated Commutation+-- constructor so that legacy behaviour remains byte-for-byte stable.+{-# OPTIONS_GHC -Wno-deprecations #-}++{- |+Module : ExchangeAlgebra.Algebra.Base.Account.Registry+Description : Canonical metadata registry for concrete account titles.++The exhaustive 'accountSpec' case is the single source of truth for account+classification, fixed/current status, and bilingual descriptions. 'accountAliases'+combines its base aliases with the frozen JCCI 2022 standard/permitted-name overlay.+-}+module ExchangeAlgebra.Algebra.Base.Account.Registry+ ( AccountSpec(..)+ , AccountSemantics(..)+ , accountAliases+ , accountSpec+ , accountSemantics+ , accountSpecMap+ , concreteAccountTitles+ , classifyAccountContra+ , accountDescriptions+ ) where++import qualified Data.List as L+import qualified Data.Map.Strict as M+import Data.Map.Strict (Map)+import Data.Maybe (mapMaybe)+import Data.Text (Text)++import ExchangeAlgebra.Algebra.Base.Account.Types+ ( AccountDivision(..), AccountRole(..), ClosingRule(..)+ , DivisionSemantics(..), FixedCurrent(..), HomeSideSemantics(..)+ , PostingCapability(..), ReportingEligibility(..), Side(..) )+import ExchangeAlgebra.Algebra.Base.Account.JcciAliases (jcciAliases)+import ExchangeAlgebra.Algebra.Base.Element (AccountTitles(..))++-- | All metadata attached to one concrete account title.+data AccountSpec = AccountSpec+ { asDivision :: AccountDivision+ , asClosing :: ClosingRule+ -- ^ Automatic final-stock closing policy. 'NetIncome' and 'NetLoss' are+ -- __permanent__ explicit 'NoClose' overrides (adjudicated 2026-08-11):+ -- their division encodes the P\/L /presentation side/ (profit sits on+ -- the debit side to balance the statement), so the division-derived+ -- rule would invert the transfer sign. Their closing is owned by the+ -- dedicated net-income transfer pipeline in+ -- "ExchangeAlgebra.Algebra.Transfer" (correct signs, engine-inserted+ -- balancing postings). GrossProfit and OrdinaryProfit retain+ -- CloseByDivision for the historical SNA/simulation transfer pipeline.+ -- That asymmetry is compatibility behaviour, not a reporting rule;+ -- statement metrics live in "ExchangeAlgebra.Reporting.Metric".+ , asIsContra :: Bool+ , asFixedCurrent :: FixedCurrent+ , asNameEn :: Text+ , asNameJa :: Text+ -- | Annotation-free Japanese display label (清書和文表示名) used for+ -- financial-statement lines+ -- ('ExchangeAlgebra.Reporting.Presentation.presentationLabel') and LLM-facing+ -- names ('ExchangeAlgebra.Assist.safeNameJa'). Unlike 'asNameJa' it never+ -- carries notes, legacy-name+ -- remarks, or constructor identifiers; the JCCI level-2 A-column sweep in+ -- the test suite pins it against the official standard names.+ , asLabelJa :: Text+ , asDescription :: Text+ -- | Registry-local aliases only. Use 'accountAliases' when constructing a+ -- parser or UI: that function also includes the frozen JCCI overlay.+ , asAliases :: [Text]+ } deriving (Show, Eq)++-- | Processing and reporting semantics attached to a concrete account title.+--+-- This record deliberately does not replace 'AccountSpec.asDivision'. The+-- legacy division remains the exchange-algebra direction input, while this+-- layer states whether that value is a financial-statement classification,+-- a bookkeeping control class, or only an internal direction encoding.+data AccountSemantics = AccountSemantics+ { asemRoles :: [AccountRole]+ , asemPostingCapability :: PostingCapability+ , asemDivisionSemantics :: DivisionSemantics+ , asemHomeSideSemantics :: HomeSideSemantics+ , asemReportingEligibility :: ReportingEligibility+ } deriving (Show, Eq)++-- | All concrete account titles in their stable Enum order.+concreteAccountTitles :: [AccountTitles]+concreteAccountTitles = filter (/= AccountTitle) [minBound .. maxBound]++-- | Look up processing and reporting semantics for a concrete account title.+-- The wildcard 'AccountTitle' is outside the metadata domain and returns+-- 'Nothing' explicitly.+accountSemantics :: AccountTitles -> Maybe AccountSemantics+accountSemantics title = do+ spec <- accountSpec title+ pure AccountSemantics+ { asemRoles = rolesFor title spec+ , asemPostingCapability = postingFor title+ , asemDivisionSemantics = divisionFor title spec+ , asemHomeSideSemantics = homeSideFor title spec+ , asemReportingEligibility = reportingFor title+ }++rolesFor :: AccountTitles -> AccountSpec -> [AccountRole]+rolesFor title _ = case title of+ Cash -> [OrdinaryAccount]+ Deposits -> [OrdinaryAccount]+ CurrentDeposits -> [OrdinaryAccount]+ Securities -> [OrdinaryAccount]+ InvestmentSecurities -> [OrdinaryAccount]+ InvestmentInAssociate -> [OrdinaryAccount]+ LongTermNationalBonds -> [OrdinaryAccount]+ ShortTermNationalBonds -> [OrdinaryAccount]+ Products -> [OrdinaryAccount]+ Machinery -> [OrdinaryAccount]+ Building -> [OrdinaryAccount]+ Vehicle -> [OrdinaryAccount]+ StockInvestment -> [OrdinaryAccount]+ EquipmentInvestment -> [OrdinaryAccount]+ LongTermLoansReceivable -> [OrdinaryAccount]+ AccountsReceivable -> [OrdinaryAccount]+ ShortTermLoansReceivable -> [OrdinaryAccount]+ ReserveDepositReceivable -> [OrdinaryAccount]+ Gold -> [OrdinaryAccount]+ GovernmentService -> [OrdinaryAccount]+ CapitalStock -> [OrdinaryAccount]+ RetainedEarnings -> [OrdinaryAccount]+ LongTermLoansPayable -> [OrdinaryAccount]+ ShortTermLoansPayable -> [OrdinaryAccount]+ LoansPayable -> [OrdinaryAccount]+ ReserveForDepreciation -> [OrdinaryAccount]+ DepositPayable -> [OrdinaryAccount]+ LongTermNationalBondsPayable -> [OrdinaryAccount]+ ShortTermNationalBondsPayable -> [OrdinaryAccount]+ ReserveDepositPayable -> [OrdinaryAccount]+ CentralBankNotePayable -> [OrdinaryAccount]+ Depreciation -> [OrdinaryAccount]+ AmortizationExpense -> [OrdinaryAccount]+ SalesCost -> [OrdinaryAccount]+ BusinessTrip -> [OrdinaryAccount]+ Commutation -> [OrdinaryAccount]+ UtilitiesExpense -> [OrdinaryAccount]+ RentExpense -> [OrdinaryAccount]+ AdvertisingExpense -> [OrdinaryAccount]+ DeliveryExpenses -> [OrdinaryAccount]+ SuppliesExpenses -> [OrdinaryAccount]+ MiscellaneousExpenses -> [OrdinaryAccount]+ WageExpenditure -> [OrdinaryAccount]+ InterestExpense -> [OrdinaryAccount]+ TaxesExpense -> [OrdinaryAccount]+ ConsumptionExpenditure -> [OrdinaryAccount]+ SubsidyExpense -> [OrdinaryAccount]+ CentralBankPaymentExpense -> [OrdinaryAccount]+ Purchases -> [OrdinaryAccount]+ NetIncome -> [PeriodResult]+ ValueAdded -> [OrdinaryAccount]+ SubsidyIncome -> [OrdinaryAccount]+ NationalBondInterestEarned -> [OrdinaryAccount]+ DepositInterestEarned -> [OrdinaryAccount]+ GrossProfit -> [ReportingSubtotal]+ OrdinaryProfit -> [ReportingSubtotal]+ InterestEarned -> [OrdinaryAccount]+ ReceiptFee -> [OrdinaryAccount]+ RentalIncome -> [OrdinaryAccount]+ WageEarned -> [OrdinaryAccount]+ TaxesRevenue -> [OrdinaryAccount]+ CentralBankPaymentIncome -> [OrdinaryAccount]+ Sales -> [OrdinaryAccount]+ EquityInEarningsOfInvestee -> [OrdinaryAccount]+ NetLoss -> [PeriodResult]+ PettyCash -> [OrdinaryAccount]+ NotesReceivable -> [OrdinaryAccount]+ ElectronicallyRecordedReceivable -> [OrdinaryAccount]+ CreditCardReceivable -> [OrdinaryAccount]+ NotesLoansReceivable -> [OrdinaryAccount]+ MerchandiseInventory -> [OrdinaryAccount]+ AdvancesPaid -> [OrdinaryAccount]+ PrepaidExpenses -> [OrdinaryAccount]+ AccruedRevenue -> [OrdinaryAccount]+ OtherReceivables -> [OrdinaryAccount]+ PaymentsOnBehalf -> [OrdinaryAccount]+ SuspensePayments -> [SuspenseOrClearingAccount]+ ConsumptionTaxPaid -> [OrdinaryAccount]+ PrepaidCorporateIncomeTaxes -> [OrdinaryAccount]+ Land -> [OrdinaryAccount]+ Fixtures -> [OrdinaryAccount]+ Patent -> [OrdinaryAccount]+ Trademark -> [OrdinaryAccount]+ Software -> [OrdinaryAccount]+ CashOverShort -> [SuspenseOrClearingAccount]+ AccountsPayable -> [OrdinaryAccount]+ NotesPayable -> [OrdinaryAccount]+ ElectronicallyRecordedObligations -> [OrdinaryAccount]+ NotesLoansPayable -> [OrdinaryAccount]+ BankOverdraft -> [OrdinaryAccount]+ AdvancesReceived -> [OrdinaryAccount]+ UnearnedRevenue -> [OrdinaryAccount]+ AccruedExpenses -> [OrdinaryAccount]+ OtherPayables -> [OrdinaryAccount]+ DepositsReceived -> [OrdinaryAccount]+ SuspenseReceipts -> [SuspenseOrClearingAccount]+ ConsumptionTaxReceived -> [OrdinaryAccount]+ AccruedConsumptionTax -> [OrdinaryAccount]+ AccruedCorporateIncomeTaxes -> [OrdinaryAccount]+ UnpaidDividends -> [OrdinaryAccount]+ AllowanceForDoubtfulAccounts -> [OrdinaryAccount, ContraAccount]+ AccumulatedDepreciation -> [OrdinaryAccount, ContraAccount]+ LegalRetainedEarnings -> [OrdinaryAccount]+ CumulativeTranslationAdjustment -> [OrdinaryAccount]+ ProvisionForDoubtfulAccounts -> [OrdinaryAccount]+ BadDebtLoss -> [OrdinaryAccount]+ LossOnSalesOfFixedAssets -> [OrdinaryAccount]+ LossOnSalesOfNotesReceivable -> [OrdinaryAccount]+ PaymentFees -> [OrdinaryAccount]+ MiscellaneousLoss -> [OrdinaryAccount]+ CorporateIncomeTaxes -> [OrdinaryAccount]+ CommunicationExpenses -> [OrdinaryAccount]+ GainOnSalesOfFixedAssets -> [OrdinaryAccount]+ RecoveryOfBadDebts -> [OrdinaryAccount]+ MiscellaneousIncome -> [OrdinaryAccount]+ ReversalOfAllowanceForDoubtfulAccounts -> [OrdinaryAccount]+ TimeDeposits -> [OrdinaryAccount]+ LoansReceivable -> [OrdinaryAccount]+ GiftCertificatesReceived -> [OrdinaryAccount]+ SecurityDepositsPaid -> [OrdinaryAccount]+ SuppliesOnHand -> [OrdinaryAccount]+ ContractAssets -> [OrdinaryAccount]+ IncomeTaxesRefundReceivable -> [OrdinaryAccount]+ WorkInProcess -> [OrdinaryAccount]+ DeferredTaxAssets -> [OrdinaryAccount]+ LeasedAssets -> [OrdinaryAccount]+ ToolsAndInstruments -> [OrdinaryAccount]+ ConstructionInProgress -> [OrdinaryAccount]+ Goodwill -> [OrdinaryAccount]+ SoftwareInProgress -> [OrdinaryAccount]+ LongTermPrepaidExpenses -> [OrdinaryAccount]+ DishonoredNotesReceivable -> [OrdinaryAccount]+ PrepaidPensionCost -> [OrdinaryAccount]+ NetDefinedBenefitAsset -> [OrdinaryAccount]+ DepositsInSpecialAccounts -> [OrdinaryAccount]+ Structures -> [OrdinaryAccount]+ LeaseholdRights -> [OrdinaryAccount]+ NonOperatingNotesReceivable -> [OrdinaryAccount]+ NonOperatingElectronicallyRecordedReceivable -> [OrdinaryAccount]+ RefundLiabilities -> [OrdinaryAccount]+ NonOperatingNotesPayable -> [OrdinaryAccount]+ NonOperatingElectronicallyRecordedObligations -> [OrdinaryAccount]+ BonusesPayable -> [OrdinaryAccount]+ AllowanceForRepairs -> [OrdinaryAccount]+ AllowanceForProductWarranties -> [OrdinaryAccount]+ AllowanceForBonuses -> [OrdinaryAccount]+ DeferredTaxLiabilities -> [OrdinaryAccount]+ LeaseObligations -> [OrdinaryAccount]+ GuaranteeDepositsReceived -> [OrdinaryAccount]+ AllowanceForRetirementBenefits -> [OrdinaryAccount]+ LongTermOtherPayables -> [OrdinaryAccount]+ NetDefinedBenefitLiability -> [OrdinaryAccount]+ StockSubscriptionDeposits -> [OrdinaryAccount]+ LegalCapitalSurplus -> [OrdinaryAccount]+ OtherCapitalSurplus -> [OrdinaryAccount]+ DividendEqualizationReserve -> [OrdinaryAccount]+ RepairFundReserve -> [OrdinaryAccount]+ ConstructionFundReserve -> [OrdinaryAccount]+ GeneralReserve -> [OrdinaryAccount]+ ValuationDifferenceOnOtherSecurities -> [OrdinaryAccount]+ NonControllingInterests -> [AttributionAccount]+ CapitalSurplus -> [OrdinaryAccount]+ EarnedSurplus -> [OrdinaryAccount]+ ServiceRevenue -> [OrdinaryAccount]+ OperatingRevenue -> [OrdinaryAccount]+ GainOnSalesOfSecurities -> [OrdinaryAccount]+ GainOnValuationOfSecurities -> [OrdinaryAccount]+ DividendsReceived -> [OrdinaryAccount]+ InterestOnSecurities -> [OrdinaryAccount]+ GainOnSalesOfInvestmentSecurities -> [OrdinaryAccount]+ InsuranceGain -> [OrdinaryAccount]+ GainOnBargainPurchase -> [OrdinaryAccount]+ ReversalOfAllowanceForRepairs -> [OrdinaryAccount]+ ReversalOfAllowanceForProductWarranties -> [OrdinaryAccount]+ GainOnDonationOfFixedAssets -> [OrdinaryAccount]+ GainOnNationalSubsidies -> [OrdinaryAccount]+ GainOnConstructionGrants -> [OrdinaryAccount]+ LandRentReceived -> [OrdinaryAccount]+ SalesRebates -> [OrdinaryAccount, ContraAccount]+ CostOfServices -> [OrdinaryAccount]+ OperatingExpenses -> [OrdinaryAccount]+ InventoryShrinkageLoss -> [OrdinaryAccount]+ LossOnValuationOfMerchandise -> [OrdinaryAccount]+ Bonuses -> [OrdinaryAccount]+ RetirementBenefitExpenses -> [OrdinaryAccount]+ ProvisionForRepairs -> [OrdinaryAccount]+ ProvisionForBonuses -> [OrdinaryAccount]+ ProvisionForProductWarranties -> [OrdinaryAccount]+ ResearchAndDevelopmentExpenses -> [OrdinaryAccount]+ AmortizationOfGoodwill -> [OrdinaryAccount]+ AmortizationOfSoftware -> [OrdinaryAccount]+ AmortizationOfPatents -> [OrdinaryAccount]+ LeaseExpenses -> [OrdinaryAccount]+ IncorporationExpenses -> [OrdinaryAccount]+ StockIssuanceCosts -> [OrdinaryAccount]+ BusinessCommencementExpenses -> [OrdinaryAccount]+ DevelopmentExpenses -> [OrdinaryAccount]+ LossOnSalesOfElectronicallyRecordedReceivables -> [OrdinaryAccount]+ LossOnSalesOfReceivables -> [OrdinaryAccount]+ LossOnSalesOfSecurities -> [OrdinaryAccount]+ LossOnValuationOfSecurities -> [OrdinaryAccount]+ LossOnSalesOfInvestmentSecurities -> [OrdinaryAccount]+ LossOnFire -> [OrdinaryAccount]+ LossOnRetirementOfFixedAssets -> [OrdinaryAccount]+ LossOnReductionOfFixedAssets -> [OrdinaryAccount]+ AdditionalIncomeTaxesForPriorPeriods -> [OrdinaryAccount]+ RefundOfIncomeTaxes -> [OrdinaryAccount, ContraAccount]+ PurchaseRebates -> [OrdinaryAccount, ContraAccount]+ WelfareExpenses -> [OrdinaryAccount]+ MaintenanceExpenses -> [OrdinaryAccount]+ StatutoryWelfareExpenses -> [OrdinaryAccount]+ LandRentPaid -> [OrdinaryAccount]+ InsuranceExpense -> [OrdinaryAccount]+ RepairsExpense -> [OrdinaryAccount]+ StorageExpenses -> [OrdinaryAccount]+ MembershipFees -> [OrdinaryAccount]+ IncomeSummary -> [ClosingDevice]+ SuspenseAccount -> [SuspenseOrClearingAccount]+ ForeignExchangeGains -> [OrdinaryAccount]+ ForeignExchangeLosses -> [OrdinaryAccount]+ ContraAccountForGuaranteeObligations -> [OrdinaryAccount]+ GuaranteeObligations -> [OrdinaryAccount]+ IncomeTaxesAdjustment -> [OrdinaryAccount]+ BranchCurrentAccount -> [ReciprocalAccount]+ HeadOfficeCurrentAccount -> [ReciprocalAccount]+ NetIncomeAttributableToNCI -> [AttributionAccount, PeriodResult]+ NetLossAttributableToNCI -> [AttributionAccount, PeriodResult]+ TradingSecurities -> [OrdinaryAccount]+ HeldToMaturityBonds -> [OrdinaryAccount]+ SubsidiaryStocks -> [OrdinaryAccount]+ AffiliateStocks -> [OrdinaryAccount]+ AvailableForSaleSecurities -> [OrdinaryAccount]+ ConsumptionTaxRefundReceivable -> [OrdinaryAccount]+ PropertyTaxPayable -> [OrdinaryAccount]+ DepositsReceivedFromOfficers -> [OrdinaryAccount]+ AccountTitle -> [OrdinaryAccount]++postingFor :: AccountTitles -> PostingCapability+postingFor title = case title of+ Cash -> OrdinaryPosting+ Deposits -> OrdinaryPosting+ CurrentDeposits -> OrdinaryPosting+ Securities -> OrdinaryPosting+ InvestmentSecurities -> OrdinaryPosting+ InvestmentInAssociate -> OrdinaryPosting+ LongTermNationalBonds -> OrdinaryPosting+ ShortTermNationalBonds -> OrdinaryPosting+ Products -> OrdinaryPosting+ Machinery -> OrdinaryPosting+ Building -> OrdinaryPosting+ Vehicle -> OrdinaryPosting+ StockInvestment -> OrdinaryPosting+ EquipmentInvestment -> OrdinaryPosting+ LongTermLoansReceivable -> OrdinaryPosting+ AccountsReceivable -> OrdinaryPosting+ ShortTermLoansReceivable -> OrdinaryPosting+ ReserveDepositReceivable -> OrdinaryPosting+ Gold -> OrdinaryPosting+ GovernmentService -> OrdinaryPosting+ CapitalStock -> OrdinaryPosting+ RetainedEarnings -> OrdinaryPosting+ LongTermLoansPayable -> OrdinaryPosting+ ShortTermLoansPayable -> OrdinaryPosting+ LoansPayable -> OrdinaryPosting+ ReserveForDepreciation -> OrdinaryPosting+ DepositPayable -> OrdinaryPosting+ LongTermNationalBondsPayable -> OrdinaryPosting+ ShortTermNationalBondsPayable -> OrdinaryPosting+ ReserveDepositPayable -> OrdinaryPosting+ CentralBankNotePayable -> OrdinaryPosting+ Depreciation -> OrdinaryPosting+ AmortizationExpense -> OrdinaryPosting+ SalesCost -> OrdinaryPosting+ BusinessTrip -> OrdinaryPosting+ Commutation -> OrdinaryPosting+ UtilitiesExpense -> OrdinaryPosting+ RentExpense -> OrdinaryPosting+ AdvertisingExpense -> OrdinaryPosting+ DeliveryExpenses -> OrdinaryPosting+ SuppliesExpenses -> OrdinaryPosting+ MiscellaneousExpenses -> OrdinaryPosting+ WageExpenditure -> OrdinaryPosting+ InterestExpense -> OrdinaryPosting+ TaxesExpense -> OrdinaryPosting+ ConsumptionExpenditure -> OrdinaryPosting+ SubsidyExpense -> OrdinaryPosting+ CentralBankPaymentExpense -> OrdinaryPosting+ Purchases -> OrdinaryPosting+ NetIncome -> EngineGeneratedOnly+ ValueAdded -> OrdinaryPosting+ SubsidyIncome -> OrdinaryPosting+ NationalBondInterestEarned -> OrdinaryPosting+ DepositInterestEarned -> OrdinaryPosting+ GrossProfit -> EngineGeneratedOnly+ OrdinaryProfit -> EngineGeneratedOnly+ InterestEarned -> OrdinaryPosting+ ReceiptFee -> OrdinaryPosting+ RentalIncome -> OrdinaryPosting+ WageEarned -> OrdinaryPosting+ TaxesRevenue -> OrdinaryPosting+ CentralBankPaymentIncome -> OrdinaryPosting+ Sales -> OrdinaryPosting+ EquityInEarningsOfInvestee -> ConsolidationOnly+ NetLoss -> EngineGeneratedOnly+ PettyCash -> OrdinaryPosting+ NotesReceivable -> OrdinaryPosting+ ElectronicallyRecordedReceivable -> OrdinaryPosting+ CreditCardReceivable -> OrdinaryPosting+ NotesLoansReceivable -> OrdinaryPosting+ MerchandiseInventory -> OrdinaryPosting+ AdvancesPaid -> OrdinaryPosting+ PrepaidExpenses -> OrdinaryPosting+ AccruedRevenue -> OrdinaryPosting+ OtherReceivables -> OrdinaryPosting+ PaymentsOnBehalf -> OrdinaryPosting+ SuspensePayments -> OrdinaryPosting+ ConsumptionTaxPaid -> OrdinaryPosting+ PrepaidCorporateIncomeTaxes -> OrdinaryPosting+ Land -> OrdinaryPosting+ Fixtures -> OrdinaryPosting+ Patent -> OrdinaryPosting+ Trademark -> OrdinaryPosting+ Software -> OrdinaryPosting+ CashOverShort -> OrdinaryPosting+ AccountsPayable -> OrdinaryPosting+ NotesPayable -> OrdinaryPosting+ ElectronicallyRecordedObligations -> OrdinaryPosting+ NotesLoansPayable -> OrdinaryPosting+ BankOverdraft -> OrdinaryPosting+ AdvancesReceived -> OrdinaryPosting+ UnearnedRevenue -> OrdinaryPosting+ AccruedExpenses -> OrdinaryPosting+ OtherPayables -> OrdinaryPosting+ DepositsReceived -> OrdinaryPosting+ SuspenseReceipts -> OrdinaryPosting+ ConsumptionTaxReceived -> OrdinaryPosting+ AccruedConsumptionTax -> OrdinaryPosting+ AccruedCorporateIncomeTaxes -> OrdinaryPosting+ UnpaidDividends -> OrdinaryPosting+ AllowanceForDoubtfulAccounts -> OrdinaryPosting+ AccumulatedDepreciation -> OrdinaryPosting+ LegalRetainedEarnings -> OrdinaryPosting+ CumulativeTranslationAdjustment -> ConsolidationOnly+ ProvisionForDoubtfulAccounts -> OrdinaryPosting+ BadDebtLoss -> OrdinaryPosting+ LossOnSalesOfFixedAssets -> OrdinaryPosting+ LossOnSalesOfNotesReceivable -> OrdinaryPosting+ PaymentFees -> OrdinaryPosting+ MiscellaneousLoss -> OrdinaryPosting+ CorporateIncomeTaxes -> OrdinaryPosting+ CommunicationExpenses -> OrdinaryPosting+ GainOnSalesOfFixedAssets -> OrdinaryPosting+ RecoveryOfBadDebts -> OrdinaryPosting+ MiscellaneousIncome -> OrdinaryPosting+ ReversalOfAllowanceForDoubtfulAccounts -> OrdinaryPosting+ TimeDeposits -> OrdinaryPosting+ LoansReceivable -> OrdinaryPosting+ GiftCertificatesReceived -> OrdinaryPosting+ SecurityDepositsPaid -> OrdinaryPosting+ SuppliesOnHand -> OrdinaryPosting+ ContractAssets -> OrdinaryPosting+ IncomeTaxesRefundReceivable -> OrdinaryPosting+ WorkInProcess -> OrdinaryPosting+ DeferredTaxAssets -> OrdinaryPosting+ LeasedAssets -> OrdinaryPosting+ ToolsAndInstruments -> OrdinaryPosting+ ConstructionInProgress -> OrdinaryPosting+ Goodwill -> OrdinaryPosting+ SoftwareInProgress -> OrdinaryPosting+ LongTermPrepaidExpenses -> OrdinaryPosting+ DishonoredNotesReceivable -> OrdinaryPosting+ PrepaidPensionCost -> OrdinaryPosting+ NetDefinedBenefitAsset -> OrdinaryPosting+ DepositsInSpecialAccounts -> OrdinaryPosting+ Structures -> OrdinaryPosting+ LeaseholdRights -> OrdinaryPosting+ NonOperatingNotesReceivable -> OrdinaryPosting+ NonOperatingElectronicallyRecordedReceivable -> OrdinaryPosting+ RefundLiabilities -> OrdinaryPosting+ NonOperatingNotesPayable -> OrdinaryPosting+ NonOperatingElectronicallyRecordedObligations -> OrdinaryPosting+ BonusesPayable -> OrdinaryPosting+ AllowanceForRepairs -> OrdinaryPosting+ AllowanceForProductWarranties -> OrdinaryPosting+ AllowanceForBonuses -> OrdinaryPosting+ DeferredTaxLiabilities -> OrdinaryPosting+ LeaseObligations -> OrdinaryPosting+ GuaranteeDepositsReceived -> OrdinaryPosting+ AllowanceForRetirementBenefits -> OrdinaryPosting+ LongTermOtherPayables -> OrdinaryPosting+ NetDefinedBenefitLiability -> OrdinaryPosting+ StockSubscriptionDeposits -> OrdinaryPosting+ LegalCapitalSurplus -> OrdinaryPosting+ OtherCapitalSurplus -> OrdinaryPosting+ DividendEqualizationReserve -> OrdinaryPosting+ RepairFundReserve -> OrdinaryPosting+ ConstructionFundReserve -> OrdinaryPosting+ GeneralReserve -> OrdinaryPosting+ ValuationDifferenceOnOtherSecurities -> OrdinaryPosting+ NonControllingInterests -> ConsolidationOnly+ CapitalSurplus -> OrdinaryPosting+ EarnedSurplus -> OrdinaryPosting+ ServiceRevenue -> OrdinaryPosting+ OperatingRevenue -> OrdinaryPosting+ GainOnSalesOfSecurities -> OrdinaryPosting+ GainOnValuationOfSecurities -> OrdinaryPosting+ DividendsReceived -> OrdinaryPosting+ InterestOnSecurities -> OrdinaryPosting+ GainOnSalesOfInvestmentSecurities -> OrdinaryPosting+ InsuranceGain -> OrdinaryPosting+ GainOnBargainPurchase -> OrdinaryPosting+ ReversalOfAllowanceForRepairs -> OrdinaryPosting+ ReversalOfAllowanceForProductWarranties -> OrdinaryPosting+ GainOnDonationOfFixedAssets -> OrdinaryPosting+ GainOnNationalSubsidies -> OrdinaryPosting+ GainOnConstructionGrants -> OrdinaryPosting+ LandRentReceived -> OrdinaryPosting+ SalesRebates -> OrdinaryPosting+ CostOfServices -> OrdinaryPosting+ OperatingExpenses -> OrdinaryPosting+ InventoryShrinkageLoss -> OrdinaryPosting+ LossOnValuationOfMerchandise -> OrdinaryPosting+ Bonuses -> OrdinaryPosting+ RetirementBenefitExpenses -> OrdinaryPosting+ ProvisionForRepairs -> OrdinaryPosting+ ProvisionForBonuses -> OrdinaryPosting+ ProvisionForProductWarranties -> OrdinaryPosting+ ResearchAndDevelopmentExpenses -> OrdinaryPosting+ AmortizationOfGoodwill -> OrdinaryPosting+ AmortizationOfSoftware -> OrdinaryPosting+ AmortizationOfPatents -> OrdinaryPosting+ LeaseExpenses -> OrdinaryPosting+ IncorporationExpenses -> OrdinaryPosting+ StockIssuanceCosts -> OrdinaryPosting+ BusinessCommencementExpenses -> OrdinaryPosting+ DevelopmentExpenses -> OrdinaryPosting+ LossOnSalesOfElectronicallyRecordedReceivables -> OrdinaryPosting+ LossOnSalesOfReceivables -> OrdinaryPosting+ LossOnSalesOfSecurities -> OrdinaryPosting+ LossOnValuationOfSecurities -> OrdinaryPosting+ LossOnSalesOfInvestmentSecurities -> OrdinaryPosting+ LossOnFire -> OrdinaryPosting+ LossOnRetirementOfFixedAssets -> OrdinaryPosting+ LossOnReductionOfFixedAssets -> OrdinaryPosting+ AdditionalIncomeTaxesForPriorPeriods -> OrdinaryPosting+ RefundOfIncomeTaxes -> OrdinaryPosting+ PurchaseRebates -> OrdinaryPosting+ WelfareExpenses -> OrdinaryPosting+ MaintenanceExpenses -> OrdinaryPosting+ StatutoryWelfareExpenses -> OrdinaryPosting+ LandRentPaid -> OrdinaryPosting+ InsuranceExpense -> OrdinaryPosting+ RepairsExpense -> OrdinaryPosting+ StorageExpenses -> OrdinaryPosting+ MembershipFees -> OrdinaryPosting+ IncomeSummary -> ClosingOnly+ SuspenseAccount -> OrdinaryPosting+ ForeignExchangeGains -> OrdinaryPosting+ ForeignExchangeLosses -> OrdinaryPosting+ ContraAccountForGuaranteeObligations -> OrdinaryPosting+ GuaranteeObligations -> OrdinaryPosting+ IncomeTaxesAdjustment -> OrdinaryPosting+ BranchCurrentAccount -> OrdinaryPosting+ HeadOfficeCurrentAccount -> OrdinaryPosting+ NetIncomeAttributableToNCI -> ConsolidationOnly+ NetLossAttributableToNCI -> ConsolidationOnly+ TradingSecurities -> OrdinaryPosting+ HeldToMaturityBonds -> OrdinaryPosting+ SubsidiaryStocks -> OrdinaryPosting+ AffiliateStocks -> OrdinaryPosting+ AvailableForSaleSecurities -> OrdinaryPosting+ ConsumptionTaxRefundReceivable -> OrdinaryPosting+ PropertyTaxPayable -> OrdinaryPosting+ DepositsReceivedFromOfficers -> OrdinaryPosting+ AccountTitle -> NotPostable++divisionFor :: AccountTitles -> AccountSpec -> DivisionSemantics+divisionFor title spec = case title of+ Cash -> StatementDivision (asDivision spec)+ Deposits -> StatementDivision (asDivision spec)+ CurrentDeposits -> StatementDivision (asDivision spec)+ Securities -> StatementDivision (asDivision spec)+ InvestmentSecurities -> StatementDivision (asDivision spec)+ InvestmentInAssociate -> StatementDivision (asDivision spec)+ LongTermNationalBonds -> StatementDivision (asDivision spec)+ ShortTermNationalBonds -> StatementDivision (asDivision spec)+ Products -> StatementDivision (asDivision spec)+ Machinery -> StatementDivision (asDivision spec)+ Building -> StatementDivision (asDivision spec)+ Vehicle -> StatementDivision (asDivision spec)+ StockInvestment -> StatementDivision (asDivision spec)+ EquipmentInvestment -> StatementDivision (asDivision spec)+ LongTermLoansReceivable -> StatementDivision (asDivision spec)+ AccountsReceivable -> StatementDivision (asDivision spec)+ ShortTermLoansReceivable -> StatementDivision (asDivision spec)+ ReserveDepositReceivable -> StatementDivision (asDivision spec)+ Gold -> StatementDivision (asDivision spec)+ GovernmentService -> StatementDivision (asDivision spec)+ CapitalStock -> StatementDivision (asDivision spec)+ RetainedEarnings -> StatementDivision (asDivision spec)+ LongTermLoansPayable -> StatementDivision (asDivision spec)+ ShortTermLoansPayable -> StatementDivision (asDivision spec)+ LoansPayable -> StatementDivision (asDivision spec)+ ReserveForDepreciation -> StatementDivision (asDivision spec)+ DepositPayable -> StatementDivision (asDivision spec)+ LongTermNationalBondsPayable -> StatementDivision (asDivision spec)+ ShortTermNationalBondsPayable -> StatementDivision (asDivision spec)+ ReserveDepositPayable -> StatementDivision (asDivision spec)+ CentralBankNotePayable -> StatementDivision (asDivision spec)+ Depreciation -> StatementDivision (asDivision spec)+ AmortizationExpense -> StatementDivision (asDivision spec)+ SalesCost -> StatementDivision (asDivision spec)+ BusinessTrip -> StatementDivision (asDivision spec)+ Commutation -> StatementDivision (asDivision spec)+ UtilitiesExpense -> StatementDivision (asDivision spec)+ RentExpense -> StatementDivision (asDivision spec)+ AdvertisingExpense -> StatementDivision (asDivision spec)+ DeliveryExpenses -> StatementDivision (asDivision spec)+ SuppliesExpenses -> StatementDivision (asDivision spec)+ MiscellaneousExpenses -> StatementDivision (asDivision spec)+ WageExpenditure -> StatementDivision (asDivision spec)+ InterestExpense -> StatementDivision (asDivision spec)+ TaxesExpense -> StatementDivision (asDivision spec)+ ConsumptionExpenditure -> StatementDivision (asDivision spec)+ SubsidyExpense -> StatementDivision (asDivision spec)+ CentralBankPaymentExpense -> StatementDivision (asDivision spec)+ Purchases -> StatementDivision (asDivision spec)+ NetIncome -> DirectionEncoding (asDivision spec)+ ValueAdded -> StatementDivision (asDivision spec)+ SubsidyIncome -> StatementDivision (asDivision spec)+ NationalBondInterestEarned -> StatementDivision (asDivision spec)+ DepositInterestEarned -> StatementDivision (asDivision spec)+ GrossProfit -> DirectionEncoding (asDivision spec)+ OrdinaryProfit -> DirectionEncoding (asDivision spec)+ InterestEarned -> StatementDivision (asDivision spec)+ ReceiptFee -> StatementDivision (asDivision spec)+ RentalIncome -> StatementDivision (asDivision spec)+ WageEarned -> StatementDivision (asDivision spec)+ TaxesRevenue -> StatementDivision (asDivision spec)+ CentralBankPaymentIncome -> StatementDivision (asDivision spec)+ Sales -> StatementDivision (asDivision spec)+ EquityInEarningsOfInvestee -> StatementDivision (asDivision spec)+ NetLoss -> DirectionEncoding (asDivision spec)+ PettyCash -> StatementDivision (asDivision spec)+ NotesReceivable -> StatementDivision (asDivision spec)+ ElectronicallyRecordedReceivable -> StatementDivision (asDivision spec)+ CreditCardReceivable -> StatementDivision (asDivision spec)+ NotesLoansReceivable -> StatementDivision (asDivision spec)+ MerchandiseInventory -> StatementDivision (asDivision spec)+ AdvancesPaid -> StatementDivision (asDivision spec)+ PrepaidExpenses -> StatementDivision (asDivision spec)+ AccruedRevenue -> StatementDivision (asDivision spec)+ OtherReceivables -> StatementDivision (asDivision spec)+ PaymentsOnBehalf -> StatementDivision (asDivision spec)+ SuspensePayments -> BookkeepingControlClass (asDivision spec)+ ConsumptionTaxPaid -> StatementDivision (asDivision spec)+ PrepaidCorporateIncomeTaxes -> StatementDivision (asDivision spec)+ Land -> StatementDivision (asDivision spec)+ Fixtures -> StatementDivision (asDivision spec)+ Patent -> StatementDivision (asDivision spec)+ Trademark -> StatementDivision (asDivision spec)+ Software -> StatementDivision (asDivision spec)+ CashOverShort -> BookkeepingControlClass (asDivision spec)+ AccountsPayable -> StatementDivision (asDivision spec)+ NotesPayable -> StatementDivision (asDivision spec)+ ElectronicallyRecordedObligations -> StatementDivision (asDivision spec)+ NotesLoansPayable -> StatementDivision (asDivision spec)+ BankOverdraft -> StatementDivision (asDivision spec)+ AdvancesReceived -> StatementDivision (asDivision spec)+ UnearnedRevenue -> StatementDivision (asDivision spec)+ AccruedExpenses -> StatementDivision (asDivision spec)+ OtherPayables -> StatementDivision (asDivision spec)+ DepositsReceived -> StatementDivision (asDivision spec)+ SuspenseReceipts -> BookkeepingControlClass (asDivision spec)+ ConsumptionTaxReceived -> StatementDivision (asDivision spec)+ AccruedConsumptionTax -> StatementDivision (asDivision spec)+ AccruedCorporateIncomeTaxes -> StatementDivision (asDivision spec)+ UnpaidDividends -> StatementDivision (asDivision spec)+ AllowanceForDoubtfulAccounts -> StatementDivision (asDivision spec)+ AccumulatedDepreciation -> StatementDivision (asDivision spec)+ LegalRetainedEarnings -> StatementDivision (asDivision spec)+ CumulativeTranslationAdjustment -> StatementDivision (asDivision spec)+ ProvisionForDoubtfulAccounts -> StatementDivision (asDivision spec)+ BadDebtLoss -> StatementDivision (asDivision spec)+ LossOnSalesOfFixedAssets -> StatementDivision (asDivision spec)+ LossOnSalesOfNotesReceivable -> StatementDivision (asDivision spec)+ PaymentFees -> StatementDivision (asDivision spec)+ MiscellaneousLoss -> StatementDivision (asDivision spec)+ CorporateIncomeTaxes -> StatementDivision (asDivision spec)+ CommunicationExpenses -> StatementDivision (asDivision spec)+ GainOnSalesOfFixedAssets -> StatementDivision (asDivision spec)+ RecoveryOfBadDebts -> StatementDivision (asDivision spec)+ MiscellaneousIncome -> StatementDivision (asDivision spec)+ ReversalOfAllowanceForDoubtfulAccounts -> StatementDivision (asDivision spec)+ TimeDeposits -> StatementDivision (asDivision spec)+ LoansReceivable -> StatementDivision (asDivision spec)+ GiftCertificatesReceived -> StatementDivision (asDivision spec)+ SecurityDepositsPaid -> StatementDivision (asDivision spec)+ SuppliesOnHand -> StatementDivision (asDivision spec)+ ContractAssets -> StatementDivision (asDivision spec)+ IncomeTaxesRefundReceivable -> StatementDivision (asDivision spec)+ WorkInProcess -> StatementDivision (asDivision spec)+ DeferredTaxAssets -> StatementDivision (asDivision spec)+ LeasedAssets -> StatementDivision (asDivision spec)+ ToolsAndInstruments -> StatementDivision (asDivision spec)+ ConstructionInProgress -> StatementDivision (asDivision spec)+ Goodwill -> StatementDivision (asDivision spec)+ SoftwareInProgress -> StatementDivision (asDivision spec)+ LongTermPrepaidExpenses -> StatementDivision (asDivision spec)+ DishonoredNotesReceivable -> StatementDivision (asDivision spec)+ PrepaidPensionCost -> StatementDivision (asDivision spec)+ NetDefinedBenefitAsset -> StatementDivision (asDivision spec)+ DepositsInSpecialAccounts -> StatementDivision (asDivision spec)+ Structures -> StatementDivision (asDivision spec)+ LeaseholdRights -> StatementDivision (asDivision spec)+ NonOperatingNotesReceivable -> StatementDivision (asDivision spec)+ NonOperatingElectronicallyRecordedReceivable -> StatementDivision (asDivision spec)+ RefundLiabilities -> StatementDivision (asDivision spec)+ NonOperatingNotesPayable -> StatementDivision (asDivision spec)+ NonOperatingElectronicallyRecordedObligations -> StatementDivision (asDivision spec)+ BonusesPayable -> StatementDivision (asDivision spec)+ AllowanceForRepairs -> StatementDivision (asDivision spec)+ AllowanceForProductWarranties -> StatementDivision (asDivision spec)+ AllowanceForBonuses -> StatementDivision (asDivision spec)+ DeferredTaxLiabilities -> StatementDivision (asDivision spec)+ LeaseObligations -> StatementDivision (asDivision spec)+ GuaranteeDepositsReceived -> StatementDivision (asDivision spec)+ AllowanceForRetirementBenefits -> StatementDivision (asDivision spec)+ LongTermOtherPayables -> StatementDivision (asDivision spec)+ NetDefinedBenefitLiability -> StatementDivision (asDivision spec)+ StockSubscriptionDeposits -> StatementDivision (asDivision spec)+ LegalCapitalSurplus -> StatementDivision (asDivision spec)+ OtherCapitalSurplus -> StatementDivision (asDivision spec)+ DividendEqualizationReserve -> StatementDivision (asDivision spec)+ RepairFundReserve -> StatementDivision (asDivision spec)+ ConstructionFundReserve -> StatementDivision (asDivision spec)+ GeneralReserve -> StatementDivision (asDivision spec)+ ValuationDifferenceOnOtherSecurities -> StatementDivision (asDivision spec)+ NonControllingInterests -> StatementDivision (asDivision spec)+ CapitalSurplus -> StatementDivision (asDivision spec)+ EarnedSurplus -> StatementDivision (asDivision spec)+ ServiceRevenue -> StatementDivision (asDivision spec)+ OperatingRevenue -> StatementDivision (asDivision spec)+ GainOnSalesOfSecurities -> StatementDivision (asDivision spec)+ GainOnValuationOfSecurities -> StatementDivision (asDivision spec)+ DividendsReceived -> StatementDivision (asDivision spec)+ InterestOnSecurities -> StatementDivision (asDivision spec)+ GainOnSalesOfInvestmentSecurities -> StatementDivision (asDivision spec)+ InsuranceGain -> StatementDivision (asDivision spec)+ GainOnBargainPurchase -> StatementDivision (asDivision spec)+ ReversalOfAllowanceForRepairs -> StatementDivision (asDivision spec)+ ReversalOfAllowanceForProductWarranties -> StatementDivision (asDivision spec)+ GainOnDonationOfFixedAssets -> StatementDivision (asDivision spec)+ GainOnNationalSubsidies -> StatementDivision (asDivision spec)+ GainOnConstructionGrants -> StatementDivision (asDivision spec)+ LandRentReceived -> StatementDivision (asDivision spec)+ SalesRebates -> StatementDivision (asDivision spec)+ CostOfServices -> StatementDivision (asDivision spec)+ OperatingExpenses -> StatementDivision (asDivision spec)+ InventoryShrinkageLoss -> StatementDivision (asDivision spec)+ LossOnValuationOfMerchandise -> StatementDivision (asDivision spec)+ Bonuses -> StatementDivision (asDivision spec)+ RetirementBenefitExpenses -> StatementDivision (asDivision spec)+ ProvisionForRepairs -> StatementDivision (asDivision spec)+ ProvisionForBonuses -> StatementDivision (asDivision spec)+ ProvisionForProductWarranties -> StatementDivision (asDivision spec)+ ResearchAndDevelopmentExpenses -> StatementDivision (asDivision spec)+ AmortizationOfGoodwill -> StatementDivision (asDivision spec)+ AmortizationOfSoftware -> StatementDivision (asDivision spec)+ AmortizationOfPatents -> StatementDivision (asDivision spec)+ LeaseExpenses -> StatementDivision (asDivision spec)+ IncorporationExpenses -> StatementDivision (asDivision spec)+ StockIssuanceCosts -> StatementDivision (asDivision spec)+ BusinessCommencementExpenses -> StatementDivision (asDivision spec)+ DevelopmentExpenses -> StatementDivision (asDivision spec)+ LossOnSalesOfElectronicallyRecordedReceivables -> StatementDivision (asDivision spec)+ LossOnSalesOfReceivables -> StatementDivision (asDivision spec)+ LossOnSalesOfSecurities -> StatementDivision (asDivision spec)+ LossOnValuationOfSecurities -> StatementDivision (asDivision spec)+ LossOnSalesOfInvestmentSecurities -> StatementDivision (asDivision spec)+ LossOnFire -> StatementDivision (asDivision spec)+ LossOnRetirementOfFixedAssets -> StatementDivision (asDivision spec)+ LossOnReductionOfFixedAssets -> StatementDivision (asDivision spec)+ AdditionalIncomeTaxesForPriorPeriods -> StatementDivision (asDivision spec)+ RefundOfIncomeTaxes -> StatementDivision (asDivision spec)+ PurchaseRebates -> StatementDivision (asDivision spec)+ WelfareExpenses -> StatementDivision (asDivision spec)+ MaintenanceExpenses -> StatementDivision (asDivision spec)+ StatutoryWelfareExpenses -> StatementDivision (asDivision spec)+ LandRentPaid -> StatementDivision (asDivision spec)+ InsuranceExpense -> StatementDivision (asDivision spec)+ RepairsExpense -> StatementDivision (asDivision spec)+ StorageExpenses -> StatementDivision (asDivision spec)+ MembershipFees -> StatementDivision (asDivision spec)+ IncomeSummary -> DirectionEncoding (asDivision spec)+ SuspenseAccount -> BookkeepingControlClass (asDivision spec)+ ForeignExchangeGains -> StatementDivision (asDivision spec)+ ForeignExchangeLosses -> StatementDivision (asDivision spec)+ ContraAccountForGuaranteeObligations -> StatementDivision (asDivision spec)+ GuaranteeObligations -> StatementDivision (asDivision spec)+ IncomeTaxesAdjustment -> StatementDivision (asDivision spec)+ BranchCurrentAccount -> BookkeepingControlClass (asDivision spec)+ HeadOfficeCurrentAccount -> BookkeepingControlClass (asDivision spec)+ NetIncomeAttributableToNCI -> DirectionEncoding (asDivision spec)+ NetLossAttributableToNCI -> DirectionEncoding (asDivision spec)+ TradingSecurities -> StatementDivision (asDivision spec)+ HeldToMaturityBonds -> StatementDivision (asDivision spec)+ SubsidiaryStocks -> StatementDivision (asDivision spec)+ AffiliateStocks -> StatementDivision (asDivision spec)+ AvailableForSaleSecurities -> StatementDivision (asDivision spec)+ ConsumptionTaxRefundReceivable -> StatementDivision (asDivision spec)+ PropertyTaxPayable -> StatementDivision (asDivision spec)+ DepositsReceivedFromOfficers -> StatementDivision (asDivision spec)+ AccountTitle -> NoStatementDivision++homeSideFor :: AccountTitles -> AccountSpec -> HomeSideSemantics+homeSideFor title spec = case title of+ Cash -> FixedHomeSide (legacyHomeSide spec)+ Deposits -> FixedHomeSide (legacyHomeSide spec)+ CurrentDeposits -> FixedHomeSide (legacyHomeSide spec)+ Securities -> FixedHomeSide (legacyHomeSide spec)+ InvestmentSecurities -> FixedHomeSide (legacyHomeSide spec)+ InvestmentInAssociate -> FixedHomeSide (legacyHomeSide spec)+ LongTermNationalBonds -> FixedHomeSide (legacyHomeSide spec)+ ShortTermNationalBonds -> FixedHomeSide (legacyHomeSide spec)+ Products -> FixedHomeSide (legacyHomeSide spec)+ Machinery -> FixedHomeSide (legacyHomeSide spec)+ Building -> FixedHomeSide (legacyHomeSide spec)+ Vehicle -> FixedHomeSide (legacyHomeSide spec)+ StockInvestment -> FixedHomeSide (legacyHomeSide spec)+ EquipmentInvestment -> FixedHomeSide (legacyHomeSide spec)+ LongTermLoansReceivable -> FixedHomeSide (legacyHomeSide spec)+ AccountsReceivable -> FixedHomeSide (legacyHomeSide spec)+ ShortTermLoansReceivable -> FixedHomeSide (legacyHomeSide spec)+ ReserveDepositReceivable -> FixedHomeSide (legacyHomeSide spec)+ Gold -> FixedHomeSide (legacyHomeSide spec)+ GovernmentService -> FixedHomeSide (legacyHomeSide spec)+ CapitalStock -> FixedHomeSide (legacyHomeSide spec)+ RetainedEarnings -> FixedHomeSide (legacyHomeSide spec)+ LongTermLoansPayable -> FixedHomeSide (legacyHomeSide spec)+ ShortTermLoansPayable -> FixedHomeSide (legacyHomeSide spec)+ LoansPayable -> FixedHomeSide (legacyHomeSide spec)+ ReserveForDepreciation -> FixedHomeSide (legacyHomeSide spec)+ DepositPayable -> FixedHomeSide (legacyHomeSide spec)+ LongTermNationalBondsPayable -> FixedHomeSide (legacyHomeSide spec)+ ShortTermNationalBondsPayable -> FixedHomeSide (legacyHomeSide spec)+ ReserveDepositPayable -> FixedHomeSide (legacyHomeSide spec)+ CentralBankNotePayable -> FixedHomeSide (legacyHomeSide spec)+ Depreciation -> FixedHomeSide (legacyHomeSide spec)+ AmortizationExpense -> FixedHomeSide (legacyHomeSide spec)+ SalesCost -> FixedHomeSide (legacyHomeSide spec)+ BusinessTrip -> FixedHomeSide (legacyHomeSide spec)+ Commutation -> FixedHomeSide (legacyHomeSide spec)+ UtilitiesExpense -> FixedHomeSide (legacyHomeSide spec)+ RentExpense -> FixedHomeSide (legacyHomeSide spec)+ AdvertisingExpense -> FixedHomeSide (legacyHomeSide spec)+ DeliveryExpenses -> FixedHomeSide (legacyHomeSide spec)+ SuppliesExpenses -> FixedHomeSide (legacyHomeSide spec)+ MiscellaneousExpenses -> FixedHomeSide (legacyHomeSide spec)+ WageExpenditure -> FixedHomeSide (legacyHomeSide spec)+ InterestExpense -> FixedHomeSide (legacyHomeSide spec)+ TaxesExpense -> FixedHomeSide (legacyHomeSide spec)+ ConsumptionExpenditure -> FixedHomeSide (legacyHomeSide spec)+ SubsidyExpense -> FixedHomeSide (legacyHomeSide spec)+ CentralBankPaymentExpense -> FixedHomeSide (legacyHomeSide spec)+ Purchases -> FixedHomeSide (legacyHomeSide spec)+ NetIncome -> FixedHomeSide (legacyHomeSide spec)+ ValueAdded -> FixedHomeSide (legacyHomeSide spec)+ SubsidyIncome -> FixedHomeSide (legacyHomeSide spec)+ NationalBondInterestEarned -> FixedHomeSide (legacyHomeSide spec)+ DepositInterestEarned -> FixedHomeSide (legacyHomeSide spec)+ GrossProfit -> FixedHomeSide (legacyHomeSide spec)+ OrdinaryProfit -> FixedHomeSide (legacyHomeSide spec)+ InterestEarned -> FixedHomeSide (legacyHomeSide spec)+ ReceiptFee -> FixedHomeSide (legacyHomeSide spec)+ RentalIncome -> FixedHomeSide (legacyHomeSide spec)+ WageEarned -> FixedHomeSide (legacyHomeSide spec)+ TaxesRevenue -> FixedHomeSide (legacyHomeSide spec)+ CentralBankPaymentIncome -> FixedHomeSide (legacyHomeSide spec)+ Sales -> FixedHomeSide (legacyHomeSide spec)+ EquityInEarningsOfInvestee -> FixedHomeSide (legacyHomeSide spec)+ NetLoss -> FixedHomeSide (legacyHomeSide spec)+ PettyCash -> FixedHomeSide (legacyHomeSide spec)+ NotesReceivable -> FixedHomeSide (legacyHomeSide spec)+ ElectronicallyRecordedReceivable -> FixedHomeSide (legacyHomeSide spec)+ CreditCardReceivable -> FixedHomeSide (legacyHomeSide spec)+ NotesLoansReceivable -> FixedHomeSide (legacyHomeSide spec)+ MerchandiseInventory -> FixedHomeSide (legacyHomeSide spec)+ AdvancesPaid -> FixedHomeSide (legacyHomeSide spec)+ PrepaidExpenses -> FixedHomeSide (legacyHomeSide spec)+ AccruedRevenue -> FixedHomeSide (legacyHomeSide spec)+ OtherReceivables -> FixedHomeSide (legacyHomeSide spec)+ PaymentsOnBehalf -> FixedHomeSide (legacyHomeSide spec)+ SuspensePayments -> FixedHomeSide (legacyHomeSide spec)+ ConsumptionTaxPaid -> FixedHomeSide (legacyHomeSide spec)+ PrepaidCorporateIncomeTaxes -> FixedHomeSide (legacyHomeSide spec)+ Land -> FixedHomeSide (legacyHomeSide spec)+ Fixtures -> FixedHomeSide (legacyHomeSide spec)+ Patent -> FixedHomeSide (legacyHomeSide spec)+ Trademark -> FixedHomeSide (legacyHomeSide spec)+ Software -> FixedHomeSide (legacyHomeSide spec)+ CashOverShort -> ContextDependentHomeSide+ AccountsPayable -> FixedHomeSide (legacyHomeSide spec)+ NotesPayable -> FixedHomeSide (legacyHomeSide spec)+ ElectronicallyRecordedObligations -> FixedHomeSide (legacyHomeSide spec)+ NotesLoansPayable -> FixedHomeSide (legacyHomeSide spec)+ BankOverdraft -> FixedHomeSide (legacyHomeSide spec)+ AdvancesReceived -> FixedHomeSide (legacyHomeSide spec)+ UnearnedRevenue -> FixedHomeSide (legacyHomeSide spec)+ AccruedExpenses -> FixedHomeSide (legacyHomeSide spec)+ OtherPayables -> FixedHomeSide (legacyHomeSide spec)+ DepositsReceived -> FixedHomeSide (legacyHomeSide spec)+ SuspenseReceipts -> FixedHomeSide (legacyHomeSide spec)+ ConsumptionTaxReceived -> FixedHomeSide (legacyHomeSide spec)+ AccruedConsumptionTax -> FixedHomeSide (legacyHomeSide spec)+ AccruedCorporateIncomeTaxes -> FixedHomeSide (legacyHomeSide spec)+ UnpaidDividends -> FixedHomeSide (legacyHomeSide spec)+ AllowanceForDoubtfulAccounts -> FixedHomeSide (legacyHomeSide spec)+ AccumulatedDepreciation -> FixedHomeSide (legacyHomeSide spec)+ LegalRetainedEarnings -> FixedHomeSide (legacyHomeSide spec)+ CumulativeTranslationAdjustment -> FixedHomeSide (legacyHomeSide spec)+ ProvisionForDoubtfulAccounts -> FixedHomeSide (legacyHomeSide spec)+ BadDebtLoss -> FixedHomeSide (legacyHomeSide spec)+ LossOnSalesOfFixedAssets -> FixedHomeSide (legacyHomeSide spec)+ LossOnSalesOfNotesReceivable -> FixedHomeSide (legacyHomeSide spec)+ PaymentFees -> FixedHomeSide (legacyHomeSide spec)+ MiscellaneousLoss -> FixedHomeSide (legacyHomeSide spec)+ CorporateIncomeTaxes -> FixedHomeSide (legacyHomeSide spec)+ CommunicationExpenses -> FixedHomeSide (legacyHomeSide spec)+ GainOnSalesOfFixedAssets -> FixedHomeSide (legacyHomeSide spec)+ RecoveryOfBadDebts -> FixedHomeSide (legacyHomeSide spec)+ MiscellaneousIncome -> FixedHomeSide (legacyHomeSide spec)+ ReversalOfAllowanceForDoubtfulAccounts -> FixedHomeSide (legacyHomeSide spec)+ TimeDeposits -> FixedHomeSide (legacyHomeSide spec)+ LoansReceivable -> FixedHomeSide (legacyHomeSide spec)+ GiftCertificatesReceived -> FixedHomeSide (legacyHomeSide spec)+ SecurityDepositsPaid -> FixedHomeSide (legacyHomeSide spec)+ SuppliesOnHand -> FixedHomeSide (legacyHomeSide spec)+ ContractAssets -> FixedHomeSide (legacyHomeSide spec)+ IncomeTaxesRefundReceivable -> FixedHomeSide (legacyHomeSide spec)+ WorkInProcess -> FixedHomeSide (legacyHomeSide spec)+ DeferredTaxAssets -> FixedHomeSide (legacyHomeSide spec)+ LeasedAssets -> FixedHomeSide (legacyHomeSide spec)+ ToolsAndInstruments -> FixedHomeSide (legacyHomeSide spec)+ ConstructionInProgress -> FixedHomeSide (legacyHomeSide spec)+ Goodwill -> FixedHomeSide (legacyHomeSide spec)+ SoftwareInProgress -> FixedHomeSide (legacyHomeSide spec)+ LongTermPrepaidExpenses -> FixedHomeSide (legacyHomeSide spec)+ DishonoredNotesReceivable -> FixedHomeSide (legacyHomeSide spec)+ PrepaidPensionCost -> FixedHomeSide (legacyHomeSide spec)+ NetDefinedBenefitAsset -> FixedHomeSide (legacyHomeSide spec)+ DepositsInSpecialAccounts -> FixedHomeSide (legacyHomeSide spec)+ Structures -> FixedHomeSide (legacyHomeSide spec)+ LeaseholdRights -> FixedHomeSide (legacyHomeSide spec)+ NonOperatingNotesReceivable -> FixedHomeSide (legacyHomeSide spec)+ NonOperatingElectronicallyRecordedReceivable -> FixedHomeSide (legacyHomeSide spec)+ RefundLiabilities -> FixedHomeSide (legacyHomeSide spec)+ NonOperatingNotesPayable -> FixedHomeSide (legacyHomeSide spec)+ NonOperatingElectronicallyRecordedObligations -> FixedHomeSide (legacyHomeSide spec)+ BonusesPayable -> FixedHomeSide (legacyHomeSide spec)+ AllowanceForRepairs -> FixedHomeSide (legacyHomeSide spec)+ AllowanceForProductWarranties -> FixedHomeSide (legacyHomeSide spec)+ AllowanceForBonuses -> FixedHomeSide (legacyHomeSide spec)+ DeferredTaxLiabilities -> FixedHomeSide (legacyHomeSide spec)+ LeaseObligations -> FixedHomeSide (legacyHomeSide spec)+ GuaranteeDepositsReceived -> FixedHomeSide (legacyHomeSide spec)+ AllowanceForRetirementBenefits -> FixedHomeSide (legacyHomeSide spec)+ LongTermOtherPayables -> FixedHomeSide (legacyHomeSide spec)+ NetDefinedBenefitLiability -> FixedHomeSide (legacyHomeSide spec)+ StockSubscriptionDeposits -> FixedHomeSide (legacyHomeSide spec)+ LegalCapitalSurplus -> FixedHomeSide (legacyHomeSide spec)+ OtherCapitalSurplus -> FixedHomeSide (legacyHomeSide spec)+ DividendEqualizationReserve -> FixedHomeSide (legacyHomeSide spec)+ RepairFundReserve -> FixedHomeSide (legacyHomeSide spec)+ ConstructionFundReserve -> FixedHomeSide (legacyHomeSide spec)+ GeneralReserve -> FixedHomeSide (legacyHomeSide spec)+ ValuationDifferenceOnOtherSecurities -> FixedHomeSide (legacyHomeSide spec)+ NonControllingInterests -> FixedHomeSide (legacyHomeSide spec)+ CapitalSurplus -> FixedHomeSide (legacyHomeSide spec)+ EarnedSurplus -> FixedHomeSide (legacyHomeSide spec)+ ServiceRevenue -> FixedHomeSide (legacyHomeSide spec)+ OperatingRevenue -> FixedHomeSide (legacyHomeSide spec)+ GainOnSalesOfSecurities -> FixedHomeSide (legacyHomeSide spec)+ GainOnValuationOfSecurities -> FixedHomeSide (legacyHomeSide spec)+ DividendsReceived -> FixedHomeSide (legacyHomeSide spec)+ InterestOnSecurities -> FixedHomeSide (legacyHomeSide spec)+ GainOnSalesOfInvestmentSecurities -> FixedHomeSide (legacyHomeSide spec)+ InsuranceGain -> FixedHomeSide (legacyHomeSide spec)+ GainOnBargainPurchase -> FixedHomeSide (legacyHomeSide spec)+ ReversalOfAllowanceForRepairs -> FixedHomeSide (legacyHomeSide spec)+ ReversalOfAllowanceForProductWarranties -> FixedHomeSide (legacyHomeSide spec)+ GainOnDonationOfFixedAssets -> FixedHomeSide (legacyHomeSide spec)+ GainOnNationalSubsidies -> FixedHomeSide (legacyHomeSide spec)+ GainOnConstructionGrants -> FixedHomeSide (legacyHomeSide spec)+ LandRentReceived -> FixedHomeSide (legacyHomeSide spec)+ SalesRebates -> FixedHomeSide (legacyHomeSide spec)+ CostOfServices -> FixedHomeSide (legacyHomeSide spec)+ OperatingExpenses -> FixedHomeSide (legacyHomeSide spec)+ InventoryShrinkageLoss -> FixedHomeSide (legacyHomeSide spec)+ LossOnValuationOfMerchandise -> FixedHomeSide (legacyHomeSide spec)+ Bonuses -> FixedHomeSide (legacyHomeSide spec)+ RetirementBenefitExpenses -> FixedHomeSide (legacyHomeSide spec)+ ProvisionForRepairs -> FixedHomeSide (legacyHomeSide spec)+ ProvisionForBonuses -> FixedHomeSide (legacyHomeSide spec)+ ProvisionForProductWarranties -> FixedHomeSide (legacyHomeSide spec)+ ResearchAndDevelopmentExpenses -> FixedHomeSide (legacyHomeSide spec)+ AmortizationOfGoodwill -> FixedHomeSide (legacyHomeSide spec)+ AmortizationOfSoftware -> FixedHomeSide (legacyHomeSide spec)+ AmortizationOfPatents -> FixedHomeSide (legacyHomeSide spec)+ LeaseExpenses -> FixedHomeSide (legacyHomeSide spec)+ IncorporationExpenses -> FixedHomeSide (legacyHomeSide spec)+ StockIssuanceCosts -> FixedHomeSide (legacyHomeSide spec)+ BusinessCommencementExpenses -> FixedHomeSide (legacyHomeSide spec)+ DevelopmentExpenses -> FixedHomeSide (legacyHomeSide spec)+ LossOnSalesOfElectronicallyRecordedReceivables -> FixedHomeSide (legacyHomeSide spec)+ LossOnSalesOfReceivables -> FixedHomeSide (legacyHomeSide spec)+ LossOnSalesOfSecurities -> FixedHomeSide (legacyHomeSide spec)+ LossOnValuationOfSecurities -> FixedHomeSide (legacyHomeSide spec)+ LossOnSalesOfInvestmentSecurities -> FixedHomeSide (legacyHomeSide spec)+ LossOnFire -> FixedHomeSide (legacyHomeSide spec)+ LossOnRetirementOfFixedAssets -> FixedHomeSide (legacyHomeSide spec)+ LossOnReductionOfFixedAssets -> FixedHomeSide (legacyHomeSide spec)+ AdditionalIncomeTaxesForPriorPeriods -> FixedHomeSide (legacyHomeSide spec)+ RefundOfIncomeTaxes -> FixedHomeSide (legacyHomeSide spec)+ PurchaseRebates -> FixedHomeSide (legacyHomeSide spec)+ WelfareExpenses -> FixedHomeSide (legacyHomeSide spec)+ MaintenanceExpenses -> FixedHomeSide (legacyHomeSide spec)+ StatutoryWelfareExpenses -> FixedHomeSide (legacyHomeSide spec)+ LandRentPaid -> FixedHomeSide (legacyHomeSide spec)+ InsuranceExpense -> FixedHomeSide (legacyHomeSide spec)+ RepairsExpense -> FixedHomeSide (legacyHomeSide spec)+ StorageExpenses -> FixedHomeSide (legacyHomeSide spec)+ MembershipFees -> FixedHomeSide (legacyHomeSide spec)+ IncomeSummary -> ContextDependentHomeSide+ SuspenseAccount -> ContextDependentHomeSide+ ForeignExchangeGains -> FixedHomeSide (legacyHomeSide spec)+ ForeignExchangeLosses -> FixedHomeSide (legacyHomeSide spec)+ ContraAccountForGuaranteeObligations -> FixedHomeSide (legacyHomeSide spec)+ GuaranteeObligations -> FixedHomeSide (legacyHomeSide spec)+ IncomeTaxesAdjustment -> FixedHomeSide (legacyHomeSide spec)+ BranchCurrentAccount -> FixedHomeSide (legacyHomeSide spec)+ HeadOfficeCurrentAccount -> FixedHomeSide (legacyHomeSide spec)+ NetIncomeAttributableToNCI -> FixedHomeSide (legacyHomeSide spec)+ NetLossAttributableToNCI -> FixedHomeSide (legacyHomeSide spec)+ TradingSecurities -> FixedHomeSide (legacyHomeSide spec)+ HeldToMaturityBonds -> FixedHomeSide (legacyHomeSide spec)+ SubsidiaryStocks -> FixedHomeSide (legacyHomeSide spec)+ AffiliateStocks -> FixedHomeSide (legacyHomeSide spec)+ AvailableForSaleSecurities -> FixedHomeSide (legacyHomeSide spec)+ ConsumptionTaxRefundReceivable -> FixedHomeSide (legacyHomeSide spec)+ PropertyTaxPayable -> FixedHomeSide (legacyHomeSide spec)+ DepositsReceivedFromOfficers -> FixedHomeSide (legacyHomeSide spec)+ AccountTitle -> NoPostingSide++legacyHomeSide :: AccountSpec -> Side+legacyHomeSide spec+ | asIsContra spec = reverseSide (divisionSide (asDivision spec))+ | otherwise = divisionSide (asDivision spec)++divisionSide :: AccountDivision -> Side+divisionSide Assets = Debit+divisionSide Cost = Debit+divisionSide Equity = Credit+divisionSide Liability = Credit+divisionSide Revenue = Credit++reverseSide :: Side -> Side+reverseSide Debit = Credit+reverseSide Credit = Debit+reverseSide Side = Side++reportingFor :: AccountTitles -> ReportingEligibility+reportingFor title = case title of+ Cash -> StatementEligible+ Deposits -> StatementEligible+ CurrentDeposits -> StatementEligible+ Securities -> StatementEligible+ InvestmentSecurities -> StatementEligible+ InvestmentInAssociate -> StatementEligible+ LongTermNationalBonds -> StatementEligible+ ShortTermNationalBonds -> StatementEligible+ Products -> StatementEligible+ Machinery -> StatementEligible+ Building -> StatementEligible+ Vehicle -> StatementEligible+ StockInvestment -> StatementEligible+ EquipmentInvestment -> StatementEligible+ LongTermLoansReceivable -> StatementEligible+ AccountsReceivable -> StatementEligible+ ShortTermLoansReceivable -> StatementEligible+ ReserveDepositReceivable -> StatementEligible+ Gold -> StatementEligible+ GovernmentService -> StatementEligible+ CapitalStock -> StatementEligible+ RetainedEarnings -> StatementEligible+ LongTermLoansPayable -> StatementEligible+ ShortTermLoansPayable -> StatementEligible+ LoansPayable -> StatementEligible+ ReserveForDepreciation -> StatementEligible+ DepositPayable -> StatementEligible+ LongTermNationalBondsPayable -> StatementEligible+ ShortTermNationalBondsPayable -> StatementEligible+ ReserveDepositPayable -> StatementEligible+ CentralBankNotePayable -> StatementEligible+ Depreciation -> StatementEligible+ AmortizationExpense -> StatementEligible+ SalesCost -> StatementEligible+ BusinessTrip -> StatementEligible+ Commutation -> StatementEligible+ UtilitiesExpense -> StatementEligible+ RentExpense -> StatementEligible+ AdvertisingExpense -> StatementEligible+ DeliveryExpenses -> StatementEligible+ SuppliesExpenses -> StatementEligible+ MiscellaneousExpenses -> StatementEligible+ WageExpenditure -> StatementEligible+ InterestExpense -> StatementEligible+ TaxesExpense -> StatementEligible+ ConsumptionExpenditure -> StatementEligible+ SubsidyExpense -> StatementEligible+ CentralBankPaymentExpense -> StatementEligible+ Purchases -> StatementEligible+ NetIncome -> DerivedPresentation+ ValueAdded -> StatementEligible+ SubsidyIncome -> StatementEligible+ NationalBondInterestEarned -> StatementEligible+ DepositInterestEarned -> StatementEligible+ GrossProfit -> DerivedPresentation+ OrdinaryProfit -> DerivedPresentation+ InterestEarned -> StatementEligible+ ReceiptFee -> StatementEligible+ RentalIncome -> StatementEligible+ WageEarned -> StatementEligible+ TaxesRevenue -> StatementEligible+ CentralBankPaymentIncome -> StatementEligible+ Sales -> StatementEligible+ EquityInEarningsOfInvestee -> ContextualPresentation+ NetLoss -> DerivedPresentation+ PettyCash -> StatementEligible+ NotesReceivable -> StatementEligible+ ElectronicallyRecordedReceivable -> StatementEligible+ CreditCardReceivable -> StatementEligible+ NotesLoansReceivable -> StatementEligible+ MerchandiseInventory -> StatementEligible+ AdvancesPaid -> StatementEligible+ PrepaidExpenses -> StatementEligible+ AccruedRevenue -> StatementEligible+ OtherReceivables -> StatementEligible+ PaymentsOnBehalf -> StatementEligible+ SuspensePayments -> ContextualPresentation+ ConsumptionTaxPaid -> StatementEligible+ PrepaidCorporateIncomeTaxes -> StatementEligible+ Land -> StatementEligible+ Fixtures -> StatementEligible+ Patent -> StatementEligible+ Trademark -> StatementEligible+ Software -> StatementEligible+ CashOverShort -> NotPresented+ AccountsPayable -> StatementEligible+ NotesPayable -> StatementEligible+ ElectronicallyRecordedObligations -> StatementEligible+ NotesLoansPayable -> StatementEligible+ BankOverdraft -> StatementEligible+ AdvancesReceived -> StatementEligible+ UnearnedRevenue -> StatementEligible+ AccruedExpenses -> StatementEligible+ OtherPayables -> StatementEligible+ DepositsReceived -> StatementEligible+ SuspenseReceipts -> ContextualPresentation+ ConsumptionTaxReceived -> StatementEligible+ AccruedConsumptionTax -> StatementEligible+ AccruedCorporateIncomeTaxes -> StatementEligible+ UnpaidDividends -> StatementEligible+ AllowanceForDoubtfulAccounts -> StatementEligible+ AccumulatedDepreciation -> StatementEligible+ LegalRetainedEarnings -> StatementEligible+ CumulativeTranslationAdjustment -> ContextualPresentation+ ProvisionForDoubtfulAccounts -> StatementEligible+ BadDebtLoss -> StatementEligible+ LossOnSalesOfFixedAssets -> StatementEligible+ LossOnSalesOfNotesReceivable -> StatementEligible+ PaymentFees -> StatementEligible+ MiscellaneousLoss -> StatementEligible+ CorporateIncomeTaxes -> StatementEligible+ CommunicationExpenses -> StatementEligible+ GainOnSalesOfFixedAssets -> StatementEligible+ RecoveryOfBadDebts -> StatementEligible+ MiscellaneousIncome -> StatementEligible+ ReversalOfAllowanceForDoubtfulAccounts -> StatementEligible+ TimeDeposits -> StatementEligible+ LoansReceivable -> StatementEligible+ GiftCertificatesReceived -> StatementEligible+ SecurityDepositsPaid -> StatementEligible+ SuppliesOnHand -> StatementEligible+ ContractAssets -> StatementEligible+ IncomeTaxesRefundReceivable -> StatementEligible+ WorkInProcess -> StatementEligible+ DeferredTaxAssets -> StatementEligible+ LeasedAssets -> StatementEligible+ ToolsAndInstruments -> StatementEligible+ ConstructionInProgress -> StatementEligible+ Goodwill -> StatementEligible+ SoftwareInProgress -> StatementEligible+ LongTermPrepaidExpenses -> StatementEligible+ DishonoredNotesReceivable -> StatementEligible+ PrepaidPensionCost -> StatementEligible+ NetDefinedBenefitAsset -> StatementEligible+ DepositsInSpecialAccounts -> StatementEligible+ Structures -> StatementEligible+ LeaseholdRights -> StatementEligible+ NonOperatingNotesReceivable -> StatementEligible+ NonOperatingElectronicallyRecordedReceivable -> StatementEligible+ RefundLiabilities -> StatementEligible+ NonOperatingNotesPayable -> StatementEligible+ NonOperatingElectronicallyRecordedObligations -> StatementEligible+ BonusesPayable -> StatementEligible+ AllowanceForRepairs -> StatementEligible+ AllowanceForProductWarranties -> StatementEligible+ AllowanceForBonuses -> StatementEligible+ DeferredTaxLiabilities -> StatementEligible+ LeaseObligations -> StatementEligible+ GuaranteeDepositsReceived -> StatementEligible+ AllowanceForRetirementBenefits -> StatementEligible+ LongTermOtherPayables -> StatementEligible+ NetDefinedBenefitLiability -> StatementEligible+ StockSubscriptionDeposits -> StatementEligible+ LegalCapitalSurplus -> StatementEligible+ OtherCapitalSurplus -> StatementEligible+ DividendEqualizationReserve -> StatementEligible+ RepairFundReserve -> StatementEligible+ ConstructionFundReserve -> StatementEligible+ GeneralReserve -> StatementEligible+ ValuationDifferenceOnOtherSecurities -> StatementEligible+ NonControllingInterests -> ContextualPresentation+ CapitalSurplus -> StatementEligible+ EarnedSurplus -> StatementEligible+ ServiceRevenue -> StatementEligible+ OperatingRevenue -> StatementEligible+ GainOnSalesOfSecurities -> StatementEligible+ GainOnValuationOfSecurities -> StatementEligible+ DividendsReceived -> StatementEligible+ InterestOnSecurities -> StatementEligible+ GainOnSalesOfInvestmentSecurities -> StatementEligible+ InsuranceGain -> StatementEligible+ GainOnBargainPurchase -> StatementEligible+ ReversalOfAllowanceForRepairs -> StatementEligible+ ReversalOfAllowanceForProductWarranties -> StatementEligible+ GainOnDonationOfFixedAssets -> StatementEligible+ GainOnNationalSubsidies -> StatementEligible+ GainOnConstructionGrants -> StatementEligible+ LandRentReceived -> StatementEligible+ SalesRebates -> StatementEligible+ CostOfServices -> StatementEligible+ OperatingExpenses -> StatementEligible+ InventoryShrinkageLoss -> StatementEligible+ LossOnValuationOfMerchandise -> StatementEligible+ Bonuses -> StatementEligible+ RetirementBenefitExpenses -> StatementEligible+ ProvisionForRepairs -> StatementEligible+ ProvisionForBonuses -> StatementEligible+ ProvisionForProductWarranties -> StatementEligible+ ResearchAndDevelopmentExpenses -> StatementEligible+ AmortizationOfGoodwill -> StatementEligible+ AmortizationOfSoftware -> StatementEligible+ AmortizationOfPatents -> StatementEligible+ LeaseExpenses -> StatementEligible+ IncorporationExpenses -> StatementEligible+ StockIssuanceCosts -> StatementEligible+ BusinessCommencementExpenses -> StatementEligible+ DevelopmentExpenses -> StatementEligible+ LossOnSalesOfElectronicallyRecordedReceivables -> StatementEligible+ LossOnSalesOfReceivables -> StatementEligible+ LossOnSalesOfSecurities -> StatementEligible+ LossOnValuationOfSecurities -> StatementEligible+ LossOnSalesOfInvestmentSecurities -> StatementEligible+ LossOnFire -> StatementEligible+ LossOnRetirementOfFixedAssets -> StatementEligible+ LossOnReductionOfFixedAssets -> StatementEligible+ AdditionalIncomeTaxesForPriorPeriods -> StatementEligible+ RefundOfIncomeTaxes -> StatementEligible+ PurchaseRebates -> StatementEligible+ WelfareExpenses -> StatementEligible+ MaintenanceExpenses -> StatementEligible+ StatutoryWelfareExpenses -> StatementEligible+ LandRentPaid -> StatementEligible+ InsuranceExpense -> StatementEligible+ RepairsExpense -> StatementEligible+ StorageExpenses -> StatementEligible+ MembershipFees -> StatementEligible+ IncomeSummary -> NotPresented+ SuspenseAccount -> ContextualPresentation+ ForeignExchangeGains -> StatementEligible+ ForeignExchangeLosses -> StatementEligible+ ContraAccountForGuaranteeObligations -> StatementEligible+ GuaranteeObligations -> StatementEligible+ IncomeTaxesAdjustment -> StatementEligible+ BranchCurrentAccount -> ContextualPresentation+ HeadOfficeCurrentAccount -> ContextualPresentation+ NetIncomeAttributableToNCI -> ContextualPresentation+ NetLossAttributableToNCI -> ContextualPresentation+ TradingSecurities -> StatementEligible+ HeldToMaturityBonds -> StatementEligible+ SubsidiaryStocks -> StatementEligible+ AffiliateStocks -> StatementEligible+ AvailableForSaleSecurities -> StatementEligible+ ConsumptionTaxRefundReceivable -> StatementEligible+ PropertyTaxPayable -> StatementEligible+ DepositsReceivedFromOfficers -> StatementEligible+ AccountTitle -> NotPresented++-- | Every accepted non-canonical alias for an account. The JCCI overlay is+-- generated from the frozen 2022 standard/permitted account-name fixture;+-- shared permitted names intentionally remain shared so parsing reports an+-- explicit ambiguity instead of silently choosing one account.+accountAliases :: AccountTitles -> [Text]+accountAliases title = L.nub (baseAliases <> jcciAliases title)+ where+ baseAliases = maybe [] asAliases (accountSpec title)++-- | Look up metadata for an account title. The wildcard has no metadata.+--+-- Complexity: O(1)+{-# INLINE accountSpec #-}+accountSpec :: AccountTitles -> Maybe AccountSpec+accountSpec Cash = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Cash"+ , asNameJa = "現金"+ , asLabelJa = "現金"+ , asDescription = "Asset: Cash (現金)"+ , asAliases = ["現金"]+ }+accountSpec Deposits = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Savings deposits"+ , asNameJa = "普通預金"+ , asLabelJa = "普通預金"+ , asDescription = "Asset: Savings deposits (普通預金)"+ , asAliases = ["普通預金"]+ }+accountSpec CurrentDeposits = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Current deposits"+ , asNameJa = "当座預金"+ , asLabelJa = "当座預金"+ , asDescription = "Asset: Current deposits (当座預金)"+ , asAliases = ["当座預金"]+ }+accountSpec Securities = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Securities"+ , asNameJa = "有価証券"+ , asLabelJa = "有価証券"+ , asDescription = "Asset: Securities (有価証券)"+ , asAliases = ["有価証券"]+ }+accountSpec InvestmentSecurities = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Investment securities"+ , asNameJa = "投資有価証券"+ , asLabelJa = "投資有価証券"+ , asDescription = "Asset: Investment securities (投資有価証券)"+ , asAliases = ["投資有価証券"]+ }+accountSpec InvestmentInAssociate = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Investment in associate"+ , asNameJa = "関係会社株式"+ , asLabelJa = "関係会社株式"+ , asDescription = "Asset: Investment in associate (関係会社株式). Carrying amount under the equity method (持分法適用投資勘定)."+ , asAliases = []+ }+accountSpec LongTermNationalBonds = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Long-term national bonds"+ , asNameJa = "長期国債"+ , asLabelJa = "長期国債"+ , asDescription = "Asset: Long-term national bonds (長期国債)"+ , asAliases = ["長期国債"]+ }+accountSpec ShortTermNationalBonds = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Short-term national bonds"+ , asNameJa = "短期国債"+ , asLabelJa = "短期国債"+ , asDescription = "Asset: Short-term national bonds (短期国債)"+ , asAliases = ["短期国債"]+ }+accountSpec Products = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Products"+ , asNameJa = "商品"+ , asLabelJa = "商品"+ , asDescription = "Asset: Products (商品)。分記法用。3 分法 (仕入\\/売上\\/繰越商品) では 'MerchandiseInventory' を使う"+ , asAliases = ["商品"]+ }+accountSpec Machinery = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Machinery and equipment"+ , asNameJa = "機械装置"+ , asLabelJa = "機械装置"+ , asDescription = "Asset: Machinery and equipment (機械装置)"+ , asAliases = ["機械装置"]+ }+accountSpec Building = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Real estate"+ , asNameJa = "建物"+ , asLabelJa = "建物"+ , asDescription = "Asset: Real estate (建物)"+ , asAliases = ["建物"]+ }+accountSpec Vehicle = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Vehicles"+ , asNameJa = "車両運搬具"+ , asLabelJa = "車両運搬具"+ , asDescription = "Asset: Vehicles (車両運搬具)"+ , asAliases = ["車両運搬具"]+ }+accountSpec StockInvestment = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Stock investment"+ , asNameJa = "株式投資"+ , asLabelJa = "株式投資"+ , asDescription = "Asset: Stock investment (株式投資)"+ , asAliases = ["株式投資"]+ }+accountSpec EquipmentInvestment = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Equipment investment"+ , asNameJa = "設備投資"+ , asLabelJa = "設備投資"+ , asDescription = "Asset: Equipment investment (設備投資)"+ , asAliases = ["設備投資"]+ }+accountSpec LongTermLoansReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Long-term loans receivable"+ , asNameJa = "長期貸付金"+ , asLabelJa = "長期貸付金"+ , asDescription = "Asset: Long-term loans receivable (長期貸付金)"+ , asAliases = ["長期貸付金"]+ }+accountSpec AccountsReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Accounts receivable"+ , asNameJa = "売掛金"+ , asLabelJa = "売掛金"+ , asDescription = "Asset: Accounts receivable (売掛金)"+ , asAliases = ["売掛金", "a/r", "accounts receivable"]+ }+accountSpec ShortTermLoansReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Short-term loans receivable"+ , asNameJa = "短期貸付金"+ , asLabelJa = "短期貸付金"+ , asDescription = "Asset: Short-term loans receivable (短期貸付金)"+ , asAliases = ["短期貸付金"]+ }+accountSpec ReserveDepositReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Reserve deposits, asset side"+ , asNameJa = "準備預金, 資産側 — 市中銀行が中央銀行に置く準備預金。SNA\\/マクロ系"+ , asLabelJa = "準備預金"+ , asDescription = "Asset: Reserve deposits, asset side (準備預金, 資産側 — 市中銀行が中央銀行に置く準備預金。SNA\\/マクロ系)"+ , asAliases = ["準備預金"]+ }+accountSpec Gold = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Gold"+ , asNameJa = "金"+ , asLabelJa = "金"+ , asDescription = "Asset: Gold (金)"+ , asAliases = ["金"]+ }+accountSpec GovernmentService = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Government service"+ , asNameJa = "政府サービス。SNA\\/マクロ系"+ , asLabelJa = "政府サービス"+ , asDescription = "Asset: Government service (政府サービス。SNA\\/マクロ系)"+ , asAliases = ["政府サービス"]+ }+accountSpec CapitalStock = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Capital stock"+ , asNameJa = "資本金"+ , asLabelJa = "資本金"+ , asDescription = "Equity: Capital stock (資本金)"+ , asAliases = ["資本金"]+ }+accountSpec RetainedEarnings = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Retained earnings"+ , asNameJa = "繰越利益剰余金"+ , asLabelJa = "繰越利益剰余金"+ , asDescription = "Equity: Retained earnings (繰越利益剰余金)"+ , asAliases = ["繰越利益剰余金"]+ }+accountSpec LongTermLoansPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Long-term loans payable"+ , asNameJa = "長期借入金"+ , asLabelJa = "長期借入金"+ , asDescription = "Liability: Long-term loans payable (長期借入金)"+ , asAliases = ["長期借入金"]+ }+accountSpec ShortTermLoansPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Short-term loans payable"+ , asNameJa = "短期借入金"+ , asLabelJa = "短期借入金"+ , asDescription = "Liability: Short-term loans payable (短期借入金)"+ , asAliases = ["短期借入金"]+ }+accountSpec LoansPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Loans payable"+ , asNameJa = "借入金"+ , asLabelJa = "借入金"+ , asDescription = "Liability: Loans payable (借入金)"+ , asAliases = ["借入金"]+ }+accountSpec ReserveForDepreciation = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Reserve for depreciation"+ , asNameJa = "減価償却引当金 — SNA\\/マクロ系の旧称。簿記の間接法には 'AccumulatedDepreciation' (減価償却累計額"+ , asLabelJa = "減価償却引当金"+ , asDescription = "Liability: Reserve for depreciation (減価償却引当金 — SNA\\/マクロ系の旧称。簿記の間接法には 'AccumulatedDepreciation' (減価償却累計額) を使う)"+ , asAliases = ["減価償却引当金"]+ }+accountSpec DepositPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Deposits accepted"+ , asNameJa = "受入預金 — 銀行側の負債としての預金。SNA\\/マクロ系。従業員等からの預り金は 'DepositsReceived'"+ , asLabelJa = "受入預金"+ , asDescription = "Liability: Deposits accepted (受入預金 — 銀行側の負債としての預金。SNA\\/マクロ系。従業員等からの預り金は 'DepositsReceived')"+ , asAliases = ["受入預金"]+ }+accountSpec LongTermNationalBondsPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Long-term national bonds payable"+ , asNameJa = "長期国債, 発行側"+ , asLabelJa = "長期国債"+ , asDescription = "Liability: Long-term national bonds payable (長期国債, 発行側)"+ , asAliases = ["長期国債"]+ }+accountSpec ShortTermNationalBondsPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Short-term national bonds payable"+ , asNameJa = "短期国債, 発行側"+ , asLabelJa = "短期国債"+ , asDescription = "Liability: Short-term national bonds payable (短期国債, 発行側)"+ , asAliases = ["短期国債"]+ }+accountSpec ReserveDepositPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Reserve deposits, liability side"+ , asNameJa = "準備預金, 負債側 — 中央銀行が受け入れる準備預金。SNA\\/マクロ系"+ , asLabelJa = "準備預金"+ , asDescription = "Liability: Reserve deposits, liability side (準備預金, 負債側 — 中央銀行が受け入れる準備預金。SNA\\/マクロ系)。※簿記の買掛金は 'AccountsPayable' を使うこと (旧 examples が本科目を買掛金の代用にしていた経緯あり)"+ , asAliases = ["準備預金"]+ }+accountSpec CentralBankNotePayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Central bank notes"+ , asNameJa = "発行銀行券。SNA\\/マクロ系"+ , asLabelJa = "発行銀行券"+ , asDescription = "Liability: Central bank notes (発行銀行券。SNA\\/マクロ系)"+ , asAliases = ["発行銀行券"]+ }+accountSpec Depreciation = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Depreciation"+ , asNameJa = "減価償却費"+ , asLabelJa = "減価償却費"+ , asDescription = "Expense: Depreciation (減価償却費)"+ , asAliases = ["減価償却費"]+ }+accountSpec AmortizationExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Amortization expense for intangibles"+ , asNameJa = "無形固定資産償却費 — 特許権・商標権・ソフトウェア等の無形資産の償却費。有形の 'Depreciation' (減価償却費"+ , asLabelJa = "無形固定資産償却費"+ , asDescription = "Expense: Amortization expense for intangibles (無形固定資産償却費 — 特許権・商標権・ソフトウェア等の無形資産の償却費。有形の 'Depreciation' (減価償却費) と区別される)"+ , asAliases = []+ }+accountSpec SalesCost = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Cost of sales"+ , asNameJa = "売上原価"+ , asLabelJa = "売上原価"+ , asDescription = "Expense: Cost of sales (売上原価)"+ , asAliases = ["売上原価", "cogs", "cost of sales"]+ }+accountSpec BusinessTrip = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Travel and transportation"+ , asNameJa = "旅費交通費"+ , asLabelJa = "旅費交通費"+ , asDescription = "Expense: Travel and transportation (旅費交通費)"+ , asAliases = ["旅費交通費"]+ }+accountSpec Commutation = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Communication"+ , asNameJa = "通信費 — 旧称。新規コードでは 'CommunicationExpenses' を使う"+ , asLabelJa = "通信費"+ , asDescription = "Expense: Communication (通信費 — 旧称。新規コードでは 'CommunicationExpenses' を使う)"+ , asAliases = ["通信費"]+ }+accountSpec UtilitiesExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Utilities"+ , asNameJa = "水道光熱費"+ , asLabelJa = "水道光熱費"+ , asDescription = "Expense: Utilities (水道光熱費)"+ , asAliases = ["水道光熱費"]+ }+accountSpec RentExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Rent"+ , asNameJa = "支払家賃"+ , asLabelJa = "支払家賃"+ , asDescription = "Expense: Rent (支払家賃)"+ , asAliases = ["支払家賃"]+ }+accountSpec AdvertisingExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Advertising"+ , asNameJa = "広告宣伝費"+ , asLabelJa = "広告宣伝費"+ , asDescription = "Expense: Advertising (広告宣伝費)"+ , asAliases = ["広告宣伝費"]+ }+accountSpec DeliveryExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Delivery"+ , asNameJa = "発送費"+ , asLabelJa = "発送費"+ , asDescription = "Expense: Delivery (発送費)"+ , asAliases = ["発送費"]+ }+accountSpec SuppliesExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Supplies"+ , asNameJa = "消耗品費"+ , asLabelJa = "消耗品費"+ , asDescription = "Expense: Supplies (消耗品費)"+ , asAliases = ["消耗品費"]+ }+accountSpec MiscellaneousExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Miscellaneous"+ , asNameJa = "雑費"+ , asLabelJa = "雑費"+ , asDescription = "Expense: Miscellaneous (雑費)"+ , asAliases = ["雑費"]+ }+accountSpec WageExpenditure = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Wages"+ , asNameJa = "給料"+ , asLabelJa = "給料"+ , asDescription = "Expense: Wages (給料)"+ , asAliases = ["給料"]+ }+accountSpec InterestExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Interest expense"+ , asNameJa = "支払利息"+ , asLabelJa = "支払利息"+ , asDescription = "Expense: Interest expense (支払利息)"+ , asAliases = ["支払利息"]+ }+accountSpec TaxesExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Taxes"+ , asNameJa = "租税公課"+ , asLabelJa = "租税公課"+ , asDescription = "Expense: Taxes (租税公課)"+ , asAliases = ["租税公課"]+ }+accountSpec ConsumptionExpenditure = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Consumption expenditure"+ , asNameJa = "消費支出。SNA\\/マクロ系"+ , asLabelJa = "消費支出"+ , asDescription = "Expense: Consumption expenditure (消費支出。SNA\\/マクロ系)"+ , asAliases = ["消費支出"]+ }+accountSpec SubsidyExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Subsidy expenditure"+ , asNameJa = "補助金支出。SNA\\/マクロ系"+ , asLabelJa = "補助金支出"+ , asDescription = "Expense: Subsidy expenditure (補助金支出。SNA\\/マクロ系)"+ , asAliases = ["補助金支出"]+ }+accountSpec CentralBankPaymentExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Central bank payment to treasury"+ , asNameJa = "国庫納付金支出。SNA\\/マクロ系"+ , asLabelJa = "国庫納付金支出"+ , asDescription = "Expense: Central bank payment to treasury (国庫納付金支出。SNA\\/マクロ系)"+ , asAliases = ["国庫納付金支出"]+ }+accountSpec Purchases = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Purchases"+ , asNameJa = "仕入"+ , asLabelJa = "仕入"+ , asDescription = "Expense: Purchases (仕入)"+ , asAliases = ["仕入"]+ }+accountSpec NetIncome = Just AccountSpec+ { asDivision = Cost+ , asClosing = NoClose+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Net income"+ , asNameJa = "当期純利益 — 決算振替用。借方側に立つため Expense 区分"+ , asLabelJa = "当期純利益"+ , asDescription = "Expense: Net income (当期純利益 — 決算振替用。借方側に立つため Expense 区分)"+ , asAliases = ["当期純利益"]+ }+accountSpec ValueAdded = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Value added"+ , asNameJa = "付加価値。SNA\\/マクロ系"+ , asLabelJa = "付加価値"+ , asDescription = "Revenue: Value added (付加価値。SNA\\/マクロ系)"+ , asAliases = ["付加価値"]+ }+accountSpec SubsidyIncome = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Subsidy income"+ , asNameJa = "補助金収入。SNA\\/マクロ系"+ , asLabelJa = "補助金収入"+ , asDescription = "Revenue: Subsidy income (補助金収入。SNA\\/マクロ系)"+ , asAliases = ["補助金収入"]+ }+accountSpec NationalBondInterestEarned = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "National bond interest earned"+ , asNameJa = "国債利息収入"+ , asLabelJa = "国債利息収入"+ , asDescription = "Revenue: National bond interest earned (国債利息収入)"+ , asAliases = ["国債利息収入"]+ }+accountSpec DepositInterestEarned = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Deposit interest earned"+ , asNameJa = "預金利息収入"+ , asLabelJa = "預金利息収入"+ , asDescription = "Revenue: Deposit interest earned (預金利息収入)"+ , asAliases = ["預金利息収入"]+ }+accountSpec GrossProfit = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gross profit"+ , asNameJa = "売上総利益 — 決算振替用"+ , asLabelJa = "売上総利益"+ , asDescription = "Revenue: Gross profit (売上総利益 — 決算振替用)"+ , asAliases = ["売上総利益"]+ }+accountSpec OrdinaryProfit = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Ordinary profit"+ , asNameJa = "経常利益 — 決算振替用"+ , asLabelJa = "経常利益"+ , asDescription = "Revenue: Ordinary profit (経常利益 — 決算振替用)"+ , asAliases = ["経常利益"]+ }+accountSpec InterestEarned = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Interest earned"+ , asNameJa = "受取利息"+ , asLabelJa = "受取利息"+ , asDescription = "Revenue: Interest earned (受取利息)"+ , asAliases = ["受取利息"]+ }+accountSpec ReceiptFee = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Receipt fee"+ , asNameJa = "受取手数料。支払側は 'PaymentFees'"+ , asLabelJa = "受取手数料"+ , asDescription = "Revenue: Receipt fee (受取手数料。支払側は 'PaymentFees')"+ , asAliases = ["受取手数料"]+ }+accountSpec RentalIncome = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Rental income"+ , asNameJa = "受取家賃"+ , asLabelJa = "受取家賃"+ , asDescription = "Revenue: Rental income (受取家賃)"+ , asAliases = ["受取家賃"]+ }+accountSpec WageEarned = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Wage income"+ , asNameJa = "賃金収入。SNA\\/マクロ系"+ , asLabelJa = "賃金収入"+ , asDescription = "Revenue: Wage income (賃金収入。SNA\\/マクロ系)"+ , asAliases = ["賃金収入"]+ }+accountSpec TaxesRevenue = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Tax revenue"+ , asNameJa = "租税収入。SNA\\/マクロ系"+ , asLabelJa = "租税収入"+ , asDescription = "Revenue: Tax revenue (租税収入。SNA\\/マクロ系)"+ , asAliases = ["租税収入"]+ }+accountSpec CentralBankPaymentIncome = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Central bank payment to treasury"+ , asNameJa = "国庫納付金収入。SNA\\/マクロ系"+ , asLabelJa = "国庫納付金収入"+ , asDescription = "Revenue: Central bank payment to treasury (国庫納付金収入。SNA\\/マクロ系)"+ , asAliases = ["国庫納付金収入"]+ }+accountSpec Sales = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Sales"+ , asNameJa = "売上"+ , asLabelJa = "売上"+ , asDescription = "Revenue: Sales (売上)"+ , asAliases = ["売上"]+ }+accountSpec EquityInEarningsOfInvestee = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Equity in earnings of investee"+ , asNameJa = "持分法による投資利益"+ , asLabelJa = "持分法による投資利益"+ , asDescription = "Revenue: Equity in earnings of investee (持分法による投資利益). Recognised under the equity method."+ , asAliases = []+ }+accountSpec NetLoss = Just AccountSpec+ { asDivision = Revenue+ , asClosing = NoClose+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Net loss"+ , asNameJa = "当期純損失 — 決算振替用。貸方側に立つため Revenue 区分"+ , asLabelJa = "当期純損失"+ , asDescription = "Revenue: Net loss (当期純損失 — 決算振替用。貸方側に立つため Revenue 区分)"+ , asAliases = ["当期純損失"]+ }+accountSpec PettyCash = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Petty cash"+ , asNameJa = "小口現金"+ , asLabelJa = "小口現金"+ , asDescription = "Asset: Petty cash (小口現金)"+ , asAliases = ["小口現金"]+ }+accountSpec NotesReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Notes receivable"+ , asNameJa = "受取手形"+ , asLabelJa = "受取手形"+ , asDescription = "Asset: Notes receivable (受取手形)"+ , asAliases = ["受取手形"]+ }+accountSpec ElectronicallyRecordedReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Electronically recorded monetary claims"+ , asNameJa = "電子記録債権"+ , asLabelJa = "電子記録債権"+ , asDescription = "Asset: Electronically recorded monetary claims (電子記録債権)"+ , asAliases = ["電子記録債権"]+ }+accountSpec CreditCardReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Credit card receivable"+ , asNameJa = "クレジット売掛金"+ , asLabelJa = "クレジット売掛金"+ , asDescription = "Asset: Credit card receivable (クレジット売掛金)"+ , asAliases = ["クレジット売掛金"]+ }+accountSpec NotesLoansReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Loans receivable on notes"+ , asNameJa = "手形貸付金"+ , asLabelJa = "手形貸付金"+ , asDescription = "Asset: Loans receivable on notes (手形貸付金)"+ , asAliases = ["手形貸付金"]+ }+accountSpec MerchandiseInventory = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Merchandise inventory"+ , asNameJa = "繰越商品"+ , asLabelJa = "繰越商品"+ , asDescription = "Asset: Merchandise inventory (繰越商品). Use under the periodic/3-account method (3 分法: Purchases\\/Sales\\/MerchandiseInventory). For the perpetual\\/specific-identification method (分記法) use 'Products' instead."+ , asAliases = ["繰越商品"]+ }+accountSpec AdvancesPaid = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Advances paid"+ , asNameJa = "前払金"+ , asLabelJa = "前払金"+ , asDescription = "Asset: Advances paid (前払金)"+ , asAliases = ["前払金"]+ }+accountSpec PrepaidExpenses = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Prepaid expenses"+ , asNameJa = "前払費用"+ , asLabelJa = "前払費用"+ , asDescription = "Asset: Prepaid expenses (前払費用), deferral accrual account (経過勘定)"+ , asAliases = ["前払費用"]+ }+accountSpec AccruedRevenue = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Accrued revenue"+ , asNameJa = "未収収益"+ , asLabelJa = "未収収益"+ , asDescription = "Asset: Accrued revenue (未収収益), deferral accrual account (経過勘定)"+ , asAliases = ["未収収益"]+ }+accountSpec OtherReceivables = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Other receivables"+ , asNameJa = "未収入金"+ , asLabelJa = "未収入金"+ , asDescription = "Asset: Other receivables (未収入金)"+ , asAliases = ["未収入金"]+ }+accountSpec PaymentsOnBehalf = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Payments made on behalf"+ , asNameJa = "立替金"+ , asLabelJa = "立替金"+ , asDescription = "Asset: Payments made on behalf (立替金)"+ , asAliases = ["立替金"]+ }+accountSpec SuspensePayments = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Suspense payments"+ , asNameJa = "仮払金"+ , asLabelJa = "仮払金"+ , asDescription = "Asset: Suspense payments (仮払金)"+ , asAliases = ["仮払金"]+ }+accountSpec ConsumptionTaxPaid = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Consumption tax paid"+ , asNameJa = "仮払消費税"+ , asLabelJa = "仮払消費税"+ , asDescription = "Asset: Consumption tax paid (仮払消費税)"+ , asAliases = ["仮払消費税"]+ }+accountSpec PrepaidCorporateIncomeTaxes = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Prepaid corporate income taxes"+ , asNameJa = "仮払法人税等"+ , asLabelJa = "仮払法人税等"+ , asDescription = "Asset: Prepaid corporate income taxes (仮払法人税等)"+ , asAliases = ["仮払法人税等"]+ }+accountSpec Land = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Land"+ , asNameJa = "土地"+ , asLabelJa = "土地"+ , asDescription = "Asset: Land (土地)"+ , asAliases = ["土地"]+ }+accountSpec Fixtures = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Fixtures and equipment"+ , asNameJa = "備品"+ , asLabelJa = "備品"+ , asDescription = "Asset: Fixtures and equipment (備品)"+ , asAliases = ["備品"]+ }+accountSpec Patent = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Patent"+ , asNameJa = "特許権"+ , asLabelJa = "特許権"+ , asDescription = "Asset: Patent (特許権)"+ , asAliases = ["特許権"]+ }+accountSpec Trademark = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Trademark"+ , asNameJa = "商標権"+ , asLabelJa = "商標権"+ , asDescription = "Asset: Trademark (商標権)"+ , asAliases = ["商標権"]+ }+accountSpec Software = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Software"+ , asNameJa = "ソフトウェア"+ , asLabelJa = "ソフトウェア"+ , asDescription = "Asset: Software (ソフトウェア)"+ , asAliases = ["ソフトウェア"]+ }+accountSpec CashOverShort = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Cash over and short"+ , asNameJa = "現金過不足"+ , asLabelJa = "現金過不足"+ , asDescription = "Asset: Cash over and short (現金過不足), a temporary/suspense account cleared at closing to MiscellaneousIncome\\/MiscellaneousLoss"+ , asAliases = ["現金過不足"]+ }+accountSpec AccountsPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Accounts payable"+ , asNameJa = "買掛金"+ , asLabelJa = "買掛金"+ , asDescription = "Liability: Accounts payable (買掛金)"+ , asAliases = ["買掛金", "a/p", "accounts payable"]+ }+accountSpec NotesPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Notes payable"+ , asNameJa = "支払手形"+ , asLabelJa = "支払手形"+ , asDescription = "Liability: Notes payable (支払手形)"+ , asAliases = ["支払手形"]+ }+accountSpec ElectronicallyRecordedObligations = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Electronically recorded monetary obligations"+ , asNameJa = "電子記録債務"+ , asLabelJa = "電子記録債務"+ , asDescription = "Liability: Electronically recorded monetary obligations (電子記録債務)"+ , asAliases = ["電子記録債務"]+ }+accountSpec NotesLoansPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Loans payable on notes"+ , asNameJa = "手形借入金"+ , asLabelJa = "手形借入金"+ , asDescription = "Liability: Loans payable on notes (手形借入金)"+ , asAliases = ["手形借入金"]+ }+accountSpec BankOverdraft = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Bank overdraft"+ , asNameJa = "当座借越"+ , asLabelJa = "当座借越"+ , asDescription = "Liability: Bank overdraft (当座借越)"+ , asAliases = ["当座借越"]+ }+accountSpec AdvancesReceived = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Advances received"+ , asNameJa = "前受金"+ , asLabelJa = "前受金"+ , asDescription = "Liability: Advances received (前受金)"+ , asAliases = ["前受金"]+ }+accountSpec UnearnedRevenue = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Unearned revenue"+ , asNameJa = "前受収益"+ , asLabelJa = "前受収益"+ , asDescription = "Liability: Unearned revenue (前受収益), deferral accrual account (経過勘定)"+ , asAliases = ["前受収益"]+ }+accountSpec AccruedExpenses = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Accrued expenses"+ , asNameJa = "未払費用"+ , asLabelJa = "未払費用"+ , asDescription = "Liability: Accrued expenses (未払費用), deferral accrual account (経過勘定)"+ , asAliases = ["未払費用"]+ }+accountSpec OtherPayables = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Other payables"+ , asNameJa = "未払金"+ , asLabelJa = "未払金"+ , asDescription = "Liability: Other payables (未払金)"+ , asAliases = ["未払金"]+ }+accountSpec DepositsReceived = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Deposits received"+ , asNameJa = "預り金"+ , asLabelJa = "預り金"+ , asDescription = "Liability: Deposits received (預り金)"+ , asAliases = ["預り金"]+ }+accountSpec SuspenseReceipts = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Suspense receipts"+ , asNameJa = "仮受金"+ , asLabelJa = "仮受金"+ , asDescription = "Liability: Suspense receipts (仮受金)"+ , asAliases = ["仮受金"]+ }+accountSpec ConsumptionTaxReceived = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Consumption tax received"+ , asNameJa = "仮受消費税"+ , asLabelJa = "仮受消費税"+ , asDescription = "Liability: Consumption tax received (仮受消費税)"+ , asAliases = ["仮受消費税"]+ }+accountSpec AccruedConsumptionTax = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Accrued (unpaid) consumption tax"+ , asNameJa = "未払消費税"+ , asLabelJa = "未払消費税"+ , asDescription = "Liability: Accrued (unpaid) consumption tax (未払消費税)"+ , asAliases = ["未払消費税"]+ }+accountSpec AccruedCorporateIncomeTaxes = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Accrued (unpaid) corporate income taxes"+ , asNameJa = "未払法人税等"+ , asLabelJa = "未払法人税等"+ , asDescription = "Liability: Accrued (unpaid) corporate income taxes (未払法人税等)"+ , asAliases = ["未払法人税等"]+ }+accountSpec UnpaidDividends = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Unpaid dividends"+ , asNameJa = "未払配当金"+ , asLabelJa = "未払配当金"+ , asDescription = "Liability: Unpaid dividends (未払配当金)"+ , asAliases = ["未払配当金"]+ }+accountSpec AllowanceForDoubtfulAccounts = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = True+ , asFixedCurrent = Current+ , asNameEn = "Allowance for doubtful accounts"+ , asNameJa = "貸倒引当金"+ , asLabelJa = "貸倒引当金"+ , asDescription = "Asset (contra): Allowance for doubtful accounts (貸倒引当金), a credit-balance valuation account (評価勘定) deducted from receivables. Home side is Credit because it is a contra asset (isContra); values stay non-negative and the Hat\\/Not structure is intact. B\\/S deduction (net) presentation is the Write side's job."+ , asAliases = ["貸倒引当金"]+ }+accountSpec AccumulatedDepreciation = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = True+ , asFixedCurrent = Fixed+ , asNameEn = "Accumulated depreciation"+ , asNameJa = "減価償却累計額"+ , asLabelJa = "減価償却累計額"+ , asDescription = "Asset (contra): Accumulated depreciation (減価償却累計額), a credit-balance valuation account (評価勘定) under the indirect method (間接法), deducted from the related depreciable assets. Home side is Credit because it is a contra asset (isContra). This is the canonical bookkeeping account for accumulated depreciation; the existing 'ReserveForDepreciation' is retained as the legacy SNA\\/macro-accounting name."+ , asAliases = ["減価償却累計額"]+ }+accountSpec LegalRetainedEarnings = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Legal (appropriated) retained earnings reserve"+ , asNameJa = "利益準備金"+ , asLabelJa = "利益準備金"+ , asDescription = "Equity: Legal (appropriated) retained earnings reserve (利益準備金)"+ , asAliases = ["利益準備金"]+ }+accountSpec CumulativeTranslationAdjustment = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Cumulative translation adjustment"+ , asNameJa = "為替換算調整勘定; 在外子会社等の外貨建財務諸表の換算差額を計上する OCI/資本の部の項目"+ , asLabelJa = "為替換算調整勘定"+ , asDescription = "Equity: Cumulative translation adjustment (為替換算調整勘定; 在外子会社等の外貨建財務諸表の換算差額を計上する OCI/資本の部の項目)"+ , asAliases = []+ }+accountSpec ProvisionForDoubtfulAccounts = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Provision for doubtful accounts"+ , asNameJa = "貸倒引当金繰入"+ , asLabelJa = "貸倒引当金繰入"+ , asDescription = "Cost: Provision for doubtful accounts (貸倒引当金繰入)"+ , asAliases = ["貸倒引当金繰入"]+ }+accountSpec BadDebtLoss = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Bad debt loss"+ , asNameJa = "貸倒損失"+ , asLabelJa = "貸倒損失"+ , asDescription = "Cost: Bad debt loss (貸倒損失)"+ , asAliases = ["貸倒損失"]+ }+accountSpec LossOnSalesOfFixedAssets = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on sales of fixed assets"+ , asNameJa = "固定資産売却損"+ , asLabelJa = "固定資産売却損"+ , asDescription = "Cost: Loss on sales of fixed assets (固定資産売却損)"+ , asAliases = ["固定資産売却損"]+ }+accountSpec LossOnSalesOfNotesReceivable = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on sales of notes receivable"+ , asNameJa = "手形売却損"+ , asLabelJa = "手形売却損"+ , asDescription = "Cost: Loss on sales of notes receivable (手形売却損)"+ , asAliases = ["手形売却損"]+ }+accountSpec PaymentFees = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Payment fees / fees paid"+ , asNameJa = "支払手数料"+ , asLabelJa = "支払手数料"+ , asDescription = "Cost: Payment fees / fees paid (支払手数料), the debit counterpart of 'ReceiptFee'"+ , asAliases = ["支払手数料"]+ }+accountSpec MiscellaneousLoss = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Miscellaneous loss"+ , asNameJa = "雑損"+ , asLabelJa = "雑損"+ , asDescription = "Cost: Miscellaneous loss (雑損)"+ , asAliases = ["雑損"]+ }+accountSpec CorporateIncomeTaxes = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Corporate income taxes"+ , asNameJa = "法人税等"+ , asLabelJa = "法人税等"+ , asDescription = "Cost: Corporate income taxes (法人税等)"+ , asAliases = ["法人税等"]+ }+accountSpec CommunicationExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Communication expenses"+ , asNameJa = "通信費"+ , asLabelJa = "通信費"+ , asDescription = "Cost: Communication expenses (通信費). Explicitly named counterpart of the legacy 'Commutation' (also \"Communication\"); 'Commutation' is retained for backward compatibility."+ , asAliases = ["通信費"]+ }+accountSpec GainOnSalesOfFixedAssets = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gain on sales of fixed assets"+ , asNameJa = "固定資産売却益"+ , asLabelJa = "固定資産売却益"+ , asDescription = "Revenue: Gain on sales of fixed assets (固定資産売却益)"+ , asAliases = ["固定資産売却益"]+ }+accountSpec RecoveryOfBadDebts = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Recovery of bad debts written off"+ , asNameJa = "償却債権取立益"+ , asLabelJa = "償却債権取立益"+ , asDescription = "Revenue: Recovery of bad debts written off (償却債権取立益)"+ , asAliases = ["償却債権取立益"]+ }+accountSpec MiscellaneousIncome = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Miscellaneous income"+ , asNameJa = "雑益"+ , asLabelJa = "雑益"+ , asDescription = "Revenue: Miscellaneous income (雑益)"+ , asAliases = ["雑益"]+ }+accountSpec ReversalOfAllowanceForDoubtfulAccounts = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Reversal of allowance for doubtful accounts"+ , asNameJa = "貸倒引当金戻入"+ , asLabelJa = "貸倒引当金戻入"+ , asDescription = "Revenue: Reversal of allowance for doubtful accounts (貸倒引当金戻入). Credit counterpart used by the 差額補充法/洗替法 when the estimated allowance is smaller than the existing balance (the excess of 'AllowanceForDoubtfulAccounts' is released)."+ , asAliases = ["貸倒引当金戻入"]+ }+accountSpec TimeDeposits = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Time deposits"+ , asNameJa = "定期預金"+ , asLabelJa = "定期預金"+ , asDescription = "Assets: Time deposits (定期預金)"+ , asAliases = ["定期預金"]+ }+accountSpec LoansReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Loans receivable"+ , asNameJa = "貸付金"+ , asLabelJa = "貸付金"+ , asDescription = "Assets: Loans receivable (貸付金)"+ , asAliases = ["貸付金"]+ }+accountSpec GiftCertificatesReceived = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Gift certificates received"+ , asNameJa = "受取商品券"+ , asLabelJa = "受取商品券"+ , asDescription = "Assets: Gift certificates received (受取商品券)"+ , asAliases = ["受取商品券"]+ }+accountSpec SecurityDepositsPaid = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Guarantee deposits"+ , asNameJa = "差入保証金"+ , asLabelJa = "差入保証金"+ , asDescription = "Assets: Guarantee deposits (差入保証金)"+ , asAliases = ["差入保証金"]+ }+accountSpec SuppliesOnHand = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Supplies"+ , asNameJa = "貯蔵品"+ , asLabelJa = "貯蔵品"+ , asDescription = "Assets: Supplies (貯蔵品)"+ , asAliases = ["貯蔵品"]+ }+accountSpec ContractAssets = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Contract assets"+ , asNameJa = "契約資産"+ , asLabelJa = "契約資産"+ , asDescription = "Assets: Contract assets (契約資産)"+ , asAliases = ["契約資産"]+ }+accountSpec IncomeTaxesRefundReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Income taxes refund receivable"+ , asNameJa = "未収還付法人税等"+ , asLabelJa = "未収還付法人税等"+ , asDescription = "Assets: Income taxes refund receivable (未収還付法人税等)"+ , asAliases = ["未収還付法人税等"]+ }+accountSpec WorkInProcess = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Work in process"+ , asNameJa = "仕掛品"+ , asLabelJa = "仕掛品"+ , asDescription = "Assets: Work in process (仕掛品)"+ , asAliases = ["仕掛品"]+ }+accountSpec DeferredTaxAssets = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Deferred tax assets"+ , asNameJa = "繰延税金資産"+ , asLabelJa = "繰延税金資産"+ , asDescription = "Assets: Deferred tax assets (繰延税金資産)"+ , asAliases = ["繰延税金資産"]+ }+accountSpec LeasedAssets = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Leased assets"+ , asNameJa = "リース資産"+ , asLabelJa = "リース資産"+ , asDescription = "Assets: Leased assets (リース資産)"+ , asAliases = ["リース資産"]+ }+accountSpec ToolsAndInstruments = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Tools and instruments"+ , asNameJa = "工具器具"+ , asLabelJa = "工具器具"+ , asDescription = "Assets: Tools and instruments (工具器具)"+ , asAliases = ["工具器具"]+ }+accountSpec ConstructionInProgress = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Construction in progress"+ , asNameJa = "建設仮勘定"+ , asLabelJa = "建設仮勘定"+ , asDescription = "Assets: Construction in progress (建設仮勘定)"+ , asAliases = ["建設仮勘定"]+ }+accountSpec Goodwill = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Goodwill"+ , asNameJa = "のれん"+ , asLabelJa = "のれん"+ , asDescription = "Assets: Goodwill (のれん)"+ , asAliases = ["のれん"]+ }+accountSpec SoftwareInProgress = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Software in progress"+ , asNameJa = "ソフトウェア仮勘定"+ , asLabelJa = "ソフトウェア仮勘定"+ , asDescription = "Assets: Software in progress (ソフトウェア仮勘定)"+ , asAliases = ["ソフトウェア仮勘定"]+ }+accountSpec LongTermPrepaidExpenses = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Long-term prepaid expenses"+ , asNameJa = "長期前払費用"+ , asLabelJa = "長期前払費用"+ , asDescription = "Assets: Long-term prepaid expenses (長期前払費用)"+ , asAliases = ["長期前払費用"]+ }+accountSpec DishonoredNotesReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Dishonored notes receivable"+ , asNameJa = "不渡手形"+ , asLabelJa = "不渡手形"+ , asDescription = "Assets: Dishonored notes receivable (不渡手形)"+ , asAliases = ["不渡手形"]+ }+accountSpec PrepaidPensionCost = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Prepaid pension costs"+ , asNameJa = "前払年金費用"+ , asLabelJa = "前払年金費用"+ , asDescription = "Assets: Prepaid pension costs (前払年金費用)"+ , asAliases = ["前払年金費用"]+ }+accountSpec NetDefinedBenefitAsset = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Retirement benefit asset"+ , asNameJa = "退職給付に係る資産"+ , asLabelJa = "退職給付に係る資産"+ , asDescription = "Assets: Retirement benefit asset (退職給付に係る資産)"+ , asAliases = ["退職給付に係る資産"]+ }+accountSpec DepositsInSpecialAccounts = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Deposits in special accounts"+ , asNameJa = "別段預金"+ , asLabelJa = "別段預金"+ , asDescription = "Assets: Deposits in special accounts (別段預金)"+ , asAliases = ["別段預金"]+ }+accountSpec Structures = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Structures"+ , asNameJa = "構築物"+ , asLabelJa = "構築物"+ , asDescription = "Assets: Structures (構築物)"+ , asAliases = ["構築物"]+ }+accountSpec LeaseholdRights = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Leasehold interests in land"+ , asNameJa = "借地権"+ , asLabelJa = "借地権"+ , asDescription = "Assets: Leasehold interests in land (借地権)"+ , asAliases = ["借地権"]+ }+accountSpec NonOperatingNotesReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Non-operating notes receivable"+ , asNameJa = "営業外受取手形"+ , asLabelJa = "営業外受取手形"+ , asDescription = "Assets: Non-operating notes receivable (営業外受取手形)"+ , asAliases = ["営業外受取手形"]+ }+accountSpec NonOperatingElectronicallyRecordedReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Electronically recorded monetary claims - non-operating"+ , asNameJa = "営業外電子記録債権"+ , asLabelJa = "営業外電子記録債権"+ , asDescription = "Assets: Electronically recorded monetary claims - non-operating (営業外電子記録債権)"+ , asAliases = ["営業外電子記録債権"]+ }+accountSpec RefundLiabilities = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Refund liabilities"+ , asNameJa = "返金負債"+ , asLabelJa = "返金負債"+ , asDescription = "Liability: Refund liabilities (返金負債)"+ , asAliases = ["返金負債"]+ }+accountSpec NonOperatingNotesPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Non-operating notes payable"+ , asNameJa = "営業外支払手形"+ , asLabelJa = "営業外支払手形"+ , asDescription = "Liability: Non-operating notes payable (営業外支払手形)"+ , asAliases = ["営業外支払手形"]+ }+accountSpec NonOperatingElectronicallyRecordedObligations = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Electronically recorded obligations - non-operating"+ , asNameJa = "営業外電子記録債務"+ , asLabelJa = "営業外電子記録債務"+ , asDescription = "Liability: Electronically recorded obligations - non-operating (営業外電子記録債務)"+ , asAliases = ["営業外電子記録債務"]+ }+accountSpec BonusesPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Accrued bonuses"+ , asNameJa = "未払賞与"+ , asLabelJa = "未払賞与"+ , asDescription = "Liability: Accrued bonuses (未払賞与)"+ , asAliases = ["未払賞与"]+ }+accountSpec AllowanceForRepairs = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Provision for repairs"+ , asNameJa = "修繕引当金"+ , asLabelJa = "修繕引当金"+ , asDescription = "Liability: Provision for repairs (修繕引当金)"+ , asAliases = ["修繕引当金"]+ }+accountSpec AllowanceForProductWarranties = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Provision for warranties"+ , asNameJa = "商品保証引当金"+ , asLabelJa = "商品保証引当金"+ , asDescription = "Liability: Provision for warranties (商品保証引当金)"+ , asAliases = ["商品保証引当金"]+ }+accountSpec AllowanceForBonuses = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Provision for bonuses"+ , asNameJa = "賞与引当金"+ , asLabelJa = "賞与引当金"+ , asDescription = "Liability: Provision for bonuses (賞与引当金)"+ , asAliases = ["賞与引当金"]+ }+accountSpec DeferredTaxLiabilities = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Deferred tax liabilities"+ , asNameJa = "繰延税金負債"+ , asLabelJa = "繰延税金負債"+ , asDescription = "Liability: Deferred tax liabilities (繰延税金負債)"+ , asAliases = ["繰延税金負債"]+ }+accountSpec LeaseObligations = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Lease liabilities"+ , asNameJa = "リース債務"+ , asLabelJa = "リース債務"+ , asDescription = "Liability: Lease liabilities (リース債務)"+ , asAliases = ["リース債務"]+ }+accountSpec GuaranteeDepositsReceived = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Guarantee deposits received"+ , asNameJa = "預り保証金"+ , asLabelJa = "預り保証金"+ , asDescription = "Liability: Guarantee deposits received (預り保証金)"+ , asAliases = ["預り保証金"]+ }+accountSpec AllowanceForRetirementBenefits = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Provision for retirement benefits"+ , asNameJa = "退職給付引当金"+ , asLabelJa = "退職給付引当金"+ , asDescription = "Liability: Provision for retirement benefits (退職給付引当金)"+ , asAliases = ["退職給付引当金"]+ }+accountSpec LongTermOtherPayables = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Long-term accounts payable - other"+ , asNameJa = "長期未払金"+ , asLabelJa = "長期未払金"+ , asDescription = "Liability: Long-term accounts payable - other (長期未払金)"+ , asAliases = ["長期未払金"]+ }+accountSpec NetDefinedBenefitLiability = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Retirement benefit liability"+ , asNameJa = "退職給付に係る負債"+ , asLabelJa = "退職給付に係る負債"+ , asDescription = "Liability: Retirement benefit liability (退職給付に係る負債)"+ , asAliases = ["退職給付に係る負債"]+ }+accountSpec StockSubscriptionDeposits = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Deposits for subscriptions of shares"+ , asNameJa = "株式申込証拠金"+ , asLabelJa = "株式申込証拠金"+ , asDescription = "Equity: Deposits for subscriptions of shares (株式申込証拠金)"+ , asAliases = ["株式申込証拠金"]+ }+accountSpec LegalCapitalSurplus = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Legal capital surplus"+ , asNameJa = "資本準備金"+ , asLabelJa = "資本準備金"+ , asDescription = "Equity: Legal capital surplus (資本準備金)"+ , asAliases = ["資本準備金"]+ }+accountSpec OtherCapitalSurplus = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Other capital surplus"+ , asNameJa = "その他資本剰余金"+ , asLabelJa = "その他資本剰余金"+ , asDescription = "Equity: Other capital surplus (その他資本剰余金)"+ , asAliases = ["その他資本剰余金"]+ }+accountSpec DividendEqualizationReserve = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Reserve for dividend equalization"+ , asNameJa = "配当平均積立金"+ , asLabelJa = "配当平均積立金"+ , asDescription = "Equity: Reserve for dividend equalization (配当平均積立金)"+ , asAliases = ["配当平均積立金"]+ }+accountSpec RepairFundReserve = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Reserve for repairs"+ , asNameJa = "修繕積立金"+ , asLabelJa = "修繕積立金"+ , asDescription = "Equity: Reserve for repairs (修繕積立金)"+ , asAliases = ["修繕積立金"]+ }+accountSpec ConstructionFundReserve = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Reserve for new construction"+ , asNameJa = "新築積立金"+ , asLabelJa = "新築積立金"+ , asDescription = "Equity: Reserve for new construction (新築積立金)"+ , asAliases = ["新築積立金"]+ }+accountSpec GeneralReserve = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "General reserve"+ , asNameJa = "別途積立金"+ , asLabelJa = "別途積立金"+ , asDescription = "Equity: General reserve (別途積立金)"+ , asAliases = ["別途積立金"]+ }+accountSpec ValuationDifferenceOnOtherSecurities = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Valuation difference on available-for-sale securities"+ , asNameJa = "その他有価証券評価差額金"+ , asLabelJa = "その他有価証券評価差額金"+ , asDescription = "Equity: Valuation difference on available-for-sale securities (その他有価証券評価差額金)"+ , asAliases = ["その他有価証券評価差額金"]+ }+accountSpec NonControllingInterests = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Non-controlling interests"+ , asNameJa = "非支配株主持分"+ , asLabelJa = "非支配株主持分"+ , asDescription = "Equity: Non-controlling interests (非支配株主持分)"+ , asAliases = ["非支配株主持分"]+ }+accountSpec CapitalSurplus = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Capital surplus"+ , asNameJa = "資本剰余金"+ , asLabelJa = "資本剰余金"+ , asDescription = "Equity: Capital surplus (資本剰余金)"+ , asAliases = ["資本剰余金"]+ }+accountSpec EarnedSurplus = Just AccountSpec+ { asDivision = Equity+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Retained earnings"+ , asNameJa = "利益剰余金"+ , asLabelJa = "利益剰余金"+ , asDescription = "Equity: Retained earnings (利益剰余金)"+ , asAliases = ["利益剰余金"]+ }+accountSpec ServiceRevenue = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Service revenue"+ , asNameJa = "役務収益"+ , asLabelJa = "役務収益"+ , asDescription = "Revenue: Service revenue (役務収益)"+ , asAliases = ["役務収益"]+ }+accountSpec OperatingRevenue = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Operating revenue"+ , asNameJa = "営業収益"+ , asLabelJa = "営業収益"+ , asDescription = "Revenue: Operating revenue (営業収益)"+ , asAliases = ["営業収益"]+ }+accountSpec GainOnSalesOfSecurities = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gain on sale of securities"+ , asNameJa = "有価証券売却益"+ , asLabelJa = "有価証券売却益"+ , asDescription = "Revenue: Gain on sale of securities (有価証券売却益)"+ , asAliases = ["有価証券売却益"]+ }+accountSpec GainOnValuationOfSecurities = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gain on valuation of securities"+ , asNameJa = "有価証券評価益"+ , asLabelJa = "有価証券評価益"+ , asDescription = "Revenue: Gain on valuation of securities (有価証券評価益)"+ , asAliases = ["有価証券評価益"]+ }+accountSpec DividendsReceived = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Dividend income"+ , asNameJa = "受取配当金"+ , asLabelJa = "受取配当金"+ , asDescription = "Revenue: Dividend income (受取配当金)"+ , asAliases = ["受取配当金"]+ }+accountSpec InterestOnSecurities = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Interest on securities"+ , asNameJa = "有価証券利息"+ , asLabelJa = "有価証券利息"+ , asDescription = "Revenue: Interest on securities (有価証券利息)"+ , asAliases = ["有価証券利息"]+ }+accountSpec GainOnSalesOfInvestmentSecurities = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gain on sale of investment securities"+ , asNameJa = "投資有価証券売却益"+ , asLabelJa = "投資有価証券売却益"+ , asDescription = "Revenue: Gain on sale of investment securities (投資有価証券売却益)"+ , asAliases = ["投資有価証券売却益"]+ }+accountSpec InsuranceGain = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gain on insurance claims"+ , asNameJa = "保険差益"+ , asLabelJa = "保険差益"+ , asDescription = "Revenue: Gain on insurance claims (保険差益)"+ , asAliases = ["保険差益"]+ }+accountSpec GainOnBargainPurchase = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gain on bargain purchase"+ , asNameJa = "負ののれん発生益"+ , asLabelJa = "負ののれん発生益"+ , asDescription = "Revenue: Gain on bargain purchase (負ののれん発生益)"+ , asAliases = ["負ののれん発生益"]+ }+accountSpec ReversalOfAllowanceForRepairs = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Reversal of provision for repairs"+ , asNameJa = "修繕引当金戻入"+ , asLabelJa = "修繕引当金戻入"+ , asDescription = "Revenue: Reversal of provision for repairs (修繕引当金戻入)"+ , asAliases = ["修繕引当金戻入"]+ }+accountSpec ReversalOfAllowanceForProductWarranties = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Reversal of provision for warranties"+ , asNameJa = "商品保証引当金戻入"+ , asLabelJa = "商品保証引当金戻入"+ , asDescription = "Revenue: Reversal of provision for warranties (商品保証引当金戻入)"+ , asAliases = ["商品保証引当金戻入"]+ }+accountSpec GainOnDonationOfFixedAssets = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gain on receipt of donated non-current assets"+ , asNameJa = "固定資産受贈益"+ , asLabelJa = "固定資産受贈益"+ , asDescription = "Revenue: Gain on receipt of donated non-current assets (固定資産受贈益)"+ , asAliases = ["固定資産受贈益"]+ }+accountSpec GainOnNationalSubsidies = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gain on receipt of national subsidies"+ , asNameJa = "国庫補助金受贈益"+ , asLabelJa = "国庫補助金受贈益"+ , asDescription = "Revenue: Gain on receipt of national subsidies (国庫補助金受贈益)"+ , asAliases = ["国庫補助金受贈益"]+ }+accountSpec GainOnConstructionGrants = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Gain on contribution received for construction"+ , asNameJa = "工事負担金受贈益"+ , asLabelJa = "工事負担金受贈益"+ , asDescription = "Revenue: Gain on contribution received for construction (工事負担金受贈益)"+ , asAliases = ["工事負担金受贈益"]+ }+accountSpec LandRentReceived = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Rental income from land"+ , asNameJa = "受取地代"+ , asLabelJa = "受取地代"+ , asDescription = "Revenue: Rental income from land (受取地代)"+ , asAliases = ["受取地代"]+ }+accountSpec SalesRebates = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = True+ , asFixedCurrent = Other+ , asNameEn = "Sales rebates"+ , asNameJa = "売上割戻"+ , asLabelJa = "売上割戻"+ , asDescription = "Revenue: Sales rebates (売上割戻)"+ , asAliases = ["売上割戻"]+ }+accountSpec CostOfServices = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Cost of services"+ , asNameJa = "役務原価"+ , asLabelJa = "役務原価"+ , asDescription = "Cost: Cost of services (役務原価)"+ , asAliases = ["役務原価"]+ }+accountSpec OperatingExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Operating expenses"+ , asNameJa = "営業費用"+ , asLabelJa = "営業費用"+ , asDescription = "Cost: Operating expenses (営業費用)"+ , asAliases = ["営業費用"]+ }+accountSpec InventoryShrinkageLoss = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Inventory shrinkage loss"+ , asNameJa = "棚卸減耗損"+ , asLabelJa = "棚卸減耗損"+ , asDescription = "Cost: Inventory shrinkage loss (棚卸減耗損)"+ , asAliases = ["棚卸減耗損"]+ }+accountSpec LossOnValuationOfMerchandise = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Valuation loss on goods"+ , asNameJa = "商品評価損"+ , asLabelJa = "商品評価損"+ , asDescription = "Cost: Valuation loss on goods (商品評価損)"+ , asAliases = ["商品評価損"]+ }+accountSpec Bonuses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Bonuses"+ , asNameJa = "賞与"+ , asLabelJa = "賞与"+ , asDescription = "Cost: Bonuses (賞与)"+ , asAliases = ["賞与"]+ }+accountSpec RetirementBenefitExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Retirement benefit expenses"+ , asNameJa = "退職給付費用"+ , asLabelJa = "退職給付費用"+ , asDescription = "Cost: Retirement benefit expenses (退職給付費用)"+ , asAliases = ["退職給付費用"]+ }+accountSpec ProvisionForRepairs = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Provision for repairs"+ , asNameJa = "修繕引当金繰入"+ , asLabelJa = "修繕引当金繰入"+ , asDescription = "Cost: Provision for repairs (修繕引当金繰入)"+ , asAliases = ["修繕引当金繰入"]+ }+accountSpec ProvisionForBonuses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Provision for bonuses"+ , asNameJa = "賞与引当金繰入"+ , asLabelJa = "賞与引当金繰入"+ , asDescription = "Cost: Provision for bonuses (賞与引当金繰入)"+ , asAliases = ["賞与引当金繰入"]+ }+accountSpec ProvisionForProductWarranties = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Provision for warranties"+ , asNameJa = "商品保証引当金繰入"+ , asLabelJa = "商品保証引当金繰入"+ , asDescription = "Cost: Provision for warranties (商品保証引当金繰入)"+ , asAliases = ["商品保証引当金繰入"]+ }+accountSpec ResearchAndDevelopmentExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Research and development expenses"+ , asNameJa = "研究開発費"+ , asLabelJa = "研究開発費"+ , asDescription = "Cost: Research and development expenses (研究開発費)"+ , asAliases = ["研究開発費"]+ }+accountSpec AmortizationOfGoodwill = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Amortization of goodwill"+ , asNameJa = "のれん償却"+ , asLabelJa = "のれん償却"+ , asDescription = "Cost: Amortization of goodwill (のれん償却)"+ , asAliases = ["のれん償却"]+ }+accountSpec AmortizationOfSoftware = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Amortization of software"+ , asNameJa = "ソフトウェア償却"+ , asLabelJa = "ソフトウェア償却"+ , asDescription = "Cost: Amortization of software (ソフトウェア償却)"+ , asAliases = ["ソフトウェア償却"]+ }+accountSpec AmortizationOfPatents = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Amortization of patent rights"+ , asNameJa = "特許権償却"+ , asLabelJa = "特許権償却"+ , asDescription = "Cost: Amortization of patent rights (特許権償却)"+ , asAliases = ["特許権償却"]+ }+accountSpec LeaseExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Lease expenses"+ , asNameJa = "支払リース料"+ , asLabelJa = "支払リース料"+ , asDescription = "Cost: Lease expenses (支払リース料)"+ , asAliases = ["支払リース料"]+ }+accountSpec IncorporationExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Organization expenses"+ , asNameJa = "創立費"+ , asLabelJa = "創立費"+ , asDescription = "Cost: Organization expenses (創立費)"+ , asAliases = ["創立費"]+ }+accountSpec StockIssuanceCosts = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Share issuance costs"+ , asNameJa = "株式交付費"+ , asLabelJa = "株式交付費"+ , asDescription = "Cost: Share issuance costs (株式交付費)"+ , asAliases = ["株式交付費"]+ }+accountSpec BusinessCommencementExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Business commencement expenses"+ , asNameJa = "開業費"+ , asLabelJa = "開業費"+ , asDescription = "Cost: Business commencement expenses (開業費)"+ , asAliases = ["開業費"]+ }+accountSpec DevelopmentExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Development expenses"+ , asNameJa = "開発費"+ , asLabelJa = "開発費"+ , asDescription = "Cost: Development expenses (開発費)"+ , asAliases = ["開発費"]+ }+accountSpec LossOnSalesOfElectronicallyRecordedReceivables = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on sale of electronically recorded monetary claims"+ , asNameJa = "電子記録債権売却損"+ , asLabelJa = "電子記録債権売却損"+ , asDescription = "Cost: Loss on sale of electronically recorded monetary claims (電子記録債権売却損)"+ , asAliases = ["電子記録債権売却損"]+ }+accountSpec LossOnSalesOfReceivables = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on sale of receivables"+ , asNameJa = "債権売却損"+ , asLabelJa = "債権売却損"+ , asDescription = "Cost: Loss on sale of receivables (債権売却損)"+ , asAliases = ["債権売却損"]+ }+accountSpec LossOnSalesOfSecurities = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on sale of securities"+ , asNameJa = "有価証券売却損"+ , asLabelJa = "有価証券売却損"+ , asDescription = "Cost: Loss on sale of securities (有価証券売却損)"+ , asAliases = ["有価証券売却損"]+ }+accountSpec LossOnValuationOfSecurities = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on valuation of securities"+ , asNameJa = "有価証券評価損"+ , asLabelJa = "有価証券評価損"+ , asDescription = "Cost: Loss on valuation of securities (有価証券評価損)"+ , asAliases = ["有価証券評価損"]+ }+accountSpec LossOnSalesOfInvestmentSecurities = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on sale of investment securities"+ , asNameJa = "投資有価証券売却損"+ , asLabelJa = "投資有価証券売却損"+ , asDescription = "Cost: Loss on sale of investment securities (投資有価証券売却損)"+ , asAliases = ["投資有価証券売却損"]+ }+accountSpec LossOnFire = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on fire"+ , asNameJa = "火災損失"+ , asLabelJa = "火災損失"+ , asDescription = "Cost: Loss on fire (火災損失)"+ , asAliases = ["火災損失"]+ }+accountSpec LossOnRetirementOfFixedAssets = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on retirement of non-current assets"+ , asNameJa = "固定資産除却損"+ , asLabelJa = "固定資産除却損"+ , asDescription = "Cost: Loss on retirement of non-current assets (固定資産除却損)"+ , asAliases = ["固定資産除却損"]+ }+accountSpec LossOnReductionOfFixedAssets = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss on tax purpose reduction entry of non-current assets"+ , asNameJa = "固定資産圧縮損"+ , asLabelJa = "固定資産圧縮損"+ , asDescription = "Cost: Loss on tax purpose reduction entry of non-current assets (固定資産圧縮損)"+ , asAliases = ["固定資産圧縮損"]+ }+accountSpec AdditionalIncomeTaxesForPriorPeriods = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Additional income taxes for prior periods"+ , asNameJa = "追徴法人税等"+ , asLabelJa = "追徴法人税等"+ , asDescription = "Cost: Additional income taxes for prior periods (追徴法人税等)"+ , asAliases = ["追徴法人税等"]+ }+accountSpec RefundOfIncomeTaxes = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = True+ , asFixedCurrent = Other+ , asNameEn = "Refund of income taxes"+ , asNameJa = "還付法人税等"+ , asLabelJa = "還付法人税等"+ , asDescription = "Cost: Refund of income taxes (還付法人税等)"+ , asAliases = ["還付法人税等"]+ }+accountSpec PurchaseRebates = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = True+ , asFixedCurrent = Other+ , asNameEn = "Purchase rebates"+ , asNameJa = "仕入割戻"+ , asLabelJa = "仕入割戻"+ , asDescription = "Cost: Purchase rebates (仕入割戻)"+ , asAliases = ["仕入割戻"]+ }+accountSpec WelfareExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Welfare expenses"+ , asNameJa = "福利厚生費"+ , asLabelJa = "福利厚生費"+ , asDescription = "Cost: Welfare expenses (福利厚生費)"+ , asAliases = ["福利厚生費"]+ }+accountSpec MaintenanceExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Maintenance expenses"+ , asNameJa = "保守費"+ , asLabelJa = "保守費"+ , asDescription = "Cost: Maintenance expenses (保守費)"+ , asAliases = ["保守費"]+ }+accountSpec StatutoryWelfareExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Legal welfare expenses"+ , asNameJa = "法定福利費"+ , asLabelJa = "法定福利費"+ , asDescription = "Cost: Legal welfare expenses (法定福利費)"+ , asAliases = ["法定福利費"]+ }+accountSpec LandRentPaid = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Rent expenses on land"+ , asNameJa = "支払地代"+ , asLabelJa = "支払地代"+ , asDescription = "Cost: Rent expenses on land (支払地代)"+ , asAliases = ["支払地代"]+ }+accountSpec InsuranceExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Insurance expenses"+ , asNameJa = "保険料"+ , asLabelJa = "保険料"+ , asDescription = "Cost: Insurance expenses (保険料)"+ , asAliases = ["保険料"]+ }+accountSpec RepairsExpense = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Repair expenses"+ , asNameJa = "修繕費"+ , asLabelJa = "修繕費"+ , asDescription = "Cost: Repair expenses (修繕費)"+ , asAliases = ["修繕費"]+ }+accountSpec StorageExpenses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Storage costs"+ , asNameJa = "保管費"+ , asLabelJa = "保管費"+ , asDescription = "Cost: Storage costs (保管費)"+ , asAliases = ["保管費"]+ }+accountSpec MembershipFees = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Membership fees"+ , asNameJa = "諸会費"+ , asLabelJa = "諸会費"+ , asDescription = "Cost: Membership fees (諸会費)"+ , asAliases = ["諸会費"]+ }+accountSpec IncomeSummary = Just AccountSpec+ { asDivision = Assets+ , asClosing = NoClose+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Income summary"+ , asNameJa = "損益"+ , asLabelJa = "損益"+ , asDescription = "Bookkeeping device: Income summary (損益). Assets/NoClose is a technical debit-side placeholder; it is not a balance-sheet asset and is not automatically closed."+ , asAliases = ["損益"]+ }+accountSpec SuspenseAccount = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Suspense account"+ , asNameJa = "未決算"+ , asLabelJa = "未決算"+ , asDescription = "Assets: Suspense account (未決算)"+ , asAliases = ["未決算"]+ }+accountSpec ForeignExchangeGains = Just AccountSpec+ { asDivision = Revenue+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Foreign exchange gains"+ , asNameJa = "為替差益"+ , asLabelJa = "為替差益"+ , asDescription = "Revenue: Foreign exchange gains (為替差益)"+ , asAliases = ["為替差益"]+ }+accountSpec ForeignExchangeLosses = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Foreign exchange losses"+ , asNameJa = "為替差損"+ , asLabelJa = "為替差損"+ , asDescription = "Cost: Foreign exchange losses (為替差損)"+ , asAliases = ["為替差損"]+ }+accountSpec ContraAccountForGuaranteeObligations = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Contra account for guarantee obligations"+ , asNameJa = "保証債務見返"+ , asLabelJa = "保証債務見返"+ , asDescription = "Assets: Contra account for guarantee obligations (保証債務見返)"+ , asAliases = ["保証債務見返"]+ }+accountSpec GuaranteeObligations = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Guarantee obligations"+ , asNameJa = "保証債務"+ , asLabelJa = "保証債務"+ , asDescription = "Liability: Guarantee obligations (保証債務)"+ , asAliases = ["保証債務"]+ }+accountSpec IncomeTaxesAdjustment = Just AccountSpec+ { asDivision = Cost+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Income taxes - deferred"+ , asNameJa = "法人税等調整額"+ , asLabelJa = "法人税等調整額"+ , asDescription = "Cost: Income taxes - deferred (法人税等調整額)"+ , asAliases = ["法人税等調整額"]+ }+accountSpec BranchCurrentAccount = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Branch current account"+ , asNameJa = "支店"+ , asLabelJa = "支店"+ , asDescription = "Assets: Branch current account (支店)"+ , asAliases = ["支店"]+ }+accountSpec HeadOfficeCurrentAccount = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Head office current account"+ , asNameJa = "本店"+ , asLabelJa = "本店"+ , asDescription = "Liability: Head office current account (本店), the credit-balance reciprocal account in branch books."+ , asAliases = ["本店"]+ }+accountSpec NetIncomeAttributableToNCI = Just AccountSpec+ { asDivision = Cost+ , asClosing = NoClose+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Profit attributable to non-controlling interests"+ , asNameJa = "非支配株主に帰属する当期純利益"+ , asLabelJa = "非支配株主に帰属する当期純利益"+ , asDescription = "Cost: Profit attributable to non-controlling interests (非支配株主に帰属する当期純利益). NoClose: consolidation procedures transfer it separately to non-controlling interests."+ , asAliases = ["非支配株主に帰属する当期純利益"]+ }+accountSpec NetLossAttributableToNCI = Just AccountSpec+ { asDivision = Revenue+ , asClosing = NoClose+ , asIsContra = False+ , asFixedCurrent = Other+ , asNameEn = "Loss attributable to non-controlling interests"+ , asNameJa = "非支配株主に帰属する当期純損失"+ , asLabelJa = "非支配株主に帰属する当期純損失"+ , asDescription = "Revenue: Loss attributable to non-controlling interests (非支配株主に帰属する当期純損失). NoClose: consolidation procedures transfer it separately to non-controlling interests."+ , asAliases = ["非支配株主に帰属する当期純損失"]+ }+accountSpec TradingSecurities = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Trading securities"+ , asNameJa = "売買目的有価証券"+ , asLabelJa = "売買目的有価証券"+ , asDescription = "Assets: Trading securities (売買目的有価証券)"+ , asAliases = ["売買目的有価証券"]+ }+accountSpec HeldToMaturityBonds = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Held-to-maturity bonds"+ , asNameJa = "満期保有目的債券"+ , asLabelJa = "満期保有目的債券"+ , asDescription = "Assets: Held-to-maturity bonds (満期保有目的債券)"+ , asAliases = ["満期保有目的債券"]+ }+accountSpec SubsidiaryStocks = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Shares of subsidiaries"+ , asNameJa = "子会社株式"+ , asLabelJa = "子会社株式"+ , asDescription = "Assets: Shares of subsidiaries (子会社株式)"+ , asAliases = ["子会社株式"]+ }+accountSpec AffiliateStocks = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Shares of associates"+ , asNameJa = "関連会社株式"+ , asLabelJa = "関連会社株式"+ , asDescription = "Assets: Shares of associates (関連会社株式)"+ , asAliases = ["関連会社株式"]+ }+accountSpec AvailableForSaleSecurities = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Fixed+ , asNameEn = "Available-for-sale securities"+ , asNameJa = "その他有価証券"+ , asLabelJa = "その他有価証券"+ , asDescription = "Assets: Available-for-sale securities (その他有価証券)"+ , asAliases = ["その他有価証券"]+ }+accountSpec ConsumptionTaxRefundReceivable = Just AccountSpec+ { asDivision = Assets+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Consumption tax refund receivable"+ , asNameJa = "未収還付消費税"+ , asLabelJa = "未収還付消費税"+ , asDescription = "Asset: Consumption tax refund receivable (未収還付消費税)"+ , asAliases = ["未収還付消費税", "未収消費税"]+ }+accountSpec PropertyTaxPayable = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Property tax payable"+ , asNameJa = "未払固定資産税"+ , asLabelJa = "未払固定資産税"+ , asDescription = "Liability: Property tax payable (未払固定資産税)"+ , asAliases = ["未払固定資産税"]+ }+accountSpec DepositsReceivedFromOfficers = Just AccountSpec+ { asDivision = Liability+ , asClosing = CloseByDivision+ , asIsContra = False+ , asFixedCurrent = Current+ , asNameEn = "Deposits received from officers"+ , asNameJa = "役員預り金"+ , asLabelJa = "役員預り金"+ , asDescription = "Liability: Deposits received from officers (役員預り金)"+ , asAliases = ["役員預り金"]+ }+accountSpec AccountTitle = Nothing++-- | Derived strict map view. The exhaustive function above remains canonical.+accountSpecMap :: Map AccountTitles AccountSpec+accountSpecMap = M.fromList+ [ (title, spec)+ | title <- concreteAccountTitles+ , Just spec <- [accountSpec title]+ ]++-- | Whether an account is a contra account.+--+-- The result is projected from 'AccountSpec.asIsContra'; the wildcard has no+-- specification and therefore returns 'False'. Complexity: O(1)+{-# INLINE classifyAccountContra #-}+classifyAccountContra :: AccountTitles -> Bool+classifyAccountContra title = maybe False asIsContra (accountSpec title)++-- | Compatibility projection used by the assistance API.+accountDescriptions :: [(AccountTitles, Text, Text, Text)]+accountDescriptions = mapMaybe project concreteAccountTitles+ where+ project title = do+ spec <- accountSpec title+ pure (title, asNameEn spec, asNameJa spec, asDescription spec)
+ src/ExchangeAlgebra/Algebra/Base/Account/Types.hs view
@@ -0,0 +1,102 @@+{- |+Module : ExchangeAlgebra.Algebra.Base.Account.Types+Description : Account metadata types shared by Base and the account registry.+-}+module ExchangeAlgebra.Algebra.Base.Account.Types+ ( AccountDivision(..)+ , Side(..)+ , ClosingRule(..)+ , FixedCurrent(..)+ , AccountRole(..)+ , PostingCapability(..)+ , DivisionSemantics(..)+ , HomeSideSemantics(..)+ , ReportingEligibility(..)+ ) where++-- | Account division (financial-statement classification). The+-- 'ExchangeAlgebra.Algebra.Base.AccountBase'+-- correspondence instance lives in "ExchangeAlgebra.Algebra.Base" (the class's+-- home module), so this declaration stays instance-free.+data AccountDivision = Assets -- ^ Assets+ | Equity -- ^ Equity+ | Liability -- ^ Liability+ | Cost -- ^ Cost+ | Revenue -- ^ Revenue+ deriving (Ord, Show, Eq)++-- | Credit/debit distinction. v'Side' is the wildcard used by legacy APIs.+data Side = Credit -- ^ Credit side.+ | Debit -- ^ Debit side.+ | Side -- ^ Wildcard.+ deriving (Ord, Show, Eq)++-- | Registry-level policy for automatic closing entries.+--+-- 'CloseByDivision' derives the transfer side from 'AccountDivision'.+-- 'NoClose' is an explicit override. Future policies may add explicit+-- keep/flip constructors without returning to an account-title case split.+data ClosingRule = CloseByDivision -- ^ Close Cost/Revenue accounts according to their division.+ | NoClose -- ^ Do not generate an automatic closing entry.+ deriving (Show, Eq)++-- | Fixed/Current distinction. Used for classifying account titles as fixed or current.+data FixedCurrent = Fixed -- ^ Fixed+ | Current -- ^ Current+ | Other -- ^ Other (expenses, revenues, etc.)+ deriving (Show, Eq)++-- | Accounting role of an account-basis coordinate. Roles are not assumed to+-- be mutually exclusive; see+-- 'ExchangeAlgebra.Algebra.Base.Account.Registry.AccountSemantics' in the+-- account registry.+data AccountRole+ = OrdinaryAccount+ | ContraAccount+ | ReciprocalAccount+ | SuspenseOrClearingAccount+ | ClosingDevice+ | AttributionAccount+ | PeriodResult+ | ReportingSubtotal+ deriving (Show, Eq)++-- | Context in which an account title may be used as a posting coordinate.+-- Enforcement is introduced by the checked-conversion API in a later land;+-- this type is the canonical metadata used by that gate.+data PostingCapability+ = OrdinaryPosting+ | ClosingOnly+ | ConsolidationOnly+ | EngineGeneratedOnly+ | NotPostable+ deriving (Show, Eq)++-- | Meaning of the legacy five-way 'AccountDivision' value.+--+-- This separates a genuine statement classification from a bookkeeping+-- control class or an internal direction encoding. The wrapped legacy value+-- remains available for 0.4.x-compatible algebraic behaviour.+data DivisionSemantics+ = StatementDivision AccountDivision+ | BookkeepingControlClass AccountDivision+ | DirectionEncoding AccountDivision+ | NoStatementDivision+ deriving (Show, Eq)++-- | Semantic status of an account's normal posting side.+data HomeSideSemantics+ = FixedHomeSide Side+ | ContextDependentHomeSide+ | NoFixedHomeSide+ | NoPostingSide+ deriving (Show, Eq)++-- | Coarse reporting eligibility. Actual presentation remains a function of+-- reporting context and policy and is implemented in a later land.+data ReportingEligibility+ = StatementEligible+ | ContextualPresentation+ | DerivedPresentation+ | NotPresented+ deriving (Show, Eq)
src/ExchangeAlgebra/Algebra/Base/Element.hs view
@@ -25,7 +25,7 @@ deriving (Eq, Ord, Show, Generic, Hashable, Typeable) instance Element Company where- wiledcard = CompanyWildcard+ wildcard = CompanyWildcard @ == Import guidance@@ -46,10 +46,15 @@ {-# LANGUAGE Strict #-} {-# LANGUAGE DeriveGeneric #-} +-- NB. Only the two names a user needs to define an 'Element' instance are+-- re-exported ('Hashable' for the constraint/instance, 'Generic' for deriving).+-- The former whole-module re-exports of "Data.Hashable" and "GHC.Generics"+-- leaked their entire namespaces (hash, hashUsing, Rep, from/to, …) through+-- Base -> Algebra -> the ExchangeAlgebra umbrella. module ExchangeAlgebra.Algebra.Base.Element ( module ExchangeAlgebra.Algebra.Base.Element- , module Data.Hashable- , module GHC.Generics) where+ , Hashable(..)+ , Generic) where import qualified Data.Text as T import Data.Text (Text)@@ -59,6 +64,8 @@ import Data.Hashable import Data.Typeable (Typeable, cast, typeOf) import qualified Data.Binary as Binary+import qualified Data.Binary.Get as BinaryGet+import qualified Data.Binary.Put as BinaryPut ------------------------------------------------------------------ -- * Element (components of bases)@@ -74,33 +81,33 @@ -- | The wildcard value. Used for pattern matching in search, transfer transformation, etc. -- -- Complexity: O(1)- wiledcard :: a+ wildcard :: a -- | Determines whether the element itself or any of its internal components contains a wildcard. -- For tuple elements, returns True if any component is a wildcard. -- -- Complexity: O(k) (k is the number of tuple components; O(1) for primitive types)- {-# INLINE haveWiledcard #-}- haveWiledcard :: a -> Bool- haveWiledcard = isWiledcard+ {-# INLINE haveWildcard #-}+ haveWildcard :: a -> Bool+ haveWildcard = isWildcard -- | Determines whether the value is exactly the wildcard. -- -- Complexity: O(1)- {-# INLINE isWiledcard #-}- isWiledcard :: a -> Bool- isWiledcard a = a == wiledcard+ {-# INLINE isWildcard #-}+ isWildcard :: a -> Bool+ isWildcard a = a == wildcard -- | Wildcard-ignoring transformation. -- If @after@ is a wildcard, returns @before@. -- Used inside transfer to fill wildcard positions in the target basis with the original values. -- -- Complexity: O(k) (k is the number of tuple components; O(1) for primitive types)- {-# INLINE ignoreWiledcard #-}- ignoreWiledcard :: a -> a -> a- ignoreWiledcard before after+ {-# INLINE ignoreWildcard #-}+ ignoreWildcard :: a -> a -> a+ ignoreWildcard before after | before == after = before- | isWiledcard after = before+ | isWildcard after = before | otherwise = after -- | Wildcard-aware equality test.@@ -109,8 +116,8 @@ -- Complexity: O(k) (k is the number of tuple components; O(1) for primitive types) {-# INLINE equal #-} equal :: a -> a -> Bool- equal a b | isWiledcard a = True- | isWiledcard b = True+ equal a b | isWildcard a = True+ | isWildcard b = True | otherwise = a == b -- | Equality operator that treats wildcards as equal.@@ -119,7 +126,7 @@ -- Complexity: O(k) (k is the number of tuple components; O(1) for primitive types) {-# INLINE (.==) #-} (.==) :: a -> a -> Bool- (.==) a b = a == b || (haveWiledcard a || haveWiledcard b) && equal a b+ (.==) a b = a == b || (haveWildcard a || haveWildcard b) && equal a b -- | Inequality operator that treats wildcards as equal. Negation of @(.==)@. --@@ -192,7 +199,7 @@ {-# INLINE axisIsWildcard #-} axisIsWildcard :: AxisKey -> Bool-axisIsWildcard (AxisKey x) = isWiledcard x+axisIsWildcard (AxisKey x) = isWildcard x -- | A type class for decomposing a basis element into a list of per-axis t'AxisKey's. -- Overlapping instances are defined for tuple types so that each component@@ -206,13 +213,13 @@ {-# INLINE toAxisKeys #-} toAxisKeys a = [AxisKey a] --- | Shorthand notation for the wildcard. An alias for @wiledcard@.+-- | Shorthand notation for the wildcard. An alias for @wildcard@. -- Write @(.#)@ when specifying patterns in projections and transfer transformations. -- -- Complexity: O(1) {-# INLINE (.#) #-} (.#) :: Element a => a-(.#) = wiledcard+(.#) = wildcard infix 4 .== infix 4 ./=@@ -222,84 +229,277 @@ -- ** Account Titles -data AccountTitles = Cash -- ^ Asset: Cash- | Deposits -- ^ Asset: Savings deposits- | CurrentDeposits -- ^ Asset: Current deposits- | Securities -- ^ Asset: Securities- | InvestmentSecurities -- ^ Asset: Investment securities- | LongTermNationalBonds -- ^ Asset: Long-term national bonds- | ShortTermNationalBonds -- ^ Asset: Short-term national bonds- | Products -- ^ Asset: Products- | Machinery -- ^ Asset: Machinery and equipment- | Building -- ^ Asset: Real estate- | Vehicle -- ^ Asset: Vehicles- | StockInvestment -- ^ Asset: Stock investment- | EquipmentInvestment -- ^ Asset: Equipment investment- | LongTermLoansReceivable -- ^ Asset: Loans receivable- | AccountsReceivable -- ^ Asset: Accounts receivable- | ShortTermLoansReceivable -- ^ Asset: Short-term loans receivable- | ReserveDepositReceivable -- ^ Asset: Reserve deposit receivable- | Gold -- ^ Asset: Gold- | GovernmentService -- ^ Asset: Government service expenditure- | CapitalStock -- ^ Equity: Capital stock- | RetainedEarnings -- ^ Equity: Retained earnings- | LongTermLoansPayable -- ^ Liability: Long-term loans payable- | ShortTermLoansPayable -- ^ Liability: Short-term loans payable- | LoansPayable -- ^ Liability: Loans payable- | ReserveForDepreciation -- ^ Liability: Reserve for depreciation- | DepositPayable -- ^ Liability: Deposits received- | LongTermNationalBondsPayable -- ^ Liability: Long-term national bonds payable- | ShortTermNationalBondsPayable -- ^ Liability: Short-term national bonds payable- | ReserveDepositPayable -- ^ Liability: Accounts payable- | CentralBankNotePayable -- ^ Liability: Central bank notes payable- | Depreciation -- ^ Expense: Depreciation- | SalesCost -- ^ Expense: Cost of sales- | BusinessTrip -- ^ Expense: Travel and transportation- | Commutation -- ^ Expense: Communication- | UtilitiesExpense -- ^ Expense: Utilities- | RentExpense -- ^ Expense: Rent- | AdvertisingExpense -- ^ Expense: Advertising- | DeliveryExpenses -- ^ Expense: Delivery- | SuppliesExpenses -- ^ Expense: Supplies- | MiscellaneousExpenses -- ^ Expense: Miscellaneous- | WageExpenditure -- ^ Expense: Wages- | InterestExpense -- ^ Expense: Interest expense- | TaxesExpense -- ^ Expense: Taxes- | ConsumptionExpenditure -- ^ Expense: Consumables- | SubsidyExpense -- ^ Expense: Subsidy expenditure- | CentralBankPaymentExpense -- ^ Expense- | Purchases -- ^ Expense: Purchases- | NetIncome -- ^ Expense: Net income- | ValueAdded -- ^ Revenue: Value added- | SubsidyIncome -- ^ Revenue: Subsidy income- | NationalBondInterestEarned -- ^ Revenue: National bond interest earned- | DepositInterestEarned -- ^ Revenue: Deposit interest earned- | GrossProfit -- ^ Revenue: Gross profit- | OrdinaryProfit -- ^ Revenue: Ordinary profit- | InterestEarned -- ^ Revenue: Interest earned- | ReceiptFee -- ^ Revenue: Receipt fee- | RentalIncome -- ^ Revenue: Rental income- | WageEarned -- ^ Revenue: Wage income- | TaxesRevenue -- ^ Revenue: Tax revenue- | CentralBankPaymentIncome -- ^ Revenue- | Sales -- ^ Revenue: Sales- | NetLoss -- ^ Revenue: Net loss- | AccountTitle -- ^ Wildcard- deriving (Show, Ord, Eq, Enum, Generic)+data AccountTitles = Cash -- ^ Asset: Cash (現金)+ | Deposits -- ^ Asset: Savings deposits (普通預金)+ | CurrentDeposits -- ^ Asset: Current deposits (当座預金)+ | Securities -- ^ Asset: Securities (有価証券)+ | InvestmentSecurities -- ^ Asset: Investment securities (投資有価証券)+ | InvestmentInAssociate -- ^ Asset: Investment in associate (関係会社株式). Carrying amount under the equity method (持分法適用投資勘定).+ | LongTermNationalBonds -- ^ Asset: Long-term national bonds (長期国債)+ | ShortTermNationalBonds -- ^ Asset: Short-term national bonds (短期国債)+ | Products -- ^ Asset: Products (商品)。分記法用。3 分法 (仕入\/売上\/繰越商品) では 'MerchandiseInventory' を使う+ | Machinery -- ^ Asset: Machinery and equipment (機械装置)+ | Building -- ^ Asset: Real estate (建物)+ | Vehicle -- ^ Asset: Vehicles (車両運搬具)+ | StockInvestment -- ^ Asset: Stock investment (株式投資)+ | EquipmentInvestment -- ^ Asset: Equipment investment (設備投資)+ | LongTermLoansReceivable -- ^ Asset: Long-term loans receivable (長期貸付金)+ | AccountsReceivable -- ^ Asset: Accounts receivable (売掛金)+ | ShortTermLoansReceivable -- ^ Asset: Short-term loans receivable (短期貸付金)+ | ReserveDepositReceivable -- ^ Asset: Reserve deposits, asset side (準備預金, 資産側 — 市中銀行が中央銀行に置く準備預金。SNA\/マクロ系)+ | Gold -- ^ Asset: Gold (金)+ | GovernmentService -- ^ Asset: Government service (政府サービス。SNA\/マクロ系)+ | CapitalStock -- ^ Equity: Capital stock (資本金)+ | RetainedEarnings -- ^ Equity: Retained earnings (繰越利益剰余金)+ | LongTermLoansPayable -- ^ Liability: Long-term loans payable (長期借入金)+ | ShortTermLoansPayable -- ^ Liability: Short-term loans payable (短期借入金)+ | LoansPayable -- ^ Liability: Loans payable (借入金)+ | ReserveForDepreciation -- ^ Liability: Reserve for depreciation (減価償却引当金 — SNA\/マクロ系の旧称。簿記の間接法には 'AccumulatedDepreciation' (減価償却累計額) を使う)+ | DepositPayable -- ^ Liability: Deposits accepted (受入預金 — 銀行側の負債としての預金。SNA\/マクロ系。従業員等からの預り金は 'DepositsReceived')+ | LongTermNationalBondsPayable -- ^ Liability: Long-term national bonds payable (長期国債, 発行側)+ | ShortTermNationalBondsPayable -- ^ Liability: Short-term national bonds payable (短期国債, 発行側)+ | ReserveDepositPayable -- ^ Liability: Reserve deposits, liability side (準備預金, 負債側 — 中央銀行が受け入れる準備預金。SNA\/マクロ系)。※簿記の買掛金は 'AccountsPayable' を使うこと (旧 examples が本科目を買掛金の代用にしていた経緯あり)+ | CentralBankNotePayable -- ^ Liability: Central bank notes (発行銀行券。SNA\/マクロ系)+ | Depreciation -- ^ Expense: Depreciation (減価償却費)+ | AmortizationExpense -- ^ Expense: Amortization expense for intangibles (無形固定資産償却費 — 特許権・商標権・ソフトウェア等の無形資産の償却費。有形の 'Depreciation' (減価償却費) と区別される)+ | SalesCost -- ^ Expense: Cost of sales (売上原価)+ | BusinessTrip -- ^ Expense: Travel and transportation (旅費交通費)+ | Commutation -- ^ Expense: Communication (通信費 — 旧称。新規コードでは 'CommunicationExpenses' を使う)+ | UtilitiesExpense -- ^ Expense: Utilities (水道光熱費)+ | RentExpense -- ^ Expense: Rent (支払家賃)+ | AdvertisingExpense -- ^ Expense: Advertising (広告宣伝費)+ | DeliveryExpenses -- ^ Expense: Delivery (発送費)+ | SuppliesExpenses -- ^ Expense: Supplies (消耗品費)+ | MiscellaneousExpenses -- ^ Expense: Miscellaneous (雑費)+ | WageExpenditure -- ^ Expense: Wages (給料)+ | InterestExpense -- ^ Expense: Interest expense (支払利息)+ | TaxesExpense -- ^ Expense: Taxes (租税公課)+ | ConsumptionExpenditure -- ^ Expense: Consumption expenditure (消費支出。SNA\/マクロ系)+ | SubsidyExpense -- ^ Expense: Subsidy expenditure (補助金支出。SNA\/マクロ系)+ | CentralBankPaymentExpense -- ^ Expense: Central bank payment to treasury (国庫納付金支出。SNA\/マクロ系)+ | Purchases -- ^ Expense: Purchases (仕入)+ | NetIncome -- ^ Expense: Net income (当期純利益 — 決算振替用。借方側に立つため Expense 区分)+ | ValueAdded -- ^ Revenue: Value added (付加価値。SNA\/マクロ系)+ | SubsidyIncome -- ^ Revenue: Subsidy income (補助金収入。SNA\/マクロ系)+ | NationalBondInterestEarned -- ^ Revenue: National bond interest earned (国債利息収入)+ | DepositInterestEarned -- ^ Revenue: Deposit interest earned (預金利息収入)+ | GrossProfit -- ^ Revenue: Gross profit (売上総利益 — 決算振替用)+ | OrdinaryProfit -- ^ Revenue: Ordinary profit (経常利益 — 決算振替用)+ | InterestEarned -- ^ Revenue: Interest earned (受取利息)+ | ReceiptFee -- ^ Revenue: Receipt fee (受取手数料。支払側は 'PaymentFees')+ | RentalIncome -- ^ Revenue: Rental income (受取家賃)+ | WageEarned -- ^ Revenue: Wage income (賃金収入。SNA\/マクロ系)+ | TaxesRevenue -- ^ Revenue: Tax revenue (租税収入。SNA\/マクロ系)+ | CentralBankPaymentIncome -- ^ Revenue: Central bank payment to treasury (国庫納付金収入。SNA\/マクロ系)+ | Sales -- ^ Revenue: Sales (売上)+ | EquityInEarningsOfInvestee -- ^ Revenue: Equity in earnings of investee (持分法による投資利益). Recognised under the equity method.+ | NetLoss -- ^ Revenue: Net loss (当期純損失 — 決算振替用。貸方側に立つため Revenue 区分)+ -- Elementary bookkeeping (日商簿記 3 級水準) additions.+ -- Appended before the 'AccountTitle' wildcard so that only the+ -- wildcard's Enum/Binary ordinal shifts (existing serialized+ -- bases remain compatible).+ --+ -- Assets (資産)+ | PettyCash -- ^ Asset: Petty cash (小口現金)+ | NotesReceivable -- ^ Asset: Notes receivable (受取手形)+ | ElectronicallyRecordedReceivable -- ^ Asset: Electronically recorded monetary claims (電子記録債権)+ | CreditCardReceivable -- ^ Asset: Credit card receivable (クレジット売掛金)+ | NotesLoansReceivable -- ^ Asset: Loans receivable on notes (手形貸付金)+ | MerchandiseInventory -- ^ Asset: Merchandise inventory (繰越商品). Use under the periodic/3-account method (3 分法: Purchases\/Sales\/MerchandiseInventory). For the perpetual\/specific-identification method (分記法) use 'Products' instead.+ | AdvancesPaid -- ^ Asset: Advances paid (前払金)+ | PrepaidExpenses -- ^ Asset: Prepaid expenses (前払費用), deferral accrual account (経過勘定)+ | AccruedRevenue -- ^ Asset: Accrued revenue (未収収益), deferral accrual account (経過勘定)+ | OtherReceivables -- ^ Asset: Other receivables (未収入金)+ | PaymentsOnBehalf -- ^ Asset: Payments made on behalf (立替金)+ | SuspensePayments -- ^ Asset: Suspense payments (仮払金)+ | ConsumptionTaxPaid -- ^ Asset: Consumption tax paid (仮払消費税)+ | PrepaidCorporateIncomeTaxes -- ^ Asset: Prepaid corporate income taxes (仮払法人税等)+ | Land -- ^ Asset: Land (土地)+ | Fixtures -- ^ Asset: Fixtures and equipment (備品)+ | Patent -- ^ Asset: Patent (特許権)+ | Trademark -- ^ Asset: Trademark (商標権)+ | Software -- ^ Asset: Software (ソフトウェア)+ | CashOverShort -- ^ Asset: Cash over and short (現金過不足), a temporary/suspense account cleared at closing to MiscellaneousIncome\/MiscellaneousLoss+ -- Liability (負債)+ | AccountsPayable -- ^ Liability: Accounts payable (買掛金)+ | NotesPayable -- ^ Liability: Notes payable (支払手形)+ | ElectronicallyRecordedObligations -- ^ Liability: Electronically recorded monetary obligations (電子記録債務)+ | NotesLoansPayable -- ^ Liability: Loans payable on notes (手形借入金)+ | BankOverdraft -- ^ Liability: Bank overdraft (当座借越)+ | AdvancesReceived -- ^ Liability: Advances received (前受金)+ | UnearnedRevenue -- ^ Liability: Unearned revenue (前受収益), deferral accrual account (経過勘定)+ | AccruedExpenses -- ^ Liability: Accrued expenses (未払費用), deferral accrual account (経過勘定)+ | OtherPayables -- ^ Liability: Other payables (未払金)+ | DepositsReceived -- ^ Liability: Deposits received (預り金)+ | SuspenseReceipts -- ^ Liability: Suspense receipts (仮受金)+ | ConsumptionTaxReceived -- ^ Liability: Consumption tax received (仮受消費税)+ | AccruedConsumptionTax -- ^ Liability: Accrued (unpaid) consumption tax (未払消費税)+ | AccruedCorporateIncomeTaxes -- ^ Liability: Accrued (unpaid) corporate income taxes (未払法人税等)+ | UnpaidDividends -- ^ Liability: Unpaid dividends (未払配当金)+ | AllowanceForDoubtfulAccounts -- ^ Asset (contra): Allowance for doubtful accounts (貸倒引当金), a credit-balance valuation account (評価勘定) deducted from receivables. Division is Assets with @isContra = True@ (Definition 7 amendment, 0.5.0.0); home side stays Credit, values stay non-negative, Hat\/Not structure intact. B\/S deduction (net) presentation is the Write side's job.+ | AccumulatedDepreciation -- ^ Asset (contra): Accumulated depreciation (減価償却累計額), valuation account (評価勘定) under the indirect method (間接法), deducted from the related depreciable assets. Division is Assets with @isContra = True@ for the same reason as 'AllowanceForDoubtfulAccounts'. This is the canonical bookkeeping account for accumulated depreciation; the existing 'ReserveForDepreciation' is retained as the legacy SNA\/macro-accounting name.+ -- Equity (資本)+ | LegalRetainedEarnings -- ^ Equity: Legal (appropriated) retained earnings reserve (利益準備金)+ | CumulativeTranslationAdjustment -- ^ Equity: Cumulative translation adjustment (為替換算調整勘定; 在外子会社等の外貨建財務諸表の換算差額を計上する OCI/資本の部の項目)+ -- Cost (費用)+ | ProvisionForDoubtfulAccounts -- ^ Cost: Provision for doubtful accounts (貸倒引当金繰入)+ | BadDebtLoss -- ^ Cost: Bad debt loss (貸倒損失)+ | LossOnSalesOfFixedAssets -- ^ Cost: Loss on sales of fixed assets (固定資産売却損)+ | LossOnSalesOfNotesReceivable -- ^ Cost: Loss on sales of notes receivable (手形売却損)+ | PaymentFees -- ^ Cost: Payment fees / fees paid (支払手数料), the debit counterpart of 'ReceiptFee'+ | MiscellaneousLoss -- ^ Cost: Miscellaneous loss (雑損)+ | CorporateIncomeTaxes -- ^ Cost: Corporate income taxes (法人税等)+ | CommunicationExpenses -- ^ Cost: Communication expenses (通信費). Explicitly named counterpart of the legacy 'Commutation' (also "Communication"); 'Commutation' is retained for backward compatibility.+ -- Revenue (収益)+ | GainOnSalesOfFixedAssets -- ^ Revenue: Gain on sales of fixed assets (固定資産売却益)+ | RecoveryOfBadDebts -- ^ Revenue: Recovery of bad debts written off (償却債権取立益)+ | MiscellaneousIncome -- ^ Revenue: Miscellaneous income (雑益)+ | ReversalOfAllowanceForDoubtfulAccounts -- ^ Revenue: Reversal of allowance for doubtful accounts (貸倒引当金戻入). Credit counterpart used by the 差額補充法/洗替法 when the estimated allowance is smaller than the existing balance (the excess of 'AllowanceForDoubtfulAccounts' is released).+ -- V-Land 2: JCCI grade-2 commercial-bookkeeping vocabulary.+ | TimeDeposits -- ^ Assets: Time deposits (定期預金)+ | LoansReceivable -- ^ Assets: Loans receivable (貸付金)+ | GiftCertificatesReceived -- ^ Assets: Gift certificates received (受取商品券)+ | SecurityDepositsPaid -- ^ Assets: Guarantee deposits (差入保証金)+ | SuppliesOnHand -- ^ Assets: Supplies (貯蔵品)+ | ContractAssets -- ^ Assets: Contract assets (契約資産)+ | IncomeTaxesRefundReceivable -- ^ Assets: Income taxes refund receivable (未収還付法人税等)+ | WorkInProcess -- ^ Assets: Work in process (仕掛品)+ | DeferredTaxAssets -- ^ Assets: Deferred tax assets (繰延税金資産)+ | LeasedAssets -- ^ Assets: Leased assets (リース資産)+ | ToolsAndInstruments -- ^ Assets: Tools and instruments (工具器具)+ | ConstructionInProgress -- ^ Assets: Construction in progress (建設仮勘定)+ | Goodwill -- ^ Assets: Goodwill (のれん)+ | SoftwareInProgress -- ^ Assets: Software in progress (ソフトウェア仮勘定)+ | LongTermPrepaidExpenses -- ^ Assets: Long-term prepaid expenses (長期前払費用)+ | DishonoredNotesReceivable -- ^ Assets: Dishonored notes receivable (不渡手形)+ | PrepaidPensionCost -- ^ Assets: Prepaid pension costs (前払年金費用)+ | NetDefinedBenefitAsset -- ^ Assets: Retirement benefit asset (退職給付に係る資産)+ | DepositsInSpecialAccounts -- ^ Assets: Deposits in special accounts (別段預金)+ | Structures -- ^ Assets: Structures (構築物)+ | LeaseholdRights -- ^ Assets: Leasehold interests in land (借地権)+ | NonOperatingNotesReceivable -- ^ Assets: Non-operating notes receivable (営業外受取手形)+ | NonOperatingElectronicallyRecordedReceivable -- ^ Assets: Electronically recorded monetary claims - non-operating (営業外電子記録債権)+ | RefundLiabilities -- ^ Liability: Refund liabilities (返金負債)+ | NonOperatingNotesPayable -- ^ Liability: Non-operating notes payable (営業外支払手形)+ | NonOperatingElectronicallyRecordedObligations -- ^ Liability: Electronically recorded obligations - non-operating (営業外電子記録債務)+ | BonusesPayable -- ^ Liability: Accrued bonuses (未払賞与)+ | AllowanceForRepairs -- ^ Liability: Provision for repairs (修繕引当金)+ | AllowanceForProductWarranties -- ^ Liability: Provision for warranties (商品保証引当金)+ | AllowanceForBonuses -- ^ Liability: Provision for bonuses (賞与引当金)+ | DeferredTaxLiabilities -- ^ Liability: Deferred tax liabilities (繰延税金負債)+ | LeaseObligations -- ^ Liability: Lease liabilities (リース債務)+ | GuaranteeDepositsReceived -- ^ Liability: Guarantee deposits received (預り保証金)+ | AllowanceForRetirementBenefits -- ^ Liability: Provision for retirement benefits (退職給付引当金)+ | LongTermOtherPayables -- ^ Liability: Long-term accounts payable - other (長期未払金)+ | NetDefinedBenefitLiability -- ^ Liability: Retirement benefit liability (退職給付に係る負債)+ | StockSubscriptionDeposits -- ^ Equity: Deposits for subscriptions of shares (株式申込証拠金)+ | LegalCapitalSurplus -- ^ Equity: Legal capital surplus (資本準備金)+ | OtherCapitalSurplus -- ^ Equity: Other capital surplus (その他資本剰余金)+ | DividendEqualizationReserve -- ^ Equity: Reserve for dividend equalization (配当平均積立金)+ | RepairFundReserve -- ^ Equity: Reserve for repairs (修繕積立金)+ | ConstructionFundReserve -- ^ Equity: Reserve for new construction (新築積立金)+ | GeneralReserve -- ^ Equity: General reserve (別途積立金)+ | ValuationDifferenceOnOtherSecurities -- ^ Equity: Valuation difference on available-for-sale securities (その他有価証券評価差額金)+ | NonControllingInterests -- ^ Equity: Non-controlling interests (非支配株主持分)+ | CapitalSurplus -- ^ Equity: Capital surplus (資本剰余金)+ | EarnedSurplus -- ^ Equity: Retained earnings (利益剰余金)+ | ServiceRevenue -- ^ Revenue: Service revenue (役務収益)+ | OperatingRevenue -- ^ Revenue: Operating revenue (営業収益)+ | GainOnSalesOfSecurities -- ^ Revenue: Gain on sale of securities (有価証券売却益)+ | GainOnValuationOfSecurities -- ^ Revenue: Gain on valuation of securities (有価証券評価益)+ | DividendsReceived -- ^ Revenue: Dividend income (受取配当金)+ | InterestOnSecurities -- ^ Revenue: Interest on securities (有価証券利息)+ | GainOnSalesOfInvestmentSecurities -- ^ Revenue: Gain on sale of investment securities (投資有価証券売却益)+ | InsuranceGain -- ^ Revenue: Gain on insurance claims (保険差益)+ | GainOnBargainPurchase -- ^ Revenue: Gain on bargain purchase (負ののれん発生益)+ | ReversalOfAllowanceForRepairs -- ^ Revenue: Reversal of provision for repairs (修繕引当金戻入)+ | ReversalOfAllowanceForProductWarranties -- ^ Revenue: Reversal of provision for warranties (商品保証引当金戻入)+ | GainOnDonationOfFixedAssets -- ^ Revenue: Gain on receipt of donated non-current assets (固定資産受贈益)+ | GainOnNationalSubsidies -- ^ Revenue: Gain on receipt of national subsidies (国庫補助金受贈益)+ | GainOnConstructionGrants -- ^ Revenue: Gain on contribution received for construction (工事負担金受贈益)+ | LandRentReceived -- ^ Revenue: Rental income from land (受取地代)+ | SalesRebates -- ^ Revenue: Sales rebates (売上割戻)+ | CostOfServices -- ^ Cost: Cost of services (役務原価)+ | OperatingExpenses -- ^ Cost: Operating expenses (営業費用)+ | InventoryShrinkageLoss -- ^ Cost: Inventory shrinkage loss (棚卸減耗損)+ | LossOnValuationOfMerchandise -- ^ Cost: Valuation loss on goods (商品評価損)+ | Bonuses -- ^ Cost: Bonuses (賞与)+ | RetirementBenefitExpenses -- ^ Cost: Retirement benefit expenses (退職給付費用)+ | ProvisionForRepairs -- ^ Cost: Provision for repairs (修繕引当金繰入)+ | ProvisionForBonuses -- ^ Cost: Provision for bonuses (賞与引当金繰入)+ | ProvisionForProductWarranties -- ^ Cost: Provision for warranties (商品保証引当金繰入)+ | ResearchAndDevelopmentExpenses -- ^ Cost: Research and development expenses (研究開発費)+ | AmortizationOfGoodwill -- ^ Cost: Amortization of goodwill (のれん償却)+ | AmortizationOfSoftware -- ^ Cost: Amortization of software (ソフトウェア償却)+ | AmortizationOfPatents -- ^ Cost: Amortization of patent rights (特許権償却)+ | LeaseExpenses -- ^ Cost: Lease expenses (支払リース料)+ | IncorporationExpenses -- ^ Cost: Organization expenses (創立費)+ | StockIssuanceCosts -- ^ Cost: Share issuance costs (株式交付費)+ | BusinessCommencementExpenses -- ^ Cost: Business commencement expenses (開業費)+ | DevelopmentExpenses -- ^ Cost: Development expenses (開発費)+ | LossOnSalesOfElectronicallyRecordedReceivables -- ^ Cost: Loss on sale of electronically recorded monetary claims (電子記録債権売却損)+ | LossOnSalesOfReceivables -- ^ Cost: Loss on sale of receivables (債権売却損)+ | LossOnSalesOfSecurities -- ^ Cost: Loss on sale of securities (有価証券売却損)+ | LossOnValuationOfSecurities -- ^ Cost: Loss on valuation of securities (有価証券評価損)+ | LossOnSalesOfInvestmentSecurities -- ^ Cost: Loss on sale of investment securities (投資有価証券売却損)+ | LossOnFire -- ^ Cost: Loss on fire (火災損失)+ | LossOnRetirementOfFixedAssets -- ^ Cost: Loss on retirement of non-current assets (固定資産除却損)+ | LossOnReductionOfFixedAssets -- ^ Cost: Loss on tax purpose reduction entry of non-current assets (固定資産圧縮損)+ | AdditionalIncomeTaxesForPriorPeriods -- ^ Cost: Additional income taxes for prior periods (追徴法人税等)+ | RefundOfIncomeTaxes -- ^ Cost: Refund of income taxes (還付法人税等)+ | PurchaseRebates -- ^ Cost: Purchase rebates (仕入割戻)+ | WelfareExpenses -- ^ Cost: Welfare expenses (福利厚生費)+ | MaintenanceExpenses -- ^ Cost: Maintenance expenses (保守費)+ | StatutoryWelfareExpenses -- ^ Cost: Legal welfare expenses (法定福利費)+ | LandRentPaid -- ^ Cost: Rent expenses on land (支払地代)+ | InsuranceExpense -- ^ Cost: Insurance expenses (保険料)+ | RepairsExpense -- ^ Cost: Repair expenses (修繕費)+ | StorageExpenses -- ^ Cost: Storage costs (保管費)+ | MembershipFees -- ^ Cost: Membership fees (諸会費)+ | IncomeSummary -- ^ Assets: Income summary (損益)+ | SuspenseAccount -- ^ Assets: Suspense account (未決算)+ | ForeignExchangeGains -- ^ Revenue: Foreign exchange gains (為替差益)+ | ForeignExchangeLosses -- ^ Cost: Foreign exchange losses (為替差損)+ | ContraAccountForGuaranteeObligations -- ^ Assets: Contra account for guarantee obligations (保証債務見返)+ | GuaranteeObligations -- ^ Liability: Guarantee obligations (保証債務)+ | IncomeTaxesAdjustment -- ^ Cost: Income taxes - deferred (法人税等調整額)+ | BranchCurrentAccount -- ^ Assets: Branch current account (支店)+ | HeadOfficeCurrentAccount -- ^ Assets: Head office current account (本店)+ | NetIncomeAttributableToNCI -- ^ Cost: Profit attributable to non-controlling interests (非支配株主に帰属する当期純利益)+ | NetLossAttributableToNCI -- ^ Revenue: Loss attributable to non-controlling interests (非支配株主に帰属する当期純損失)+ | TradingSecurities -- ^ Assets: Trading securities (売買目的有価証券)+ | HeldToMaturityBonds -- ^ Assets: Held-to-maturity bonds (満期保有目的債券)+ | SubsidiaryStocks -- ^ Assets: Shares of subsidiaries (子会社株式)+ | AffiliateStocks -- ^ Assets: Shares of associates (関連会社株式)+ | AvailableForSaleSecurities -- ^ Assets: Available-for-sale securities (その他有価証券)+ | ConsumptionTaxRefundReceivable -- ^ Assets: Consumption tax refund receivable (未収還付消費税)+ | PropertyTaxPayable -- ^ Liability: Property tax payable (未払固定資産税)+ | DepositsReceivedFromOfficers -- ^ Liability: Deposits received from officers (役員預り金)+ | AccountTitle -- ^ Wildcard (ワイルドカード — 任意の科目にマッチ。projWithBase 等の問い合わせ用で, 実 posting には使わない)+ deriving (Show, Ord, Eq, Enum, Generic, Bounded) +-- Commutation の実用法は examples の文脈から「通信費」であることが確認済+-- (2026-06-11 調査)。Enum/Binary 序数の安定のため削除はせず, 新名称への移行を促す。+{-# DEPRECATED Commutation "通信費 (communication expenses) — use 'CommunicationExpenses' instead" #-}+ instance Hashable AccountTitles where {-# INLINE hashWithSalt #-} hashWithSalt salt x = hashWithSalt salt (fromEnum x) instance Binary.Binary AccountTitles where {-# INLINE put #-}- put = Binary.putWord8 . fromIntegral . fromEnum+ put = BinaryPut.putWord16be . fromIntegral . fromEnum {-# INLINE get #-}- get = toEnum . fromIntegral <$> Binary.getWord8+ get = do+ tag <- BinaryGet.getWord16be+ let maxTag = fromIntegral (fromEnum (maxBound :: AccountTitles))+ if tag <= maxTag+ then pure (toEnum (fromIntegral tag))+ else fail ("AccountTitles: invalid binary tag " ++ show tag) instance Element AccountTitles where- {-# INLINE wiledcard #-}- wiledcard = AccountTitle+ {-# INLINE wildcard #-}+ wildcard = AccountTitle @@ -310,8 +510,8 @@ type Subject = Text instance Element Text where - {-# INLINE wiledcard #-}- wiledcard = T.empty+ {-# INLINE wildcard #-}+ wildcard = T.empty -- | Currency unit or physical quantity data CountUnit = Yen@@ -334,45 +534,45 @@ instance Element CountUnit where - {-# INLINE wiledcard #-}- wiledcard = CountUnit+ {-# INLINE wildcard #-}+ wildcard = CountUnit -- TimeOfDay internally holds hour, minute, and second (Pico), so each is hashed individually instance Hashable TimeOfDay where- hashWithSalt salt (TimeOfDay hour min sec) =- salt `hashWithSalt` hour `hashWithSalt` min `hashWithSalt` sec+ hashWithSalt salt (TimeOfDay hour minute sec) =+ salt `hashWithSalt` hour `hashWithSalt` minute `hashWithSalt` sec -- Day internally holds an Integer in ModifiedJulianDay format, so that is used for hashing instance Hashable Day where hashWithSalt salt day = hashWithSalt salt (toModifiedJulianDay day) instance Element TimeOfDay where- wiledcard = Time.midnight+ wildcard = Time.midnight instance Element Day where- wiledcard = ModifiedJulianDay 0+ wildcard = ModifiedJulianDay 0 instance (Element a ,Element b) => Element (a, b) where - {-# INLINE wiledcard #-}- wiledcard = (wiledcard, wiledcard)+ {-# INLINE wildcard #-}+ wildcard = (wildcard, wildcard) - {-# INLINE haveWiledcard #-}- haveWiledcard (a,b)- = isWiledcard a- || isWiledcard b+ {-# INLINE haveWildcard #-}+ haveWildcard (a,b)+ = isWildcard a+ || isWildcard b {-# INLINE equal #-} equal (a1, a2) (b1, b2) = (a1 .== b1) && (a2 .== b2) - {-# INLINE ignoreWiledcard #-}- ignoreWiledcard (a1, a2) (b1, b2)- = ( ignoreWiledcard a1 b1- , ignoreWiledcard a2 b2)+ {-# INLINE ignoreWildcard #-}+ ignoreWildcard (a1, a2) (b1, b2)+ = ( ignoreWildcard a1 b1+ , ignoreWildcard a2 b2) {-# INLINE compareElement #-} compareElement (a1, a2) (b1, b2)@@ -388,16 +588,16 @@ instance (Element a, Element b, Element c) => Element (a, b, c) where - {-# INLINE wiledcard #-}- wiledcard = ( wiledcard- , wiledcard- , wiledcard)+ {-# INLINE wildcard #-}+ wildcard = ( wildcard+ , wildcard+ , wildcard) - {-# INLINE haveWiledcard #-}- haveWiledcard (a,b,c)- = isWiledcard a- || isWiledcard b- || isWiledcard c+ {-# INLINE haveWildcard #-}+ haveWildcard (a,b,c)+ = isWildcard a+ || isWildcard b+ || isWildcard c {-# INLINE equal #-}@@ -406,11 +606,11 @@ && (a2 .== b2) && (a3 .== b3) - {-# INLINE ignoreWiledcard #-}- ignoreWiledcard (a1, a2, a3) (b1, b2, b3)- = ( ignoreWiledcard a1 b1- , ignoreWiledcard a2 b2- , ignoreWiledcard a3 b3)+ {-# INLINE ignoreWildcard #-}+ ignoreWildcard (a1, a2, a3) (b1, b2, b3)+ = ( ignoreWildcard a1 b1+ , ignoreWildcard a2 b2+ , ignoreWildcard a3 b3) {-# INLINE compareElement #-} compareElement (a1, a2, a3) (b1, b2, b3)@@ -426,19 +626,19 @@ instance (Element a, Element b, Element c, Element d) => Element (a, b, c, d) where - {-# INLINE wiledcard #-}- wiledcard = ( wiledcard- , wiledcard- , wiledcard- , wiledcard)+ {-# INLINE wildcard #-}+ wildcard = ( wildcard+ , wildcard+ , wildcard+ , wildcard) - {-# INLINE haveWiledcard #-}- haveWiledcard (a,b,c,d)- = isWiledcard a- || isWiledcard b- || isWiledcard c- || isWiledcard d+ {-# INLINE haveWildcard #-}+ haveWildcard (a,b,c,d)+ = isWildcard a+ || isWildcard b+ || isWildcard c+ || isWildcard d {-# INLINE equal #-} equal (a1, a2, a3, a4) (b1, b2, b3, b4)@@ -447,12 +647,12 @@ && (a3 .== b3) && (a4 .== b4) - {-# INLINE ignoreWiledcard #-}- ignoreWiledcard (a1, a2, a3, a4) (b1, b2, b3, b4)- = ( ignoreWiledcard a1 b1- , ignoreWiledcard a2 b2- , ignoreWiledcard a3 b3- , ignoreWiledcard a4 b4)+ {-# INLINE ignoreWildcard #-}+ ignoreWildcard (a1, a2, a3, a4) (b1, b2, b3, b4)+ = ( ignoreWildcard a1 b1+ , ignoreWildcard a2 b2+ , ignoreWildcard a3 b3+ , ignoreWildcard a4 b4) {-# INLINE compareElement #-} compareElement (a1, a2, a3, a4) (b1, b2, b3, b4)@@ -468,21 +668,21 @@ instance (Element a, Element b, Element c, Element d, Element e) => Element (a, b, c, d, e) where - {-# INLINE wiledcard #-}- wiledcard = ( wiledcard- , wiledcard- , wiledcard- , wiledcard- , wiledcard)+ {-# INLINE wildcard #-}+ wildcard = ( wildcard+ , wildcard+ , wildcard+ , wildcard+ , wildcard) - {-# INLINE haveWiledcard #-}- haveWiledcard (a,b,c,d,e)- = isWiledcard a- || isWiledcard b- || isWiledcard c- || isWiledcard d- || isWiledcard e+ {-# INLINE haveWildcard #-}+ haveWildcard (a,b,c,d,e)+ = isWildcard a+ || isWildcard b+ || isWildcard c+ || isWildcard d+ || isWildcard e {-# INLINE equal #-} equal (a1, a2, a3, a4, a5) (b1, b2, b3, b4, b5)@@ -492,13 +692,13 @@ && (a4 .== b4) && (a5 .== b5) - {-# INLINE ignoreWiledcard #-}- ignoreWiledcard (a1, a2, a3, a4, a5) (b1, b2, b3, b4, b5)- = ( ignoreWiledcard a1 b1- , ignoreWiledcard a2 b2- , ignoreWiledcard a3 b3- , ignoreWiledcard a4 b4- , ignoreWiledcard a5 b5)+ {-# INLINE ignoreWildcard #-}+ ignoreWildcard (a1, a2, a3, a4, a5) (b1, b2, b3, b4, b5)+ = ( ignoreWildcard a1 b1+ , ignoreWildcard a2 b2+ , ignoreWildcard a3 b3+ , ignoreWildcard a4 b4+ , ignoreWildcard a5 b5) {-# INLINE compareElement #-} compareElement (a1, a2, a3, a4, a5) (b1, b2, b3, b4, b5)@@ -514,22 +714,22 @@ instance (Element a, Element b, Element c, Element d, Element e, Element f) => Element (a, b, c, d, e, f) where - {-# INLINE wiledcard #-}- wiledcard = ( wiledcard- , wiledcard- , wiledcard- , wiledcard- , wiledcard- , wiledcard)+ {-# INLINE wildcard #-}+ wildcard = ( wildcard+ , wildcard+ , wildcard+ , wildcard+ , wildcard+ , wildcard) - {-# INLINE haveWiledcard #-}- haveWiledcard (a,b,c,d,e,f)- = isWiledcard a- || isWiledcard b- || isWiledcard c- || isWiledcard d- || isWiledcard e- || isWiledcard f+ {-# INLINE haveWildcard #-}+ haveWildcard (a,b,c,d,e,f)+ = isWildcard a+ || isWildcard b+ || isWildcard c+ || isWildcard d+ || isWildcard e+ || isWildcard f {-# INLINE equal #-} equal (a1, a2, a3, a4, a5, a6) (b1, b2, b3, b4, b5, b6)@@ -540,14 +740,14 @@ && (a5 .== b5) && (a6 .== b6) - {-# INLINE ignoreWiledcard #-}- ignoreWiledcard (a1, a2, a3, a4, a5, a6) (b1, b2, b3, b4, b5, b6)- = ( ignoreWiledcard a1 b1- , ignoreWiledcard a2 b2- , ignoreWiledcard a3 b3- , ignoreWiledcard a4 b4- , ignoreWiledcard a5 b5- , ignoreWiledcard a6 b6)+ {-# INLINE ignoreWildcard #-}+ ignoreWildcard (a1, a2, a3, a4, a5, a6) (b1, b2, b3, b4, b5, b6)+ = ( ignoreWildcard a1 b1+ , ignoreWildcard a2 b2+ , ignoreWildcard a3 b3+ , ignoreWildcard a4 b4+ , ignoreWildcard a5 b5+ , ignoreWildcard a6 b6) {-# INLINE compareElement #-} compareElement (a1, a2, a3, a4, a5, a6) (b1, b2, b3, b4, b5, b6)@@ -562,24 +762,24 @@ instance (Element a, Element b, Element c, Element d, Element e, Element f, Element g) => Element (a, b, c, d, e, f, g) where- {-# INLINE wiledcard #-}- wiledcard = ( wiledcard- , wiledcard- , wiledcard- , wiledcard- , wiledcard- , wiledcard- , wiledcard)+ {-# INLINE wildcard #-}+ wildcard = ( wildcard+ , wildcard+ , wildcard+ , wildcard+ , wildcard+ , wildcard+ , wildcard) - {-# INLINE haveWiledcard #-}- haveWiledcard (a,b,c,d,e,f,g)- = isWiledcard a- || isWiledcard b- || isWiledcard c- || isWiledcard d- || isWiledcard e- || isWiledcard f- || isWiledcard g+ {-# INLINE haveWildcard #-}+ haveWildcard (a,b,c,d,e,f,g)+ = isWildcard a+ || isWildcard b+ || isWildcard c+ || isWildcard d+ || isWildcard e+ || isWildcard f+ || isWildcard g {-# INLINE equal #-} equal (a1, a2, a3, a4, a5, a6, a7) (b1, b2, b3, b4, b5, b6, b7)@@ -591,15 +791,15 @@ && (a6 .== b6) && (a7 .== b7) - {-# INLINE ignoreWiledcard #-}- ignoreWiledcard (a1, a2, a3, a4, a5, a6, a7) (b1, b2, b3, b4, b5, b6, b7)- = ( ignoreWiledcard a1 b1- , ignoreWiledcard a2 b2- , ignoreWiledcard a3 b3- , ignoreWiledcard a4 b4- , ignoreWiledcard a5 b5- , ignoreWiledcard a6 b6- , ignoreWiledcard a7 b7)+ {-# INLINE ignoreWildcard #-}+ ignoreWildcard (a1, a2, a3, a4, a5, a6, a7) (b1, b2, b3, b4, b5, b6, b7)+ = ( ignoreWildcard a1 b1+ , ignoreWildcard a2 b2+ , ignoreWildcard a3 b3+ , ignoreWildcard a4 b4+ , ignoreWildcard a5 b5+ , ignoreWildcard a6 b6+ , ignoreWildcard a7 b7) {-# INLINE compareElement #-} compareElement (a1, a2, a3, a4, a5, a6, a7) (b1, b2, b3, b4, b5, b6, b7)
+ src/ExchangeAlgebra/Algebra/Internal.hs view
@@ -0,0 +1,2062 @@+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE InstanceSigs #-}+{-# LANGUAGE TypeSynonymInstances #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE PatternGuards #-}+{-# LANGUAGE InstanceSigs #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE UndecidableInstances #-}+{-# LANGUAGE StrictData #-}+{-# LANGUAGE Strict #-}+{-# LANGUAGE PatternSynonyms #-}+{-# LANGUAGE ViewPatterns #-}+{-# LANGUAGE OverloadedStrings #-}++{- |+ Module : ExchangeAlgebra.Algebra.Internal+ Copyright : (c) Kaya Akagi. 2018-2026+ Maintainer : yakagika@icloud.com+ Description : Internal representation of 'Alg' (all constructors, cache+ fields and rebuild helpers). Not covered by the PVP contract;+ import "ExchangeAlgebra.Algebra" instead unless you are+ writing a test or an engine that must see 'Liner'.++ Released under the OWL license++ Package for Exchange Algebra defined by Hiroshi Deguchi.++ Exchange Algebra is an algebraic description of bookkeeping system.+ Details are below.++ <https://www.springer.com/gp/book/9784431209850>++ <https://repository.kulib.kyoto-u.ac.jp/dspace/bitstream/2433/82987/1/0809-7.pdf>++-}+++module ExchangeAlgebra.Algebra.Internal+ ( module ExchangeAlgebra.Algebra.Base+ , Nearly(..)+ , isNearlyNum+ , nearlyEqScaled+ , Redundant(..)+ , Exchange(..)+ , HatVal(..)+ , Pair(..)+ , Alg(..)+ , isZero+ , (.@)+ , (<@)+ , vals+ , bases+ , fromList+ , toList+ , foldEntries+ , sigma+ , sigma2When+ , sigmaFromMap+ , toASCList+ , map+ , mapPosting+ , mapMaybePosting+ , mapBasePart+ , filter+ , proj+ , projCredit+ , projDebit+ , projByAccountTitle+ , projNetNorm+ , projNorm+ , balanceBy+ , balanceMapBy+ , netPairMapBy+ , foldEntriesToMap+ , decBy+ , postFromNetBy+ , projCurrentAssets+ , projFixedAssets+ , projDeferredAssets+ , projCurrentLiability+ , projFixedLiability+ , projCapitalStock+ , projContraAssets+ , projContra+ , rounding+ , unionsMerge)where++import ExchangeAlgebra.Algebra.Base++import qualified Data.List as L (foldl', sort,sortOn,any)+import Prelude hiding (map, head, filter,tail, traverse, mapM)+import qualified Data.HashMap.Strict as Map+import qualified Data.IntMap.Strict as IntMap+import qualified Data.IntSet as IntSet+import qualified Data.Map.Strict as M+import qualified Data.Foldable as Foldable (foldl',foldr)+import qualified Data.Sequence as Seq+import Data.Sequence (Seq)+import qualified Data.Maybe as Maybe+import qualified Number.NonNegative as NN -- Non-negative real numbers+import qualified Data.Scientific as D (fromFloatDigits, formatScientific, FPFormat(..))+import Control.DeepSeq+import GHC.Stack (HasCallStack)+import qualified Data.Binary as Binary++------------------------------------------------------------------+-- * Approximate equality+------------------------------------------------------------------++-- | Type class providing approximate equality for numeric values.+-- Performs equality comparison with tolerance for floating-point rounding errors.+class (Eq a, Ord a) => Nearly a where+ -- | @isNearly x y t@ : Returns True if the difference between x and y is within the tolerance t.+ -- Complexity: O(1)+ isNearly :: a -> a -> a -> Bool++instance Nearly Int where+ {-# INLINE isNearly #-}+ isNearly = isNearlyNum++instance Nearly Integer where+ {-# INLINE isNearly #-}+ isNearly = isNearlyNum++instance Nearly Float where+ {-# INLINE isNearly #-}+ isNearly = isNearlyNum++instance Nearly Double where+ {-# INLINE isNearly #-}+ isNearly = isNearlyNum++instance Nearly NN.Double where+ {-# INLINE isNearly #-}+ isNearly = isNearlyNum++{-# 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 = 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+------------------------------------------------------------+------------------------------------------------------------------+-- ** Definition of Redundancy (subclassing this makes a redundant algebra)+------------------------------------------------------------------++-- | Type class for Redundant Algebra.+-- Provides fundamental exchange algebra operations: hat, bar, norm, scalar product, and compress.+--+-- Redundant ⊃ Exchange+--+-- hat calculation+-- >>> (.^) (10:@Not:<Cash .+ 10:@Hat:<Deposits)+-- 10.00:@Hat:<Cash .+ 10.00:@Not:<Deposits+--+-- bar calculation+-- >>> x = 10:@Not:<Cash .+ 10:@Hat:<Deposits+-- >>> y = 5:@Hat:<Cash .+ 5:@Not:<Deposits+-- >>> (.-) $ x .+ y+-- 5.00:@Not:<Cash .+ 5.00:@Hat:<Deposits+--+-- norm calculation+-- >>> norm $ 10:@Not:<Cash .+ 10:@Hat:<Deposits+-- 20.0+--+-- (.*) calculation+-- >>> (.*) 5 $ 10:@Not:<Cash .+ 10:@Hat:<Deposits+-- 50.00:@Not:<Cash .+ 50.00:@Hat:<Deposits+--+-- compress calculation+-- >>> compress $ 10:@Not:<Cash .+ 5:@Hat:<Cash .+ 3:@Not:<Cash+-- 5.00:@Hat:<Cash .+ 13.00:@Not:<Cash+--+-- == Redundant-algebra axioms (Akagi 2026, Appendix A, Definition 6)+--+-- The operations above satisfy the following /axioms/ (the paper states five;+-- they are verified as QuickCheck properties in @test\/Spec.hs@, see+-- @axiomProperties@):+--+-- 1. Hat involution: @(.^) ((.^) x) = x@+-- 2. Scalar on an element: @a '.*' (v ':@' b) = (a*v) ':@' b@+-- 3. Scalar distribution: @a '.*' (x '.+' y) = (a '.*' x) '.+' (a '.*' y)@+-- 4. Norm homogeneity: @'norm' (a '.*' x) = a * 'norm' x@ (for @a >= 0@)+-- 5. Norm additivity: @'norm' (x '.+' y) = 'norm' x + 'norm' y@+--+-- Derived lemmas (also property-tested): @'bar'@ idempotence+-- (@'bar' ('bar' x) = 'bar' x@), 'Zero' identity, and associativity of @('.+')@.+-- Note @('.+')@ accumulates same-base postings as an ordered sequence (the+-- /redundancy/), so 'Show' \/ 'Eq' observe that order; for the exact value type+-- t'ExchangeAlgebra.Value.MoneyDecimal', 'norm' \/ 'bar' are order-independent.+-- The order itself is furthermore /construction-path dependent/: the+-- pairwise-union path ('fromList'\/'mconcat') and the bulk-merge path+-- ('sigma'\/'unionsMerge') arrange the same multiset of postings differently.+-- Do not rely on @('==')@ to compare algebras built by different routes —+-- compare after 'compress'\/'bar', or use an exact value type and compare+-- the netted content.++class (HatVal n, HatBaseClass b, Monoid (a n b)) => Redundant a n b where+ -- | Hat operation. Flips Hat/Not on all elements.+ -- Complexity: O(1) for singleton, O(n) for Liner (n is the number of base keys)+ (.^) :: a n b -> a n b++ -- | Bar operation. Cancels Hat/Not on the same base and retains only the difference.+ -- Complexity: O(n) (n is the number of base keys)+ (.-) :: a n b -> a n b++ -- | Alias for bar operation. Identical to @(.-)@.+ bar :: a n b -> a n b+ bar = (.-)++ -- | Aggregates values on the same base. Sums while preserving the Hat/Not distinction.+ -- Complexity: O(n) (n is the number of base keys)+ compress :: a n b -> a n b++ -- | Addition of algebra elements. Alias for the Monoid @<>@ operation.+ -- Complexity: O(union cost)+ (.+) :: a n b -> a n b -> a n b++ -- | Scalar product. Multiplies all element values by a scalar.+ -- Complexity: O(1) for singleton, O(n) for Liner+ (.*) :: n -> a n b -> a n b++ -- | Norm. Sum of all element values (both Hat and Not sides), i.e. the+ -- homomorphism from the algebra into the value domain @n@ (Akagi 2026,+ -- Appendix A, Definition 6). It is /additive/: @norm (x '.+' y) = norm x ++ -- norm y@ (axiom 5), and /homogeneous/: @norm (a '.*' x) = a * norm x@ for+ -- @a >= 0@ (axiom 4). Because it sums both sides it does not cancel Hat+ -- against Not; @norm ('bar' x) <= norm x@ (bar discards the cancelled part).+ --+ -- >>> norm (100:@Not:<Cash .+ 50:@Not:<Sales :: Alg Double (HatBase AccountTitles))+ -- 150.0+ --+ -- Complexity: O(n) (n is the number of base keys)+ norm :: a n b -> n++ -- | Addition in an Applicative context.+ -- Complexity: O(union cost)+ {-# INLINE (<+) #-}+ (<+) :: (Applicative f) => f (a n b) -> f (a n b) -> f (a n b)+ (<+) x y = (.+) <$> x <*> y+++infixr 7 .^+infixr 2 .-+infixr 3 .++infixr 3 <+++------------------------------------------------------------+-- ** Definition of Exchange Algebra+------------------------------------------------------------++-- | Type class for Exchange Algebra. In addition to Redundant Algebra, provides+-- the decomposition operators of Deguchi & Nakano (1986, Definition 2.16) and+-- balance checking. Following the original convention, __L = Left = Debit+-- (借方)__ and __R = Right = Credit (貸方)__: 'decL' extracts the debit side,+-- 'decR' the credit side. ('decP' \/ 'decM' split along the Hat\/Not label+-- instead of the debit\/credit side.)+class (Redundant a n b ) => Exchange a n b where+ -- | Extracts only the credit-side elements (R = Right = Credit, 貸方),+ -- i.e. those whose 'whichSide' is 'Credit'. Complexity: O(s)+ decR :: a n b -> a n b+ -- | Extracts only the debit-side elements (L = Left = Debit, 借方),+ -- i.e. those whose 'whichSide' is 'Debit'. Complexity: O(s)+ decL :: a n b -> a n b+ -- | Extracts only the Hat-side elements (the P-projection of the+ -- decomposition; @isHat@ holds). Complexity: O(s)+ decP :: a n b -> a n b+ -- | Extracts only the Not-side elements (the M-projection of the+ -- decomposition; @isHat@ does not hold). Complexity: O(s)+ decM :: a n b -> a n b+ -- | Checks whether the norms of debit and credit sides are equal. Complexity: O(s)+ balance :: a n b -> Bool+ -- | Returns the debit-credit difference as a (Side, difference) pair. Complexity: O(s)+ diffRL :: a n b -> (Side, n)+++------------------------------------------------------------------+-- * Algebra+------------------------------------------------------------------++-- | Type class for algebra element values.+-- Provides zero-value / error-value predicates and a representation-specific+-- renderer ('showValue').+--+-- == Choosing an instance+--+-- * 'Prelude.Double' — fast IEEE-754 (this module); the low-friction default.+-- * @MoneyDouble@ ("ExchangeAlgebra.Value") — same speed, dedicated money newtype.+-- * @MoneyDecimal@ ("ExchangeAlgebra.Value") — exact decimal, construction-order+-- independent totals; use for audited\/deterministic ledgers.+-- * @NN.Double@ (@Number.NonNegative.Double@) — __deprecated__ since 0.5.0.0,+-- to be removed in 0.6: its @(-)@ /errors/ on a negative intermediate+-- (e.g. inside @bar@\/@(.-)@ comparisons), and everything it offered is+-- covered by @MoneyDouble@. Migrate to @MoneyDouble@ or bare 'Prelude.Double'.+--+-- DESIGN NOTE (2026-06-06, selectable value type — Double vs exact Decimal):+-- The @RealFloat@ superclass was intentionally *removed* so that exact,+-- non-floating-point value types (the planned @MoneyDecimal@ = non-negative+-- 'Data.Decimal.Decimal') can be 'HatVal' instances and give construction-order+-- -independent, exact summation. @RealFloat@ was only ever needed in two places:+-- * @showV@ (rendering via 'Data.Scientific.fromFloatDigits') — now replaced by+-- the per-instance 'showValue' method, so each representation formats itself;+-- * the @Double@/@NN.Double@ 'isErrorValue' (NaN/Infinity tests) — these stay+-- inside the floating-point instances, which may require @RealFloat@ locally.+-- @Fractional@ is *kept*: 'Data.Decimal' provides it (so numeric literals like+-- @0.08@ still work without wrapping), and only an @Integer@ instance would need+-- it dropped. @Integer@ is intentionally out of scope — it cannot represent the+-- fractional / relative prices that the ABM simulations depend on.+class ( Show n+ , Ord n+ , Eq n+ , Nearly n+ , Fractional n+ , Num n) => HatVal n where++ -- | Zero value. Complexity: O(1)+ zeroValue :: n++ -- | Tests whether the value is zero. Complexity: O(1)+ isZeroValue :: n -> Bool+ isZeroValue x+ | zeroValue == x = True+ | otherwise = False++ -- | Tests whether the value is an error value (NaN, Infinity, negative, …).+ -- Complexity: O(1)+ isErrorValue :: n -> Bool++ -- | Render the value for the 'Show' instance of 'Alg'.+ -- Per-instance because formatting is representation-specific: floating-point+ -- types format to a fixed number of decimal places via 'Data.Scientific',+ -- whereas exact decimal types print their own canonical form. This replaces+ -- the former floating-point-only @showV@, which hard-wired @RealFloat@+ -- through @fromFloatDigits@ and so blocked exact value types.+ showValue :: n -> String+++instance RealFloat NN.Double where+ floatRadix = floatRadix . NN.toNumber+ floatDigits = floatDigits . NN.toNumber+ floatRange = floatRange . NN.toNumber+ decodeFloat = decodeFloat . NN.toNumber+ encodeFloat m e = NN.fromNumber (encodeFloat m e)+ exponent = exponent . NN.toNumber+ significand = NN.fromNumber . significand . NN.toNumber+ scaleFloat n = NN.fromNumber . scaleFloat n . NN.toNumber+ isNaN = isNaN . NN.toNumber+ isInfinite = isInfinite . NN.toNumber+ isDenormalized = isDenormalized . NN.toNumber+ isNegativeZero = isNegativeZero . NN.toNumber+ isIEEE = isIEEE . NN.toNumber++-- | __Deprecated__ since 0.5.0.0 (removal planned for 0.6): @NN.Double@'s+-- @(-)@ errors on a negative intermediate, and @MoneyDouble@ covers the same+-- use case safely. Migrate to @MoneyDouble@ or bare 'Prelude.Double'.+-- (GHC cannot attach a @DEPRECATED@ pragma to an instance, so this notice+-- lives in the Haddock and the ChangeLog.)+instance HatVal NN.Double where+ {-# INLINE zeroValue #-}+ zeroValue = 0+ {-# INLINE isErrorValue #-}+ isErrorValue x = isNaN (NN.toNumber x)+ || isInfinite (NN.toNumber x)+ -- Identical formatting to the old top-level @showV@ (fixed 2-decimal+ -- Scientific rendering); moved here so the class no longer needs @RealFloat@.+ {-# INLINE showValue #-}+ showValue = D.formatScientific D.Generic (Just 2) . D.fromFloatDigits++instance HatVal Prelude.Double where+ {-# INLINE zeroValue #-}+ zeroValue = 0++ {-# INLINE isErrorValue #-}+ isErrorValue x = isNaN x+ || isInfinite x+ || x < 0+ -- Identical formatting to the old top-level @showV@ (see NN.Double above).+ {-# INLINE showValue #-}+ showValue = D.formatScientific D.Generic (Just 2) . D.fromFloatDigits++data Pair v where+ Pair :: {_hatSide :: !(Seq v)+ ,_notSide :: !(Seq v)} -> Pair v+ deriving (Eq)++instance (Binary.Binary v) => Binary.Binary (Pair v) where+ {-# INLINABLE put #-}+ {-# INLINABLE get #-}+ put (Pair hs ns) = do+ Binary.put (Seq.length hs :: Int)+ Foldable.foldr (\x k -> Binary.put x >> k) (pure ()) hs+ Binary.put (Seq.length ns :: Int)+ Foldable.foldr (\x k -> Binary.put x >> k) (pure ()) ns+ get = do+ hsLen <- Binary.get :: Binary.Get Int+ hs <- go hsLen Seq.empty+ nsLen <- Binary.get :: Binary.Get Int+ ns <- go nsLen Seq.empty+ pure (Pair hs ns)+ where+ go :: Binary.Binary a => Int -> Seq a -> Binary.Get (Seq a)+ go n !acc+ | n <= 0 = pure acc+ | otherwise = do+ x <- Binary.get+ go (n - 1) (acc Seq.|> x)+++instance (HatVal v) => Ord (Pair v) where+ {-# INLINE compare #-}+ compare (Pair hs1 ns1) (Pair hs2 ns2) = compare ((sum hs1) - (sum ns1)) ((sum hs2) - (sum ns2))++ (<) x y | compare x y == LT = True+ | otherwise = False++ (>) x y | compare x y == GT = True+ | otherwise = False++ (<=) x y | compare x y == LT = True+ | compare x y == EQ = True+ | otherwise = False++ (>=) x y | compare x y == GT = True+ | compare x y == EQ = True+ | otherwise = False++ max x y | x >= y = x+ | otherwise = y++ min x y | x <= y = x+ | otherwise = y++{-# INLINE nullPair #-}+-- | Complexity: O(1)+nullPair :: Pair v+nullPair = Pair Seq.empty Seq.empty++{-# INLINE pairAppend #-}+-- | Complexity: O(log(min(h1,h2)) + log(min(n1,n2)))+-- where h1/h2 and n1/n2 are the lengths of the appended 'Seq's on each side.+pairAppend :: Pair v -> Pair v -> Pair v+pairAppend (Pair x1 y1) (Pair x2 y2) =+ let !hs = x1 Seq.>< x2+ !ns = y1 Seq.>< y2+ in Pair hs ns++{-# INLINE pairUnion #-}+-- | Set-style merge of two single-base projection results.+--+-- Used by the multi-pattern 'proj'/'projNetNorm' paths where a query list is+-- treated as a /set/: when several queries select the same posting (duplicate+-- bases, or an exact base overlapping a wildcard query), the selected sides+-- come from the /same/ underlying t'Pair' in @_realg@, so the per-side+-- sequences are identical. Taking each side from whichever operand supplies it+-- (and keeping a single copy) therefore unions the selections without double+-- counting. Contrast @pairAppend@, which concatenates and would duplicate.+--+-- Each operand is a side-restricted projection ('choosePairByHat'), so at most+-- one of the two contributes a non-empty hat side and at most one a non-empty+-- not side for a given base; @pairUnion@ keeps the non-empty one per side.+--+-- Complexity: O(1).+pairUnion :: Pair v -> Pair v -> Pair v+pairUnion (Pair x1 y1) (Pair x2 y2) =+ let !hs = if Seq.null x1 then x2 else x1+ !ns = if Seq.null y1 then y2 else y1+ in Pair hs ns++-- | Algebra element. An element of exchange algebra consisting of a value-base pair.+-- Zero is the zero element, @(:@)@ is a singleton, and Liner is a HashMap-based multi-element representation.+--+-- __Invariants (do not hand-construct @Liner@).__ The @Liner@ constructor carries+-- internal cache\/index fields (@_axisPosting@, @_idToBp@, @_allBpIds@) that are+-- /derived from/ @_realg@ and must stay consistent with it: @_axisPosting@,+-- @_idToBp@ and @_allBpIds@ must be exactly the axis index, id↔base map and id set+-- built from the keys of @_realg@ (as the internal @linerFromMap@ does). The+-- wildcard projection path (@projWildMap@, @filterByAxis@) reads those indices and+-- will return wrong answers (silently, not an exception) if they disagree with+-- @_realg@. The fields @_bpToId@ and @_nextBpId@ are currently unmaintained+-- /poison/ (reserved for a dormant scheme) — forcing them throws. Always build+-- values via 'fromList' or the smart constructor '(.@)' rather than applying+-- @Liner@ (or @(:@)@) directly.+data Alg v b where+ Zero :: Alg v b+ (:@) :: {_val :: !v, _hatBase :: !b} -> Alg v b+ Liner :: { _realg :: !(Map.HashMap (BasePart b) (Pair v))+ , _axisPosting :: ~(IntMap.IntMap (Map.HashMap AxisKey IntSet.IntSet))+ , _bpToId :: ~(Map.HashMap (BasePart b) Int)+ , _idToBp :: ~(IntMap.IntMap (BasePart b))+ , _nextBpId :: ~Int+ , _allBpIds :: ~IntSet.IntSet+ } -> Alg v b++instance ( HatBaseClass b+ , Binary.Binary v+ , Binary.Binary b+ , Binary.Binary (BasePart b)+ ) => Binary.Binary (Alg v b) where+ {-# INLINABLE put #-}+ {-# INLINABLE get #-}+ put Zero = Binary.put (0 :: Int)+ put (v :@ b) = do+ Binary.put (1 :: Int)+ Binary.put v+ Binary.put b+ put (Liner m _ _ _ _ _) = do+ Binary.put (2 :: Int)+ Binary.put (Map.size m :: Int)+ Map.foldrWithKey+ (\bp p k -> Binary.put bp >> Binary.put p >> k)+ (pure ())+ m++ get = do+ tag <- Binary.get+ case (tag :: Int) of+ 0 -> pure Zero+ 1 -> (:@) <$> Binary.get <*> Binary.get+ 2 -> do+ n <- Binary.get :: Binary.Get Int+ linerFromMap <$> go n Map.empty+ _ -> fail ("Binary decode failure for Alg: unknown tag " ++ show tag)+ where+ go n !acc+ | n <= 0 = pure acc+ | otherwise = do+ bp <- Binary.get+ p <- Binary.get+ go (n - 1) (Map.insert bp p acc)++type AxisPosting = IntMap.IntMap (Map.HashMap AxisKey IntSet.IntSet)++{-# INLINE emptyAxisPosting #-}+-- | Complexity: O(1)+emptyAxisPosting :: AxisPosting+emptyAxisPosting = IntMap.empty++{-# INLINE insertAxisPosting #-}+-- | Complexity: O(d * (hash-insert + intset-insert))+-- In practice this is near O(d), where d is the number of axes in the base part.+insertAxisPosting :: [AxisKey] -> Int -> AxisPosting -> AxisPosting+insertAxisPosting !keys !bpId !idx =+ snd $ L.foldl' step (0 :: Int, idx) keys+ where+ step (!axis, !acc) !k =+ let !axisMap = IntMap.findWithDefault Map.empty axis acc+ !ids0 = Map.lookupDefault IntSet.empty k axisMap+ !ids1 = IntSet.insert bpId ids0+ !axisMap' = Map.insert k ids1 axisMap+ !acc' = IntMap.insert axis axisMap' acc+ in (axis + 1, acc')++{-# INLINE queryAxisPosting #-}+-- | Complexity: O(d + intersection cost)+-- d is the number of axes; intersections are performed in ascending set-size order.+queryAxisPosting :: [AxisKey] -> AxisPosting -> IntSet.IntSet -> IntSet.IntSet+queryAxisPosting !keys !idx !allIds =+ case matchedSets of+ Left () -> IntSet.empty+ Right [] -> allIds+ -- 'L.sortOn' preserves length, so a non-empty 'xs' sorts to a non-empty+ -- list; matching @(x:rest)@ here (rather than on a lazy let-binding) makes+ -- exhaustiveness explicit and avoids the partial pattern warning.+ Right xs@(_:_) ->+ case L.sortOn IntSet.size xs of+ (x:rest) -> L.foldl' IntSet.intersection x rest+ [] -> allIds -- unreachable: xs is non-empty+ where+ matchedSets =+ L.foldl' collect (Right []) (zip [0 :: Int ..] keys)++ collect (Left ()) _ = Left ()+ collect (Right acc) (!axis, !k)+ | axisIsWildcard k = Right acc+ | otherwise =+ case IntMap.lookup axis idx of+ Nothing -> Left ()+ Just axisMap -> case Map.lookup k axisMap of+ Nothing -> Left ()+ Just ids -> Right (ids : acc)++{-# INLINE linerFromMap #-}+-- | Complexity: O(n * d * (hash-insert + intset-insert))+-- n is the number of distinct base keys in the map.+linerFromMap :: (HatBaseClass b)+ => Map.HashMap (BasePart b) (Pair v)+ -> Alg v b+-- | @_bpToId@ and @_nextBpId@ are reserved for the (currently dormant) P1a+-- incremental-id scheme and are not consumed by any read path. We therefore skip+-- building them and leave them as lazy poison: nothing forces them in normal+-- operation (the wildcard 'proj' path uses @idx@/@idToBp@/@allIds@ only). Forcing+-- either field is a bug, surfaced loudly here instead of silently returning a+-- stale value. Guarded by a poison-field regression test.+--+-- NOTE: the @error@ thunks are written inline in the constructor application (the+-- @Liner@ fields are lazy @~@) rather than as @where@-bindings, because this+-- module is compiled @{-\# LANGUAGE Strict \#-}@, under which a @where@-bound thunk+-- would be forced when 'linerFromMap' is evaluated.+linerFromMap m =+ Liner m idx+ (error "Liner internal: _bpToId is not maintained (reserved for P1a); do not force")+ idToBp+ (error "Liner internal: _nextBpId is not maintained (reserved for P1a); do not force")+ allIds+ where+ ~(idx, idToBp, allIds) =+ Map.foldlWithKey'+ (\(!idxAcc, !idToBpAcc, !allIdsAcc) bp _ ->+ let !bpId = IntMap.size idToBpAcc+ !idx' = insertAxisPosting (toAxisKeys bp) bpId idxAcc+ !idToBp' = IntMap.insert bpId bp idToBpAcc+ !allIds' = IntSet.insert bpId allIdsAcc+ in (idx', idToBp', allIds'))+ (emptyAxisPosting, IntMap.empty, IntSet.empty)+ m++-- | Tests whether the algebra element is zero (empty).+--+-- Complexity: O(1)+isZero :: Alg v b -> Bool+isZero Zero = True+isZero _ = False++{-# INLINE singleton #-}+-- | Complexity: O(1)+singleton :: (HatVal v, HatBaseClass b) => v -> b -> Alg v b+singleton v b | isZeroValue v = Zero+ | isErrorValue v = error $ "errorValue at (.@) val: "+ ++ show v+ ++ show ":@"+ ++ show b+ | otherwise = v :@ b++{-# INLINE (.@) #-}+-- | Smart constructor that builds an algebra element from a value and a base.+-- Returns Zero for zero values, and throws an exception for error values.+--+-- Complexity: O(1)+(.@) :: (HatVal n, HatBaseClass b) => n -> b -> Alg n b+(.@) v b = singleton v b++-- | Constructs an algebra element in an Applicative context. Lifted version of @(.@)@.+--+-- Complexity: O(1) + Applicative effects+(<@) :: (HatVal n, Applicative f, HatBaseClass b)+ => f n -> b -> f (Alg n b)+(<@) v b = (.@) <$> v <*> (pure b)+++infixr 6 :@+infixr 6 .@+infixr 6 <@++-- NOTE: the former top-level @showV@ (which hard-wired @RealFloat@ via+-- @fromFloatDigits@) has been replaced by the per-instance 'showValue' method of+-- 'HatVal', so that exact value types can render themselves. The 'Show' instance+-- of 'Alg' below now calls 'showValue'. The @Double@/@NN.Double@ 'showValue'+-- implementations reproduce the old formatting byte-for-byte.++instance (HatVal v, HatBaseClass b) => Eq (Alg v b) where+ (==) Zero Zero = True+ (==) Zero _ = False+ (==) _ Zero = False+ (==) (v1:@b1) (v2:@b2) = (v1 == v2) && (b1 == b2)+ (==) (Liner m1 _ _ _ _ _) (Liner m2 _ _ _ _ _) = m1 == m2+ (==) _ _ = False+ (/=) x y = not (x == y)++instance (HatVal v, HatBaseClass b) => Ord (Alg v b) where+ {-# INLINE compare #-}+ compare Zero Zero = EQ+ compare Zero _ = LT+ compare _ Zero = GT++ compare (_:@_) (Liner _ _ _ _ _ _) = LT+ compare (Liner _ _ _ _ _ _) (_:@_) = GT+ compare (v1:@b1) (v2:@b2)+ | b1 == b2 = compare v1 v2+ | b1 > b2 = GT+ | otherwise = LT -- b1 < b2 (Ord is total; otherwise keeps it exhaustive)++ compare (Liner m1 _ _ _ _ _) (Liner m2 _ _ _ _ _) = compare m1 m2++ (<) x y | compare x y == LT = True+ | otherwise = False++ (>) x y | compare x y == GT = True+ | otherwise = False++ (<=) x y | compare x y == LT = True+ | compare x y == EQ = True+ | otherwise = False++ (>=) x y | compare x y == GT = True+ | compare x y == EQ = True+ | otherwise = False++ max x y | x >= y = x+ | otherwise = y++ min x y | x <= y = x+ | otherwise = y++instance (HatVal v, HatBaseClass b) => Show (Alg v b) where+ show Zero = "0"+ show (v:@b) = (showValue v) ++ ":@" ++ show b+ show xs = let ls = toASCList xs+ in go ls+ where+ go [] = "0"+ go [y] = show y+ go (y:ys) = show y ++ " .+ " ++ go ys+++instance NFData (Alg v b) where+ rnf Zero = Zero `seq` ()+ rnf (v:@b) = v `seq` b `seq` ()+ rnf (Liner m _ _ _ _ _) = Map.foldrWithKey (\k v acc -> k `seq` v `seq` acc) () m+------------------------------------------------------------------+-- Semigroup+------------------------------------------------------------------++instance (HatVal n, HatBaseClass b) => Semigroup (Alg n b) where+ {-# INLINE (<>) #-}+ -- | Associative law ;convert to right join+ (<>) = union++++-- | union two trees+--+-- >>> type Test = Alg Double (HatBase CountUnit)+-- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test+-- >>> y = 2:@Hat:<Yen .+ 2:@Not:<Amount :: Test+-- >>> union x y+-- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Yen .+ 1.00:@Not:<Amount .+ 2.00:@Not:<Amount+{-# INLINE union #-}+-- | Complexity:+-- - singleton/singleton and singleton/liner cases: O(n * d * index-build)+-- - liner/liner case: O(n + m) for map union plus O((n+m) * d * index-build)+-- where n and m are distinct key counts on each side.+union :: (HatVal n, HatBaseClass b) => Alg n b -> Alg n b -> Alg n b+union Zero x = x+union x Zero = x+-- singletons+-- NOTE: a zero-valued singleton contributes nothing, so the result must keep the+-- /nonzero/ value on its OWN base. Earlier code returned @v2:@b1@ / @v1:@b2@,+-- relabeling the surviving value onto the zero posting's base — this preserved+-- 'norm' but silently moved the value to the wrong base, corrupting per-base+-- projection and making construction order observable (raw @(:@)@ on a sparsified+-- zero coefficient builds an explicit @0:@base@ singleton). Keep @v2:@b2@ / @v1:@b1@.+union (v1:@b1) (v2:@b2)+ | isZeroValue v1 = case isZeroValue v2 of+ True -> Zero+ False -> v2:@b2+ | isZeroValue v2 = v1:@b1+ | otherwise = insert b2 v2 (v1:@b1)+-- If one side is a singleton+union x (v:@b) = insert b v x+union (v:@b) x = insert b v x++-- In the case of multiple elements+union (Liner m1 _ _ _ _ _) (Liner m2 _ _ _ _ _) = linerFromMap (Map.unionWith pairAppend m1 m2)+++{-# INLINE insert #-}+-- | Complexity:+-- - into Zero or singleton: O(1) to O(d * index-build)+-- - into Liner: O(n * d * index-build) due to rebuilding 'linerFromMap'+-- where n is the number of distinct base keys after insertion.+insert :: (HatVal v,HatBaseClass b) => b -> v -> Alg v b -> Alg v b+insert _ v x | isZeroValue v = x+insert !b !v Zero = v .@ b+insert !b1 !v1 (v2:@b2) = case isHat b1 of+ True -> insert b2 v2+ $ linerFromMap+ $ Map.singleton (base b1)+ $ nullPair {_hatSide = Seq.singleton v1}+ False -> insert b2 v2+ $ linerFromMap+ $ Map.singleton (base b1)+ $ nullPair {_notSide = Seq.singleton v1}+insert !b !v (Liner m _ _ _ _ _) = case isHat b of+ True -> insertLiner (nullPair {_hatSide = Seq.singleton v})+ False -> insertLiner (nullPair {_notSide = Seq.singleton v})+ where+ !bp = base b+ insertLiner !pairToInsert =+ let !m' = Map.insertWith pairAppend bp pairToInsert m+ in linerFromMap m'++------------------------------------------------------------------+-- Monoid+------------------------------------------------------------------++instance (HatVal n, HatBaseClass b) => Monoid (Alg n b) where+ -- Identity element+ mempty = Zero+ mappend = (<>)+ mconcat = unions++{-# INLINE unions #-}+-- | Complexity: O(sum of HashMap union costs over the fold)+-- For a long list this is typically the dominant construction cost.+unions :: (HatVal n, Foldable f, HatBaseClass b) => f (Alg n b) -> Alg n b+unions ts = Foldable.foldl' union Zero ts++{-# INLINE mergeAlgMap #-}+mergeAlgMap :: (HatVal n, HatBaseClass b)+ => Map.HashMap (BasePart b) (Pair n)+ -> Alg n b+ -> Map.HashMap (BasePart b) (Pair n)+mergeAlgMap !acc Zero = acc+mergeAlgMap !acc (v :@ b)+ | isZeroValue v = acc+ | otherwise =+ let !p = if isHat b+ then nullPair {_hatSide = Seq.singleton v}+ else nullPair {_notSide = Seq.singleton v}+ in Map.insertWith pairAppend (base b) p acc+mergeAlgMap !acc (Liner m _ _ _ _ _)+ | Map.null m = acc+ | otherwise = Map.unionWith pairAppend acc m++{-# INLINE mergeAlgMapIfNonZero #-}+mergeAlgMapIfNonZero :: (HatVal n, HatBaseClass b)+ => Map.HashMap (BasePart b) (Pair n)+ -> Alg n b+ -> Map.HashMap (BasePart b) (Pair n)+mergeAlgMapIfNonZero !acc Zero = acc+mergeAlgMapIfNonZero !acc alg@(v :@ _)+ | isZeroValue v = acc+ | otherwise = mergeAlgMap acc alg+mergeAlgMapIfNonZero !acc alg = mergeAlgMap acc alg++{-# INLINE unionsMerge #-}+-- | Merge multiple Algs by directly combining their internal HashMaps,+-- building the AxisPosting index only once at the end.+--+-- Produces the same /multiset/ of postings as @unions@\/'mconcat', but the+-- same-base sequence order differs (the bulk-merge accumulates a new same-base+-- singleton in front of the previously merged values, whereas the pairwise+-- union path interleaves differently). 'Eq'\/@Binary@ observe that order, and+-- 'Double' observes it through the last ULP of 'norm'\/'bar'; see the+-- characterization test @testSameBaseSeqOrderPathDependence@.+unionsMerge :: (HatVal n, Foldable f, HatBaseClass b) => f (Alg n b) -> Alg n b+unionsMerge ts =+ let !m = Foldable.foldl' mergeAlgMap Map.empty ts+ in mkAlgFromMap m++------------------------------------------------------------------+-- Redundant+------------------------------------------------------------------++instance (HatVal n, HatBaseClass b) => Redundant Alg n b where+ (.^) Zero = Zero+ (.^) (n:@ b) = n :@ (revHat b)+ (.^) (Liner ms idx bpToId idToBp nextBpId allIds) = Liner+ (Map.map (\ (Pair hs ns) -> Pair ns hs) ms)+ idx+ bpToId+ idToBp+ nextBpId+ allIds++ (.+) = mappend++ _ .* Zero = Zero+ 0 .* _ = Zero+ -- The algebra is over non-negative values: reject a negative / non-finite+ -- scalar instead of silently producing out-of-domain (negative) postings.+ -- One check on the scalar suffices — x >= 0 and the existing values are >= 0,+ -- so x*v stays non-negative and the cheap raw fmap below is safe.+ x .* _ | isErrorValue x =+ error ("(.*): non-negative finite scalar required, got " ++ show x)+ x .* (v:@b) = (x * v) :@ b+ x .* (Liner ms idx bpToId idToBp nextBpId allIds) = Liner+ (Map.map (\ (Pair hs ns) -> Pair (fmap (x *) hs) (fmap (x *) ns)) ms)+ idx+ bpToId+ idToBp+ nextBpId+ allIds++ norm Zero = 0+ norm (v:@_) = v+ norm (Liner ms _ _ _ _ _) = Map.foldl' (\ !x (Pair hs ns) -> x + Foldable.foldl' (+) 0 hs + Foldable.foldl' (+) 0 ns) 0 ms++ {-# INLINE (.-) #-}+ (.-) Zero = Zero+ (.-) (v:@b) = v:@b+ (.-) (Liner m _ _ _ _ _) = let !res = Map.mapMaybe f m+ in case null res of+ True -> Zero+ False -> linerFromMap res+ where+ {-# INLINE f #-}+ f p@(Pair hs ns) =+ let !h = Foldable.foldl' (+) 0 hs+ !n = Foldable.foldl' (+) 0 ns+ 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+ (1, 0) | h > n -> Just p+ (0, 1) | n > h -> Just p+ -- @EQ@ is unreachable here: this branch is only entered+ -- when 'nearlyEqScaled' h n is False above, so h and n are+ -- not (even approximately) equal; @compare h n@ is GT or+ -- LT. The non-exhaustive @case@ is by design (audited) — a+ -- defensive @EQ@ arm would be dead code with no canonical+ -- result to return.+ _ -> case compare h n of+ GT -> Just (Pair (Seq.singleton (h - n)) Seq.empty)+ LT -> Just (Pair Seq.empty (Seq.singleton (n - h)))++ {-# INLINE compress #-}+ compress Zero = Zero+ compress (v:@b) = v:@b+ compress (Liner m idx bpToId idToBp nextBpId allIds) = Liner+ (Map.map compressPair m)+ idx+ bpToId+ idToBp+ nextBpId+ allIds+ where+ {-# INLINE compressPair #-}+ compressPair p@(Pair hs ns) = case (Seq.length hs, Seq.length ns) of+ (1, 1) -> p -- already singleton on both sides, reuse+ (1, 0) -> p -- already singleton + empty, reuse+ (0, 1) -> p -- already empty + singleton, reuse+ _ -> Pair (Seq.singleton (Foldable.foldl' (+) 0 hs))+ (Seq.singleton (Foldable.foldl' (+) 0 ns))+++instance (HatVal n, ExBaseClass b) => Exchange Alg n b where+ -- | filter Credit side+ decR xs = filter (\x -> x /= Zero && (whichSide . _hatBase) x == Credit) xs++ -- | filter Debit side+ decL xs = filter (\x -> x /= Zero && (whichSide . _hatBase) x == Debit) xs++ -- | filter Plus Stock+ decP xs = filter (\x -> x /= Zero && (isHat . _hatBase ) x) xs++ -- | filter Minus Stock+ decM xs = filter (\x -> x /= Zero && (not. isHat. _hatBase) x) xs++ -- | check Credit Debit balance (scale-aware tolerance, WI-12)+ balance xs = nearlyEqScaled ((norm . decR) xs) ((norm . decL) xs)++ -- | (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++------------------------------------------------------------------+-- * Basic functions+------------------------------------------------------------------++-- | Returns all values contained in the algebra element as a list.+--+-- Complexity: O(s) (s is the total number of scalar entries)+vals :: (HatVal v, HatBaseClass b) => Alg v b -> [v]+vals Zero = []+vals (v:@_) = [v]+vals (Liner m _ _ _ _ _) =+ reverse $+ Map.foldl'+ (\acc (Pair hs ns) ->+ Foldable.foldl' (flip (:))+ (Foldable.foldl' (flip (:)) acc hs)+ ns+ )+ []+ m+++-- | Returns all bases contained in the algebra element as a list.+--+-- Complexity: O(s) (s is the total number of scalar entries)+bases :: (HatVal v, HatBaseClass b) => Alg v b -> [b]+bases Zero = []+bases (_:@b) = [b]+bases (Liner m _ _ _ _ _) = Map.foldlWithKey' f [] m+ 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) ns++ g :: (HatVal v, HatBaseClass b) => Hat -> BasePart b -> [b] -> v -> [b]+ g h b ys _ = (merge h b):ys++{-# INLINE fromList #-}+-- | convert List to Alg n b+-- Complexity: O(sum of HashMap union costs), because this is implemented via 'mconcat'.+--+-- >>> type Test = Alg Double (HatBase AccountTitles)+-- >>> xs = [1:@Hat:<Cash,1:@Not:<Deposits, 2:@Hat:<Cash, 2:@Not:<Deposits] :: [Test]+-- >>> fromList xs+-- 1.00:@Hat:<Cash .+ 2.00:@Hat:<Cash .+ 1.00:@Not:<Deposits .+ 2.00:@Not:<Deposits+--+-- >>> type Test = Alg Double (HatBase CountUnit)+-- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test+-- >>> y = 2:@Hat:<Yen .+ 2:@Not:<Amount :: Test+-- >>> fromList [x,y]+-- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Yen .+ 1.00:@Not:<Amount .+ 2.00:@Not:<Amount++fromList ::(HatVal v, HatBaseClass b ) => [Alg v b] -> Alg v b+fromList = mconcat++++-- | Summation function that applies a function to each element of a list and sums the results.+-- Complexity: O(sum of HashMap union costs over produced elements).+--+-- Uses the bulk-merge path ('unionsMerge'); see there for the same-base+-- sequence-order caveat relative to 'fromList'\/'mconcat'.+--+-- >>> type Test = Alg Double (HatBase CountUnit)+-- >>> sigma [1,2] (\x -> x:@Hat:<Yen)+-- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Yen++{-# INLINE sigma #-}+sigma :: (HatVal v, HatBaseClass b) => [a] -> (a -> Alg v b) -> Alg v b+sigma xs f = mkAlgFromMap $ L.foldl' step Map.empty xs+ where+ step !acc !x = mergeAlgMapIfNonZero acc (f x)++-- | Conditional summation over a double loop. For all combinations of two lists,+-- applies the function only to pairs that satisfy the condition and sums the results.+--+-- Complexity: O(|xs| * |ys| * union cost)+{-# INLINE sigma2When #-}+sigma2When :: (HatVal v, HatBaseClass b)+ => [a]+ -> [c]+ -> (a -> c -> Bool)+ -> (a -> c -> Alg v b)+ -> Alg v b+sigma2When xs ys cond f =+ mkAlgFromMap $ L.foldl' outer Map.empty xs+ where+ outer !acc !x = L.foldl' (inner x) acc ys+ inner !x !acc !y+ | cond x y = mergeAlgMapIfNonZero acc (f x y)+ | otherwise = acc++-- | Summation using keys and values from a Map. Skips entries with zero values.+--+-- Complexity: O(|map| * union cost)+{-# INLINE sigmaFromMap #-}+sigmaFromMap :: (HatVal v, HatBaseClass b, Ord k)+ => M.Map k v+ -> (k -> v -> Alg v b)+ -> Alg v b+sigmaFromMap kvs f =+ mkAlgFromMap $ M.foldlWithKey' step Map.empty kvs+ where+ step !acc !k !v+ | isZeroValue v = acc+ | otherwise = mergeAlgMapIfNonZero acc (f k v)++-- | Converts an algebra element to a list.+-- Complexity: O(s) (s is the total number of scalar entries)+--+-- >>> toList (10:@Hat:<(Cash) .+ 10:@Hat:<(Deposits) .+ Zero :: Alg Double (HatBase AccountTitles))+-- [10.00:@Hat:<Deposits,10.00:@Hat:<Cash]+--+-- you need define type variables to use this for Zero+-- >>> toList Zero :: [Alg Double (HatBase AccountTitles)]+-- []+toList :: (HatVal v, HatBaseClass b) => Alg v b -> [Alg v b]+toList Zero = []+toList (v:@b) = [v:@b]+toList (Liner m _ _ _ _ _) = Map.foldlWithKey' f [] m+ where+ f :: (HatVal v, HatBaseClass b) => [Alg v b] -> BasePart b -> Pair v -> [Alg v b]+ f xs b Pair {_hatSide = hs, _notSide = ns}+ = Foldable.foldl' (g Hat b) (Foldable.foldl' (g Not b) xs ns) hs++ g :: (HatVal v, HatBaseClass b) => Hat -> BasePart b -> [Alg v b] -> v -> [Alg v b]+ g h b ys v+ | isZeroValue v = ys+ | otherwise = (v :@ (merge h b)):ys++{-# INLINE foldEntries #-}+-- | Strict left fold over scalar entries without building an intermediate list.+--+-- This is the implementation vehicle for the universal extension from the free+-- commutative monoid of entries when each step acts through an associative,+-- commutative accumulator operation (equivalently, entry updates commute).+-- Under that condition the result is independent of both the internal+-- @HashMap@ traversal and each side's sequence order. For a non-commutative+-- accumulator, such as list append, this is only an ordinary left fold: its+-- result records the actual traversal order and can distinguish different+-- sequence orders of the same entry multiset.+foldEntries :: (HatVal v, HatBaseClass b)+ => (acc -> v -> b -> acc)+ -> acc+ -> Alg v b+ -> acc+foldEntries _ !acc Zero = acc+foldEntries f !acc (v :@ b)+ | isZeroValue v = acc+ | otherwise = f acc v b+foldEntries f !acc (Liner m _ _ _ _ _) =+ Map.foldlWithKey' step acc m+ where+ step !acc0 !bp (Pair hs ns) =+ let !hatBase = merge Hat bp+ !notBase = merge Not bp+ !acc1 = Foldable.foldl' (\a v -> if isZeroValue v then a else f a v hatBase) acc0 hs+ in Foldable.foldl' (\a v -> if isZeroValue v then a else f a v notBase) acc1 ns++{-# INLINE toASCList #-}+-- | Complexity: O(s log s), dominated by sorting the list representation.+toASCList :: (HatVal v, HatBaseClass b) => Alg v b -> [Alg v b]+toASCList = L.sort . toList+++-- | map+-- Complexity: O(s + c), where s is traversed scalar entries and c is transformed output size.+-- Typed alternatives: 'mapPosting' and 'mapMaybePosting'.+--+-- >>> type Test = Alg Double (HatBase CountUnit)+-- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test+-- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test+-- >>> map (\ (x:@hb) -> x:@(toHat hb)) $ x .+ y+-- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Yen .+ 1.00:@Hat:<Amount .+ 2.00:@Hat:<Amount+--+-- >>> type Test = Alg Double Hat+-- >>> x = 1:@Hat .+ 1:@Not :: Test+-- >>> y = 2:@Not .+ 2:@Hat :: Test+-- >>> map (\ (x:@hb) -> (2 * x):@hb) $ x .+ y+-- 2.00:@Hat .+ 4.00:@Hat .+ 2.00:@Not .+ 4.00:@Not++-- NB. 'map' applies its function to one singleton posting at a time and requires+-- the result to itself be a singleton @(v2 :@ b2)@ (or 'Zero', handled via the+-- 'isZeroValue' check / the 'r' helper below). The @let v2:@b2 = ...@ and the+-- @case fn (...)@ in 'r' therefore intentionally match only the @(:@)@ shape; a+-- 'Zero'\/'Liner' result is outside this contract, so the non-exhaustive patterns+-- are by design (audited). Adding catch-all arms would silently change behaviour.+map :: (HasCallStack,HatVal v, HatBaseClass b)+ => (Alg v b -> Alg v b) -> Alg v b -> Alg v b+map _ Zero = Zero+map f (v:@b) = let v2:@b2 = f (v:@b)+ in case isZeroValue v2 of+ True -> Zero+ False -> (v2 :@ b2)+map f (Liner m _ _ _ _ _) = mkAlgFromMap $ (Map.foldrWithKey (p f) dnilMap m) Map.empty+ where+ {-# INLINE dnilMap #-}+ dnilMap = id+ {-# INLINE dappendMap #-}+ dappendMap = (.)+ {-# INLINE dsingleMap #-}+ dsingleMap (bp, p') = Map.insertWith pairAppend bp p'++ {-# INLINE p #-}+ p :: (HatVal v, HatBaseClass b)+ => (Alg v b -> Alg v b)+ -> BasePart b+ -> Pair v+ -> DMap (BasePart b) (Pair v)+ -> DMap (BasePart b) (Pair v)+ p fn b Pair {_hatSide=hs, _notSide=ns} accDList =+ let (dl1, hs2) = q fn Hat b hs+ (dl2, ns2) = q fn Not b ns+ prefix = dappendMap dl1 dl2+ in case (Seq.null hs2, Seq.null ns2) of+ (True,True) -> dappendMap prefix accDList+ (True,False) -> dappendMap prefix+ . dappendMap (dsingleMap (b, nullPair{_notSide = ns2}))+ $ accDList+ (False,True) -> dappendMap prefix+ . dappendMap (dsingleMap (b, nullPair{_hatSide = hs2}))+ $ accDList+ (False,False) -> dappendMap prefix+ . dappendMap (dsingleMap (b, Pair hs2 ns2))+ $ accDList+ {-# INLINE q #-}+ q :: (HatVal v, HatBaseClass b)+ => (Alg v b -> Alg v b)+ -> Hat+ -> BasePart b+ -> Seq v+ -> (DMap (BasePart b) (Pair v), Seq v)+ q fn h b vs = Foldable.foldl' (r fn h b) (dnilMap, Seq.empty) vs++ {-# INLINE r #-}+ r :: (HatVal v, HatBaseClass b)+ => (Alg v b -> Alg v b)+ -> Hat+ -> BasePart b+ -> (DMap (BasePart b) (Pair v), Seq v)+ -> v+ -> (DMap (BasePart b) (Pair v), Seq v)+ r fn h b (dlAcc,vsAcc) v = case fn (v:@(merge h b)) of+ Zero -> (dlAcc, vsAcc)+ ------------------------------------------------------------------+ v2:@b2+ | isZeroValue v2 -> (dlAcc, vsAcc)+ | b2 .== (merge h b) -> (dlAcc, v2 Seq.<| vsAcc)+ | isHat (hat b2) -> (dappendMap dlAcc (dsingleMap ( base b2+ ,nullPair{_hatSide = Seq.singleton v2}))+ ,vsAcc )+ | otherwise -> (dappendMap dlAcc (dsingleMap ( base b2+ ,nullPair{_notSide = Seq.singleton v2} ))+ ,vsAcc )++-- | Map every posting to exactly one posting. The typed form of 'map'.+mapPosting :: (HatVal v, HatVal v2, HatBaseClass b, HatBaseClass b2)+ => (v -> b -> (v2, b2)) -> Alg v b -> Alg v2 b2+mapPosting f = mapMaybePosting (\v b -> Just (f v b))++-- | Map every posting to zero or one posting. A 'Nothing' drops the posting;+-- a zero value is normalised as by '(.@)'.+mapMaybePosting :: (HatVal v, HatVal v2, HatBaseClass b, HatBaseClass b2)+ => (v -> b -> Maybe (v2, b2)) -> Alg v b -> Alg v2 b2+mapMaybePosting _ Zero = Zero+mapMaybePosting f (v :@ b) = case f v b of+ Nothing -> Zero+ Just (v2, b2) -> v2 .@ b2+mapMaybePosting f (Liner m _ _ _ _ _) =+ mkAlgFromMap $ Map.foldrWithKey addPair id m Map.empty+ where+ {-# INLINE addPair #-}+ addPair bp (Pair hs ns) accDMap =+ mapSide Hat bp hs . mapSide Not bp ns . accDMap++ {-# INLINE mapSide #-}+ mapSide h bp = Foldable.foldl' (mapOne (merge h bp)) id++ {-# INLINE mapOne #-}+ mapOne b dlAcc v = case f v b of+ Nothing -> dlAcc+ Just (v2, b2) -> case v2 .@ b2 of+ Zero -> dlAcc+ v3 :@ b3 -> dlAcc . Map.insertWith pairAppend (base b3) (postingPair v3 b3)+ Liner {} -> dlAcc++ {-# INLINE postingPair #-}+ postingPair v b+ | isHat (hat b) = nullPair {_hatSide = Seq.singleton v}+ | otherwise = nullPair {_notSide = Seq.singleton v}++-- Difference-map (endo) used by 'map' to accumulate Liner rebuilds in O(1).+-- NB. The plain difference-list helpers (dnil/dappend/dsingle/dToList/dFromList)+-- and the unused 'DList' type alias were removed as dead code: only the DMap+-- variants (dnilMap/dappendMap/dsingleMap, defined locally in 'map') are used.+type DMap k v = Map.HashMap k v -> Map.HashMap k v++{-# INLINE filter #-}+-- | filter+-- Complexity: O(s), where s is total number of scalar entries.+--+-- >>> type Test = Alg Double (HatBase CountUnit)+-- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test+-- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test+-- >>> filter (isHat . _hatBase) $ x .+ y+-- 1.00:@Hat:<Yen .+ 2.00:@Hat:<Amount+--+-- >>> type Test = Alg Double (HatBase CountUnit)+-- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test+-- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test+-- >>> filter ((1 <). _val) $ x .+ y+-- 2.00:@Not:<Yen .+ 2.00:@Hat:<Amount+++filter :: (HatVal v, HatBaseClass b) => (Alg v b -> Bool) -> Alg v b -> Alg v b+filter _ Zero = Zero+filter f (v:@b) | f (v:@b) = v:@b+ | otherwise = Zero++filter f (Liner m _ _ _ _ _) =+ -- Build a new Map using mapMaybeWithKey+ let m' = Map.mapMaybeWithKey+ (\basePart (Pair hs ns) ->+ -- Filter each of hs and ns+ let hs' = filterSide basePart Hat hs+ ns' = filterSide basePart Not ns+ in+ -- Remove the entry (Nothing) if both become empty+ if Seq.null hs' && Seq.null ns'+ then Nothing+ else Just (Pair hs' ns'))+ m+ in+ -- If the resulting Map is empty, return Zero; otherwise Liner m'+ if Map.null m' then Zero else linerFromMap m'+ where+ ----------------------------------------------------------------+ -- Filter function that constructs "v:@(merge h basePart)" from+ -- basePart and Hat/Not, and tests whether it satisfies predicate f+ ----------------------------------------------------------------+ -- filterSide :: BasePart b -> Hat -> Seq v -> Seq v+ {-# INLINE filterSide #-}+ filterSide bp h = Seq.filter (\val -> f (val :@ merge h bp))++------------------------------------------------------------+-- | Relabel the /base part/ of every element, preserving the Hat\/Not structure+-- and the redundancy (the ordered Hat- and Not-side sequences). Only the+-- 'BasePart' is rewritten by @f@; the Hat\/Not side and the values are untouched.+-- When @f@ maps two distinct base parts onto the same target, their sequences are+-- concatenated (pair-append), so no value is lost — hence @'norm'@ is preserved.+-- Preserving wildcards (@(.#)@) is the caller's responsibility (in @f@).+--+-- == Laws and their layer of validity+--+-- On the full subcategory of 'HatBaseClass' bases, this relabelling is a+-- functor only after observing an algebra through ℘, where ℘ forgets sequence+-- order but retains, for every full Hat\/Not base, the multiset of values.+-- The functor and additive laws at this layer are:+--+-- * @mapBasePart id x@ and @x@ are equal through ℘. Raw equality can fail when+-- @x@ is a one-key, one-value @Liner@ produced by 'bar' or filtering:+-- rebuilding it chooses the singleton @(:@)@ representation, and 'Eq'+-- distinguishes those constructors.+-- * @mapBasePart (g . f) x@ and+-- @mapBasePart g (mapBasePart f x)@ are equal through ℘, but need not be raw+-- equal.+-- * @mapBasePart f (x .+ y)@ and+-- @mapBasePart f x .+ mapBasePart f y@ are equal through ℘, but need not be+-- raw equal.+-- * @norm (mapBasePart f x) == norm x@ for exact additive value types. For+-- floating-point values, regrouping after collisions has the usual rounding+-- caveat.+-- * @mapBasePart f ((.^) x) == (.^) (mapBasePart f x)@ (raw).+-- * Algebraically, @bar (mapBasePart f (bar x))@ and+-- @bar (mapBasePart f x)@ are equal through ℘ whenever the implementation's+-- 'nearlyEqScaled' tolerance does not discard a source-base residual before+-- relabelling. Raw equality can additionally distinguish singleton from+-- one-key @Liner@. With tolerance-triggering magnitudes, even ℘ equality is+-- not guaranteed.+--+-- In particular, @mapBasePart f (bar x) == bar (mapBasePart f x)@ is false in+-- general: distinct source bases may collide only after relabelling. When+-- collisions occur, @pairAppend@ preserves every value but the resulting+-- sequence order is a representation detail determined by @HashMap@ traversal;+-- callers must not attach semantics to it.+--+-- Complexity: O(n) over distinct base keys (rebuilds the posting index once).+--+-- >>> type T = Alg Double (HatBase CountUnit)+-- >>> mapBasePart id (10:@Hat:<Yen :: T) :: T+-- 10.00:@Hat:<Yen+--+-- >>> norm (mapBasePart (const Amount) (10:@Not:<Yen .+ 5:@Not:<Dollar :: T) :: T)+-- 15.0+mapBasePart :: (HatVal v, HatBaseClass b, HatBaseClass b')+ => (BasePart b -> BasePart b') -> Alg v b -> Alg v b'+mapBasePart _ Zero = Zero+mapBasePart f (v :@ b) = singleton v (merge (hat b) (f (base b)))+mapBasePart f (Liner m _ _ _ _ _) =+ mkAlgFromMap $+ Map.foldlWithKey'+ (\acc bp p -> Map.insertWith pairAppend (f bp) p acc)+ Map.empty+ m++------------------------------------------------------------+-- | proj+--+-- Projects an 'Alg' onto the bases matching a query list. The query list is+-- treated as a __set__: duplicate queries, or an exact base that also matches a+-- wildcard query in the same list, select each underlying posting __at most+-- once__ (no double counting). The result is the union of the selected+-- @(base, side)@ cells.+--+-- Complexity:+-- exact single-key path: expected O(1)+-- wildcard single-key path: O(queryAxisPosting + c * verify)+-- multi-pattern path: O(sum pattern costs + union costs)+--+-- where c is candidate count returned by the posting index.+-- >>> type Test = Alg Double (HatBase CountUnit)+-- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test+-- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test+-- >>> proj [Hat:<Yen] $ x .+ y+-- 1.00:@Hat:<Yen+--+-- >>> type Test = Alg Double (HatBase CountUnit)+-- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test+-- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test+-- >>> proj [HatNot:<Amount] $ x .+ y+-- 2.00:@Hat:<Amount .+ 1.00:@Not:<Amount+--+-- >>> type Test = Alg Double (HatBase (AccountTitles, CountUnit))+-- >>> x = 1:@Hat:<(Cash,Yen) .+ 1:@Not:<(Products,Amount) :: Test+-- >>> y = 2:@Not:<(Cash,Yen) .+ 2:@Hat:<(Deposits,Yen) :: Test+-- >>> proj [Hat:<((.#),Yen)] $ x .+ y+-- 1.00:@Hat:<(Cash,Yen) .+ 2.00:@Hat:<(Deposits,Yen)+--+-- >>> type Test = HatBase CountUnit+-- >>> compareHatBase (Not:<(.#) :: Test) (Not:<Yen :: Test)+-- EQ+--+-- >>> type Test = Alg Double (HatBase CountUnit)+-- >>> x = 1:@Hat:<Yen .+ 1:@Not:<Amount :: Test+-- >>> y = 2:@Not:<Yen .+ 2:@Hat:<Amount :: Test+-- >>> proj [Not:<(.#)] $ x .+ y+-- 2.00:@Not:<Yen .+ 1.00:@Not:<Amount+--+------------------------------------------------------------++proj :: (HatVal v, HatBaseClass b) => [b] -> Alg v b -> Alg v b+proj [] _ = Zero+proj _ Zero = Zero+proj [b] (v:@b2)+ | b .== b2 = v:@b2+ | otherwise = Zero+-- Index fields bound lazily (@~@) so a concrete (non-wildcard) base never forces+-- the axis index (the module is @Strict@; see 'projExactMap').+proj [b] (Liner m ~idx _ ~idToBp _ ~allIds) =+ mkAlgFromMap $+ if haveWildcard (base b)+ then projWildMap b m idx idToBp allIds+ else projExactMap b m+proj (b:bs) (v:@b2)+ | b .== b2 = v:@b2+ | otherwise = proj bs (v:@b2)+-- Multi-pattern path: the query list is treated as a /set/. Overlapping or+-- duplicate queries (e.g. a duplicated base, or an exact base subsumed by a+-- wildcard) select the same posting only once. Per-base results are merged+-- with 'pairUnion' (set union of the selected sides) rather than @pairAppend@+-- (concatenation), so no posting is double counted.+proj (b:bs) (Liner m ~idx _ ~idToBp _ ~allIds) =+ mkAlgFromMap $+ L.foldl'+ (\acc q -> Map.unionWith pairUnion acc+ (if haveWildcard (base q)+ then projWildMap q m idx idToBp allIds+ else projExactMap q m))+ Map.empty+ (b:bs)++{-# INLINE choosePairByHat #-}+-- | Complexity: O(1)+choosePairByHat :: Hat -> Pair v -> Pair v+choosePairByHat h Pair {_hatSide = hs, _notSide = ns} =+ case h of+ Hat -> nullPair {_hatSide = hs}+ Not -> nullPair {_notSide = ns}+ HatNot -> Pair {_hatSide = hs, _notSide = ns}++{-# INLINE projExactMap #-}+-- | Exact (non-wildcard) single-base projection: a direct 'Map.lookup' on the+-- @_realg@ map. It does NOT reference the axis index, so the concrete projection+-- path can keep that lazy index unforced.+--+-- This matters because the module is compiled @{-\# LANGUAGE Strict \#-}@: handing+-- the (lazy) index to a helper that takes it as a strict argument would force its+-- whole construction even for a concrete lookup that never needs it. Callers+-- therefore dispatch on 'haveWildcard' BEFORE touching the index, binding the+-- index fields lazily (@~@) and only mentioning them on the wildcard branch.+-- Guarded by the poison-index regression test in the test suite.+--+-- Complexity: expected O(1).+projExactMap+ :: (HatBaseClass b)+ => b+ -> Map.HashMap (BasePart b) (Pair v)+ -> Map.HashMap (BasePart b) (Pair v)+projExactMap b m = case Map.lookup bp m of+ Nothing -> Map.empty+ Just p -> Map.singleton bp (choosePairByHat h p)+ where+ !bp = base b+ !h = hat b++{-# INLINE projWildMap #-}+-- | Wildcard single-base projection: resolves candidates through the axis index+-- ('queryAxisPosting'), so it necessarily forces the index. Only invoked when+-- 'haveWildcard' holds.+--+-- Complexity: O(queryAxisPosting + c * verify).+projWildMap+ :: (HatBaseClass b)+ => b+ -> Map.HashMap (BasePart b) (Pair v)+ -> AxisPosting+ -> IntMap.IntMap (BasePart b)+ -> IntSet.IntSet+ -> Map.HashMap (BasePart b) (Pair v)+projWildMap b m idx idToBp allIds =+ let !ids = queryAxisPosting (toAxisKeys bp) idx allIds+ in IntSet.foldl'+ (\acc bpId -> case IntMap.lookup bpId idToBp of+ Nothing -> acc+ Just bp0 -> case Map.lookup bp0 m of+ Nothing -> acc+ Just p -> if bp .== bp0+ then Map.insert bp0 (choosePairByHat h p) acc+ else acc)+ Map.empty+ ids+ where+ !bp = base b+ !h = hat b++{-# INLINE mkAlgFromMap #-}+-- | Complexity: O(n) to inspect shape and possibly rebuild index.+mkAlgFromMap :: (HatVal v, HatBaseClass b) => Map.HashMap (BasePart b) (Pair v) -> Alg v b+mkAlgFromMap m+ | Map.null m = Zero+ | otherwise = case Map.toList m of+ [(b, p)] -> Maybe.fromMaybe (linerFromMap $ Map.singleton b p) (singlePairToAlg b p)+ _ -> linerFromMap m++{-# INLINE singlePairToAlg #-}+-- | Complexity: O(1)+singlePairToAlg :: (HatVal v, HatBaseClass b) => BasePart b -> Pair v -> Maybe (Alg v b)+singlePairToAlg b (Pair hs ns) = case (Seq.viewl hs, Seq.viewl ns) of+ (Seq.EmptyL, n Seq.:< nsRest) | Seq.null nsRest -> Just (n :@ merge Not b)+ (h Seq.:< hsRest, Seq.EmptyL) | Seq.null hsRest -> Just (h :@ merge Hat b)+ _ -> Nothing++------------------------------------------------------------------++-- | Projects only the credit-side elements. For 'Alg' this coincides with the+-- 'Exchange' class method 'decR' (R = Right = Credit, 貸方); the top-level name+-- makes the selected side explicit at call sites. (An earlier doc sentence+-- restricting this to non-'Enum' bases referred to long-removed 'Enum'-based+-- class defaults and no longer applies.)+--+-- Complexity: O(s) (s is the total number of scalar entries)+projCredit :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projCredit = filter (\x -> (whichSide . _hatBase) x == Credit)++-- | Projects only the debit-side elements. For 'Alg' this coincides with the+-- 'Exchange' class method 'decL' (L = Left = Debit, 借方); the top-level name+-- makes the selected side explicit at call sites. (An earlier doc sentence+-- restricting this to non-'Enum' bases referred to long-removed 'Enum'-based+-- class defaults and no longer applies.)+--+-- Complexity: O(s) (s is the total number of scalar entries)+projDebit :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projDebit = filter (\x -> (whichSide . _hatBase) x == Debit)++-- | Projects only the elements matching the specified account title.+--+-- Complexity: O(s) (s is the total number of scalar entries)+projByAccountTitle :: (HatVal n, ExBaseClass b) => AccountTitles -> Alg n b -> Alg n b+projByAccountTitle at alg = filter (f at) alg+ where+ f :: (HatVal n,ExBaseClass b) => AccountTitles -> Alg n b -> Bool+ f _ Zero = False+ f t x = ((getAccountTitle ._hatBase) x) .== t++-- | Bar-netted norm of a projection. The query list is treated as a __set__+-- (see 'proj'): overlapping or duplicate queries do not double count.+--+-- Note the semantics include the @bar@ netting: each projected base is reduced+-- to the non-negative net of its hat and not sides (@barNormPair@). Hence+--+-- @projNetNorm bs x == norm (bar (proj bs x))@+--+-- which is /not/ the same as @norm (proj bs x)@ when a base carries both sides.+--+-- Complexity: O(cost(proj) + cost(bar) + cost(norm)).+projNetNorm :: (HatVal n, HatBaseClass b) => [b] -> Alg n b -> n+projNetNorm [] _ = 0+projNetNorm _ Zero = 0+projNetNorm bs (v :@ b)+ | L.any (.== b) bs = v+ | otherwise = 0+-- Index fields bound lazily (@~@); a concrete base uses 'projExactMap' (a plain+-- 'Map.lookup') and never forces the axis index. See 'projExactMap'.+projNetNorm [b] (Liner m ~idx _ ~idToBp _ ~allIds) =+ foldProjectedNorm $+ if haveWildcard (base b)+ then projWildMap b m idx idToBp allIds+ else projExactMap b m+-- Multi-pattern path: the query list is a /set/ (see 'proj'). Per-base results+-- are merged with 'pairUnion' so overlapping/duplicate queries do not double+-- count. Note 'projNetNorm' returns a bar-netted norm: 'foldProjectedNorm' applies+-- @barNormPair@ (net of hat/not sides) per base, so the result equals+-- @norm (bar (proj bs x))@, not @norm (proj bs x)@.+projNetNorm bs (Liner m ~idx _ ~idToBp _ ~allIds) =+ foldProjectedNorm $+ L.foldl'+ (\acc q -> Map.unionWith pairUnion acc+ (if haveWildcard (base q)+ then projWildMap q m idx idToBp allIds+ else projExactMap q m))+ Map.empty+ bs++{-# DEPRECATED projNorm "renamed to 'projNetNorm': the result is the bar-netted norm (norm (bar (proj bs x))), which the old name concealed — 'norm (proj bs x)' is NOT what this computes. 'projNorm' will be removed in 0.6" #-}+-- | Deprecated alias for 'projNetNorm' (renamed in 0.5.0.0 so the name states+-- the bar-netting).+projNorm :: (HatVal n, HatBaseClass b) => [b] -> Alg n b -> n+projNorm = projNetNorm++{-# INLINE foldProjectedNorm #-}+-- | Complexity: O(k), where k is the number of projected base keys.+foldProjectedNorm :: (HatVal n) => Map.HashMap k (Pair n) -> n+foldProjectedNorm = Map.foldl' (\acc p -> acc + barNormPair p) 0++{-# INLINE barNormPair #-}+-- | Complexity: O(h + n), where h/n are side lengths within the pair.+barNormPair :: (HatVal n) => Pair n -> n+barNormPair (Pair hs ns) =+ let !h = Foldable.foldl' (+) 0 hs+ !n = Foldable.foldl' (+) 0 ns+ in if nearlyEqScaled h n+ then 0+ else if h > n then h - n else n - h+++-- | Compute the net balance as the difference of two projections.+-- @balanceBy plusBases minusBases alg@ computes+-- @projNetNorm plusBases alg - projNetNorm minusBases alg@.+--+-- Useful for calculating stock quantities, profits, etc.+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let alg = 100 :@ Not:<Cash .+ 30 :@ Hat:<Cash :: T+-- >>> balanceBy [Not:<Cash] [Hat:<Cash] alg+-- 70.0+--+-- >>> balanceBy [Hat:<Cash] [Not:<Cash] alg+-- -70.0+balanceBy :: (HatVal n, HatBaseClass b) => [b] -> [b] -> Alg n b -> n+balanceBy plusBases minusBases alg =+ projNetNorm plusBases alg - projNetNorm minusBases alg++-- | Aggregate the net balance of an 'Alg' by a key, in a single pass.+--+-- @balanceMapBy keyOf@ is the bucketed form of 'balanceBy': for every entry it+-- projects the (side-stripped) 'BasePart' to a bucket key with @keyOf@ ('Nothing'+-- drops the entry), and nets each bucket using the Hat\/Not convention (Not adds,+-- Hat subtracts) — exactly @projNetNorm [Not:<k] - projNetNorm [Hat:<k]@ per key.+-- @keyOf@ sees only the 'BasePart', not the Hat\/Not side, so it cannot split one+-- key across sides.+--+-- This replaces @[ (k, balanceBy [Not:<k] [Hat:<k] alg) | k <- keys ]@ — one+-- wildcard projection per key — with a single fold; the result is identical up to+-- floating-point reassociation. For per-key reporting over many keys this is the+-- difference between @O(keys * entries)@ and @O(entries)@.+--+-- The values are /signed/ net balances and may be negative, so use a signed value+-- type (e.g. 'Double', @MoneyDouble@, @MoneyDecimal@); a non-negative-only type+-- such as @Number.NonNegative.Double@ is unsuitable here. Keys whose net is zero+-- are kept (like 'foldEntriesToMap'); filter afterwards if undesired.+--+-- Complexity: O(total number of entries) — a single fold, no per-key projection.+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let alg = 100 :@ Not:<Cash .+ 30 :@ Hat:<Cash .+ 50 :@ Not:<Deposits :: T+-- >>> balanceMapBy Just alg+-- fromList [(Cash,70.0),(Deposits,50.0)]+{-# INLINE balanceMapBy #-}+balanceMapBy :: (HatVal v, HatBaseClass b, Ord k)+ => (BasePart b -> Maybe k) -> Alg v b -> M.Map k v+balanceMapBy keyOf = foldEntriesToMap step+ where+ step v b = case keyOf (base b) of+ Nothing -> Nothing+ Just k -> Just (k, if isHat b then negate v else v)++-- | Aggregate the per-base /netted/ balance of an 'Alg' by a key, keeping the+-- winning side, in a single pass.+--+-- This is the implementation counterpart of the paper's @def:class-net@+-- (\(\nu_\kappa\)), the __pair__ read-out of the class-net operator:+-- 'balanceMapBy' is its /signed difference/ version+-- (@balanceMapBy kf == fmap (\\(n,h) -> n - h) . netPairMapBy kf@).+--+-- @netPairMapBy keyOf@ projects each entry's (side-stripped) 'BasePart' to a+-- bucket key with @keyOf@ ('Nothing' drops the entry), and for every key+-- returns a pair @(notTotal, hatTotal)@ built as follows: for each base \(b\)+-- it first nets the two sides (@bar@-like cancellation of the redundant+-- sequences), keeping only the residual on the side that wins, then sums the+-- residuals across all bases mapping to the key —+--+-- \[ \Big( \textstyle\sum_{n_b > h_b} (n_b - h_b),\ \sum_{h_b > n_b} (h_b - n_b) \Big). \]+--+-- This per-base netting is named and documented here — it is /not/ an implicit+-- 'bar'; the function performs exactly the standard-element reduction the name+-- promises, mirroring how 'balanceMapBy' reports a netted read-out.+--+-- __Both components are non-negative__ (consistent with the value domain+-- \(V \subseteq \mathbb{R}_{\ge 0}\)): a base contributes to at most one side,+-- whichever residual is larger. Bases whose two sides are equal (up to+-- 'nearlyEqScaled') contribute nothing. Because of the non-negativity,+-- @netPairMapBy@ is well behaved for non-negative-only value types, whereas the+-- @n - h@ identity with 'balanceMapBy' only holds on a /signed/ value type+-- (e.g. 'Double', @MoneyDouble@, @MoneyDecimal@) where the difference can be+-- negative.+--+-- Complexity: O(total number of entries) — a single fold over the entries,+-- followed by one collapse over the distinct bases.+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let alg = 100 :@ Not:<Cash .+ 30 :@ Hat:<Cash .+ 50 :@ Not:<Deposits :: T+-- >>> netPairMapBy Just alg+-- fromList [(Cash,(70.0,0.0)),(Deposits,(50.0,0.0))]+{-# INLINE netPairMapBy #-}+netPairMapBy :: (HatVal v, HatBaseClass b, Ord k)+ => (BasePart b -> Maybe k) -> Alg v b -> M.Map k (v, v)+netPairMapBy keyOf alg =+ -- collapse the per-base (notSum, hatSum) accumulator into per-key residuals+ Map.foldlWithKey' collapse M.empty perBase+ where+ -- pass 1: accumulate (notSum, hatSum) per BasePart in one fold+ perBase = foldEntries step Map.empty alg+ step !acc v b =+ let !bp = base b+ !(notV, hatV) = if isHat b then (zeroValue, v) else (v, zeroValue)+ in Map.insertWith addPair bp (notV, hatV) acc+ addPair (!n1, !h1) (!n2, !h2) = (n1 + n2, h1 + h2)+ collapse !acc bp (!n, !h) = case keyOf bp of+ Nothing -> acc+ Just k+ | nearlyEqScaled h n -> acc+ | n > h -> M.insertWith addPair k (n - h, zeroValue) acc+ | otherwise -> M.insertWith addPair k (zeroValue, h - n) acc++-- | Fold algebra entries into a @Map@, combining values with @(+)@.+--+-- The selector function examines each entry @(v, b)@ and optionally returns+-- a @(key, value)@ pair. Values for duplicate keys are summed.+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let alg = 10 :@ Hat:<Cash .+ 20 :@ Hat:<Deposits .+ 5 :@ Hat:<Cash :: T+-- >>> let f v (Hat :< a) = Just (a, v); f _ _ = Nothing+-- >>> foldEntriesToMap f alg+-- fromList [(Cash,15.0),(Deposits,20.0)]+foldEntriesToMap :: (HatVal v, HatBaseClass b, Ord k)+ => (v -> b -> Maybe (k, v))+ -> Alg v b+ -> M.Map k v+foldEntriesToMap f = foldEntries step M.empty+ where+ step acc v b = case f v b of+ Just (k, v') -> M.insertWith (+) k v' acc+ Nothing -> acc++-- | Quotient decomposition (dec_κ): partition an algebra along the equivalence+-- classes induced by a classifier on the full 'HatBase', in a single pass.+--+-- For each entry, @keyOf@ maps its base (Hat\/Not state included) to a class+-- key; @Nothing@ drops the entry as residual. Each class is returned as an+-- 'Alg' that is exactly the restriction of the input to that class:+-- __redundancy (the per-base value sequences) is fully preserved__ — no 'bar',+-- no 'norm', no aggregation. The pieces reconstruct the input:+-- @mconcat (M.elems (decBy keyOf x)) .+ residual == x@ (up to per-base+-- sequence order).+--+-- This generalizes the decomposition operators of Deguchi & Nakano (1986)+-- ('decR'\/'decL'\/'decP'\/'decM' are two-class special cases) and replaces+-- per-class projection loops: one pass over the entries instead of one+-- projection query per class.+--+-- Choosing between the per-key family:+--+-- +--------------------+----------------------------------+---------------------++-- | function | returns | redundancy |+-- +====================+==================================+=====================++-- | 'decBy' | @Map k (Alg v b)@ (structure) | preserved |+-- +--------------------+----------------------------------+---------------------++-- | 'balanceMapBy' | @Map k v@ (signed net per key) | lost (bar-like) |+-- +--------------------+----------------------------------+---------------------++-- | 'netPairMapBy' | @Map k (v,v)@ (non-neg net pair) | lost (bar-like) |+-- +--------------------+----------------------------------+---------------------++-- | 'foldEntriesToMap' | @Map k v@ (custom collection) | lost |+-- +--------------------+----------------------------------+---------------------++-- | 'mapBasePart' | @Alg v b'@ (base coarsening π_κ) | preserved |+-- +--------------------+----------------------------------+---------------------++--+-- Note on 'bar': @bar@ commutes with 'decBy' componentwise iff @keyOf@ does not+-- distinguish Hat\/Not (i.e. factors through 'base'). Side-sensitive classifiers+-- (such as the ones underlying 'decP'\/'decM' or 'decL'\/'decR') do not commute+-- with @bar@ — netting before or after such a split is a semantic choice.+--+-- Complexity: O(m) over distinct bases (single fold; per-class insert costs+-- O(log k) in the result 'M.Map').+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let alg = 100 :@ Not:<Cash .+ 30 :@ Hat:<Cash .+ 50 :@ Not:<Deposits :: T+-- >>> M.toList (M.map norm (decBy (\(_ :< a) -> Just a) alg))+-- [(Cash,130.0),(Deposits,50.0)]+--+-- >>> M.toList (M.map norm (decBy (\b -> if isHat b then Just () else Nothing) alg))+-- [((),30.0)]+{-# INLINE decBy #-}+decBy :: (HatVal v, HatBaseClass b, Ord k)+ => (b -> Maybe k)+ -> Alg v b+ -> M.Map k (Alg v b)+decBy _ Zero = M.empty+decBy kf a@(v :@ b)+ | isZeroValue v = M.empty+ | otherwise = case kf b of+ Nothing -> M.empty+ Just k -> M.singleton k a+decBy kf (Liner m _ _ _ _ _) =+ M.map mkAlgFromMap (Map.foldlWithKey' step M.empty m)+ where+ step !acc !bp (Pair hs ns) =+ let !acc1 = if Seq.null hs+ then acc+ else insertSide (merge Hat bp) bp (nullPair {_hatSide = hs}) acc+ in if Seq.null ns+ then acc1+ else insertSide (merge Not bp) bp (nullPair {_notSide = ns}) acc1+ {-# INLINE insertSide #-}+ insertSide hb bp p acc = case kf hb of+ Nothing -> acc+ Just k -> M.insertWith (Map.unionWith (flip pairAppend)) k (Map.singleton bp p) acc++-- | Classify-net-post, fused: net the algebra per base ('bar' — explicit in the+-- name), classify each netted entry with @keyOf@ (class totals are summed with+-- @(+)@), then generate postings per class and bulk-merge them.+--+-- @postFromNetBy keyOf post x == sigmaFromMap (foldEntriesToMap collect (bar x)) post@+-- where @collect@ pairs each netted entry with its class. The common+-- \"shortage detection → purchase postings\" pattern becomes a single call:+--+-- @+-- purchases = postFromNetBy shortageKey purchasePosting termAlg+-- @+--+-- and runs in one pass over the netted entries — no per-pair projection loop+-- (the naive all-pairs formulation costs O(N²) queries; this costs O(m)).+--+-- __This function applies 'bar' internally__ (per-base netting, the standard+-- positive-part normalization). Redundancy of the input is not preserved in the+-- intermediate; the output is whatever @post@ builds. If you need the+-- redundancy-preserving split itself, use 'decBy'.+-- Thus it factors through the quotient induced by 'bar': it is not the free+-- extension that acts independently on entries in the redundant layer.+--+-- Complexity: O(m + Σ cost(post)).+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let stock = 100 :@ Not:<Products .+ 130 :@ Hat:<Products .+ 20 :@ Not:<Cash :: T+-- >>> let shortageKey b = case b of { Hat :< Products -> Just () ; _ -> Nothing }+-- >>> norm (postFromNetBy shortageKey (\_ v -> v :@ Not:<Products .+ v :@ Hat:<Cash) stock)+-- 60.0+{-# INLINE postFromNetBy #-}+postFromNetBy :: (HatVal v, HatBaseClass b, Ord k)+ => (b -> Maybe k)+ -> (k -> v -> Alg v b)+ -> Alg v b+ -> Alg v b+postFromNetBy kf post x =+ sigmaFromMap (foldEntriesToMap (\v b -> (\k -> (k, v)) <$> kf b) ((.-) x)) post++-- | Projects only current assets.+-- Extracts asset items classified as current from the debit side.+--+-- Selection predicate (over every scalar entry @x@ of the input, on the debit side):+-- @whatDiv (_hatBase x) == Assets && fixedCurrent (_hatBase x) == Current && not (isContra (_hatBase x))@.+-- Contra accounts are excluded, so the result is the /gross/ figure of this+-- class; the net figure is @norm (projCurrentAssets x) - norm ('bar' (contra x))@ where+-- @contra@ is 'projContraAssets' (Assets) or 'projContra' (any division).+-- See 'projContraAssets' for the rationale (Definition 7 amendment, Land 2).+--+-- Complexity: O(s) (s is the total number of scalar entries)+projCurrentAssets :: ( HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projCurrentAssets = (filter (\x -> (fixedCurrent . _hatBase) x == Current))+ . (filter (\x -> (whatDiv . _hatBase) x == Assets))+ . (filter (not . isContra . _hatBase))+ . projDebit++-- | Projects only fixed assets.+-- Extracts asset items classified as fixed from the debit side.+--+-- Selection predicate (over every scalar entry @x@ of the input, on the debit side):+-- @whatDiv (_hatBase x) == Assets && fixedCurrent (_hatBase x) == Fixed && not (isContra (_hatBase x))@.+-- Contra accounts are excluded, so the result is the /gross/ figure of this+-- class; the net figure is @norm (projFixedAssets x) - norm ('bar' (contra x))@ where+-- @contra@ is 'projContraAssets' (Assets) or 'projContra' (any division).+-- See 'projContraAssets' for the rationale (Definition 7 amendment, Land 2).+--+-- Complexity: O(s) (s is the total number of scalar entries)+projFixedAssets :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projFixedAssets = (filter (\x -> (fixedCurrent . _hatBase) x == Fixed))+ . (filter (\x -> (whatDiv . _hatBase) x == Assets))+ . (filter (not . isContra . _hatBase))+ . projDebit++-- | Projects only deferred assets.+-- Tax-specific deferred assets are presented under "investments and other assets" with appropriate items such as long-term prepaid expenses.+--+-- Selection predicate (over every scalar entry @x@ of the input, on the debit side):+-- @whatDiv (_hatBase x) == Assets && fixedCurrent (_hatBase x) == Other && not (isContra (_hatBase x))@.+-- Contra accounts are excluded, so the result is the /gross/ figure of this+-- class; the net figure is @norm (projDeferredAssets x) - norm ('bar' (contra x))@ where+-- @contra@ is 'projContraAssets' (Assets) or 'projContra' (any division).+-- See 'projContraAssets' for the rationale (Definition 7 amendment, Land 2).+--+-- Complexity: O(s) (s is the total number of scalar entries)+projDeferredAssets :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projDeferredAssets = (filter (\x -> (fixedCurrent . _hatBase) x == Other))+ . (filter (\x -> (whatDiv . _hatBase) x == Assets))+ . (filter (not . isContra . _hatBase))+ . projDebit++-- | Projects only current liabilities.+-- Extracts liability items classified as current from the credit side.+--+-- Selection predicate (over every scalar entry @x@ of the input, on the credit side):+-- @whatDiv (_hatBase x) == Liability && fixedCurrent (_hatBase x) == Current && not (isContra (_hatBase x))@.+-- Contra accounts are excluded, so the result is the /gross/ figure of this+-- class; the net figure is @norm (projCurrentLiability x) - norm ('bar' (contra x))@ where+-- @contra@ selects the Liability-division entries of 'projContra' (the current+-- registry has no contra liability account, so gross and net coincide today).+-- See 'projContraAssets' for the rationale (Definition 7 amendment, Land 2).+--+-- Complexity: O(s) (s is the total number of scalar entries)+projCurrentLiability :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projCurrentLiability = (filter (\x -> (fixedCurrent . _hatBase) x == Current))+ . (filter (\x -> (whatDiv . _hatBase) x == Liability))+ . (filter (not . isContra . _hatBase))+ . projCredit++-- | Projects only fixed liabilities.+-- Extracts liability items classified as fixed from the credit side.+--+-- Selection predicate (over every scalar entry @x@ of the input, on the credit side):+-- @whatDiv (_hatBase x) == Liability && fixedCurrent (_hatBase x) == Fixed && not (isContra (_hatBase x))@.+-- Contra accounts are excluded, so the result is the /gross/ figure of this+-- class; the net figure is @norm (projFixedLiability x) - norm ('bar' (contra x))@ where+-- @contra@ selects the Liability-division entries of 'projContra' (the current+-- registry has no contra liability account, so gross and net coincide today).+-- See 'projContraAssets' for the rationale (Definition 7 amendment, Land 2).+--+-- Complexity: O(s) (s is the total number of scalar entries)+projFixedLiability :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projFixedLiability = (filter (\x -> (fixedCurrent . _hatBase) x == Fixed))+ . (filter (\x -> (whatDiv . _hatBase) x == Liability))+ . (filter (not . isContra . _hatBase))+ . projCredit++-- | Projects only capital stock (equity).+-- Extracts items classified under the 'Equity' division from the credit side.+--+-- Selection predicate (over every scalar entry @x@ of the input, on the credit side):+-- @whatDiv (_hatBase x) == Equity && not (isContra (_hatBase x))@.+-- Contra accounts are excluded, so the result is the /gross/ figure of this+-- class; the net figure is @norm (projCapitalStock x) - norm ('bar' (contra x))@ where+-- @contra@ selects the Equity-division entries of 'projContra' (the current+-- registry has no contra equity account, so gross and net coincide today).+-- See 'projContraAssets' for the rationale (Definition 7 amendment, Land 2).+--+-- Complexity: O(s) (s is the total number of scalar entries)+--+-- >>> type Test = Alg Double (HatBase AccountTitles)+-- >>> x = 100:@Not:<CapitalStock .+ 30:@Not:<Cash .+ 20:@Not:<RetainedEarnings :: Test+-- >>> norm (projCapitalStock x)+-- 120.0+projCapitalStock :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projCapitalStock = (filter (\x -> (whatDiv . _hatBase) x == Equity))+ . (filter (not . isContra . _hatBase))+ . projCredit++-- | Projects contra-asset entries (@whatDiv == Assets && isContra@, e.g.+-- 貸倒引当金\/減価償却累計額) — an /attribute/ selection, not a physical-side+-- one: both Hat and Not postings of the contra account are kept, and normal+-- assets' credit-side (Hat) postings are NOT included. The division+-- projections (@proj*Assets@\/@proj*Liability@\/'projCapitalStock')+-- exclude ALL contra accounts, so within the Assets division this projection+-- is the sole selector — no double counting when combining them. A net+-- figure is @gross - contra balance@, e.g.+-- @norm (projCurrentAssets x) - norm ('ExchangeAlgebra.Algebra.bar' (projContraAssets x))@+-- when the contra accounts hold normal (credit) balances; deduction\/netting+-- /presentation/ policy is the Write side's job (Land 3).+--+-- NOTE: this selects the Assets division only. In the current registry every+-- contra account is an asset, but the type class does not forbid contra+-- accounts in other divisions (e.g. a future treasury-stock contra equity) —+-- those are excluded from the division projections too and must be selected+-- with the generic 'projContra'. Consumers that need a+-- net asset figure combine the gross @proj*Assets@ family with this+-- projection themselves; deduction\/netting presentation policy is the+-- Write side's job (Land 3 of the Definition 7 amendment).+--+-- Complexity: O(s) (s is the total number of scalar entries)+--+-- >>> type Test = Alg Double (HatBase AccountTitles)+-- >>> x = 100:@Not:<AllowanceForDoubtfulAccounts .+ 20:@Hat:<AllowanceForDoubtfulAccounts .+ 30:@Not:<Cash .+ 10:@Hat:<Cash :: Test+-- >>> projContraAssets x+-- 20.00:@Hat:<AllowanceForDoubtfulAccounts .+ 100.00:@Not:<AllowanceForDoubtfulAccounts+projContraAssets :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projContraAssets = filter+ (\x -> (whatDiv . _hatBase) x == Assets && (isContra . _hatBase) x)++-- | Projects ALL contra entries regardless of division — the exact+-- complement, w.r.t. contra-ness, of the six division projections (which all+-- exclude contra accounts). Use this when the chart may contain contra+-- accounts outside the Assets division; @'projContraAssets' = filter by+-- Assets ∘ projContra@.+--+-- Complexity: O(s) (s is the total number of scalar entries)+projContra :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b+projContra = filter (isContra . _hatBase)+++-- * Rounding++-- | Rounding (ceiling), fixed to @NN.Double@ and to whole units.+--+-- Superseded by the explicit, value-type-appropriate rounding functions in+-- "ExchangeAlgebra.Value": 'ExchangeAlgebra.Value.bankersRound' (unbiased+-- financial default) and 'ExchangeAlgebra.Value.ceilingRound' (this function's+-- behaviour, with a decimal-places argument). There is no single correct+-- rounding rule, so the rule should be chosen explicitly at the call site.+--+-- Complexity: O(1)+rounding :: NN.Double -> NN.Double+rounding = fromIntegral . ceiling++{-# DEPRECATED rounding "NN.Double-only whole-unit ceiling; use ExchangeAlgebra.Value.ceilingRound / bankersRound (explicit, value-type-appropriate) instead" #-}
src/ExchangeAlgebra/Algebra/Transfer.hs view
@@ -31,7 +31,7 @@ module ExchangeAlgebra.Algebra.Transfer ( Size- , TransTable (..)+ , TransTable , isNullTable , transfer , table@@ -51,26 +51,17 @@ import qualified ExchangeAlgebra.Algebra as EA import ExchangeAlgebra.Algebra --import qualified Number.NonNegative as NN ( Double- , fromNumber- , toNumber,T) -- Non-negative real numbers-import qualified Data.Maybe as Maybe import Text.Show.Unicode ( ushow) import GHC.Exts ( reallyUnsafePtrEquality# , isTrue#- , build , lazy) import Data.Semigroup ( Semigroup(stimes) , stimesIdempotentMonoid)-import Data.Monoid ( Monoid(..)) import qualified Data.Foldable as Foldable-import Data.Foldable ( Foldable()) import Data.Bits ( shiftL , shiftR) import qualified Data.HashMap.Strict as HM import Utils.Containers.Internal.StrictPair-import Debug.Trace ------------------------------------------------------------------ -- * Core computation@@ -100,7 +91,7 @@ instance (HatBaseClass b) => Show (TransTable n b) where show NullTable = "[]"- show (TransTable s b f a l r) = "[(" ++ ushow b+ show (TransTable _ b _ a l r) = "[(" ++ ushow b ++ "," ++ ushow a ++ ",<function>)"@@ -174,11 +165,6 @@ unions :: (HatVal n, Foldable f, HatBaseClass b) => f (TransTable n b) -> TransTable n b unions ts = Foldable.foldl' union NullTable ts -{-# INLINE null #-}-null :: (HatBaseClass b) => TransTable n b -> Bool-null NullTable = True-null (TransTable _ _ _ _ _ _) = False- {-# INLINE size #-} size :: (HatBaseClass b) => TransTable n b -> Size size NullTable = 0@@ -215,7 +201,7 @@ -- >>> transfer (x .+ y) $ table $ HatNot:<(Products,Amount) :-> HatNot:<(Products,Yen) |% id -- 1.00:@Hat:<(Cash,Yen) .+ 2.00:@Not:<(Cash,Yen) .+ 2.00:@Hat:<(Deposits,Yen) .+ 1.00:@Not:<(Products,Yen) ----- >>> instance Element Int where wiledcard = -1+-- >>> instance Element Int where wildcard = -1 -- >>> type Test = Alg Double (HatBase (AccountTitles, Int,CountUnit)) -- >>> x = 1:@Hat:<(Cash,(.#),Yen) .+ 1:@Not:<(Products,1,Yen) :: Test -- >>> transfer x $ table $ HatNot:<((.#),(.#),Yen) :-> HatNot:<((.#),(.#),Amount) |% id@@ -258,7 +244,7 @@ LT -> resolveByTree l hb1 GT -> resolveByTree r hb1 EQ -> error $ "transfer: " ++ show hb1 ++ "," ++ show hb2- | otherwise = Just (f, ignoreWiledcard hb1 a)+ | otherwise = Just (f, ignoreWildcard hb1 a) {-# INLINE ruleEntries #-} ruleEntries :: TransTable n b -> [(b, n -> n, b)]@@ -269,8 +255,8 @@ {-# INLINE baseKey #-} baseKey :: (ExBaseClass b) => b -> Maybe (Hat, AccountTitles) baseKey b- | isWiledcard h = Nothing- | haveWiledcard at = Nothing+ | isWildcard h = Nothing+ | haveWildcard at = Nothing | otherwise = Just (h, at) where h = hat b@@ -301,7 +287,7 @@ resolveByIndex idx hb = case baseKey hb >>= (`HM.lookup` tiByHatTitle idx) of Just (IndexedUnique before f after)- | hb .== before -> Just (f, ignoreWiledcard hb after)+ | hb .== before -> Just (f, ignoreWildcard hb after) | otherwise -> resolveByTree (tiTree idx) hb _ -> resolveByTree (tiTree idx) hb @@ -349,24 +335,9 @@ where !r' = go orig bx fx ax r EQ -> t --- | Update the transformation function in the table-updateFunction:: (HatVal n,HatBaseClass b) => b -> (n -> n) -> b -> TransTable n b -> TransTable n b-updateFunction b = go b b- where- {-# INLINE go #-}- go :: (HatVal n,HatBaseClass b) => b -> b -> (n -> n) -> b -> TransTable n b -> TransTable n b- go orig !_ f x NullTable = singleton (lazy orig) f x- go orig !kx fx x t@(TransTable sz ky fy y l r) =- case compareElement kx ky of- LT | l' `ptrEq` l -> t- | otherwise -> balanceL ky fy y l' r- where !l' = go orig kx fx x l- GT | r' `ptrEq` r -> t- | otherwise -> balanceR ky fy y l r'- where !r' = go orig kx fx x r- EQ | x `ptrEq` y && (lazy orig `seq` (orig `ptrEq` ky)) -> t- | otherwise -> TransTable sz (lazy orig) (fx . fy) x l r-+-- NB. The unused\/unexported 'updateFunction' helper (update the transformation+-- function in the table) was removed as dead code; reintroduce from history if a+-- function-update operation on 'TransTable' is needed. {-# INLINE ptrEq #-} ptrEq :: a -> a -> Bool@@ -433,21 +404,12 @@ (_, _) -> error "Failure in Data.Map.balanceR" | otherwise -> TransTable (1+ls+rs) b f a l r -lookup :: (HatVal n, HatBaseClass b) => b -> TransTable n b -> Maybe (TransTable n b)-lookup k = k `seq` go- where- go NullTable = Nothing- go (TransTable s b f a l r) =- case compare k b of- LT -> go l- GT -> go r- EQ -> Just (TransTable s b f a l r)--+-- NB. The unused\/unexported 'lookup' helper (point lookup on 'TransTable') was+-- removed as dead code; reintroduce from history if needed. -- | make TransTable from list ----- >>> ExchangeAlgebra.Algebra.Transfer.fromList [(Hat:<(Cash),Hat:<(Building),(id :: NN.Double -> NN.Double) ),(Not:<(Building),Not:<(Cash),id)]+-- >>> ExchangeAlgebra.Algebra.Transfer.fromList [(Hat:<(Cash),Hat:<(Building),(id :: Double -> Double) ),(Not:<(Building),Not:<(Cash),id)] -- [(Hat:<Cash,Hat:<Building,<function>),(Not:<Building,Not:<Cash,<function>)] fromList :: (HatVal n, HatBaseClass b) => [(b,b,(n -> n))] -> TransTable n b@@ -487,7 +449,7 @@ -- | make TransTable from list -- same as fromList--- >>> table $ Hat:<(Cash) :-> Hat:<(Building) |% (id :: NN.Double -> NN.Double) ++ Hat:<(Building) :-> Hat:<(Cash) |% id+-- >>> table $ Hat:<(Cash) :-> Hat:<(Building) |% (id :: Double -> Double) ++ Hat:<(Building) :-> Hat:<(Cash) |% id -- [(Hat:<Cash,Hat:<Building,<function>),(Hat:<Building,Hat:<Cash,<function>)] {-# INLINE table #-} table :: (HatVal n, HatBaseClass b) => [(b,b,(n -> n))] -> TransTable n b@@ -509,8 +471,8 @@ -- | Syntax to make list for makeList ----- >>> Hat:<(Yen,Cash):-> Hat:<(Yen,Building) |% (id :: NN.Double -> NN.Double) ++ Not:<(Yen,Building) :-> Not:<(Yen, Cash) |% id--- [(Hat:<(Yen,Cash),Hat:<(Yen,Building),<function>),(Not:<(Yen,Building),Not:<(Yen,Cash),<function>)]+-- >>> fmap (\(b, a, _) -> (b, a)) $ Hat:<(Yen,Cash):-> Hat:<(Yen,Building) |% (id :: Double -> Double) ++ Not:<(Yen,Building) :-> Not:<(Yen, Cash) |% id+-- [(Hat:<(Yen,Cash),Hat:<(Yen,Building)),(Not:<(Yen,Building),Not:<(Yen,Cash))] {-# INLINE (|%) #-} (|%) :: (HatVal n, HatBaseClass b) => TransTableParts b -> (n -> n) -> [(b,b,(n -> n))] (|%) (b1 :-> b2) f = [(b1,b2,f)]@@ -519,8 +481,10 @@ infixr 8 :-> infixr 7 |% -instance (HatVal n) => Show (n -> n) where- show f = "<function>"+-- NB. A universal @instance (HatVal n) => Show (n -> n)@ used to live here for+-- the sake of showing raw rule-list tuples in one doctest. It leaked a function+-- Show instance into every downstream module, so it was removed (design-review+-- C5); 'TransTable''s own 'Show' prints @<function>@ without it. -- | Build an indexed fast transfer function from a list of transfer rules. -- More efficient than @transfer@ when repeatedly applying the same TransTable.@@ -536,12 +500,17 @@ -- | Income Summary Account: compute net income for the current period. -- When the ledger is balanced (credit == debit, net income is zero), @diffRL@--- reports the wildcard 'Side'; in that case the input is returned unchanged.+-- reports the wildcard v'Side'; in that case the input is returned unchanged+-- (balanced ledger = identity; appending a zero posting is not added).+-- The result contains a legacy NetIncome/NetLoss balancing coordinate and is+-- an intermediate closing state, not input for reporting presentation. New+-- reporting code should derive the result from a validated before-closing+-- trial balance with "ExchangeAlgebra.Reporting.Metric". incomeSummaryAccount :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b incomeSummaryAccount alg = let (dc,diff) = diffRL alg in case dc of- Credit -> alg .+ (diff :@ (toNot wiledcard) .~ NetIncome)- Debit -> alg .+ (diff :@ (toNot wiledcard) .~ NetLoss)+ Credit -> alg .+ (diff :@ (toNot wildcard) .~ NetIncome)+ Debit -> alg .+ (diff :@ (toNot wildcard) .~ NetLoss) Side -> alg -- | Net income transfer. Transfers NetIncome/NetLoss to RetainedEarnings.@@ -549,36 +518,43 @@ -- Complexity: O(s) (s = total number of scalar entries) netIncomeTransfer :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b netIncomeTransfer = createTransfer- $ (toNot wiledcard) .~ NetIncome :-> (toNot wiledcard) .~ RetainedEarnings |% id- ++ (toHat wiledcard) .~ NetIncome :-> (toHat wiledcard) .~ RetainedEarnings |% id- ++ (toNot wiledcard) .~ NetLoss :-> (toHat wiledcard) .~ RetainedEarnings |% id- ++ (toHat wiledcard) .~ NetLoss :-> (toNot wiledcard) .~ RetainedEarnings |% id+ $ (toNot wildcard) .~ NetIncome :-> (toNot wildcard) .~ RetainedEarnings |% id+ ++ (toHat wildcard) .~ NetIncome :-> (toHat wildcard) .~ RetainedEarnings |% id+ ++ (toNot wildcard) .~ NetLoss :-> (toHat wildcard) .~ RetainedEarnings |% id+ ++ (toHat wildcard) .~ NetLoss :-> (toNot wildcard) .~ RetainedEarnings |% id -- ** Journalizing --- | Transfer to Gross Profit.--- Consolidates Sales, Purchases, WageExpenditure, Depreciation, and ValueAdded into GrossProfit.+-- | Historical SNA/simulation transfer to the legacy GrossProfit coordinate.+-- Consolidates Sales, Purchases, WageExpenditure, Depreciation, and ValueAdded.+-- This fixed list is not a JGAAP gross-profit definition: it excludes+-- SalesCost and MerchandiseInventory. New statement reporting should use+-- "ExchangeAlgebra.Reporting.Metric" and+-- "ExchangeAlgebra.Reporting.Presentation" instead. -- -- Complexity: O(s) (s = total number of scalar entries) grossProfitTransfer :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b grossProfitTransfer = createTransfer- $ (toNot wiledcard) .~ WageExpenditure :-> (toHat wiledcard) .~ GrossProfit |% id- ++ (toHat wiledcard) .~ WageExpenditure :-> (toNot wiledcard) .~ GrossProfit |% id+ $ (toNot wildcard) .~ WageExpenditure :-> (toHat wildcard) .~ GrossProfit |% id+ ++ (toHat wildcard) .~ WageExpenditure :-> (toNot wildcard) .~ GrossProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ Depreciation :-> (toHat wiledcard) .~ GrossProfit |% id- ++ (toHat wiledcard) .~ Depreciation :-> (toNot wiledcard) .~ GrossProfit |% id+ ++ (toNot wildcard) .~ Depreciation :-> (toHat wildcard) .~ GrossProfit |% id+ ++ (toHat wildcard) .~ Depreciation :-> (toNot wildcard) .~ GrossProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ ValueAdded :-> (toNot wiledcard) .~ GrossProfit |% id- ++ (toHat wiledcard) .~ ValueAdded :-> (toHat wiledcard) .~ GrossProfit |% id+ ++ (toNot wildcard) .~ ValueAdded :-> (toNot wildcard) .~ GrossProfit |% id+ ++ (toHat wildcard) .~ ValueAdded :-> (toHat wildcard) .~ GrossProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ Sales :-> (toNot wiledcard) .~ GrossProfit |% id- ++ (toHat wiledcard) .~ Sales :-> (toHat wiledcard) .~ GrossProfit |% id+ ++ (toNot wildcard) .~ Sales :-> (toNot wildcard) .~ GrossProfit |% id+ ++ (toHat wildcard) .~ Sales :-> (toHat wildcard) .~ GrossProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ Purchases :-> (toHat wiledcard) .~ GrossProfit |% id- ++ (toHat wiledcard) .~ Purchases :-> (toNot wiledcard) .~ GrossProfit |% id+ ++ (toNot wildcard) .~ Purchases :-> (toHat wildcard) .~ GrossProfit |% id+ ++ (toHat wildcard) .~ Purchases :-> (toNot wildcard) .~ GrossProfit |% id --- | Ordinary Profit Transfer+-- | Historical SNA/simulation transfer to the legacy OrdinaryProfit+-- coordinate. Its fixed list predates the JCCI vocabulary and is not a+-- complete JGAAP ordinary-profit definition. New statement reporting should+-- use the typed reporting metric API instead. -- -- >>> type Test = Alg Double (HatBase (CountUnit, AccountTitles)) -- >>> x = 2279.0:@Not:<(Yen,Depreciation) .+ 500475.0:@Not:<(Yen,InterestEarned) :: Test@@ -588,45 +564,45 @@ ordinaryProfitTransfer :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b ordinaryProfitTransfer = createTransfer- $ (toNot wiledcard) .~ GrossProfit :-> (toNot wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ GrossProfit :-> (toHat wiledcard) .~ OrdinaryProfit |% id+ $ (toNot wildcard) .~ GrossProfit :-> (toNot wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ GrossProfit :-> (toHat wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ InterestEarned :-> (toNot wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ InterestEarned :-> (toHat wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ InterestEarned :-> (toNot wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ InterestEarned :-> (toHat wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ InterestExpense :-> (toHat wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ InterestExpense :-> (toNot wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ InterestExpense :-> (toHat wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ InterestExpense :-> (toNot wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ SubsidyIncome :-> (toNot wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ SubsidyIncome :-> (toHat wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ SubsidyIncome :-> (toNot wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ SubsidyIncome :-> (toHat wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ TaxesExpense :-> (toHat wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ TaxesExpense :-> (toNot wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ TaxesExpense :-> (toHat wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ TaxesExpense :-> (toNot wildcard) .~ OrdinaryProfit |% id -- Government- ++ (toNot wiledcard) .~ TaxesRevenue :-> (toNot wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ TaxesRevenue :-> (toHat wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ TaxesRevenue :-> (toNot wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ TaxesRevenue :-> (toHat wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ CentralBankPaymentIncome :-> (toNot wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ CentralBankPaymentIncome :-> (toHat wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ CentralBankPaymentIncome :-> (toNot wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ CentralBankPaymentIncome :-> (toHat wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ Depreciation :-> (toHat wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ Depreciation :-> (toNot wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ Depreciation :-> (toHat wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ Depreciation :-> (toNot wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ WageExpenditure :-> (toHat wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ WageExpenditure :-> (toNot wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ WageExpenditure :-> (toHat wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ WageExpenditure :-> (toNot wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ SubsidyExpense :-> (toHat wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ SubsidyExpense :-> (toNot wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ SubsidyExpense :-> (toHat wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ SubsidyExpense :-> (toNot wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------ -- Household- ++ (toNot wiledcard) .~ WageEarned :-> (toNot wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ WageEarned :-> (toHat wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ WageEarned :-> (toNot wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ WageEarned :-> (toHat wildcard) .~ OrdinaryProfit |% id ------------------------------------------------------------------- ++ (toNot wiledcard) .~ ConsumptionExpenditure :-> (toHat wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ ConsumptionExpenditure :-> (toNot wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ ConsumptionExpenditure :-> (toHat wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ ConsumptionExpenditure :-> (toNot wildcard) .~ OrdinaryProfit |% id -- CentralBank- ++ (toNot wiledcard) .~ CentralBankPaymentExpense :-> (toHat wiledcard) .~ OrdinaryProfit |% id- ++ (toHat wiledcard) .~ CentralBankPaymentExpense :-> (toNot wiledcard) .~ OrdinaryProfit |% id+ ++ (toNot wildcard) .~ CentralBankPaymentExpense :-> (toHat wildcard) .~ OrdinaryProfit |% id+ ++ (toHat wildcard) .~ CentralBankPaymentExpense :-> (toNot wildcard) .~ OrdinaryProfit |% id -- | Transfer to Retained Earnings.@@ -636,38 +612,43 @@ retainedEarningTransfer :: (HatVal n, ExBaseClass b) => Alg n b -> Alg n b retainedEarningTransfer = createTransfer- $ (toNot wiledcard) .~ OrdinaryProfit :-> (toNot wiledcard) .~ RetainedEarnings |% id- ++ (toHat wiledcard) .~ OrdinaryProfit :-> (toHat wiledcard) .~ RetainedEarnings |% id+ $ (toNot wildcard) .~ OrdinaryProfit :-> (toNot wildcard) .~ RetainedEarnings |% id+ ++ (toHat wildcard) .~ OrdinaryProfit :-> (toHat wildcard) .~ RetainedEarnings |% id data FinalStockSide = FinalStockKeep | FinalStockFlip +-- The closing side is derived from the account's PIMO direction (which is+-- contra-aware), not from the bare division: IN (flow in — non-contra+-- Revenue, and contra Cost such as purchase rebates) transfers as-is+-- (Keep); OUT (flow out — non-contra Cost, and contra Revenue such as+-- sales rebates) transfers with Hat\/Not flipped (Flip). Deriving from the+-- division alone would invert the transfer sign for contra P\/L accounts.+-- For the current chart (no contra P\/L accounts) this is extensionally+-- identical to the former division-based rule, which the+-- pre-vocab fixture test verifies. {-# INLINE finalStockRule #-} finalStockRule :: AccountTitles -> Maybe FinalStockSide-finalStockRule title = case title of- WageExpenditure -> Just FinalStockFlip- Depreciation -> Just FinalStockFlip- Purchases -> Just FinalStockFlip- InterestExpense -> Just FinalStockFlip- TaxesExpense -> Just FinalStockFlip- SubsidyExpense -> Just FinalStockFlip- ConsumptionExpenditure -> Just FinalStockFlip- CentralBankPaymentExpense -> Just FinalStockFlip- ValueAdded -> Just FinalStockKeep- Sales -> Just FinalStockKeep- GrossProfit -> Just FinalStockKeep- InterestEarned -> Just FinalStockKeep- SubsidyIncome -> Just FinalStockKeep- TaxesRevenue -> Just FinalStockKeep- CentralBankPaymentIncome -> Just FinalStockKeep- WageEarned -> Just FinalStockKeep- OrdinaryProfit -> Just FinalStockKeep- _ -> Nothing+finalStockRule title = case accountSpec title of+ Nothing -> Nothing+ Just spec -> case asClosing spec of+ NoClose -> Nothing+ CloseByDivision ->+ let pimo0 = pimoFromDivision (classifyAccountDivision title)+ pimo = if classifyAccountContra title then pimoFlip pimo0 else pimo0+ in case pimo of+ IN -> Just FinalStockKeep+ OUT -> Just FinalStockFlip+ _ -> Nothing -- | Internal step of the final stock transfer from income statement to retained earnings. -- Cost accounts are transferred to RetainedEarnings with Hat/Not flipped;--- revenue accounts are transferred to RetainedEarnings as-is.+-- revenue accounts are transferred to RetainedEarnings as-is. The registry's+-- explicit 'NoClose' policy permanently exempts the balancing aggregates+-- 'NetIncome'\/'NetLoss': their division encodes the P\/L presentation side,+-- so this division-derived rule would invert their transfer sign — their+-- closing is owned by the dedicated net-income transfer in this module. -- -- Complexity: O(s) (s = total number of scalar entries) {-# INLINE finalStockTransferStep #-}@@ -683,7 +664,8 @@ go x = x -- | Final Stock Transfer (closing entries).--- Transfers all cost and revenue accounts to RetainedEarnings and cancels via the bar operation.+-- Transfers registry-eligible cost and revenue accounts to RetainedEarnings+-- and cancels via the bar operation. -- -- Complexity: O(s) (s = total number of scalar entries) finalStockTransfer ::(HatVal n, ExBaseClass b) => Alg n b -> Alg n b
+ src/ExchangeAlgebra/Assist.hs view
@@ -0,0 +1,289 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module : ExchangeAlgebra.Assist+Description : Assistance helpers for LLM-facing account selection and validation feedback.++This module provides a small deterministic assistance layer for generated+journal-entry workflows. It exposes account-title metadata from the canonical+account registry, with semantic descriptions for technical and derived titles,+plus one-line explanations for validation errors from+"ExchangeAlgebra.Convert.Checked".+-}+module ExchangeAlgebra.Assist+ ( AccountInfo(..)+ , describeAccount+ , allAccountInfos+ , suggestAccounts+ , explainEntryError+ , explainJournalErrors+ , explainSourceErrors+ ) where++import Data.List (sortOn)+import Data.List.NonEmpty (NonEmpty)+import qualified Data.List.NonEmpty as NE+import Data.Maybe (mapMaybe)+import Data.Ord (Down(..))+import Data.Text (Text)+import qualified Data.Text as T++import ExchangeAlgebra.Algebra.Base+ ( AccountRole+ , AccountSemantics(..)+ , AccountSpec(..)+ , AccountTitles(..)+ , DivisionSemantics+ , HomeSideSemantics+ , PostingCapability+ , ReportingEligibility+ , accountSemantics+ , accountSpec+ , concreteAccountTitles+ )+import ExchangeAlgebra.Convert+ ( ConvError(..) )+import ExchangeAlgebra.Convert.Checked+ ( EntryError(..)+ , JournalError(..)+ , SourceError(..)+ )++-- $setup+-- >>> :set -XOverloadedStrings+-- >>> import Data.List.NonEmpty (NonEmpty(..))+-- >>> import ExchangeAlgebra.Algebra.Base (AccountTitles(..), PostingCapability(..))+-- >>> import ExchangeAlgebra.Convert (ConvError(..))+-- >>> import ExchangeAlgebra.Convert.Checked (ProcessingContext(..), EntryError(..), JournalError(..), SourceError(..))++-- | Account-title metadata for LLM-facing lookup.+data AccountInfo = AccountInfo+ { aiTitle :: AccountTitles+ , aiRoles :: [AccountRole]+ , aiPostingCapability :: PostingCapability+ , aiDivisionSemantics :: DivisionSemantics+ , aiHomeSideSemantics :: HomeSideSemantics+ , aiReportingEligibility :: ReportingEligibility+ , aiNameEn :: Text+ , aiNameJa :: Text+ , aiDesc :: Text+ } deriving (Show, Eq)++-- | Describe a concrete account title.+--+-- The wildcard 'AccountTitle' is rejected because+-- 'ExchangeAlgebra.Algebra.Base.classifyAccountDivision' is intentionally+-- undefined for it.+--+-- >>> fmap ((== "現金") . aiNameJa) (describeAccount Cash)+-- Just True+-- >>> fmap aiDivisionSemantics (describeAccount Cash)+-- Just (StatementDivision Assets)+-- >>> fmap aiDivisionSemantics (describeAccount IncomeSummary)+-- Just (DirectionEncoding Assets)+-- >>> describeAccount AccountTitle+-- Nothing+describeAccount :: AccountTitles -> Maybe AccountInfo+describeAccount title = do+ spec <- accountSpec title+ semantics <- accountSemantics title+ pure (toInfo title spec semantics)++-- | All concrete account-title descriptions in 'Enum' order.+--+-- >>> length allAccountInfos+-- 235+-- >>> take 1 (map aiTitle allAccountInfos)+-- [Cash]+-- >>> aiTitle (last allAccountInfos)+-- DepositsReceivedFromOfficers+allAccountInfos :: [AccountInfo]+allAccountInfos = mapMaybe describeAccount concreteAccountTitles++toInfo :: AccountTitles -> AccountSpec -> AccountSemantics -> AccountInfo+toInfo title spec semantics = AccountInfo+ { aiTitle = title+ , aiRoles = asemRoles semantics+ , aiPostingCapability = asemPostingCapability semantics+ , aiDivisionSemantics = asemDivisionSemantics semantics+ , aiHomeSideSemantics = asemHomeSideSemantics semantics+ , aiReportingEligibility = asemReportingEligibility semantics+ , aiNameEn = asNameEn spec+ , aiNameJa = safeNameJa title spec+ , aiDesc = safeDescription title spec+ }++-- | LLM-facing names use the cleaned statement label from the registry.+safeNameJa :: AccountTitles -> AccountSpec -> Text+safeNameJa _ spec = asLabelJa spec++-- | Semantic descriptions for technical, derived, and contextual titles.+-- Ordinary statement accounts retain their canonical registry description.+safeDescription :: AccountTitles -> AccountSpec -> Text+safeDescription NetIncome _ =+ "Period result: net income (当期純利益). Engine-generated only; the legacy Cost value is an internal direction encoding, not an expense classification."+safeDescription NetLoss _ =+ "Period result: net loss (当期純損失). Engine-generated only; the legacy Revenue value is an internal direction encoding, not a revenue classification."+safeDescription GrossProfit _ =+ "Legacy engine coordinate: gross profit (売上総利益). Engine-generated only; the legacy Revenue value is an internal direction encoding, not a statement subtotal definition."+safeDescription OrdinaryProfit _ =+ "Legacy engine coordinate: ordinary profit (経常利益). Engine-generated only; the legacy Revenue value is an internal direction encoding, not a statement subtotal definition."+safeDescription IncomeSummary _ =+ "Closing device: income summary (損益). Available only during closing; the legacy Assets value is an internal direction encoding, not a balance-sheet classification."+safeDescription SuspensePayments _ =+ "Temporary account: suspense payments (仮払金). Its legacy Assets value is a bookkeeping control class; unresolved balances require review before presentation."+safeDescription SuspenseReceipts _ =+ "Temporary account: suspense receipts (仮受金). Its legacy Liability value is a bookkeeping control class; unresolved balances require review before presentation."+safeDescription CashOverShort _ =+ "Temporary account: cash over and short (現金過不足). It must be cleared at closing and is not presented in financial statements."+safeDescription SuspenseAccount _ =+ "Temporary account: suspense account (未決算). Its legacy Assets value is a bookkeeping control class; unresolved balances require review before presentation."+safeDescription BranchCurrentAccount _ =+ "Reciprocal account: branch current account (支店). It may remain in head-office books but is eliminated when head-office and branch balances are combined."+safeDescription HeadOfficeCurrentAccount _ =+ "Reciprocal account: head-office current account (本店). It may remain in branch books but is eliminated when head-office and branch balances are combined."+safeDescription NetIncomeAttributableToNCI _ =+ "Consolidation attribution result: profit attributable to non-controlling interests (非支配株主に帰属する当期純利益). Available only in consolidation worksheets; the legacy Cost value is an internal direction encoding."+safeDescription NetLossAttributableToNCI _ =+ "Consolidation attribution result: loss attributable to non-controlling interests (非支配株主に帰属する当期純損失). Available only in consolidation worksheets; the legacy Revenue value is an internal direction encoding."+safeDescription EquityInEarningsOfInvestee _ =+ "Consolidation result: equity in earnings of investee (持分法による投資利益). Available only in consolidation worksheets."+safeDescription CumulativeTranslationAdjustment _ =+ "Consolidation adjustment: cumulative translation adjustment (為替換算調整勘定). Available only in consolidation worksheets."+safeDescription _ spec = asDescription spec++-- | Suggest account titles by deterministic substring matching.+--+-- The query is split with 'T.words'. Each token is matched case-insensitively+-- against the constructor name, English name, Japanese name, and full+-- description. Results are ranked by the number of matched tokens, with 'Enum'+-- order as the tie-breaker.+--+-- >>> map aiTitle (take 3 (suggestAccounts "cash"))+-- [Cash,PettyCash,CashOverShort]+-- >>> map aiTitle (take 3 (suggestAccounts "現金"))+-- [Cash,PettyCash,CashOverShort]+-- >>> suggestAccounts ""+-- []+-- >>> suggestAccounts "zzzznomatch"+-- []+suggestAccounts :: Text -> [AccountInfo]+suggestAccounts query+ | null tokens = []+ | otherwise =+ map snd+ . sortOn (\(rank, info) -> (Down rank, fromEnum (aiTitle info)))+ . filter ((> 0) . fst)+ $ [ (matchRank info, info) | info <- allAccountInfos ]+ where+ tokens = map T.toCaseFold (T.words query)++ matchRank info =+ length+ [ token+ | token <- tokens+ , any (T.isInfixOf token) (searchFields info)+ ]++ searchFields info = map T.toCaseFold+ [ T.pack (show (aiTitle info))+ , aiNameEn info+ , aiNameJa info+ , aiDesc info+ ]++-- | Explain one checked-entry validation error as one English line.+--+-- >>> explainEntryError (Imbalanced 1500 1400 :: EntryError Int)+-- "entry is not balanced: debit total 1500 /= credit total 1400"+-- >>> explainEntryError (NonPositiveAmount 2 Cash (0 :: Int))+-- "posting 2 (Cash): amount must be > 0, got 0"+-- >>> explainEntryError (EntryParse 0 (UnknownAccount "Supplies") :: EntryError Int)+-- "posting 0: account \"Supplies\" does not resolve to a ledger account"+-- >>> explainEntryError (PostingNotAllowed 1 NetIncome EngineGeneratedOnly OrdinaryJournal :: EntryError Int)+-- "posting 1 (NetIncome): EngineGeneratedOnly is not allowed in OrdinaryJournal"+explainEntryError :: (Show v) => EntryError v -> Text+explainEntryError (EntryParse i err) =+ postingOnly i <> ": " <> explainConvError err+explainEntryError (NonPositiveAmount i account amount) =+ postingAccount i account <> ": amount must be > 0, got " <> showText amount+explainEntryError (WildcardAccount i) =+ postingOnly i <> ": wildcard AccountTitle is not a ledger account"+explainEntryError (WildcardSide i) =+ postingOnly i <> ": wildcard Side is not debit or credit"+explainEntryError (PostingNotAllowed i account capability context) =+ postingAccount i account+ <> ": " <> showText capability+ <> " is not allowed in " <> showText context+explainEntryError EmptyEntry =+ "entry has no postings"+explainEntryError (Imbalanced debitTotal creditTotal) =+ "entry is not balanced: debit total "+ <> showText debitTotal+ <> " /= credit total "+ <> showText creditTotal++-- | Explain checked-journal errors, one line per error.+--+-- >>> explainJournalErrors (EntryErrors "tx1" (Imbalanced 1500 1400 :| []) :| [] :: NonEmpty (JournalError String Int))+-- "transaction \"tx1\": entry is not balanced: debit total 1500 /= credit total 1400"+-- >>> explainJournalErrors (DuplicateTxId "tx1" :| [] :: NonEmpty (JournalError String Int))+-- "transaction \"tx1\" appears more than once"+explainJournalErrors :: (Show n, Show v) => NonEmpty (JournalError n v) -> Text+explainJournalErrors =+ T.intercalate "\n" . concatMap explainJournalError . NE.toList++-- | Explain source-coverage errors, one line per error.+--+-- >>> explainSourceErrors [MissingSource "tx7" :: SourceError String Int]+-- "source transaction \"tx7\" has no journal entry"+-- >>> explainSourceErrors [AmountMismatch "tx7" 1500 1400 :: SourceError String Int]+-- "source transaction \"tx7\" amount mismatch: expected 1500, journal entry has 1400"+explainSourceErrors :: (Show n, Show v) => [SourceError n v] -> Text+explainSourceErrors = T.intercalate "\n" . map explainSourceError++explainJournalError :: (Show n, Show v) => JournalError n v -> [Text]+explainJournalError (EntryErrors txid errs) =+ [ "transaction " <> showText txid <> ": " <> explainEntryError err+ | err <- NE.toList errs+ ]+explainJournalError (DuplicateTxId txid) =+ [ "transaction " <> showText txid <> " appears more than once" ]++explainSourceError :: (Show n, Show v) => SourceError n v -> Text+explainSourceError (MissingSource txid) =+ "source transaction " <> showText txid <> " has no journal entry"+explainSourceError (UnknownSource txid) =+ "journal entry " <> showText txid <> " has no source transaction"+explainSourceError (AmountMismatch txid expected actual) =+ "source transaction "+ <> showText txid+ <> " amount mismatch: expected "+ <> showText expected+ <> ", journal entry has "+ <> showText actual++explainConvError :: ConvError -> Text+explainConvError (UnknownAccount account) =+ "account " <> showText account <> " does not resolve to a ledger account"+explainConvError (AmbiguousAccount account candidates) =+ "account "+ <> showText account+ <> " is ambiguous; candidates: "+ <> T.intercalate ", " (map showText candidates)+explainConvError (UnknownSide sideText) =+ "side " <> showText sideText <> " is not debit or credit"+explainConvError (MalformedCsv message) =+ "malformed CSV input: " <> showText message+explainConvError (BadAmount message) =+ "amount field is invalid: " <> showText message++postingOnly :: Int -> Text+postingOnly i = "posting " <> showText i++postingAccount :: Int -> AccountTitles -> Text+postingAccount i account =+ postingOnly i <> " (" <> showText account <> ")"++showText :: (Show a) => a -> Text+showText = T.pack . show
+ src/ExchangeAlgebra/Assist/Descriptions.hs view
@@ -0,0 +1,9 @@+{- |+Module : ExchangeAlgebra.Assist.Descriptions+Description : Compatibility projection of the canonical account registry.+-}+module ExchangeAlgebra.Assist.Descriptions+ ( accountDescriptions+ ) where++import ExchangeAlgebra.Algebra.Base.Account.Registry (accountDescriptions)
+ src/ExchangeAlgebra/Bookkeeping.hs view
@@ -0,0 +1,609 @@+{- |+ Module : ExchangeAlgebra.Bookkeeping+ Copyright : (c) Kaya Akagi. 2018-2026+ Maintainer : yakagika@icloud.com++ Released under the OWL license++ Package for Exchange Algebra defined by Hiroshi Deguchi.++ Exchange Algebra is an algebraic description of bookkeeping systems.+ Details are below.++ <https://www.springer.com/gp/book/9784431209850>++ <https://repository.kulib.kyoto-u.ac.jp/dspace/bitstream/2433/82987/1/0809-7.pdf>++ == Closing-adjustment entry builders (決算整理仕訳)++ This module provides /posting builders/ (journal-entry templates) for the+ closing adjustments taught at the elementary-bookkeeping (日商簿記 3 級) level:+ cost of goods sold under the periodic (3-account) method, depreciation, the+ allowance for doubtful accounts, deferral\/accrual (経過勘定) entries, and the+ settlement of consumption\/corporate income tax.++ Unlike "ExchangeAlgebra.Algebra.Transfer" (which /relabels existing ledger+ balances/), most closing adjustments record /new postings whose amounts come+ from outside the ledger/ (period-end inventory, estimated allowance,+ depreciation expense, …). Each builder here therefore returns a fresh,+ balanced @'Alg' v b@ value.++ === Design++ * Every builder is constructed exclusively with the smart constructor+ @('.@')@, so a zero amount normalises to @Zero@ and a negative\/non-finite+ amount is rejected by @('.@')@'s @error@ path. No implicit @bar@\/@compress@+ is performed — builders only /generate/ new postings.++ * The base polymorphism is absorbed by an injection function @'MkBase'@ that+ the caller supplies once (see below).++ * The Hat\/Not label of each line follows the /accounting meaning/ of that+ line: an account that /increases/ uses @Not@ (so it lands on its home side),+ an account that /decreases/ uses @Hat@ (the opposite side). 'whichSide'+ then places each line on the correct debit\/credit side. Consequently every+ builder is debit-credit balanced: @'norm' ('decL' x) == 'norm' ('decR' x)@.+-}++{-# LANGUAGE FlexibleContexts #-}++module ExchangeAlgebra.Bookkeeping+ ( -- * Base injection+ MkBase+ -- * Cost of goods sold (売上原価, 3 分法)+ , cogsAdjustmentEntries+ -- * Depreciation (減価償却)+ , depreciationIndirectEntry+ , depreciationDirectEntry+ -- * Allowance for doubtful accounts (貸倒引当金)+ , allowanceReplenishmentEntry+ , allowanceResetEntries+ -- * Deferral / accrual (経過勘定)+ , prepaidExpenseEntry+ , unearnedRevenueEntry+ , accruedRevenueEntry+ , accruedExpenseEntry+ , reversingEntry+ -- * Tax settlement (消費税・法人税等)+ , consumptionTaxSettlementEntry+ , corporateTaxInterimEntry+ , corporateTaxSettlementEntries+ -- * Equity method (持分法)+ , equityMethodEarningsEntry+ , equityMethodDividendEntry+ , equityMethodEntries+ , equityMethodBalance+ -- * Prior-period error correction (前期修正)+ , priorPeriodErrorCorrection+ ) where++import ExchangeAlgebra.Algebra+-- @MoneyDecimal@ is used only by the Haddock doctest examples (which run in this+-- module's import scope), not the module body; keep it imported for the doctests.+import ExchangeAlgebra.Value (MoneyDecimal)+import GHC.Stack (HasCallStack)++-- | Injection that tells a builder how to wrap a @(v'Hat', 'AccountTitles')@ pair+-- into the concrete base @b@ in use. The caller supplies it once.+--+-- @+-- -- single-axis base (account title only):+-- mk1 :: MkBase (HatBase AccountTitles)+-- mk1 = (':<')+--+-- -- multi-axis base, filling the extra axes with fixed values:+-- mk4 :: MkBase (HatBase (AccountTitles, Name, CountUnit, Subject))+-- mk4 h t = h :< (t, \"\", Yen, \"\")+-- @+type MkBase b = Hat -> AccountTitles -> b++-- | (借) debit-meaning convenience: the account /increases/, so it carries 'Not'+-- and lands on its home side.+{-# INLINE up #-}+up :: (HatVal v, ExBaseClass b) => MkBase b -> v -> AccountTitles -> Alg v b+up mk v t = v .@ mk Not t++-- | The account /decreases/, so it carries v'Hat' (the opposite of its home side).+{-# INLINE down #-}+down :: (HatVal v, ExBaseClass b) => MkBase b -> v -> AccountTitles -> Alg v b+down mk v t = v .@ mk Hat t++------------------------------------------------------------------+-- * Cost of goods sold (売上原価, 3 分法)+------------------------------------------------------------------++-- | Cost-of-goods-sold adjustment under the periodic\/3-account method+-- (3 分法; lecture ch.10, 24), the four "しいくりくりしい" postings:+--+-- > (借) 仕入 beg (貸) 繰越商品 beg -- opening inventory+-- > (借) 繰越商品 end (貸) 仕入 end -- closing inventory+--+-- After this entry the @Purchases@ balance equals cost of goods sold and the+-- @MerchandiseInventory@ balance equals the closing inventory.+--+-- The result is debit-credit balanced (each side sums to @beg + end@):+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = cogsAdjustmentEntries mk 100 50 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+-- >>> norm (decL e)+-- 150+--+-- Complexity: O(1)+cogsAdjustmentEntries :: (HatVal v, ExBaseClass b)+ => MkBase b -- ^ base injection+ -> v -- ^ beginning inventory (期首商品棚卸高)+ -> v -- ^ ending inventory (期末商品棚卸高)+ -> Alg v b+cogsAdjustmentEntries mk beg end =+ up mk beg Purchases -- (借) 仕入 期首+ .+ down mk beg MerchandiseInventory -- (貸) 繰越商品 期首+ .+ up mk end MerchandiseInventory -- (借) 繰越商品 期末+ .+ down mk end Purchases -- (貸) 仕入 期末++------------------------------------------------------------------+-- * Depreciation (減価償却)+------------------------------------------------------------------++-- | Indirect-method depreciation (間接法; lecture ch.18):+--+-- > (借) 減価償却費 amt (貸) 減価償却累計額 amt+--+-- 'AccumulatedDepreciation' is a credit-balance valuation account (評価勘定).+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = depreciationIndirectEntry mk 150 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+--+-- Complexity: O(1)+depreciationIndirectEntry :: (HatVal v, ExBaseClass b)+ => MkBase b -> v -> Alg v b+depreciationIndirectEntry mk amt =+ up mk amt Depreciation -- (借) 減価償却費+ .+ up mk amt AccumulatedDepreciation -- (貸) 減価償却累計額++-- | Direct-method depreciation (直接法; lecture ch.18):+--+-- > (借) 減価償却費 amt (貸) <asset> amt+--+-- The asset's carrying amount is reduced directly. @assetTitle@ is the fixed+-- asset being depreciated (e.g. 'Fixtures', 'Building', 'Vehicle').+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = depreciationDirectEntry mk 90 Fixtures :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+--+-- Complexity: O(1)+depreciationDirectEntry :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ depreciation amount+ -> AccountTitles -- ^ asset being depreciated+ -> Alg v b+depreciationDirectEntry mk amt assetTitle =+ up mk amt Depreciation -- (借) 減価償却費+ .+ down mk amt assetTitle -- (貸) <asset>++------------------------------------------------------------------+-- * Allowance for doubtful accounts (貸倒引当金)+------------------------------------------------------------------++-- | Allowance for doubtful accounts under the /差額補充法/ (replenishment;+-- lecture ch.16). Only the difference between the estimate and the current+-- balance is recorded:+--+-- * @estimate > current@: provide the shortfall —+-- @(借) 貸倒引当金繰入 / (貸) 貸倒引当金@.+-- * @estimate < current@: release the excess —+-- @(借) 貸倒引当金 / (貸) 貸倒引当金戻入@ (uses 'ReversalOfAllowanceForDoubtfulAccounts').+-- * @estimate == current@: no entry (@Zero@).+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = allowanceReplenishmentEntry mk 1400 1000 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+-- >>> norm (decL e)+-- 400+-- >>> allowanceReplenishmentEntry mk 2000 2000 == (Zero :: Alg MoneyDecimal (HatBase AccountTitles))+-- True+--+-- Complexity: O(1)+allowanceReplenishmentEntry :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ estimated allowance (当期末貸倒見積額)+ -> v -- ^ current allowance balance (貸倒引当金残高)+ -> Alg v b+allowanceReplenishmentEntry mk estimate current+ | estimate >= current =+ let diff = estimate - current+ in up mk diff ProvisionForDoubtfulAccounts -- (借) 貸倒引当金繰入+ .+ up mk diff AllowanceForDoubtfulAccounts -- (貸) 貸倒引当金+ | otherwise =+ let diff = current - estimate+ in down mk diff AllowanceForDoubtfulAccounts -- (借) 貸倒引当金+ .+ up mk diff ReversalOfAllowanceForDoubtfulAccounts -- (貸) 貸倒引当金戻入++-- | Allowance for doubtful accounts under the /洗替法/ (reset; lecture ch.16):+-- release the whole current balance, then provide the whole estimate.+--+-- > (借) 貸倒引当金 current (貸) 貸倒引当金戻入 current+-- > (借) 貸倒引当金繰入 estimate (貸) 貸倒引当金 estimate+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = allowanceResetEntries mk 2400 1000 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+-- >>> norm (decL e)+-- 3400+--+-- Complexity: O(1)+allowanceResetEntries :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ estimated allowance for this period+ -> v -- ^ current allowance balance to release+ -> Alg v b+allowanceResetEntries mk estimate current =+ down mk current AllowanceForDoubtfulAccounts -- (借) 貸倒引当金+ .+ up mk current ReversalOfAllowanceForDoubtfulAccounts -- (貸) 貸倒引当金戻入+ .+ up mk estimate ProvisionForDoubtfulAccounts -- (借) 貸倒引当金繰入+ .+ up mk estimate AllowanceForDoubtfulAccounts -- (貸) 貸倒引当金++------------------------------------------------------------------+-- * Deferral / accrual (経過勘定; lecture ch.21)+------------------------------------------------------------------++-- | Deferral of a prepaid expense (費用の前払; 前払費用):+--+-- > (借) 前払費用 amt (貸) <expense> amt+--+-- Moves the unexpired portion of @expenseTitle@ out to 'PrepaidExpenses'. The+-- opening reversing entry of the next period is @'reversingEntry'@ of this.+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = prepaidExpenseEntry mk 4000 RentExpense :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+--+-- Complexity: O(1)+prepaidExpenseEntry :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ prepaid amount+ -> AccountTitles -- ^ expense being deferred+ -> Alg v b+prepaidExpenseEntry mk amt expenseTitle =+ up mk amt PrepaidExpenses -- (借) 前払費用+ .+ down mk amt expenseTitle -- (貸) <expense>++-- | Deferral of unearned revenue (収益の前受; 前受収益):+--+-- > (借) <revenue> amt (貸) 前受収益 amt+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = unearnedRevenueEntry mk 4000 RentalIncome :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+--+-- Complexity: O(1)+unearnedRevenueEntry :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ unearned amount+ -> AccountTitles -- ^ revenue being deferred+ -> Alg v b+unearnedRevenueEntry mk amt revenueTitle =+ down mk amt revenueTitle -- (借) <revenue>+ .+ up mk amt UnearnedRevenue -- (貸) 前受収益++-- | Accrual of accrued revenue (収益の未収; 未収収益):+--+-- > (借) 未収収益 amt (貸) <revenue> amt+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = accruedRevenueEntry mk 2500 InterestEarned :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+--+-- Complexity: O(1)+accruedRevenueEntry :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ accrued amount+ -> AccountTitles -- ^ revenue being accrued+ -> Alg v b+accruedRevenueEntry mk amt revenueTitle =+ up mk amt AccruedRevenue -- (借) 未収収益+ .+ up mk amt revenueTitle -- (貸) <revenue>++-- | Accrual of an accrued expense (費用の未払; 未払費用):+--+-- > (借) <expense> amt (貸) 未払費用 amt+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = accruedExpenseEntry mk 2500 InterestExpense :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+--+-- Complexity: O(1)+accruedExpenseEntry :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ accrued amount+ -> AccountTitles -- ^ expense being accrued+ -> Alg v b+accruedExpenseEntry mk amt expenseTitle =+ up mk amt expenseTitle -- (借) <expense>+ .+ up mk amt AccruedExpenses -- (貸) 未払費用++-- | Reversing\/cancelling entry, a vocabulary alias for the Hat operation+-- @('.^')@ (lecture ch.20-21, 再振替仕訳\/訂正仕訳). This is the direct+-- application of /Hat involution/: flipping Hat\/Not on every posting turns an+-- entry into its exact reversal.+--+-- * Opening reversal (再振替仕訳): @reversingEntry deferral@ at the start of the+-- next period undoes a deferral\/accrual entry.+--+-- * Correction (訂正仕訳): a mistaken entry @wrong@ is corrected by+-- @reversingEntry wrong '.+' correct@. Because @('.+')@ keeps same-base+-- postings as an ordered /sequence/ (the redundancy), the wrong entry and its+-- reversal both remain in the algebra as an /audit trail/ of the correction;+-- @'bar'@ then nets them out to the corrected balance.+--+-- @reversingEntry@ is an involution (@reversingEntry . reversingEntry = id@) and+-- @'bar' (x '.+' reversingEntry x) = Zero@ (an entry plus its reversal cancels).+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let x = accruedExpenseEntry mk 2500 InterestExpense :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> reversingEntry (reversingEntry x) == x+-- True+-- >>> bar (x .+ reversingEntry x) == Zero+-- True+--+-- Complexity: O(1) for a singleton, O(n) for an+-- 'ExchangeAlgebra.Algebra.Internal.Liner' (n base keys).+{-# INLINE reversingEntry #-}+reversingEntry :: (Redundant a v b) => a v b -> a v b+reversingEntry = (.^)++------------------------------------------------------------------+-- * Tax settlement (消費税・法人税等; lecture ch.23)+------------------------------------------------------------------++-- | Consumption-tax settlement at closing (税抜処理方式; lecture ch.23): offset+-- consumption tax received against consumption tax paid and book the unpaid+-- balance.+--+-- > (借) 仮受消費税 received (貸) 仮払消費税 paid+-- > (貸) 未払消費税 received - paid+--+-- __Out of 3-級 scope:__ @received < paid@ (a tax refund, 還付) is rejected with+-- @error@. At the elementary level the consumption tax received always exceeds+-- the consumption tax paid.+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = consumptionTaxSettlementEntry mk 1000 20000 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+-- >>> norm (projByAccountTitle AccruedConsumptionTax e)+-- 19000+--+-- Complexity: O(1)+consumptionTaxSettlementEntry :: (HasCallStack, HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ consumption tax paid (仮払消費税)+ -> v -- ^ consumption tax received (仮受消費税)+ -> Alg v b+consumptionTaxSettlementEntry mk paid received+ | received < paid =+ error $ "consumptionTaxSettlementEntry: received (" ++ show received+ ++ ") < paid (" ++ show paid+ ++ "); a consumption-tax refund is out of 日商簿記 3 級 scope."+ | otherwise =+ let unpaid = received - paid+ in down mk received ConsumptionTaxReceived -- (借) 仮受消費税+ .+ down mk paid ConsumptionTaxPaid -- (貸) 仮払消費税+ .+ up mk unpaid AccruedConsumptionTax -- (貸) 未払消費税++-- | Interim (mid-year) payment of corporate income tax (中間納付; lecture ch.23):+--+-- > (借) 仮払法人税等 amt (貸) 現金 amt+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = corporateTaxInterimEntry mk 500000 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+--+-- Complexity: O(1)+corporateTaxInterimEntry :: (HatVal v, ExBaseClass b)+ => MkBase b -> v -> Alg v b+corporateTaxInterimEntry mk amt =+ up mk amt PrepaidCorporateIncomeTaxes -- (借) 仮払法人税等+ .+ down mk amt Cash -- (貸) 現金++-- | Corporate income tax settlement at closing (lecture ch.23): book the total+-- tax expense, credit the interim payment already made, and recognise the+-- unpaid balance.+--+-- > (借) 法人税等 total (貸) 仮払法人税等 interim+-- > (貸) 未払法人税等 total - interim+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = corporateTaxSettlementEntries mk 800000 500000 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+-- >>> norm (projByAccountTitle AccruedCorporateIncomeTaxes e)+-- 300000+--+-- Complexity: O(1)+corporateTaxSettlementEntries :: (HasCallStack, HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ total corporate income tax (法人税等)+ -> v -- ^ interim payment already made (仮払法人税等)+ -> Alg v b+corporateTaxSettlementEntries mk total interim+ -- Same guard style as 'consumptionTaxSettlementEntry': an over-prepayment+ -- (refund position) is out of 3-級 scope, so reject it with a clear error+ -- instead of letting the negative @unpaid@ hit @('.@')@'s generic one.+ | interim > total =+ error $ "corporateTaxSettlementEntries: interim (" ++ show interim+ ++ ") > total (" ++ show total+ ++ "); a corporate-tax refund (over-prepayment) is out of 日商簿記 3 級 scope."+ | otherwise =+ let unpaid = total - interim+ in up mk total CorporateIncomeTaxes -- (借) 法人税等+ .+ down mk interim PrepaidCorporateIncomeTaxes -- (貸) 仮払法人税等+ .+ up mk unpaid AccruedCorporateIncomeTaxes -- (貸) 未払法人税等++------------------------------------------------------------------+-- * Equity method (持分法; lecture T4b)+------------------------------------------------------------------++-- | Equity-method earnings accrual (持分法による投資利益の計上):+--+-- > (借) 関係会社株式 share (貸) 持分法による投資利益 share+--+-- @share@ is the investor's proportionate share of the investee's net income+-- (= investee NI × ownership %). The investment carrying amount increases by+-- @share@.+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = equityMethodEarningsEntry mk 438000 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+-- >>> norm (decL e)+-- 438000+--+-- Complexity: O(1)+equityMethodEarningsEntry :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ investor's share of investee NI (持分利益)+ -> Alg v b+equityMethodEarningsEntry mk share =+ up mk share InvestmentInAssociate -- (借) 関係会社株式+ .+ up mk share EquityInEarningsOfInvestee -- (貸) 持分法による投資利益++-- | Equity-method dividend received (受取配当による投資簿価の減額):+--+-- > (借) 現金 div (貸) 関係会社株式 div+--+-- Under the equity method a dividend received from the investee is /not/ income;+-- it reduces the carrying amount of the investment.+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = equityMethodDividendEntry mk 800000 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+-- >>> norm (decL e)+-- 800000+--+-- Complexity: O(1)+equityMethodDividendEntry :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ dividend received (受取配当金)+ -> Alg v b+equityMethodDividendEntry mk div' =+ up mk div' Cash -- (借) 現金+ .+ down mk div' InvestmentInAssociate -- (貸) 関係会社株式++-- | Combined equity-method closing entries: record the earnings accrual+-- then the dividend reduction.+--+-- > (借) 関係会社株式 share (貸) 持分法による投資利益 share+-- > (借) 現金 div (貸) 関係会社株式 div+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = equityMethodEntries mk 438000 800000 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+--+-- Complexity: O(1)+equityMethodEntries :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ investor's share of investee NI (持分利益)+ -> v -- ^ dividend received (受取配当金)+ -> Alg v b+equityMethodEntries mk share div' =+ equityMethodEarningsEntry mk share+ .+ equityMethodDividendEntry mk div'++-- | Equity-method carrying-amount balance (関係会社株式の簿価残高).+--+-- Projects all @'InvestmentInAssociate'@ postings from an accumulated @'Alg'@+-- and returns their @'norm'@ (= net carrying amount). The caller accumulates+-- the initial acquisition entry plus any 'equityMethodEarningsEntry' and+-- 'equityMethodDividendEntry' calls; this function reads the result without+-- recomputing it by hand — /correct-by-construction/.+--+-- Example (#18 anchor doctest):+-- acquisition cost 2,400,000 + share of NI 0.30 × 1,460,000 (= 438,000)+-- − dividend received 800,000 = carrying amount 2,038,000.+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> -- initial acquisition: Dr 関係会社株式 2,400,000 / Cr Cash 2,400,000+-- >>> let acq = up mk 2400000 InvestmentInAssociate .+ down mk 2400000 Cash :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> let entries = equityMethodEntries mk 438000 800000 :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> let ledger = acq .+ entries+-- >>> equityMethodBalance ledger+-- 2038000+--+-- Complexity: O(s) (s = number of scalar entries in the accumulated algebra)+equityMethodBalance :: (HatVal v, ExBaseClass b) => Alg v b -> v+equityMethodBalance = norm . bar . projByAccountTitle InvestmentInAssociate++------------------------------------------------------------------+-- * Prior-period error correction (前期修正)+------------------------------------------------------------------++-- | Prior-period error correction (前期修正・誤謬訂正; #15 Briggs anchor).+--+-- Records the correction of an error discovered in a prior period:+-- the current-period portion goes to an expense account, while the+-- prior-period portion bypasses the income statement and is charged+-- directly to 'RetainedEarnings' (繰越利益剰余金) — conforming to+-- IAS 8 \/ ASC 250-10 retrospective-restatement treatment.+--+-- > (借) \<expense\> current (貸) \<asset\> current + prior+-- > (借) RetainedEarnings prior+--+-- Structural guarantee (thesis property): the prior-period amount is+-- /always/ routed to 'RetainedEarnings'; the caller cannot accidentally+-- book it to an income-statement account — the parameter type forces the+-- split.+--+-- 'RetainedEarnings' is a credit-balance equity account (@home = Credit@),+-- so a reduction (debit-side charge) is recorded with @down@ (@Hat@),+-- which places it on the debit side — correct-by-construction.+--+-- The credit leg (asset reduction) is collapsed into one posting+-- @down mk (current + prior) assetAcc@ because @'Num' v@ is available+-- from the @'HatVal'@ superclass, making value addition clean; the+-- two-line @.+@ alternative is equally valid under the seq-redundancy+-- principle but the single line is more readable here.+--+-- === Example (#15 anchor: patent 55,000 \/ 10 yr, discovered in 2028)+--+-- * Patent cost 55,000, useful life 10 years → annual amortisation 5,500.+-- * Error discovered in 2028: 2 prior years (2026, 2027) were not amortised.+-- * Current-year amortisation 5,500 → expense (@AmortizationExpense@).+-- * Prior 2 years 11,000 → directly to @RetainedEarnings@ (前期修正).+-- * Credit @Patent@ 16,500 total.+--+-- >>> let mk = (:<) :: MkBase (HatBase AccountTitles)+-- >>> let e = priorPeriodErrorCorrection mk 5500 11000 AmortizationExpense Patent :: Alg MoneyDecimal (HatBase AccountTitles)+-- >>> norm (decL e) == norm (decR e)+-- True+-- >>> norm (decR e)+-- 16500+-- >>> norm (decL e)+-- 16500+--+-- Complexity: O(1)+priorPeriodErrorCorrection :: (HatVal v, ExBaseClass b)+ => MkBase b+ -> v -- ^ current-period portion (当期費用; → @expenseAcc@)+ -> v -- ^ prior-period portion (前期修正; → 'RetainedEarnings')+ -> AccountTitles -- ^ current-period expense account (e.g. 'AmortizationExpense')+ -> AccountTitles -- ^ affected asset credited (e.g. 'Patent')+ -> Alg v b+priorPeriodErrorCorrection mk current prior expenseAcc assetAcc =+ up mk current expenseAcc -- (借) 当期費用 (expense 増, home=Debit → Not)+ .+ down mk prior RetainedEarnings -- (借) 繰越利益剰余金 (equity 減, home=Credit → Hat=debit側)+ .+ down mk (current + prior) assetAcc -- (貸) 資産 (合計 current+prior 分だけ減額)
+ src/ExchangeAlgebra/Consolidation/Worksheet.hs view
@@ -0,0 +1,489 @@+{-# LANGUAGE FlexibleContexts #-}+{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}++{- |+Module : ExchangeAlgebra.Consolidation.Worksheet+Description : Validation boundary for consolidation worksheets.++A consolidation worksheet is not an entity's journal. Its adjustments are+therefore accepted as exchange-algebra elements, while this module checks that+each adjustment is independently balanced before any aggregation takes place.+This prevents two malformed adjustments from cancelling each other only at the+worksheet total.++The validated value retains source trial balances and adjustments separately.+'combinedWorksheet' is an explicit projection and uses redundant-algebra+addition, so same-base postings remain separate sequences until a caller asks+for normalization. The projection itself does not carry per-posting provenance;+use 'validatedSources' and 'validatedAdjustments' when that provenance is+required.++Validation uses exact equality, consistently with the checked-conversion+boundary. Prefer an exact value type such as @MoneyDecimal@ for deterministic+worksheet validation rather than a floating-point representation.+-}+module ExchangeAlgebra.Consolidation.Worksheet+ ( PeriodResult(..)+ , AccountBalance(..)+ , LinkField(..)+ , TrialBalanceSource(..)+ , WorksheetAdjustment(..)+ , WorksheetLinkage(..)+ , WorksheetInput(..)+ , WorksheetError(..)+ , ValidatedWorksheet+ , validateConsolidationWorksheet+ , validatedSources+ , validatedAdjustments+ , validatedLinkage+ , combinedWorksheet+ ) where++import Data.List.NonEmpty (NonEmpty(..))+import qualified Data.List as L+import qualified Data.Map.Strict as M++import ExchangeAlgebra.Algebra+ ( Alg+ , Exchange(decL, decR)+ , HatVal(..)+ , Redundant((.+), norm)+ , bases+ , vals+ )+import ExchangeAlgebra.Accounting.PostingPolicy+ ( ProcessingContext(ConsolidationWorksheet)+ , postingAllowedIn+ , postingCapabilityFor+ )+import ExchangeAlgebra.Algebra.Base+ ( AccountTitles(..)+ , Hat(..)+ , HatBase((:<))+ , PostingCapability+ )+import ExchangeAlgebra.Reporting.Metric (PeriodResult(..))+import ExchangeAlgebra.TrialBalance.Balance+ ( AccountBalance(..)+ , balanceAmount+ , balancePair+ )++-- | Named linkage fields, used when reporting an invalid non-negative amount.+data LinkField+ = ProfitOrLossNetIncome+ | ProfitOrLossNetIncomeAttributableToOwners+ | StatementOfChangesNetIncomeAttributableToOwners+ | OpeningRetainedEarnings+ | RetainedEarningsDividends+ | StatementOfChangesClosingRetainedEarnings+ | BalanceSheetRetainedEarnings+ | OpeningNonControllingInterests+ | NonControllingInterestsPeriodShare+ | NonControllingInterestsDividends+ | StatementOfChangesClosingNonControllingInterests+ | BalanceSheetNonControllingInterests+ deriving (Show, Eq, Ord)++-- | One entity trial balance and its provenance identifier. A source trial+-- balance is accepted as historical input and is therefore not restricted by+-- a processing-context posting-capability gate; it is still checked for+-- structural coordinates, valid values, and exact balance.+data TrialBalanceSource source v = TrialBalanceSource+ { _sourceId :: source+ , _sourceTrialBalance :: Alg v (HatBase AccountTitles)+ }+ deriving (Show)++-- | One atomic worksheet adjustment. The algebra element need not originate+-- from a journal entry, but it must be independently balanced and use only+-- coordinates admitted by the consolidation processing context.+data WorksheetAdjustment source adjustment v = WorksheetAdjustment+ { _adjustmentId :: adjustment+ , _adjustmentSourceIds :: NonEmpty source+ , _adjustmentElement :: Alg v (HatBase AccountTitles)+ }+ deriving (Show)++-- | Cross-column equations for the statement of profit or loss (P/L),+-- statement of changes in equity (S/S), and balance sheet (B/S).+--+-- The P/L total is checked against its owners-of-parent and NCI attribution,+-- and the owners amount is then linked to S/S. Retained earnings and NCI+-- balances keep credit/debit position structural, so accumulated deficits do+-- not require negative scalar values.+--+-- These values are caller-supplied worksheet-column facts. This Land 2b type+-- validates their equations but cannot derive them from the unlabelled 'Alg'+-- alone; anchoring statement columns to a reporting projection is a later+-- reporting-layer responsibility. The roll-forwards model period result and+-- dividends only; worksheets with OCI, ownership changes, reserve transfers,+-- or other equity movements require a later reporting-layer extension.+data WorksheetLinkage v = WorksheetLinkage+ { _profitOrLossNetIncome :: PeriodResult v+ , _profitOrLossNetIncomeAttributableToOwners :: PeriodResult v+ , _statementOfChangesNetIncomeAttributableToOwners :: PeriodResult v+ , _openingRetainedEarnings :: AccountBalance v+ , _retainedEarningsDividends :: v+ , _statementOfChangesClosingRetainedEarnings :: AccountBalance v+ , _balanceSheetRetainedEarnings :: AccountBalance v+ , _openingNonControllingInterests :: AccountBalance v+ , _nonControllingInterestsPeriodShare :: PeriodResult v+ , _nonControllingInterestsDividends :: v+ , _statementOfChangesClosingNonControllingInterests+ :: AccountBalance v+ , _balanceSheetNonControllingInterests :: AccountBalance v+ }+ deriving (Show, Eq)++-- | Unvalidated consolidation worksheet input.+data WorksheetInput source adjustment v = WorksheetInput+ { _worksheetSources :: NonEmpty (TrialBalanceSource source v)+ , _worksheetAdjustments :: [WorksheetAdjustment source adjustment v]+ , _worksheetLinkage :: WorksheetLinkage v+ }+ deriving (Show)++-- | Every rejected invariant is tied to a source, adjustment, or linkage.+data WorksheetError source adjustment v+ = DuplicateSourceId source+ | DuplicateAdjustmentId adjustment+ | DuplicateAdjustmentSource adjustment source+ | UnknownAdjustmentSource adjustment source+ | InvalidSourceValue source v+ | WildcardSourceAccount source+ | WildcardSourceSide source+ | UnbalancedSourceTrialBalance source v v+ | EmptyAdjustment adjustment+ | InvalidAdjustmentValue adjustment v+ | WildcardAdjustmentAccount adjustment+ | WildcardAdjustmentSide adjustment+ | AdjustmentPostingNotAllowed adjustment AccountTitles PostingCapability+ | UnbalancedAdjustment adjustment v v+ | InvalidLinkAmount LinkField v+ | NetIncomeAttributionMismatch v v+ | OwnersPeriodResultLinkMismatch (PeriodResult v) (PeriodResult v)+ | RetainedEarningsRollForwardMismatch v v+ | BalanceSheetRetainedEarningsMismatch+ (AccountBalance v) (AccountBalance v)+ | NonControllingInterestsRollForwardMismatch v v+ | BalanceSheetNonControllingInterestsMismatch+ (AccountBalance v) (AccountBalance v)+ deriving (Show, Eq)++-- | A worksheet whose provenance, atomic balance, processing capability, and+-- cross-column linkage have all passed validation. The constructor is hidden.+data ValidatedWorksheet source adjustment v = ValidatedWorksheet+ (NonEmpty (TrialBalanceSource source v))+ [WorksheetAdjustment source adjustment v]+ (WorksheetLinkage v)++-- | Validate a consolidation worksheet without normalizing any algebra term.+validateConsolidationWorksheet+ :: (HatVal v, Ord source, Ord adjustment)+ => WorksheetInput source adjustment v+ -> Either (NonEmpty (WorksheetError source adjustment v))+ (ValidatedWorksheet source adjustment v)+validateConsolidationWorksheet input =+ case errors of+ [] -> Right (ValidatedWorksheet sources adjustments linkage)+ e : es -> Left (e :| es)+ where+ sources = _worksheetSources input+ adjustments = _worksheetAdjustments input+ linkage = _worksheetLinkage input+ errors = duplicateIdErrors sources adjustments+ ++ concatMap validateSource (toListNE sources)+ ++ concatMap (validateAdjustment (sourceIds sources)) adjustments+ ++ validateWorksheetLinkage linkage++-- | Recover the source trial balances with their provenance intact.+validatedSources+ :: ValidatedWorksheet source adjustment v+ -> NonEmpty (TrialBalanceSource source v)+validatedSources (ValidatedWorksheet sources _ _) = sources++-- | Recover the atomic adjustments with their provenance intact.+validatedAdjustments+ :: ValidatedWorksheet source adjustment v+ -> [WorksheetAdjustment source adjustment v]+validatedAdjustments (ValidatedWorksheet _ adjustments _) = adjustments++-- | Recover the validated cross-column linkage values.+validatedLinkage+ :: ValidatedWorksheet source adjustment v+ -> WorksheetLinkage v+validatedLinkage (ValidatedWorksheet _ _ linkage) = linkage++-- | Explicitly combine source trial balances and atomic adjustments.+-- Redundant-algebra addition preserves same-base posting sequences. This is a+-- calculation projection; inspect the validated source and adjustment lists+-- when provenance is required.+combinedWorksheet+ :: (HatVal v)+ => ValidatedWorksheet source adjustment v+ -> Alg v (HatBase AccountTitles)+combinedWorksheet (ValidatedWorksheet sources adjustments _) =+ L.foldl' (.+) mempty+ (map _sourceTrialBalance (toListNE sources)+ ++ map _adjustmentElement adjustments)++duplicateIdErrors+ :: (Ord source, Ord adjustment)+ => NonEmpty (TrialBalanceSource source v)+ -> [WorksheetAdjustment source adjustment v]+ -> [WorksheetError source adjustment v]+duplicateIdErrors sources adjustments =+ [ DuplicateSourceId sourceId+ | (sourceId, count) <- M.toList sourceCounts+ , count > 1+ ]+ ++ [ DuplicateAdjustmentId adjustmentId+ | (adjustmentId, count) <- M.toList adjustmentCounts+ , count > 1+ ]+ where+ sourceCounts = M.fromListWith (+)+ [ (_sourceId source, 1 :: Int) | source <- toListNE sources ]+ adjustmentCounts = M.fromListWith (+)+ [ (_adjustmentId adjustment, 1 :: Int) | adjustment <- adjustments ]++sourceIds :: Ord source+ => NonEmpty (TrialBalanceSource source v)+ -> M.Map source ()+sourceIds = M.fromList . map (\source -> (_sourceId source, ())) . toListNE++validateSource+ :: HatVal v+ => TrialBalanceSource source v+ -> [WorksheetError source adjustment v]+validateSource source = structuralErrors ++ balanceErrors+ where+ sourceId = _sourceId source+ alg = _sourceTrialBalance source+ structuralErrors =+ [ InvalidSourceValue sourceId value+ | value <- vals alg+ , isErrorValue value+ ]+ ++ [ WildcardSourceAccount sourceId+ | _ :< AccountTitle <- bases alg+ ]+ ++ [ WildcardSourceSide sourceId+ | HatNot :< _ <- bases alg+ ]+ balanceErrors+ | not (null structuralErrors) = []+ | debit /= credit = [UnbalancedSourceTrialBalance sourceId debit credit]+ | otherwise = []+ where+ (debit, credit) = sideTotals alg++validateAdjustment+ :: (HatVal v, Ord source)+ => M.Map source ()+ -> WorksheetAdjustment source adjustment v+ -> [WorksheetError source adjustment v]+validateAdjustment knownSources adjustment =+ provenanceErrors ++ structuralErrors ++ balanceErrors+ where+ adjustmentId = _adjustmentId adjustment+ alg = _adjustmentElement adjustment+ structuralErrors =+ [ EmptyAdjustment adjustmentId | null (vals alg) ]+ ++ [ InvalidAdjustmentValue adjustmentId value+ | value <- vals alg+ , isErrorValue value || not (value > 0)+ ]+ ++ [ WildcardAdjustmentAccount adjustmentId+ | _ :< AccountTitle <- bases alg+ ]+ ++ [ WildcardAdjustmentSide adjustmentId+ | HatNot :< _ <- bases alg+ ]+ ++ [ AdjustmentPostingNotAllowed adjustmentId account capability+ | _ :< account <- bases alg+ , account /= AccountTitle+ , let capability = postingCapabilityFor account+ , not (postingAllowedIn ConsolidationWorksheet capability)+ ]+ refs = toListNE (_adjustmentSourceIds adjustment)+ refCounts = M.fromListWith (+) [ (sourceId, 1 :: Int) | sourceId <- refs ]+ provenanceErrors =+ [ DuplicateAdjustmentSource adjustmentId sourceId+ | (sourceId, count) <- M.toList refCounts+ , count > 1+ ]+ ++ [ UnknownAdjustmentSource adjustmentId sourceId+ | sourceId <- M.keys refCounts+ , M.notMember sourceId knownSources+ ]+ balanceErrors+ | not (null structuralErrors) = []+ | debit /= credit = [UnbalancedAdjustment adjustmentId debit credit]+ | otherwise = []+ where+ (debit, credit) = sideTotals alg++validateWorksheetLinkage+ :: HatVal v+ => WorksheetLinkage v+ -> [WorksheetError source adjustment v]+validateWorksheetLinkage linkage+ | not (null invalidAmounts) = invalidAmounts+ | otherwise = attributionErrors+ ++ ownersLinkErrors+ ++ retainedEarningsErrors+ ++ balanceSheetErrors+ ++ nciErrors+ where+ invalidAmounts =+ [ InvalidLinkAmount field value+ | (field, value) <- linkageAmounts linkage+ , isErrorValue value+ ]++ totalResult = _profitOrLossNetIncome linkage+ ownersPlResult = _profitOrLossNetIncomeAttributableToOwners linkage+ ownersSsResult = _statementOfChangesNetIncomeAttributableToOwners linkage+ nciResult = _nonControllingInterestsPeriodShare linkage++ (attributionLeft, attributionRight) = attributionSides+ totalResult ownersPlResult nciResult+ attributionErrors =+ [ NetIncomeAttributionMismatch attributionLeft attributionRight+ | attributionLeft /= attributionRight+ ]++ ownersLinkErrors =+ [ OwnersPeriodResultLinkMismatch ownersPlResult ownersSsResult+ | not (periodResultEquivalent ownersPlResult ownersSsResult)+ ]++ (retainedLeft, retainedRight) = rollForwardSides+ (_openingRetainedEarnings linkage)+ ownersSsResult+ (_retainedEarningsDividends linkage)+ (_statementOfChangesClosingRetainedEarnings linkage)+ retainedEarningsErrors =+ [ RetainedEarningsRollForwardMismatch retainedLeft retainedRight+ | retainedLeft /= retainedRight+ ]++ ssClosing = _statementOfChangesClosingRetainedEarnings linkage+ bsClosing = _balanceSheetRetainedEarnings linkage+ balanceSheetErrors =+ [ BalanceSheetRetainedEarningsMismatch ssClosing bsClosing+ | not (balancePositionEquivalent ssClosing bsClosing)+ ]++ (nciLeft, nciRight) = rollForwardSides+ (_openingNonControllingInterests linkage)+ nciResult+ (_nonControllingInterestsDividends linkage)+ (_statementOfChangesClosingNonControllingInterests linkage)+ nciErrors =+ [ NonControllingInterestsRollForwardMismatch nciLeft nciRight+ | nciLeft /= nciRight+ ]+ ++ [ BalanceSheetNonControllingInterestsMismatch+ ssClosingNci bsNci+ | not (balancePositionEquivalent ssClosingNci bsNci)+ ]+ ssClosingNci =+ _statementOfChangesClosingNonControllingInterests linkage+ bsNci = _balanceSheetNonControllingInterests linkage++linkageAmounts :: Num v => WorksheetLinkage v -> [(LinkField, v)]+linkageAmounts linkage =+ [ (ProfitOrLossNetIncome, periodAmount (_profitOrLossNetIncome linkage))+ , (ProfitOrLossNetIncomeAttributableToOwners,+ periodAmount (_profitOrLossNetIncomeAttributableToOwners linkage))+ , (StatementOfChangesNetIncomeAttributableToOwners,+ periodAmount (_statementOfChangesNetIncomeAttributableToOwners linkage))+ , (OpeningRetainedEarnings,+ balanceAmount (_openingRetainedEarnings linkage))+ , (RetainedEarningsDividends, _retainedEarningsDividends linkage)+ , (StatementOfChangesClosingRetainedEarnings,+ balanceAmount (_statementOfChangesClosingRetainedEarnings linkage))+ , (BalanceSheetRetainedEarnings,+ balanceAmount (_balanceSheetRetainedEarnings linkage))+ , (OpeningNonControllingInterests,+ balanceAmount (_openingNonControllingInterests linkage))+ , (NonControllingInterestsPeriodShare,+ periodAmount (_nonControllingInterestsPeriodShare linkage))+ , (NonControllingInterestsDividends,+ _nonControllingInterestsDividends linkage)+ , (StatementOfChangesClosingNonControllingInterests,+ balanceAmount+ (_statementOfChangesClosingNonControllingInterests linkage))+ , (BalanceSheetNonControllingInterests,+ balanceAmount (_balanceSheetNonControllingInterests linkage))+ ]++periodAmount :: Num v => PeriodResult v -> v+periodAmount (PeriodProfit value) = value+periodAmount (PeriodLoss value) = value+periodAmount PeriodBreakEven = 0++periodSides :: Num v => PeriodResult v -> (v, v)+periodSides (PeriodProfit value) = (value, 0)+periodSides (PeriodLoss value) = (0, value)+periodSides PeriodBreakEven = (0, 0)++-- Total result equals owners' attribution plus NCI attribution. Moving all+-- loss-side values across the equation avoids signed scalars.+attributionSides :: Num v+ => PeriodResult v+ -> PeriodResult v+ -> PeriodResult v+ -> (v, v)+attributionSides total owners nci =+ (totalProfit + ownersLoss + nciLoss,+ totalLoss + ownersProfit + nciProfit)+ where+ (totalProfit, totalLoss) = periodSides total+ (ownersProfit, ownersLoss) = periodSides owners+ (nciProfit, nciLoss) = periodSides nci++periodResultEquivalent :: (Eq v, Num v)+ => PeriodResult v -> PeriodResult v -> Bool+periodResultEquivalent left right =+ leftProfit + rightLoss == leftLoss + rightProfit+ where+ (leftProfit, leftLoss) = periodSides left+ (rightProfit, rightLoss) = periodSides right++balancePositionEquivalent :: (Eq v, Num v)+ => AccountBalance v -> AccountBalance v -> Bool+balancePositionEquivalent left right =+ leftCredit + rightDebit == leftDebit + rightCredit+ where+ (leftDebit, leftCredit) = balancePair left+ (rightDebit, rightCredit) = balancePair right++rollForwardSides :: Num v+ => AccountBalance v+ -> PeriodResult v+ -> v+ -> AccountBalance v+ -> (v, v)+rollForwardSides opening result dividends closing = case result of+ PeriodProfit amount ->+ (openingCredit + amount + closingDebit,+ openingDebit + dividends + closingCredit)+ PeriodLoss amount ->+ (openingCredit + closingDebit,+ openingDebit + amount + dividends + closingCredit)+ PeriodBreakEven ->+ (openingCredit + closingDebit,+ openingDebit + dividends + closingCredit)+ where+ (openingDebit, openingCredit) = balancePair opening+ (closingDebit, closingCredit) = balancePair closing++sideTotals :: HatVal v => Alg v (HatBase AccountTitles) -> (v, v)+sideTotals alg = (norm (decL alg), norm (decR alg))++toListNE :: NonEmpty a -> [a]+toListNE (x :| xs) = x : xs
+ src/ExchangeAlgebra/Convert.hs view
@@ -0,0 +1,227 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+-- The alias table intentionally references the deprecated 'Commutation'+-- constructor so that its legacy Japanese label ("通信費") is recognised and+-- flagged as ambiguous against 'CommunicationExpenses'; silence the warning.+{-# OPTIONS_GHC -Wno-deprecations #-}++{- |+Module : ExchangeAlgebra.Convert+Description : Pure conversion between external (side, account-name, amount) data and+ exchange-algebra terms. No serialization dependency (Text only).++This module is the dependency-free core of the input-conversion layer: it turns+externally supplied postings---a debit\/credit t'Side', an account name as 'Text',+and an amount---into exchange-algebra 'Alg' terms, and back. The format glue+(JSON\/XML, i.e. @aeson@) deliberately lives outside the published library+(see @examples\/audit-eval\/runner@), so the algebra core stays serialization-free.++The mapping from a debit\/credit side to the @Hat@\/@Not@ marker is derived from+the library's own 'whichSide', so the credit\/debit semantics are never duplicated.+Unknown or wildcard account names are rejected (a /correct-by-construction/ guard+against hallucinated accounts). Account names that are genuinely ambiguous (one+Japanese label shared by several constructors, e.g. @準備預金@ for both the asset+and liability side of reserve deposits) are also rejected, with the candidate+accounts reported, so callers must disambiguate by canonical name.++A fixed-schema CSV reader (@side,account,amount@, optional @note@ column) is+provided in "ExchangeAlgebra.Convert.Csv".+-}+module ExchangeAlgebra.Convert+ ( ConvError(..)+ -- $concreteAccountTitles+ , concreteAccountTitles+ , normalizeTitle+ , parseAccountTitle+ , parseSide+ , markerForSide+ , postingFromSide+ , journalFromSides+ ) where++import Data.Char (isAlphaNum, isSpace)+import qualified Data.List as L+import Data.Text (Text)+import qualified Data.Text as T++import ExchangeAlgebra.Algebra ( Alg, HatVal, Redundant((.+)), (.@) )+import ExchangeAlgebra.Algebra.Base ( AccountTitles(..)+ , HatBase(..)+ , Hat(..)+ , Side(..)+ , accountAliases+ , whichSide )+import ExchangeAlgebra.Algebra.Base.Account.Registry ( concreteAccountTitles )++-- $setup+-- The examples use 'Text' literals, so enable @OverloadedStrings@ in doctest.+-- >>> :set -XOverloadedStrings++-- | Conversion errors. Kept structural so callers can report precisely.+data ConvError = UnknownAccount Text -- ^ name matched no concrete account title+ | AmbiguousAccount Text [AccountTitles] -- ^ name matched several accounts (disambiguate by canonical name)+ | UnknownSide Text -- ^ side string was not debit\/credit+ | MalformedCsv Text -- ^ CSV header\/row was structurally invalid (see "ExchangeAlgebra.Convert.Csv")+ | BadAmount Text -- ^ amount field was not a valid non-negative number+ deriving (Eq, Show)++-- $concreteAccountTitles+-- 'concreteAccountTitles' lists all concrete account titles, /excluding/ the+-- wildcard 'AccountTitle', in stable Enum order. It is re-exported from+-- "ExchangeAlgebra.Algebra.Base.Account.Registry", which is the single+-- definition; external input must never resolve to the wildcard, so callers+-- should use this safe range rather than @[minBound .. maxBound]@.+--+-- >>> take 1 concreteAccountTitles+-- [Cash]+-- >>> Cash `elem` concreteAccountTitles && Sales `elem` concreteAccountTitles+-- True+-- >>> AccountsPayable `elem` concreteAccountTitles+-- True+-- >>> AccountTitle `elem` concreteAccountTitles+-- False++-- | Account-name lookup table: every key (canonical constructor names and the+-- Japanese\/abbreviation aliases below) is run through 'normalizeTitle', so matching is+-- case-, whitespace-, and symbol-insensitive. Keys are tagged so that a single+-- alias shared by several accounts is reported as 'AmbiguousAccount' rather than+-- silently resolving to whichever entry happens to come first.+--+-- The canonical English names (derived from 'show' over 'concreteAccountTitles')+-- are always unique, so they are never ambiguous and always resolve.+accountTable :: [(Text, [AccountTitles])]+accountTable = collapse [ (normalizeTitle k, a) | (k, a) <- entries ]+ where+ -- Group entries by normalised key, accumulating every account that key maps+ -- to (so genuine collisions become ambiguous, not first-wins).+ collapse kvs =+ [ (k, L.nub as)+ | k <- L.nub (L.map fst kvs)+ , let as = [ a | (k', a) <- kvs, k' == k ] ]++ entries :: [(Text, AccountTitles)]+ entries = canonical ++ aliases++ -- Canonical: the constructor name. Always unique.+ canonical = [ (T.pack (show a), a) | a <- concreteAccountTitles ]++ -- Aliases: Japanese labels (from the Element.hs translation Haddock, which is+ -- the canonical bilingual reference) plus a few English abbreviations.+ -- Genuinely shared labels are listed for each account; lookup then returns+ -- 'AmbiguousAccount' for those, forcing the caller to use a canonical name.+ aliases =+ [ (alias, title)+ | title <- aliasTitleOrder+ , alias <- accountAliases title+ ]++ -- Preserve the historical candidate order for the one shared label whose+ -- alias table order differs from Enum order: "通信費" reports the modern+ -- title before the deprecated one.+ aliasTitleOrder = CommunicationExpenses+ : L.delete CommunicationExpenses concreteAccountTitles++-- | Normalise an account name for matching: case-fold, drop punctuation\/symbols+-- (keep only alphanumerics and spaces), and collapse internal whitespace to+-- single spaces (also trimming). So @\"A\/R\"@ and @\"ar\"@ coincide, and+-- @\"Accounts Receivable\"@ matches @\"accounts receivable\"@. CJK characters+-- are alphanumeric (Unicode @Lo@), so Japanese labels survive unchanged.+--+-- (Renamed from @norm@: that name collides with the core value-domain+-- homomorphism 'ExchangeAlgebra.Algebra.norm', which is an entirely unrelated+-- operation — the two must not be confusable in downstream imports.)+--+-- >>> normalizeTitle " Accounts Receivable "+-- "accounts receivable"+-- >>> normalizeTitle "A/R"+-- "ar"+normalizeTitle :: Text -> Text+normalizeTitle = T.unwords . T.words . T.filter (\c -> isAlphaNum c || isSpace c) . T.toLower++-- | Parse an account name into a concrete 'AccountTitles'. Unknown names and the+-- wildcard are rejected; ambiguous Japanese labels (shared by several accounts)+-- are rejected with the candidates reported.+--+-- >>> parseAccountTitle "Cash"+-- Right Cash+-- >>> parseAccountTitle " accounts receivable "+-- Right AccountsReceivable+-- >>> parseAccountTitle "Accounts Receivable"+-- Right AccountsReceivable+-- >>> parseAccountTitle "A/R"+-- Right AccountsReceivable+-- >>> parseAccountTitle "売掛金"+-- Right AccountsReceivable+--+-- A label shared by two accounts (here the asset and liability side of reserve+-- deposits) is rejected as ambiguous, listing the candidates so the caller can+-- pick a canonical name:+--+-- >>> case parseAccountTitle "準備預金" of { Left (AmbiguousAccount _ as) -> as; _ -> [] }+-- [ReserveDepositReceivable,ReserveDepositPayable]+-- >>> parseAccountTitle "Goodwill_X"+-- Left (UnknownAccount "Goodwill_X")+-- >>> parseAccountTitle "AccountTitle"+-- Left (UnknownAccount "AccountTitle")+parseAccountTitle :: Text -> Either ConvError AccountTitles+parseAccountTitle t =+ case lookup (normalizeTitle t) accountTable of+ Just [a] -> Right a+ Just as -> Left (AmbiguousAccount t as)+ Nothing -> Left (UnknownAccount t)++-- | Parse a side string (@"debit"@\/@"credit"@, case-insensitive) into t'Side'.+--+-- >>> parseSide "debit"+-- Right Debit+-- >>> parseSide "CREDIT"+-- Right Credit+-- >>> parseSide "left"+-- Left (UnknownSide "left")+parseSide :: Text -> Either ConvError Side+parseSide t = case normalizeTitle t of+ "debit" -> Right Debit+ "credit" -> Right Credit+ _ -> Left (UnknownSide t)++-- | The @Hat@\/@Not@ marker that places @account@ on the requested debit\/credit+-- @side@. Derived from the library's 'whichSide' so the credit\/debit rule is not+-- duplicated: an account sits on its \"natural\" side under 'Not'; if the+-- requested side differs, the v'Hat' (reversal) marker is used.+--+-- >>> markerForSide Debit Cash+-- Not+-- >>> markerForSide Credit Cash+-- Hat+-- >>> markerForSide Credit Sales+-- Not+-- >>> markerForSide Debit Sales+-- Hat+markerForSide :: Side -> AccountTitles -> Hat+markerForSide side account+ | whichSide (Not :< account) == side = Not+ | otherwise = Hat++-- | Build a single posting term: @amount .\@ (marker :< account)@, with the+-- marker chosen so the posting lands on @side@. Built through the smart+-- constructor '(.@)' so the non-negative\/non-error value invariant is enforced.+--+-- >>> postingFromSide Debit Cash (1000 :: Double)+-- 1000.00:@Not:<Cash+-- >>> postingFromSide Credit Sales (1000 :: Double)+-- 1000.00:@Not:<Sales+postingFromSide :: (HatVal v)+ => Side -> AccountTitles -> v -> Alg v (HatBase AccountTitles)+postingFromSide side account amount =+ amount .@ (markerForSide side account :< account)++-- | Fold a list of @(side, account, amount)@ postings into one algebra term.+-- Balance (debit == credit) is /not/ enforced here; check it with the library's+-- balance functions after conversion.+--+-- >>> journalFromSides [(Debit, Cash, 1000), (Credit, Sales, 1000)] :: Alg Double (HatBase AccountTitles)+-- 1000.00:@Not:<Cash .+ 1000.00:@Not:<Sales+journalFromSides :: (HatVal v)+ => [(Side, AccountTitles, v)] -> Alg v (HatBase AccountTitles)+journalFromSides = foldr (\(s, a, v) acc -> postingFromSide s a v .+ acc) mempty
+ src/ExchangeAlgebra/Convert/Checked.hs view
@@ -0,0 +1,570 @@+{-# LANGUAGE FlexibleContexts #-}+{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}++{- |+Module : ExchangeAlgebra.Convert.Checked+Description : Checked construction for externally generated journal entries.++This module adds a validation layer for LLM- or runner-generated postings before+they are admitted as exchange-algebra terms. The unchecked constructor+'ExchangeAlgebra.Convert.journalFromSides' deliberately preserves its historical+semantics and does not enforce balance; the functions here reject malformed+entries with structural errors instead.+-}+module ExchangeAlgebra.Convert.Checked+ ( -- $postingPolicy+ ProcessingContext(..)+ , EntryError(..)+ , JournalError(..)+ , JournalCert(..)+ , SourceError(..)+ , postingAllowedIn+ , exactBalanced+ , checkedEntryIn+ , checkedEntry+ , checkedEntryTextIn+ , checkedEntryText+ , checkedJournalIn+ , checkedJournal+ , certifyJournalTextIn+ , certifyJournalText+ , reconcileSources+ ) where++import Data.List.NonEmpty (NonEmpty(..))+import qualified Data.HashMap.Strict as HM+import qualified Data.List as L+import qualified Data.Map.Strict as M+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import qualified Data.Text as T++import ExchangeAlgebra.Algebra+ ( Alg+ , ExBaseClass+ , Exchange(decL, decR)+ , HatVal(..)+ , Redundant((.+), norm)+ )+import ExchangeAlgebra.Accounting.PostingPolicy+ ( ProcessingContext(..)+ , postingAllowedIn+ , postingCapabilityFor+ )+import ExchangeAlgebra.Algebra.Base+ ( AccountTitles(..)+ , HatBase+ , PostingCapability(..)+ , Side(..)+ )+import ExchangeAlgebra.Convert+ ( ConvError+ , journalFromSides+ , normalizeTitle+ , parseAccountTitle+ , parseSide+ )+import ExchangeAlgebra.Journal+ ( Journal+ , Note+ , (.|)+ , toMap+ )++-- $setup+-- >>> :set -XOverloadedStrings+-- >>> import Data.List.NonEmpty (NonEmpty)+-- >>> import ExchangeAlgebra.Algebra (Alg, Redundant((.+), norm), Exchange(decL))+-- >>> import ExchangeAlgebra.Algebra.Base (HatBase, AccountTitles(..), Side(..))+-- >>> import ExchangeAlgebra.Convert (journalFromSides)+-- >>> import ExchangeAlgebra.Journal (Journal)++-- $postingPolicy+-- 'ProcessingContext' and 'postingAllowedIn' are defined in+-- "ExchangeAlgebra.Accounting.PostingPolicy" and re-exported here so the+-- historical import path keeps working. The gate itself is accounting-domain+-- policy, not an input-adapter concern.++-- | Validation errors for a single generated entry.+--+-- Posting positions are 0-origin indices in the input entry.+data EntryError v+ = EntryParse Int ConvError+ | NonPositiveAmount Int AccountTitles v+ | WildcardAccount Int+ | WildcardSide Int+ | PostingNotAllowed Int AccountTitles PostingCapability ProcessingContext+ | EmptyEntry+ | Imbalanced { _debitTotal :: v, _creditTotal :: v }+ deriving (Show, Eq)++-- | Validation errors for a txid-indexed journal batch.+data JournalError n v+ = EntryErrors n (NonEmpty (EntryError v))+ | DuplicateTxId n+ deriving (Show, Eq)++-- | Staged certification of a text-originated journal batch.+--+-- A batch can be fully admitted, rejected for accounting or structural+-- reasons, or certified as structurally valid and balanced while retaining+-- account-title resolution failures for a later vocabulary pass.+--+-- The distinction that motivates the type: an externally generated journal can+-- be double-entry valid and still name accounts this chart does not carry. Folding+-- both into one 'Either' makes a correct journal in an unsupported vocabulary+-- indistinguishable from a wrong one.+--+-- Note the two totals are /batch/ totals, whereas the balance test that gates+-- 'BalancedUnresolved' is applied per entry (per txid), matching 'checkedEntry'.+-- Per-entry balance implies batch balance, so the invariant+-- @_certDebitTotal == _certCreditTotal@ holds, but the converse does not: the+-- batch totals alone would not have been a sufficient gate.+data JournalCert n v+ = FullyResolved (Journal n v (HatBase AccountTitles))+ | BalancedUnresolved+ { _certResolved :: [(n, [(Side, AccountTitles, v)])]+ , _certUnresolved :: [(n, [(Int, Text, ConvError)])]+ , _certDebitTotal :: v+ , _certCreditTotal :: v+ }+ | Rejected (NonEmpty (JournalError n v))+ deriving (Show)++instance (HatVal v, Note n) => Eq (JournalCert n v) where+ FullyResolved left == FullyResolved right = toMap left == toMap right+ BalancedUnresolved resolvedLeft unresolvedLeft debitLeft creditLeft+ == BalancedUnresolved resolvedRight unresolvedRight debitRight creditRight =+ resolvedLeft == resolvedRight+ && unresolvedLeft == unresolvedRight+ && debitLeft == debitRight+ && creditLeft == creditRight+ Rejected left == Rejected right = left == right+ _ == _ = False++-- | Source-coverage errors between input transactions and a checked journal.+data SourceError n v+ = MissingSource n+ | UnknownSource n+ | AmountMismatch n v v+ deriving (Show, Eq)++-- | Exact debit-credit balance using strict equality.+--+-- Unlike the existing 'ExchangeAlgebra.Algebra.balance', this uses @(==)@ over+-- the value type and therefore does not admit tolerance-based near-matches.+--+-- >>> let Right ok = checkedEntry [(Debit, Cash, 100), (Credit, Sales, 100)] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles))+-- >>> exactBalanced ok+-- True+-- >>> let raw = journalFromSides [(Debit, Cash, 100), (Credit, Sales, 90)] :: Alg Double (HatBase AccountTitles)+-- >>> exactBalanced raw+-- False+exactBalanced :: (HatVal v, ExBaseClass b) => Alg v b -> Bool+exactBalanced x = norm (decL x) == norm (decR x)++-- | Construct one checked entry from parsed postings in a processing context.+--+-- The success value is built with 'journalFromSides', so accepted entries keep+-- the same posting semantics as the unchecked conversion path.+--+-- >>> fmap exactBalanced (checkedEntry [(Debit, Cash, 100), (Credit, Sales, 100)] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles)))+-- Right True+-- >>> checkedEntry [] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles))+-- Left (EmptyEntry :| [])+-- >>> checkedEntry [(Debit, Cash, 0), (Credit, Sales, 0)] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles))+-- Left (NonPositiveAmount 0 Cash 0.0 :| [NonPositiveAmount 1 Sales 0.0])+-- >>> checkedEntry [(Debit, AccountTitle, 1), (Side, Cash, 1)] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles))+-- Left (WildcardAccount 0 :| [WildcardSide 1,Imbalanced {_debitTotal = 1.0, _creditTotal = 0.0}])+-- >>> checkedEntry [(Debit, Cash, 100), (Credit, Sales, 90)] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles))+-- Left (Imbalanced {_debitTotal = 100.0, _creditTotal = 90.0} :| [])+-- >>> fmap exactBalanced (checkedEntryIn ClosingProcess [(Debit, Sales, 100), (Credit, IncomeSummary, 100)] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles)))+-- Right True+checkedEntryIn :: (HatVal v)+ => ProcessingContext+ -> [(Side, AccountTitles, v)]+ -> Either (NonEmpty (EntryError v)) (Alg v (HatBase AccountTitles))+checkedEntryIn context rows =+ case validateIndexed context (null rows) indexed of+ [] -> Right (journalFromSides rows)+ e : es -> Left (e :| es)+ where+ indexed = zip [0..] rows++-- | Construct one ordinary-journal entry. This compatibility API delegates+-- to 'checkedEntryIn' and therefore rejects closing-only, consolidation-only,+-- engine-generated, and non-postable coordinates.+checkedEntry :: (HatVal v)+ => [(Side, AccountTitles, v)]+ -> Either (NonEmpty (EntryError v)) (Alg v (HatBase AccountTitles))+checkedEntry = checkedEntryIn OrdinaryJournal++-- | Parse text-side runner input and then apply 'checkedEntry' validation.+--+-- Parse errors are accumulated as 'EntryParse' instead of failing fast.+--+-- >>> fmap exactBalanced (checkedEntryText [("debit", "Cash", 100), ("credit", "Sales", 100)] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles)))+-- Right True+-- >>> checkedEntryText [("left", "Cash", 100)] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles))+-- Left (EntryParse 0 (UnknownSide "left") :| [])+-- >>> checkedEntryText [("debit", "Goodwill_X", 100)] :: Either (NonEmpty (EntryError Double)) (Alg Double (HatBase AccountTitles))+-- Left (EntryParse 0 (UnknownAccount "Goodwill_X") :| [Imbalanced {_debitTotal = 100.0, _creditTotal = 0.0}])+checkedEntryTextIn :: (HatVal v)+ => ProcessingContext+ -> [(Text, Text, v)]+ -> Either (NonEmpty (EntryError v)) (Alg v (HatBase AccountTitles))+checkedEntryTextIn context rows =+ case parseErrors ++ structuralErrors ++ textImbalanceErrors of+ [] -> Right (journalFromSides (map snd parsedRows))+ e : es -> Left (e :| es)+ where+ parsed = map parseIndexed (zip [0..] rows)+ parseErrors = concatMap first parsed+ parsedRows = mapMaybe second parsed+ sideAmounts = mapMaybe third parsed+ structuralErrors = filter (not . isImbalance)+ (validateIndexed context (null rows) parsedRows)++ -- Account vocabulary resolution and arithmetic balance are independent.+ -- Retain every successfully parsed side/amount pair so an unknown account+ -- does not make an otherwise balanced entry look imbalanced.+ textImbalanceErrors+ | null rows = []+ | length sideAmounts /= length rows = []+ | any invalidSideAmount sideAmounts = []+ | debitTotal == creditTotal = []+ | otherwise = [Imbalanced debitTotal creditTotal]+ where+ debitTotal = L.foldl' (+) 0+ [ amount | (Debit, amount) <- sideAmounts ]+ creditTotal = L.foldl' (+) 0+ [ amount | (Credit, amount) <- sideAmounts ]++ invalidSideAmount (side, amount) =+ side == Side || isErrorValue amount || not (amount > 0)++ isImbalance Imbalanced{} = True+ isImbalance _ = False++ first (x, _, _) = x+ second (_, x, _) = x+ third (_, _, x) = x++ parseIndexed (i, (sideText, accountText, amount)) =+ let sideResult = parseSide sideText+ accountResult = parseAccountTitle accountText+ errs =+ (case sideResult of+ Left err -> [EntryParse i err]+ Right _ -> [])+ ++ (case accountResult of+ Left err -> [EntryParse i err]+ Right _ -> [])+ parsedRow = case (sideResult, accountResult) of+ (Right side, Right account) -> Just (i, (side, account, amount))+ _ -> Nothing+ sideAmount = case sideResult of+ Right side -> Just (side, amount)+ Left _ -> Nothing+ in (errs, parsedRow, sideAmount)++-- | Parse and check an ordinary-journal entry.+checkedEntryText :: (HatVal v)+ => [(Text, Text, v)]+ -> Either (NonEmpty (EntryError v)) (Alg v (HatBase AccountTitles))+checkedEntryText = checkedEntryTextIn OrdinaryJournal++-- | Construct a checked txid-indexed journal.+--+-- Duplicate txids are detected before entry construction, because the 'Journal'+-- representation merges equal notes.+--+-- >>> fmap (norm . decL) (checkedJournal [("tx1", [(Debit, Cash, 100), (Credit, Sales, 100)])] :: Either (NonEmpty (JournalError String Double)) (Journal String Double (HatBase AccountTitles)))+-- Right 100.0+-- >>> checkedJournal [("tx1", [(Debit, Cash, 100), (Credit, Sales, 100)]), ("tx1", [(Debit, Cash, 5), (Credit, Sales, 5)])] :: Either (NonEmpty (JournalError String Double)) (Journal String Double (HatBase AccountTitles))+-- Left (DuplicateTxId "tx1" :| [])+-- >>> checkedJournal [("bad", [(Debit, Cash, 1)])] :: Either (NonEmpty (JournalError String Double)) (Journal String Double (HatBase AccountTitles))+-- Left (EntryErrors "bad" (Imbalanced {_debitTotal = 1.0, _creditTotal = 0.0} :| []) :| [])+checkedJournalIn :: (HatVal v, Note n, Ord n)+ => ProcessingContext+ -> [(n, [(Side, AccountTitles, v)])]+ -> Either (NonEmpty (JournalError n v)) (Journal n v (HatBase AccountTitles))+checkedJournalIn context entries =+ case duplicateErrors ++ entryErrors of+ [] -> Right (L.foldl' (.+) mempty journals)+ e : es -> Left (e :| es)+ where+ counts = M.fromListWith (+) [ (txid, 1 :: Int) | (txid, _) <- entries ]+ isDuplicate txid = M.findWithDefault 0 txid counts > 1++ duplicateErrors =+ [ DuplicateTxId txid+ | (txid, count) <- M.toList counts+ , count > 1+ ]++ checkedUnique =+ [ (txid, checkedEntryIn context rows)+ | (txid, rows) <- entries+ , not (isDuplicate txid)+ ]++ entryErrors =+ [ EntryErrors txid errs+ | (txid, Left errs) <- checkedUnique+ ]++ journals =+ [ alg .| txid+ | (txid, Right alg) <- checkedUnique+ ]++-- | Construct an ordinary-journal batch.+checkedJournal :: (HatVal v, Note n, Ord n)+ => [(n, [(Side, AccountTitles, v)])]+ -> Either (NonEmpty (JournalError n v)) (Journal n v (HatBase AccountTitles))+checkedJournal = checkedJournalIn OrdinaryJournal++-- | Certify a text-originated journal in stages.+--+-- Duplicate txids and structural errors are rejected before balance is+-- considered. Balance is then checked using only parsed sides and amounts,+-- independently of account-title resolution. Consequently, a structurally+-- valid and balanced batch whose only remaining failures are unknown or+-- ambiguous account titles is returned as 'BalancedUnresolved'.+certifyJournalTextIn :: (HatVal v, Note n, Ord n)+ => ProcessingContext+ -> [(n, [(Text, Text, v)])]+ -> JournalCert n v+certifyJournalTextIn context entries =+ case duplicateErrors of+ e : es -> Rejected (e :| es)+ [] -> case structuralErrors of+ e : es -> Rejected (e :| es)+ [] -> case imbalanceErrors of+ e : es -> Rejected (e :| es)+ []+ | null unresolvedPostings ->+ FullyResolved (L.foldl' (.+) mempty journals)+ | otherwise ->+ BalancedUnresolved+ { _certResolved = resolvedEntries+ , _certUnresolved = unresolvedEntries+ , _certDebitTotal = debitTotal+ , _certCreditTotal = creditTotal+ }+ where+ counts = M.fromListWith (+) [ (txid, 1 :: Int) | (txid, _) <- entries ]+ duplicateErrors =+ [ DuplicateTxId txid+ | (txid, count) <- M.toList counts+ , count > 1+ ]++ parsedEntries =+ [ (txid, map parseCertPosting (zip [0..] rows))+ | (txid, rows) <- entries+ ]++ structuralErrors =+ [ EntryErrors txid (err :| errs)+ | (txid, rows) <- parsedEntries+ , let entryErrors = certStructuralErrors context rows+ , err : errs <- [entryErrors]+ ]++ imbalanceErrors =+ [ EntryErrors txid (Imbalanced debit credit :| [])+ | (txid, rows) <- parsedEntries+ , let (debit, credit) = certTotals rows+ , debit /= credit+ ]++ resolvedEntries =+ [ (txid, resolved)+ | (txid, rows) <- parsedEntries+ , let resolved =+ [ (side, account, amount)+ | (_, _, amount, Right side, Right account) <- rows+ ]+ , not (null resolved)+ ]++ unresolvedEntries =+ [ (txid, unresolved)+ | (txid, rows) <- parsedEntries+ , let unresolved =+ [ (i, accountText, err)+ | (i, accountText, _, Right _, Left err) <- rows+ ]+ , not (null unresolved)+ ]++ unresolvedPostings = concatMap snd unresolvedEntries++ (debitTotal, creditTotal) =+ L.foldl' addTotals (0, 0) (map (certTotals . snd) parsedEntries)++ addTotals (debits, credits) (entryDebits, entryCredits) =+ (debits + entryDebits, credits + entryCredits)++ journals =+ [ journalFromSides rows .| txid+ | (txid, rows) <- resolvedEntries+ ]++-- | Certify an ordinary-journal batch in stages.+certifyJournalText :: (HatVal v, Note n, Ord n)+ => [(n, [(Text, Text, v)])]+ -> JournalCert n v+certifyJournalText = certifyJournalTextIn OrdinaryJournal++-- The tuple retains the original account text so resolution failures can be+-- reported without reconstructing user input.+type CertPosting v =+ (Int, Text, v, Either ConvError Side, Either ConvError AccountTitles)++-- Wildcard names have to be recognised here rather than delegated to the+-- parsers: 'parseAccountTitle' and 'parseSide' reject the wildcard+-- constructors by design (the /correct-by-construction/ guard documented in+-- "ExchangeAlgebra.Convert"), so on the text path a wildcard is indistinguishable+-- from an unknown name. Certification must tell them apart, because a wildcard+-- is a structural defect ('Rejected') whereas an unknown name is a vocabulary+-- gap ('BalancedUnresolved'). Matching uses the parsers' own 'normalizeTitle',+-- so the two paths cannot drift apart.+parseCertPosting :: (Int, (Text, Text, v)) -> CertPosting v+parseCertPosting (i, (sideText, accountText, amount)) =+ ( i+ , accountText+ , amount+ , if normalizeTitle sideText == T.pack "side"+ then Right Side+ else parseSide sideText+ , if normalizeTitle accountText == T.pack "accounttitle"+ then Right AccountTitle+ else parseAccountTitle accountText+ )++certStructuralErrors :: (HatVal v)+ => ProcessingContext+ -> [CertPosting v]+ -> [EntryError v]+certStructuralErrors context rows =+ [ EmptyEntry | null rows ] ++ concatMap rowErrors rows+ where+ rowErrors (i, _, amount, sideResult, accountResult) =+ [ EntryParse i err | Left err <- [sideResult] ]+ ++ [ NonPositiveAmount i (resolvedOrWildcard accountResult) amount+ | isErrorValue amount || not (amount > 0)+ ]+ ++ [ WildcardAccount i | Right AccountTitle <- [accountResult] ]+ ++ [ WildcardSide i | Right Side <- [sideResult] ]+ ++ [ PostingNotAllowed i account capability context+ | Right account <- [accountResult]+ , account /= AccountTitle+ , let capability = postingCapabilityFor account+ , not (postingAllowedIn context capability)+ ]++ resolvedOrWildcard (Right account) = account+ resolvedOrWildcard (Left _) = AccountTitle++certTotals :: (HatVal v) => [CertPosting v] -> (v, v)+certTotals rows =+ ( L.foldl' (+) 0+ [ amount | (_, _, amount, Right Debit, _) <- rows ]+ , L.foldl' (+) 0+ [ amount | (_, _, amount, Right Credit, _) <- rows ]+ )++-- | Compare source transactions against the note-indexed journal coverage.+--+-- The journal side is inspected with 'toMap', giving the per-note 'Alg'. The+-- actual amount is each note's debit-side total, @norm . decL@.+--+-- >>> let Right j = checkedJournal [("tx1", [(Debit, Cash, 100), (Credit, Sales, 100)])] :: Either (NonEmpty (JournalError String Double)) (Journal String Double (HatBase AccountTitles))+-- >>> reconcileSources [("tx1", 100)] j+-- []+-- >>> reconcileSources [("tx1", 90)] j+-- [AmountMismatch "tx1" 90.0 100.0]+-- >>> reconcileSources [("tx1", 100), ("tx2", 5)] j+-- [MissingSource "tx2"]+-- >>> let Right j2 = checkedJournal [("tx2", [(Debit, Cash, 5), (Credit, Sales, 5)])] :: Either (NonEmpty (JournalError String Double)) (Journal String Double (HatBase AccountTitles))+-- >>> reconcileSources [("tx1", 100)] (j .+ j2)+-- [UnknownSource "tx2"]+reconcileSources :: (HatVal v, Note n, Ord n)+ => [(n, v)]+ -> Journal n v (HatBase AccountTitles)+ -> [SourceError n v]+reconcileSources sources journal =+ missing ++ unknown ++ mismatched+ where+ expected = M.fromList sources+ actual = M.fromList (HM.toList (toMap journal))++ missing =+ [ MissingSource txid+ | (txid, _) <- sources+ , M.notMember txid actual+ ]++ unknown =+ [ UnknownSource txid+ | txid <- M.keys actual+ , M.notMember txid expected+ ]++ mismatched =+ [ AmountMismatch txid expectedAmount actualAmount+ | (txid, expectedAmount) <- sources+ , Just alg <- [M.lookup txid actual]+ , let actualAmount = norm (decL alg)+ , expectedAmount /= actualAmount+ ]++validateIndexed :: (HatVal v)+ => ProcessingContext+ -> Bool+ -> [(Int, (Side, AccountTitles, v))]+ -> [EntryError v]+validateIndexed context rawEmpty rows =+ emptyErrors ++ rowErrors ++ imbalanceErrors+ where+ emptyErrors = [ EmptyEntry | rawEmpty ]++ rowErrors = concatMap validateRow rows++ validateRow (i, (side, account, amount)) =+ [ NonPositiveAmount i account amount+ | invalidAmount amount+ ]+ ++ [ WildcardAccount i | account == AccountTitle ]+ ++ [ WildcardSide i | side == Side ]+ ++ [ PostingNotAllowed i account capability context+ | account /= AccountTitle+ , let capability = postingCapabilityFor account+ , not (postingAllowedIn context capability)+ ]++ invalidAmount amount = isErrorValue amount || not (amount > 0)++ imbalanceErrors+ | null rows = []+ | otherwise =+ let (debitTotal, creditTotal) = totals rows rowErrors+ in [ Imbalanced debitTotal creditTotal | debitTotal /= creditTotal ]++totals :: (HatVal v)+ => [(Int, (Side, AccountTitles, v))]+ -> [EntryError v]+ -> (v, v)+totals rows rowErrors+ | null rowErrors =+ let alg = journalFromSides (map snd rows)+ in (norm (decL alg), norm (decR alg))+ | otherwise =+ let debitTotal = L.foldl' (+) 0+ [ amount | (_, (Debit, _, amount)) <- rows ]+ creditTotal = L.foldl' (+) 0+ [ amount | (_, (Credit, _, amount)) <- rows ]+ in (debitTotal, creditTotal)
+ src/ExchangeAlgebra/Convert/Csv.hs view
@@ -0,0 +1,175 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}++{- |+Module : ExchangeAlgebra.Convert.Csv+Description : Fixed-schema CSV reader for general journal postings.++A deliberately tiny, dependency-free (Text + scientific only) reader for a fixed+journal CSV schema, building on the pure normalisation\/parsing in+"ExchangeAlgebra.Convert". It is the read counterpart of the report\/ledger CSV+writers in "ExchangeAlgebra.Write" (writing is /not/ handled here).++The schema is a header line @side,account,amount@ with an optional trailing+@note@ column:++> side,account,amount+> debit,Cash,1000+> credit,Sales,1000++Blank lines and lines whose first non-space character is @#@ are skipped;+surrounding whitespace on each field is trimmed; there is no quoting. The field+splitter 'splitTrim' is exported and shared with the equally minimal+@parseEdgeCsv@\/@parseCoefCsv@ readers in "ExchangeAlgebra.Simulate.Network", so+the two readers cannot drift apart on how a line is split.++@account@ is resolved with 'parseAccountTitle' (canonical English names or the+Japanese\/abbreviation aliases), so unknown and ambiguous account names are+rejected. @side@ is resolved with 'parseSide'. @amount@ is parsed by a+caller-supplied function so the value type @v@ stays open; 'scientificAmount'+covers the common case (a non-negative decimal literal via 'Data.Scientific').+All values are placed through the non-negative smart constructor @(.@)@ inside+'postingFromSide'.+-}+module ExchangeAlgebra.Convert.Csv+ ( -- * Parsing journal CSV+ parseJournalCsv+ , parseJournalCsvWith+ -- * Note-keyed journal (when the optional @note@ column is present)+ , parseNotedJournalCsv+ -- * Amount parsers+ , scientificAmount+ -- * Field splitting+ , splitTrim+ ) where++import qualified Data.Scientific as Sci+import Data.Text (Text)+import qualified Data.Text as T++import ExchangeAlgebra.Algebra ( Alg, HatVal, Redundant((.+)) )+import ExchangeAlgebra.Algebra.Base ( AccountTitles, HatBase, Side )+import ExchangeAlgebra.Convert ( ConvError(..)+ , parseAccountTitle+ , parseSide+ , postingFromSide )++-- $setup+-- >>> :set -XOverloadedStrings+-- >>> import ExchangeAlgebra.Algebra (Alg)+-- >>> import ExchangeAlgebra.Algebra.Base (HatBase, AccountTitles)++-- | Parse a non-negative decimal amount via 'Data.Scientific' (e.g. @"1000"@,+-- @"1000.50"@, @"1.5e3"@), converting to the value type @v@ exactly through+-- 'toRational' (so exact-decimal types such as @MoneyDecimal@ keep their+-- precision for terminating decimals). Negative amounts are rejected here so the+-- error is reported as a 'BadAmount' rather than surfacing later from @(.@)@.+--+-- >>> scientificAmount "1000.50" :: Either ConvError Double+-- Right 1000.5+-- >>> scientificAmount "-3" :: Either ConvError Double+-- Left (BadAmount "-3")+-- >>> scientificAmount "1,000" :: Either ConvError Double+-- Left (BadAmount "1,000")+scientificAmount :: (Fractional v) => Text -> Either ConvError v+scientificAmount t =+ case reads (T.unpack (T.strip t)) :: [(Sci.Scientific, String)] of+ [(s, "")] | s >= 0 -> Right (fromRational (toRational s))+ | otherwise -> Left (BadAmount t)+ _ -> Left (BadAmount t)++-- | Parse a fixed-schema journal CSV into a single algebra term, using+-- 'scientificAmount' for the amount column. The note column, if present, is+-- ignored at the 'Alg' level (use 'parseNotedJournalCsv' to keep notes).+--+-- >>> let csv = "side,account,amount\ndebit,Cash,1000\ncredit,Sales,1000\n"+-- >>> parseJournalCsv csv :: Either ConvError (Alg Double (HatBase AccountTitles))+-- Right 1000.00:@Not:<Cash .+ 1000.00:@Not:<Sales+parseJournalCsv :: (HatVal v, Fractional v)+ => Text -> Either ConvError (Alg v (HatBase AccountTitles))+parseJournalCsv = parseJournalCsvWith scientificAmount++-- | As 'parseJournalCsv', but with a caller-supplied amount parser, so the value+-- type @v@ need not be 'Fractional' (e.g. an integral or fixed-point reader).+parseJournalCsvWith :: (HatVal v)+ => (Text -> Either ConvError v)+ -> Text+ -> Either ConvError (Alg v (HatBase AccountTitles))+parseJournalCsvWith amount txt = do+ rows <- parseRows amount txt+ Right (foldr (\(s, a, v) acc -> postingFromSide s a v .+ acc) mempty rows)++-- | Parse the rows of a journal CSV into @(Side, AccountTitles, value, note)@+-- tuples, where @note@ is the optional 4th column (empty 'Text' if absent). The+-- note is returned as raw 'Text' so the caller can key an+-- 'ExchangeAlgebra.Journal.Journal' by it (the+-- 'ExchangeAlgebra.Journal.Note' instance for 'Text' makes the @(.|)@ operator+-- immediately applicable); this reader deliberately stays at the algebra+-- level and does not construct an 'ExchangeAlgebra.Journal.Journal' itself, to+-- avoid pulling the Journal module into the dependency-light conversion layer.+--+-- >>> let csv = "side,account,amount,note\ndebit,Cash,1000,opening\n"+-- >>> parseNotedJournalCsv scientificAmount csv :: Either ConvError [(Side, AccountTitles, Double, Text)]+-- Right [(Debit,Cash,1000.0,"opening")]+parseNotedJournalCsv :: (HatVal v)+ => (Text -> Either ConvError v)+ -> Text+ -> Either ConvError [(Side, AccountTitles, v, Text)]+parseNotedJournalCsv amount txt = do+ keptHeader <- splitHeader txt+ traverse (rowNoted amount) keptHeader++-- | Split one line on commas and strip surrounding whitespace from each field.+-- No quoting is recognised, so a comma inside a field always separates. An+-- empty line yields a single empty field, matching 'T.splitOn'.+splitTrim :: Text -> [Text]+splitTrim = map T.strip . T.splitOn ","++------------------------------------------------------------------+-- Internal: minimal CSV plumbing (no quoting), header check, row parse.+------------------------------------------------------------------++-- Parse to bare (Side, AccountTitles, v) triples (note dropped).+parseRows :: (Text -> Either ConvError v)+ -> Text+ -> Either ConvError [(Side, AccountTitles, v)]+parseRows amount txt = do+ keptHeader <- splitHeader txt+ traverse (\fs -> (\(s, a, v, _) -> (s, a, v)) <$> rowNoted amount fs) keptHeader++-- | Drop blank\/comment lines, validate the header (@side,account,amount@ with an+-- optional @note@ column), and return the remaining data rows as already-split,+-- trimmed field lists.+splitHeader :: Text -> Either ConvError [[Text]]+splitHeader txt =+ case keptLines of+ [] -> Left (MalformedCsv "empty CSV (no header)")+ (h : body)+ | header == ["side", "account", "amount"]+ || header == ["side", "account", "amount", "note"]+ -> Right (map splitTrim body)+ | otherwise -> Left (MalformedCsv+ ("unexpected header: " <> T.intercalate "," header+ <> " (expected side,account,amount[,note])"))+ where header = map T.toLower (splitTrim h)+ where+ keptLines = filter keep (T.lines txt)+ keep l = let s = T.strip l in not (T.null s) && not ("#" `T.isPrefixOf` s)++-- | Parse one already-split row into @(Side, AccountTitles, value, note)@.+rowNoted :: (Text -> Either ConvError v)+ -> [Text]+ -> Either ConvError (Side, AccountTitles, v, Text)+rowNoted amount fields = case fields of+ [s, a, v] -> build s a v ""+ [s, a, v, n] -> build s a v n+ other -> Left (MalformedCsv+ ("row expected 3 or 4 fields, got "+ <> T.pack (show (length other)) <> ": "+ <> T.intercalate "," other))+ where+ build s a v n = do+ side <- parseSide s+ account <- parseAccountTitle a+ value <- amount v+ Right (side, account, value, n)
src/ExchangeAlgebra/Journal.hs view
@@ -39,8 +39,9 @@ , Note(..) , NoteAxisKey(..) , NoteAxisPosting- , Journal(..)+ , Journal , pattern ExchangeAlgebra.Journal.Zero+ , mkJournal , (.|) , toAlg , toMap@@ -50,12 +51,16 @@ , sigma2When , sigmaOn , sigmaOnFromMap+ , decTo , sigmaM , map+ , replaceNotes , insert , projWithNote , projWithBase , projWithNoteBase+ , projWithBaseNetNorm+ , projWithNoteBaseNetNorm , projWithBaseNorm , projWithNoteNorm , filterWithNote@@ -66,7 +71,6 @@ import qualified ExchangeAlgebra.Algebra as EA import ExchangeAlgebra.Algebra.Base import ExchangeAlgebra.Algebra ( HatVal(..)- , HatBaseClass(..) , Alg(..) , Redundant(..) , Exchange(..)@@ -78,11 +82,11 @@ import Data.IntMap.Strict (IntMap) import qualified Data.HashSet as HSet import Data.HashSet (HashSet)-import Control.Parallel.Strategies (using, parTraversable, rdeepseq, NFData)+import Control.Parallel.Strategies (NFData)+import Control.DeepSeq (rnf) import qualified Data.Set as S import qualified Data.List as L import qualified Data.Map.Strict as M-import Data.Hashable import qualified Data.Text as T import qualified Control.Monad as CM import qualified Data.Binary as Binary@@ -176,6 +180,30 @@ -- indexing, mirroring how @toAxisKeys@ works for basis elements. -- For tuple Note types, each component becomes a separate axis. -- The default returns a single axis containing the Note itself.+--+-- == Prefer an ADT note over @String@+--+-- A note's /event/ axis is best modelled as a small enumeration (an ADT)+-- rather than a @String@. A @String@ tag is a __stringly-typed semantic key__:+-- the same literal must appear at the write site (@.| (\"trade\", t)@) and at+-- every read site (@projWithNote [(\"trade\", t)]@), and a typo on either side+-- still type-checks — the projection just /silently matches nothing/. With an+-- ADT a mistyped constructor is a __compile error__, so reads and writes can+-- never drift apart. Add 'plank' as its own explicit constructor (the note is a+-- pointed set, so the blank tag is a distinguished element, not the empty+-- string):+--+-- @+-- data MTag = PlankTag | Trade | Production | Report | Closing | Carryover+-- deriving (Show, Eq, Ord, Enum, Bounded, Generic)+-- instance Hashable MTag+-- instance Note MTag where plank = PlankTag+-- type MNote = (MTag, Int) -- the tuple instance keeps the (event, term) index+-- @+--+-- (The @marketEx1@ example and the @SimEvent@ note in the test suite follow+-- this pattern.) If the ledger is ever spilled\/restored, also give the tag a+-- @Binary@ instance (structurally derivable from @Generic@). class (Show a, Eq a, Ord a, Hashable a, Typeable a) => Note a where plank :: a isPlank :: a -> Bool@@ -227,11 +255,18 @@ -- Stored in a base + delta two-layer structure with per-axis indices. -- Base index is lazy (built on first axis query), while delta index is updated incrementally. -- Updates are appended only to delta and periodically compacted into base.+--+-- __Invariants.__ The constructor is not exported; build with 'mkJournal',+-- '(.|)' or 'fromList'. Its internal cache\/index fields @_jBaseAxis@ and+-- @_jDeltaAxis@ must be+-- exactly the Note axis indices (@buildNoteAxisPosting@) of @_jBase@ and+-- @_jDelta@ respectively. The axis-filtered query path ('filterByAxis') reads+-- those indices, so a value whose indices disagree with its maps yields wrong+-- answers silently (not an exception). data Journal n v b where Journal :: (Note n, HatVal v, HatBaseClass b) => { _jBase :: !(Map.HashMap n (Alg v b)) , _jDelta :: !(Map.HashMap n (Alg v b))- , _jVersion :: !Int , _jBaseAxis :: NoteAxisPosting n , _jDeltaAxis :: !(NoteAxisPosting n) } -> Journal n v b@@ -247,14 +282,16 @@ (\acc n _ -> insertNoteAxisPosting (toNoteAxisKeys n) n acc) emptyNoteAxisPosting --- | Smart constructor for Journal.--- Base axis index is lazy; delta axis index is built eagerly.+-- | The only safe way to build a t'Journal' from a base layer and a delta+-- layer. It keeps the Note-axis indices consistent with both layers; see the+-- invariants note on t'Journal'. The base axis index is lazy, while the delta+-- axis index is built eagerly. {-# INLINE mkJournal #-} mkJournal :: (Note n, HatVal v, HatBaseClass b)- => Map.HashMap n (Alg v b) -> Map.HashMap n (Alg v b) -> Int -> Journal n v b-mkJournal base delta ver = Journal base delta ver baseIdx deltaIdx+ => Map.HashMap n (Alg v b) -> Map.HashMap n (Alg v b) -> Journal n v b+mkJournal bs delta = Journal bs delta baseIdx deltaIdx where- ~baseIdx = buildNoteAxisPosting base+ ~baseIdx = buildNoteAxisPosting bs !deltaIdx = buildNoteAxisPosting delta -- | Construct a Journal from a HashMap.@@ -263,7 +300,7 @@ {-# INLINE fromMap #-} fromMap :: (HatVal v, HatBaseClass b, Note n) => Map.HashMap n (Alg v b) -> Journal n v b-fromMap m = mkJournal m Map.empty 0+fromMap m = mkJournal m Map.empty -- | Retrieve all entries of a Journal as a HashMap. -- Merges the base and delta layers.@@ -275,16 +312,24 @@ toMap = materializeMap {-# INLINE materializeMap #-}+-- Short-circuits when one layer is empty: returns the other layer verbatim+-- instead of building a fresh map via 'Map.unionWith'. This is the common+-- case for @fromMap@ products (delta empty — the Lite @sigma msgs id@ shape)+-- and for freshly @(.|)@-built journals (base empty). Values are untouched,+-- so the result is identical to the unconditional union+-- (audit R5 / ROAD_MAP P1b). materializeMap :: (HatVal v, HatBaseClass b, Note n) => Journal n v b -> Map.HashMap n (Alg v b)-materializeMap (Journal base delta _ _ _) =- Map.unionWith (.+) base delta+materializeMap (Journal bs delta _ _)+ | Map.null delta = bs+ | Map.null bs = delta+ | otherwise = Map.unionWith (.+) bs delta {-# INLINE lookupNote #-} lookupNote :: (HatVal v, HatBaseClass b, Note n) => n -> Journal n v b -> Maybe (Alg v b)-lookupNote n (Journal base delta _ _ _) =- case (Map.lookup n delta, Map.lookup n base) of+lookupNote n (Journal bs delta _ _) =+ case (Map.lookup n delta, Map.lookup n bs) of (Nothing, Nothing) -> Nothing (Just d, Nothing) -> Just d (Nothing, Just b) -> Just b@@ -293,27 +338,39 @@ {-# INLINE compactIfNeeded #-} compactIfNeeded :: (HatVal v, HatBaseClass b, Note n) => Journal n v b -> Journal n v b-compactIfNeeded j@(Journal base delta ver _ _)+compactIfNeeded j@(Journal bs delta _ _) | Map.size delta < deltaCompactThreshold = j- | otherwise = mkJournal (Map.unionWith (.+) base delta) Map.empty (ver + 1)+ | otherwise = mkJournal (Map.unionWith (.+) bs delta) Map.empty {-# INLINE appendMap #-} appendMap :: (HatVal v, HatBaseClass b, Note n) => Map.HashMap n (Alg v b) -> Journal n v b -> Journal n v b-appendMap rhs j@(Journal base delta ver baseAxis deltaAxis)+appendMap rhs j@(Journal bs delta baseAxis deltaAxis) | Map.null rhs = j- | otherwise = compactIfNeeded $ Journal base delta' (ver + 1) baseAxis deltaAxis'+ | otherwise = compactIfNeeded $ Journal bs delta' baseAxis deltaAxis' where (delta', deltaAxis') = Map.foldlWithKey' step (delta, deltaAxis) rhs step (!dAcc, !idxAcc) !k !v =- let !dMerged = case Map.lookup k dAcc of- Nothing -> v- Just dv -> dv .+ v- !keys = toNoteAxisKeys k- in if EA.isZero dMerged- then (Map.delete k dAcc, deleteNoteAxisPosting keys k idxAcc)- else (Map.insert k dMerged dAcc, insertNoteAxisPosting keys k idxAcc)+ case Map.lookup k dAcc of+ -- New note key: index it (insert) unless the value is zero.+ Nothing+ | EA.isZero v -> (dAcc, idxAcc)+ | otherwise ->+ ( Map.insert k v dAcc+ , insertNoteAxisPosting (toNoteAxisKeys k) k idxAcc )+ -- Existing note key: 'k' is already in the delta axis index+ -- (the index tracks exactly the keys present in delta, and the+ -- note IS the key, so its axis keys are unchanged). The+ -- re-insert was therefore idempotent — skip it. Only a result+ -- that collapses to zero changes the index, via delete+ -- (audit R5 / ROAD_MAP P1b).+ Just dv ->+ let !dMerged = dv .+ v+ in if EA.isZero dMerged+ then ( Map.delete k dAcc+ , deleteNoteAxisPosting (toNoteAxisKeys k) k idxAcc )+ else ( Map.insert k dMerged dAcc, idxAcc ) instance ( Note n , HatVal v@@ -346,19 +403,19 @@ -- Complexity: O(1) isZero :: (HatVal v, HatBaseClass b, Note n) => Journal n v b -> Bool-isZero (Journal base delta _ _ _) = Map.null base && Map.null delta+isZero (Journal bs delta _ _) = Map.null bs && Map.null delta pattern Zero :: (HatVal v, HatBaseClass b, Note n) => Journal n v b pattern Zero <- (isZero -> True) where- Zero = mkJournal Map.empty Map.empty 0+ Zero = mkJournal Map.empty Map.empty -- | Smart constructor that attaches a Note (annotation) to an algebra element to build a Journal. -- -- Complexity: O(1) (.|) :: (HatVal v, HatBaseClass b, Note n) => Alg v b -> n -> Journal n v b-(.|) alg n = mkJournal Map.empty (Map.singleton n alg) 1+(.|) alg n = mkJournal Map.empty (Map.singleton n alg) infixr 2 .| @@ -398,9 +455,24 @@ addJournal lhs rhs = appendMap (toMap rhs) lhs instance (HatVal v, HatBaseClass b, Note n) => Monoid (Journal n v b) where- mempty = mkJournal Map.empty Map.empty 0+ mempty = mkJournal Map.empty Map.empty mappend = (<>) +-- | Shallow-structural 'NFData', mirroring the @'Alg' v b@ instance in+-- "ExchangeAlgebra.Algebra": it forces the two-layer @base@ / @delta@ maps to+-- WHNF on their spines and forces every contained 'Alg' (via the 'Alg' 'NFData'+-- instance), but does not touch the lazily built axis indices (@_jBaseAxis@ /+-- @_jDeltaAxis@), which are derived caches. This is enough for+-- 'Control.Parallel.Strategies.rdeepseq' to fully evaluate journal "messages"+-- before merging them in parallel.+instance NFData (Journal n v b) where+ rnf (Journal bs delta _ _) =+ Map.foldr (\alg acc -> rnf alg `seq` acc)+ (Map.foldr (\alg acc -> rnf alg `seq` acc)+ ()+ bs)+ delta+ instance (HatVal v, HatBaseClass b, Note n) => Redundant (Journal n) v b where (.^) = map (.^) (.+) = mappend@@ -427,23 +499,44 @@ l = (norm . decL) xs --------------------------------------------------------------------- | fromList+-- | Build a t'Journal' from a list of postings. @O(N)@ via a strict left fold+-- (@L.foldl' (.+) mempty@). --+-- == Why a strict left fold+--+-- This was previously a lazy right fold (@foldr (.+) mempty@), kept that way to+-- freeze the same-base accumulation order for 'Double' (whose addition is+-- non-associative, so reordering shifts the last-ULP of 'norm'). The strict left+-- fold is far cheaper at scale — the lazy right fold builds a deep right-nested+-- thunk that is expensive to force (≈40x at N=20000, ~15x at N=10000 in the core+-- benchmark). The fold direction was switched to the fast version once the+-- audited/exact path moved to t'ExchangeAlgebra.Value.MoneyDecimal'; see+-- plans/in-progress/LAZY_EVAL_AUDIT.md and SELECTABLE_VALUE_TYPE_PLAN.md.+--+-- == Behaviour contract+--+-- 'fromList' preserves the /multiset/ of postings exactly (no posting is added,+-- dropped, or merged across bases that would otherwise be kept apart). When two+-- postings collide on the /same note key and same base/ (and therefore land in+-- one 'ExchangeAlgebra.Algebra.Alg' sequence) the strict left fold orders that+-- @Seq@ opposite to the old lazy right fold. That order is observable through+-- 'Eq' \/ 'Show' \/ 'toAlg' \/ @Binary@, and for 'Double' through the last-ULP of+-- 'norm' \/ 'bar' (IEEE-754 addition is non-associative). Postings that differ in+-- note or base land in separate map entries and are unaffected.+--+-- For an /exact/ value type (t'ExchangeAlgebra.Value.MoneyDecimal') addition is+-- associative, so 'norm' \/ 'bar' \/ balance are independent of construction order+-- (the fold direction here, parallel merges, etc.). Use+-- t'ExchangeAlgebra.Value.MoneyDecimal' when you need+-- deterministic, auditable totals.+-- -- >>> type Test = Journal String Double (HatBase AccountTitles) -- >>> 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+fromList = L.foldl' (.+) mempty ------------------------------------------------------------------ {-# INLINE mergeJournalMap #-}@@ -451,10 +544,10 @@ => Map.HashMap n (Alg v b) -> Journal n v b -> Map.HashMap n (Alg v b)-mergeJournalMap !acc (Journal base delta _ _ _)- | Map.null base && Map.null delta = acc+mergeJournalMap !acc (Journal bs delta _ _)+ | Map.null bs && Map.null delta = acc | otherwise =- let !acc1 = Map.foldlWithKey' mergeOne acc base+ let !acc1 = Map.foldlWithKey' mergeOne acc bs in Map.foldlWithKey' mergeOne acc1 delta where mergeOne !m !n !alg@@ -531,13 +624,49 @@ then mempty else alg .| n +-- | Quotient decomposition into the Journal (dec_κ landing on the graded+-- carrier): partition an 'Alg' along the classes induced by a classifier and+-- return the family as a t'Journal' keyed by the class 'Note'.+--+-- A t'Journal' is exactly a finite map @Note → Alg@ (paper Definition 12), i.e.+-- the library's native \"keyed family of algebras\" — so the decomposition+-- stays inside the algebra vocabulary (no external container in the result).+-- Each note's entry is the redundancy-preserving restriction of the input to+-- that class (same guarantees as 'EA.decBy'): no 'bar', no aggregation, and+-- @norm (decTo keyOf x) + norm residual == norm x@ (norm additivity of the+-- decomposition).+--+-- Entries classified to @Nothing@ or to 'plank' are dropped as residual+-- ('plank' is the blank note and cannot carry a class).+--+-- Complexity: O(m) single pass (via 'EA.decBy') + O(k) journal construction.+--+-- >>> type TJ = Journal String Double (HatBase AccountTitles)+-- >>> let alg = 100 :@ Not:<Cash .+ 30 :@ Hat:<Cash .+ 50 :@ Not:<Deposits :: Alg Double (HatBase AccountTitles)+-- >>> let j = decTo (\(_ :< a) -> Just (if a == Cash then "cash" else "other")) alg :: TJ+-- >>> norm j+-- 180.0+--+-- >>> norm (projWithNote ["cash"] j)+-- 130.0+{-# INLINE decTo #-}+decTo :: (HatVal v, HatBaseClass b, Note n)+ => (b -> Maybe n)+ -> Alg v b+ -> Journal n v b+decTo kf x =+ fromMap $ Map.fromList+ [ (n, alg)+ | (n, alg) <- M.toList (EA.decBy kf x)+ , not (isPlank n) ]+ -- | 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+-- 'Alg'/t'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).@@ -550,11 +679,11 @@ -- Complexity: O(total number of base keys across all Notes) toAlg :: (HatVal v, HatBaseClass b, Note n) => Journal n v b -> Alg v b-toAlg (Journal base delta _ _ _) =+toAlg (Journal bs 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)+ EA.unionsMerge (Map.foldr (:) (Map.elems delta) bs) ------------------------------------------------------------------ -- | Apply function f to the entry of each Note in the Journal.@@ -565,14 +694,21 @@ => (Alg v b -> Alg v b) -> Journal n v b -> Journal n v b map f = fromMap . Map.map f . toMap -parallelMap :: (NFData b, Ord k) => (a -> b) -> Map.HashMap k a -> Map.HashMap k b-parallelMap f m = Map.map f m `using` parTraversable rdeepseq+-- NB. The unused 'parallelMap'\/'parMap' helpers (a 'Control.Parallel.Strategies'+-- based variant of 'map') were removed as dead code: neither was exported nor+-- called. Reintroduce from history if a parallel journal map is needed. -parMap :: (HatVal v, HatBaseClass b, Note n)- => (Alg v b -> Alg v b) -> Journal n v b -> Journal n v b-parMap f = fromMap . parallelMap f . toMap+-- | Left-biased replacement of whole Notes. If a Note in the left Journal also+-- exists in the right Journal, the complete left value replaces the right+-- value. This is not redundant addition; contrast with '(.+)'.+--+-- Complexity: O(n + m) where n, m are the number of Notes in each Journal+replaceNotes :: (HatVal v, HatBaseClass b, Note n)+ => Journal n v b -> Journal n v b -> Journal n v b+replaceNotes x y = fromMap (Map.union (toMap x) (toMap y)) --- | Insert x into y. If x's Note already exists in y, it is overwritten with x's value.+{-# DEPRECATED insert "Use replaceNotes; insert replaces whole notes and is not the redundant (.+)" #-}+-- | Deprecated alias for 'replaceNotes'. -- -- >>> type Test = Journal String Double (HatBase AccountTitles) -- >>> x = 10.00:@Not:<Cash .| "A" :: Test@@ -582,13 +718,15 @@ -- 20.00:@Not:<Cash.|"B" .+ 30.00:@Hat:<Cash.|"A" insert :: (HatVal v, HatBaseClass b, Note n) => Journal n v b -> Journal n v b -> Journal n v b--- Complexity: O(n + m) where n, m are the number of Notes in each Journal-insert x y = fromMap (Map.union (toMap x) (toMap y))+insert = replaceNotes ------------------------------------------------------------------ -- | projWithNote -- Projecting with Note. --+-- A query list containing 'plank' acts as a __wildcard__: the journal is+-- returned unchanged (the blank note matches every note).+-- -- >>> type Test = Journal String Double (HatBase CountUnit) -- >>> x = 1.00:@Hat:<Yen .+ 1.00:@Not:<Amount .| "cat" :: Test -- >>> y = 2.00:@Hat:<Yen .+ 2.00:@Not:<Amount .| "dog" :: Test@@ -623,24 +761,36 @@ -- 3.00:@Not:<Amount.|"fish" .+ 1.00:@Not:<Amount.|"cat" projWithBase :: (HatVal v, HatBaseClass b, Note n) => [b] -> Journal n v b -> Journal n v b-{-# INLINE [0] projWithBase #-}+{-# INLINE projWithBase #-} projWithBase [] _ = mempty projWithBase bs js = fromMap $ Map.map (EA.proj bs) (toMap js) --- | Directly compute the norm after filtering by the specified bases.--- Equivalent to @norm (projWithBase bs js)@ but without constructing an intermediate Journal.+-- | Directly compute the /bar-netted/ norm of a base projection, without+-- constructing an intermediate Journal. Per note this applies 'EA.projNetNorm',+-- which nets each projected base's hat and not sides (the positive-part+-- normalization); hence --+-- @projWithBaseNetNorm bs js == norm (map bar (projWithBase bs js))@+--+-- which is __not__ the same as @norm (projWithBase bs js)@ when a query+-- (e.g. a @HatNot@ wildcard, or a list selecting both sides of one base)+-- selects both the hat and the not side of a base: the un-netted norm sums+-- both sides, the netted one cancels them. See 'EA.projNetNorm'.+-- -- Complexity: O(j * proj cost) where j is the number of Notes-projWithBaseNorm :: (HatVal v, HatBaseClass b, Note n)+projWithBaseNetNorm :: (HatVal v, HatBaseClass b, Note n) => [b] -> Journal n v b -> v-projWithBaseNorm [] _ = 0-projWithBaseNorm bs js =- Map.foldl' (\acc alg -> acc + EA.projNorm bs alg) 0 (toMap js)+projWithBaseNetNorm [] _ = 0+projWithBaseNetNorm bs js =+ Map.foldl' (\acc alg -> acc + EA.projNetNorm bs alg) 0 (toMap js) ------------------------------------------------------------------ -- | projWithNoteBase -- Projecting with Note and Base. --+-- 'plank' in the note list acts as a note-wildcard: the projection widens to+-- all notes (falls back to 'projWithBase').+-- -- >>> type Test = Journal String Double (HatBase CountUnit) -- >>> x = 1.00:@Hat:<Yen .+ 1.00:@Not:<Amount .| "cat" :: Test -- >>> y = 2.00:@Not:<Yen .+ 2.00:@Hat:<Amount .| "dog" :: Test@@ -649,7 +799,7 @@ -- 3.00:@Not:<Amount.|"fish" projWithNoteBase :: (HatVal v, HatBaseClass b, Note n) => [n] -> [b] -> Journal n v b -> Journal n v b-{-# INLINE [0] projWithNoteBase #-}+{-# INLINE projWithNoteBase #-} projWithNoteBase _ [] _ = mempty projWithNoteBase ns bs js | any isPlank ns = projWithBase bs js@@ -666,34 +816,59 @@ Map.empty (S.fromList ns) --- | Directly compute the norm after filtering by the specified Notes and bases.--- Equivalent to @norm (projWithNoteBase ns bs js)@ but without constructing an intermediate Journal.+-- | Directly compute the /bar-netted/ norm of a note-and-base projection,+-- without constructing an intermediate Journal. Like 'projWithBaseNetNorm' this+-- goes through 'EA.projNetNorm', so per note each projected base is netted+-- (positive-part normalization): --+-- @projWithNoteBaseNetNorm ns bs js == norm (map bar (projWithNoteBase ns bs js))@+--+-- 'plank' in the note list acts as a note-wildcard (widens to all notes, like+-- 'projWithNote').+--+-- which is __not__ the same as @norm (projWithNoteBase ns bs js)@ when a query+-- selects both sides of one base (see 'projWithBaseNetNorm').+-- -- Complexity: O(|ns| * proj cost)-projWithNoteNorm :: (HatVal v, HatBaseClass b, Note n)+projWithNoteBaseNetNorm :: (HatVal v, HatBaseClass b, Note n) => [n] -> [b] -> Journal n v b -> v-projWithNoteNorm _ [] _ = 0-projWithNoteNorm ns bs js- | any isPlank ns = projWithBaseNorm bs js-projWithNoteNorm [n] bs js = case lookupNote n js of+projWithNoteBaseNetNorm _ [] _ = 0+projWithNoteBaseNetNorm ns bs js+ | any isPlank ns = projWithBaseNetNorm bs js+projWithNoteBaseNetNorm [n] bs js = case lookupNote n js of Nothing -> 0- Just a -> EA.projNorm bs a-projWithNoteNorm [] bs js = projWithBaseNorm bs js-projWithNoteNorm ns bs js =+ Just a -> EA.projNetNorm bs a+projWithNoteBaseNetNorm [] bs js = projWithBaseNetNorm bs js+projWithNoteBaseNetNorm ns bs js = S.foldl' (\acc n -> case lookupNote n js of Nothing -> acc- Just a -> acc + EA.projNorm bs a)+ Just a -> acc + EA.projNetNorm bs a) 0 (S.fromList ns) -{-# RULES-"EJ.projWithBaseNorm/from-norm-projWithBase"- forall bs js. norm (projWithBase bs js) = projWithBaseNorm bs js-"EJ.projWithNoteNorm/from-norm-projWithNoteBase"- forall ns bs js. norm (projWithNoteBase ns bs js) = projWithNoteNorm ns bs js- #-}+-- NB. Two RULES that rewrote @norm (projWithBase bs js)@ to 'projWithBaseNetNorm'+-- (and the note-base analogue) were REMOVED here: the equation is false whenever+-- a query selects both sides of one base (e.g. a @HatNot@ wildcard) — the+-- left-hand side sums both sides, the right-hand side bar-nets them (verified:+-- 14.0 vs 6.0 on a both-sided base). A rewrite rule must be semantics-preserving;+-- callers who want the fused netted read-out call 'projWithBaseNetNorm' \/+-- 'projWithNoteBaseNetNorm' explicitly. +{-# DEPRECATED projWithBaseNorm "renamed to 'projWithBaseNetNorm': the result is bar-netted per base, which the old name concealed. Will be removed in 0.6" #-}+-- | Deprecated alias for 'projWithBaseNetNorm' (renamed in 0.5.0.0 so the name+-- states the bar-netting).+projWithBaseNorm :: (HatVal v, HatBaseClass b, Note n)+ => [b] -> Journal n v b -> v+projWithBaseNorm = projWithBaseNetNorm++{-# DEPRECATED projWithNoteNorm "renamed to 'projWithNoteBaseNetNorm': it takes note AND base queries and the result is bar-netted per base — both were missing from the old name. Will be removed in 0.6" #-}+-- | Deprecated alias for 'projWithNoteBaseNetNorm' (renamed in 0.5.0.0 so the+-- name states both the base argument and the bar-netting).+projWithNoteNorm :: (HatVal v, HatBaseClass b, Note n)+ => [n] -> [b] -> Journal n v b -> v+projWithNoteNorm = projWithNoteBaseNetNorm+ ------------------------------------------------------------------ -- | Filter by a predicate on Note-entry pairs. -- Applies the filter to both the base and delta layers.@@ -701,10 +876,10 @@ -- Complexity: O(n) where n is the number of Notes filterWithNote :: (HatVal v, HatBaseClass b, Note n) => (n -> Alg v b -> Bool) -> Journal n v b -> Journal n v b-filterWithNote f (Journal base delta ver _ _) =- let !base' = Map.filterWithKey f base+filterWithNote f (Journal bs delta _ _) =+ let !base' = Map.filterWithKey f bs !delta' = Map.filterWithKey f delta- in mkJournal base' delta' ver+ in mkJournal base' delta' -- | Efficiently filter a Journal to entries whose Note matches on the specified axis. -- Uses base/delta NoteAxisPosting indices for O(|result|) retrieval after index construction.@@ -730,7 +905,7 @@ {-# INLINE filterByAxis #-} filterByAxis :: (HatVal v, HatBaseClass b, Note n) => Int -> NoteAxisKey -> Journal n v b -> Journal n v b-filterByAxis axis key j@(Journal _ _ _ baseIdx deltaIdx) =+filterByAxis axis key j@(Journal _ _ baseIdx deltaIdx) = let !matched = HSet.union (queryNoteAxisPosting axis key baseIdx) (queryNoteAxisPosting axis key deltaIdx)
src/ExchangeAlgebra/Journal/Transfer.hs view
@@ -28,58 +28,37 @@ module ExchangeAlgebra.Journal.Transfer- ( TransTable (..)+ ( TransTable , isNullTable , table , TransTableParts , (.->) , (|%) , ExchangeAlgebra.Journal.Transfer.transfer+ , ExchangeAlgebra.Journal.Transfer.createTransfer , ExchangeAlgebra.Journal.Transfer.incomeSummaryAccount , ExchangeAlgebra.Journal.Transfer.netIncomeTransfer , ExchangeAlgebra.Journal.Transfer.grossProfitTransfer , ExchangeAlgebra.Journal.Transfer.ordinaryProfitTransfer , ExchangeAlgebra.Journal.Transfer.retainedEarningTransfer+ , ExchangeAlgebra.Journal.Transfer.finalStockTransferAggregated , ExchangeAlgebra.Journal.Transfer.finalStockTransfer ) where -import qualified ExchangeAlgebra.Algebra as EA import ExchangeAlgebra.Algebra hiding (map) import qualified ExchangeAlgebra.Algebra.Transfer as EAT-import ExchangeAlgebra.Algebra.Transfer (TransTable (..)+import ExchangeAlgebra.Algebra.Transfer (TransTable , isNullTable , table , TransTableParts , (.->) , (|%)- , finalStockTransferStep- , retainedEarningTransfer- , ordinaryProfitTransfer- , grossProfitTransfer)+ , finalStockTransferStep) import qualified ExchangeAlgebra.Journal as EJ import ExchangeAlgebra.Journal hiding () -import qualified Number.NonNegative as NN ( Double- , fromNumber- , toNumber,T) -- Non-negative real numbers-import qualified Data.Maybe as Maybe-import Text.Show.Unicode ( ushow)-import GHC.Exts ( reallyUnsafePtrEquality#- , isTrue#- , build- , lazy)-import Data.Semigroup ( Semigroup(stimes)- , stimesIdempotentMonoid)-import Data.Monoid ( Monoid(..))-import qualified Data.Foldable as Foldable-import Data.Foldable ( Foldable())-import Data.Bits ( shiftL- , shiftR)-import Utils.Containers.Internal.StrictPair-import Debug.Trace - -- | Apply transfer transformations to each Note entry in a Journal. -- Wildcard portions within tuples are not transformed and retain their original values. --@@ -98,14 +77,20 @@ -- | Compute net income for the current period (Income Summary Account). -- Calculate the debit-credit difference and add it as NetIncome or NetLoss to the plank Note. -- When the ledger is balanced (credit == debit, net income is zero), @diffRL@ reports the--- wildcard 'Side'; in that case the journal is returned unchanged.+-- wildcard v'Side'; in that case the journal is returned unchanged (balanced ledger =+-- identity). Appending @Zero .| plank@ is not an identity for t'Journal' because @(.|)@ builds+-- a @Map.singleton plank Zero@ and drives version/compaction, so the input is returned directly.+-- The result contains a legacy NetIncome/NetLoss balancing coordinate and is+-- an intermediate closing state, not input for reporting presentation. New+-- reporting code should derive the result from a validated before-closing+-- trial balance with "ExchangeAlgebra.Reporting.Metric". -- -- Complexity: O(s) (s = total number of scalar entries) incomeSummaryAccount :: (Note n, HatVal v, ExBaseClass b) => Journal n v b -> Journal n v b incomeSummaryAccount js = let (dc,diff) = diffRL js in case dc of- Credit -> js .+ ((diff :@ (toNot wiledcard) .~ NetIncome) .| plank)- Debit -> js .+ ((diff :@ (toNot wiledcard) .~ NetLoss) .| plank)+ Credit -> js .+ ((diff :@ (toNot wildcard) .~ NetIncome) .| plank)+ Debit -> js .+ ((diff :@ (toNot wildcard) .~ NetLoss) .| plank) Side -> js -- | Net income transfer (Journal version). Transfer NetIncome/NetLoss to RetainedEarnings for each Note.@@ -116,13 +101,15 @@ -- ** Journalizing --- | Gross profit transfer (Journal version). Aggregate sales and cost accounts into GrossProfit for each Note.+-- | Historical SNA/simulation gross-profit transfer (Journal version).+-- This delegates to the legacy fixed-list rule and is not a JGAAP subtotal. -- -- Complexity: O(j * s) grossProfitTransfer :: (Note n, HatVal v, ExBaseClass b) => Journal n v b -> Journal n v b grossProfitTransfer = EJ.map EAT.grossProfitTransfer --- | Ordinary Profit Transfer+-- | Historical SNA/simulation ordinary-profit transfer (Journal version).+-- The fixed list predates the JCCI chart and is not a JGAAP subtotal. -- -- >>> type Test = Journal String Double (HatBase (CountUnit, AccountTitles)) -- >>> x = 2279.0:@Not:<(Yen,Depreciation) .| "A" :: Test@@ -139,9 +126,18 @@ retainedEarningTransfer :: (Note n, HatVal v, ExBaseClass b) => Journal n v b -> Journal n v b retainedEarningTransfer = EJ.map EAT.retainedEarningTransfer --- | Income summary account (Journal version). Transfer all cost and revenue accounts to RetainedEarnings, then offset using the Bar operation.+-- | Apply the Algebra-level closing to every Note and then fold the Note axis+-- onto the plank via the Journal's '(.-)'. The per-Note lift without folding is+-- @EJ.map EAT.finalStockTransfer@. -- -- Complexity: O(j * s)-finalStockTransfer ::(Note n, HatVal v, ExBaseClass b) => Journal n v b -> Journal n v b-finalStockTransfer = (.-) . EJ.map finalStockTransferStep+finalStockTransferAggregated ::(Note n, HatVal v, ExBaseClass b) => Journal n v b -> Journal n v b+finalStockTransferAggregated = (.-) . EJ.map finalStockTransferStep +-- | Compatibility name for 'finalStockTransferAggregated'.+-- Its behaviour is unchanged: it closes each Note and folds the Note axis onto+-- the plank.+--+-- Complexity: O(j * s)+finalStockTransfer ::(Note n, HatVal v, ExBaseClass b) => Journal n v b -> Journal n v b+finalStockTransfer = finalStockTransferAggregated
+ src/ExchangeAlgebra/Optimize.hs view
@@ -0,0 +1,101 @@+{-# LANGUAGE TypeFamilies #-}++{- |+ Module : ExchangeAlgebra.Optimize+ Copyright : (c) Kaya Akagi. 2026+ License : OtherLicense++ A pluggable optimization solver interface.++ This module defines the strategy-agnostic 'Solver' class. Concrete+ strategies live in sibling modules:++ * "ExchangeAlgebra.Optimize.Annealing" — simulated annealing over an+ arbitrary candidate type.+ * "ExchangeAlgebra.Optimize.GA" — a real-coded genetic algorithm over+ numeric vector chromosomes.++ New strategies (differential evolution, particle swarm, CMA-ES, ...)+ are added by defining a new strategy type with a 'Solver' instance;+ the interface itself does not change.++ == Design notes++ * The objective is monadic (@'Candidate' strategy -> m Double@) so that+ it can run stateful simulations, e.g. the @ST s@ state spaces of+ "ExchangeAlgebra.Simulate". Solvers only require 'Monad', nothing+ stronger.+ * Randomness is threaded internally by each solver from a seed in its+ 'Config' (a pure generator, sequentially updated), so no random-monad+ constraint leaks into the objective. Runs are reproducible given the+ same seed /provided the objective itself is deterministic/ (same+ state transitions, no external randomness, clocks or concurrency).+ * The returned 'Double' is the objective value observed when the+ returned candidate was evaluated, in the user's orientation.+ Solvers never re-evaluate a candidate they have already scored+ (re-evaluation would be a visible side effect for stateful+ objectives such as @ST s@ simulations).+ * Objective values must be finite: solvers reject @NaN@ and+ infinities with a call to 'error' as soon as they are observed,+ because non-finite energies silently break comparisons, sorting+ and best-candidate tracking. Configurations are also validated+ up front (fail-fast; invalid settings are never clamped).+ * This subsystem is a generic numeric layer: it does not touch the+ redundant-algebra core ("ExchangeAlgebra.Algebra",+ "ExchangeAlgebra.Journal") and imposes no Hat\/Not or non-negativity+ semantics on candidates. Objective values are plain 'Double's in the+ user's orientation (see 'Direction').+ * Parallel evaluation of candidates is deliberately left to the caller+ (e.g. run several 'optimize' calls with different seeds and pick the+ best); solvers themselves evaluate sequentially.+-}+module ExchangeAlgebra.Optimize+ ( -- * Solver interface+ Solver (..)+ -- * Optimization direction+ , Direction (..)+ , orient+ ) where++-- | Whether the objective is to be maximized or minimized.+--+-- Solvers convert objective values to an internal /minimization energy/+-- ('orient') and report the final score back in the user's orientation.+data Direction = Maximize | Minimize+ deriving (Show, Eq)++-- | Convert a score in the user's orientation to a minimization energy.+--+-- >>> orient Minimize 3.0+-- 3.0+--+-- >>> orient Maximize 3.0+-- -3.0+orient :: Direction -> Double -> Double+orient Minimize = id+orient Maximize = negate+{-# INLINE orient #-}++-- | A pluggable optimization strategy.+--+-- @strategy@ is a dispatch value carrying no data of its own — a+-- /proxy/ such as @GA@ ("ExchangeAlgebra.Optimize.GA") or @Annealing@+-- ("ExchangeAlgebra.Optimize.Annealing"). The associated types fix what+-- a candidate solution looks like and what configuration the strategy+-- needs.+class Solver strategy where+ -- | Candidate solution type searched by this strategy.+ type Candidate strategy+ -- | Strategy-specific configuration (schedules, rates, seed,+ -- 'Direction', ...).+ type Config strategy++ -- | Run the optimizer: given a configuration, a monadic objective and+ -- an initial candidate, return the best candidate found together+ -- with its objective value (in the user's orientation).+ optimize :: Monad m+ => strategy+ -> Config strategy+ -> (Candidate strategy -> m Double) -- ^ objective+ -> Candidate strategy -- ^ initial candidate+ -> m (Candidate strategy, Double)
+ src/ExchangeAlgebra/Optimize/Annealing.hs view
@@ -0,0 +1,154 @@+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE BangPatterns #-}++{- |+ Module : ExchangeAlgebra.Optimize.Annealing+ Copyright : (c) Kaya Akagi. 2026+ License : OtherLicense++ Simulated annealing over an arbitrary candidate type.++ The candidate type is free (@a@ in @t'Annealing' a@): investment+ schedules, parameter records, vectors, ... — anything the+ 'acNeighbor' move can perturb. The objective may be stateful+ (e.g. run an @ST s@ simulation); see "ExchangeAlgebra.Optimize"+ for the interface contract.++ == Example++ Minimize @(x - 3)^2@ over 'Double':++ > import ExchangeAlgebra.Optimize+ > import ExchangeAlgebra.Optimize.Annealing+ > import System.Random (uniformR)+ > import Data.Functor.Identity (runIdentity)+ >+ > cfg :: AnnealingConfig Double+ > cfg = AnnealingConfig+ > { acDirection = Minimize+ > , acSteps = 2000+ > , acSchedule = geometricCooling 1.0 0.995+ > , acNeighbor = \g x -> let (d, g') = uniformR (-0.5, 0.5) g+ > in (x + d, g')+ > , acAccept = metropolis+ > , acSeed = 42+ > }+ >+ > best :: (Double, Double)+ > best = runIdentity (optimize Annealing cfg (\x -> pure ((x - 3)^2)) 0)+-}+module ExchangeAlgebra.Optimize.Annealing+ ( -- * Strategy+ Annealing (..)+ , AnnealingConfig (..)+ -- * Standard building blocks+ , geometricCooling+ , metropolis+ ) where++import ExchangeAlgebra.Optimize+import System.Random (StdGen, mkStdGen, uniformR)++-- | Simulated-annealing strategy proxy. The phantom @a@ is the candidate+-- type; it is inferred from the t'AnnealingConfig' at the call site, so+-- plain @'optimize' Annealing cfg obj x0@ needs no annotation.+data Annealing a = Annealing++-- | Configuration of one annealing run.+--+-- Energies passed to 'acAccept' are /minimization-oriented/ ('orient'+-- applied), regardless of 'acDirection'.+data AnnealingConfig a = AnnealingConfig+ { acDirection :: Direction+ -- ^ Whether the objective is maximized or minimized.+ , acSteps :: Int+ -- ^ Total number of proposal steps (@>= 0@; @0@ evaluates the+ -- initial candidate once and returns it).+ , acSchedule :: Int -> Double+ -- ^ Cooling schedule: 1-based step number to temperature.+ -- Each temperature must be finite and @>= 0@ (@0@ acts as greedy+ -- acceptance); anything else is rejected with 'error'.+ , acNeighbor :: StdGen -> a -> (a, StdGen)+ -- ^ Proposal move: perturb the current candidate, threading the+ -- solver's random generator.+ , acAccept :: Double -> Double -> Double -> Double+ -- ^ Acceptance rule: @temperature -> current energy -> proposed+ -- energy -> probability@. The probability must be finite and in+ -- @[0, 1]@ (validated at every step). See 'metropolis'.+ , acSeed :: Int+ -- ^ Seed of the solver's internal random generator.+ }++-- | Geometric cooling: @geometricCooling t0 alpha step = t0 * alpha^(step-1)@.+--+-- Typical use: @'geometricCooling' 1.0 0.995@.+geometricCooling :: Double -- ^ initial temperature @t0@+ -> Double -- ^ decay factor @alpha@ (usually just below 1)+ -> Int -- ^ 1-based step+ -> Double+geometricCooling t0 alpha step = t0 * alpha ^^ (step - 1)++-- | The Metropolis acceptance rule: always accept an improvement,+-- otherwise accept with probability @exp ((e - e') / t)@.+-- At @t = 0@ this degenerates to greedy acceptance.+metropolis :: Double -- ^ temperature+ -> Double -- ^ current energy+ -> Double -- ^ proposed energy+ -> Double+metropolis t e e'+ | e' <= e = 1+ | t <= 0 = 0+ | otherwise = exp ((e - e') / t)++instance Solver (Annealing a) where+ type Candidate (Annealing a) = a+ type Config (Annealing a) = AnnealingConfig a++ optimize _ cfg obj x0+ | acSteps cfg < 0+ = error ("ExchangeAlgebra.Optimize.Annealing: acSteps must be >= 0, got "+ ++ show (acSteps cfg))+ | otherwise+ = do e0 <- energyOf dir "initial candidate" (obj x0)+ go 1 (mkStdGen (acSeed cfg)) x0 e0 x0 e0+ where+ dir = acDirection cfg+ go !step !g !cur !eCur !best !eBest+ | step > acSteps cfg = return (best, orient dir eBest)+ -- orient is self-inverse: energy back to the user's score.+ | otherwise = do+ let t = checkTemperature step (acSchedule cfg step)+ (cand, g1) = acNeighbor cfg g cur+ eCand <- energyOf dir ("step " ++ show step) (obj cand)+ let p = checkProbability step (acAccept cfg t eCur eCand)+ (u, g2) = uniformR (0 :: Double, 1) g1+ accepted = p >= 1 || u < p+ (cur', eCur') | accepted = (cand, eCand)+ | otherwise = (cur, eCur)+ (best', eBest') | eCand < eBest = (cand, eCand)+ | otherwise = (best, eBest)+ go (step + 1) g2 cur' eCur' best' eBest'++-- | Evaluate the objective and convert to minimization energy, rejecting+-- non-finite scores (see the contract in "ExchangeAlgebra.Optimize").+energyOf :: Monad m => Direction -> String -> m Double -> m Double+energyOf dir what mScore = do+ s <- mScore+ if isNaN s || isInfinite s+ then error ("ExchangeAlgebra.Optimize.Annealing: objective returned "+ ++ "a non-finite score (" ++ show s ++ ") at " ++ what)+ else return (orient dir s)++checkTemperature :: Int -> Double -> Double+checkTemperature step t+ | isNaN t || isInfinite t || t < 0+ = error ("ExchangeAlgebra.Optimize.Annealing: acSchedule returned an "+ ++ "invalid temperature (" ++ show t ++ ") at step " ++ show step)+ | otherwise = t++checkProbability :: Int -> Double -> Double+checkProbability step p+ | isNaN p || isInfinite p || p < 0 || p > 1+ = error ("ExchangeAlgebra.Optimize.Annealing: acAccept returned an "+ ++ "invalid probability (" ++ show p ++ ") at step " ++ show step)+ | otherwise = p
+ src/ExchangeAlgebra/Optimize/GA.hs view
@@ -0,0 +1,293 @@+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE BangPatterns #-}++{- |+ Module : ExchangeAlgebra.Optimize.GA+ Copyright : (c) Kaya Akagi. 2026+ License : OtherLicense++ A real-coded genetic algorithm over numeric vector chromosomes+ (@'U.Vector' 'Double'@).++ Generational scheme with elitism: tournament selection, uniform+ crossover and per-gene Gaussian mutation. The initial population is+ the (clamped) initial candidate plus Gaussian perturbations of it+ ('gaInitSpread'), so 'optimize' needs no separate population input.+ Elites carry their already-observed scores into the next generation+ and are never re-evaluated.++ Blend/simulated-binary crossover (BLX-alpha, SBX) are deliberately+ /not/ options of t'GAConfig'; if needed they should become separate+ strategies so each strategy's rates keep one clear meaning.++ == Example++ Minimize the 3-dimensional sphere function @sum (v - 1)^2@:++ > import ExchangeAlgebra.Optimize+ > import ExchangeAlgebra.Optimize.GA+ > import qualified Data.Vector.Unboxed as U+ > import Data.Functor.Identity (runIdentity)+ >+ > sphere :: U.Vector Double -> Double+ > sphere v = U.sum (U.map (\x -> (x - 1) ^ 2) v)+ >+ > best :: (U.Vector Double, Double)+ > best = runIdentity+ > (optimize GA defaultGAConfig (pure . sphere) (U.replicate 3 0))+-}+module ExchangeAlgebra.Optimize.GA+ ( -- * Strategy+ GA (..)+ , GAConfig (..)+ , defaultGAConfig+ ) where++import ExchangeAlgebra.Optimize+import qualified Data.List as L+import Data.Ord (comparing)+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as U+import System.Random (StdGen, mkStdGen, uniformR)++-- | Genetic-algorithm strategy proxy.+data GA = GA++-- | Configuration of one GA run. See 'defaultGAConfig' for a starting+-- point; all fields are validated up front (fail-fast 'error', never+-- clamped).+data GAConfig = GAConfig+ { gaDirection :: Direction+ -- ^ Whether the objective is maximized or minimized.+ , gaPopulationSize :: Int+ -- ^ Number of individuals per generation (@>= 1@).+ , gaGenerations :: Int+ -- ^ Number of generations (@>= 0@; @0@ evaluates only the initial+ -- population).+ , gaCrossoverRate :: Double+ -- ^ Probability that a selected pair undergoes uniform crossover+ -- (in @[0, 1]@); otherwise the first parent is copied.+ , gaMutationRate :: Double+ -- ^ Per-gene mutation probability (in @[0, 1]@).+ , gaMutationScale :: Double+ -- ^ Standard deviation of the Gaussian gene perturbation (@>= 0@).+ , gaTournamentSize :: Int+ -- ^ Tournament size for parent selection (@>= 1@; @1@ degenerates+ -- to uniform random selection, which barely selects at all).+ , gaEliteCount :: Int+ -- ^ Number of best individuals carried over unchanged+ -- (@0 <= gaEliteCount <= gaPopulationSize@).+ , gaInitSpread :: Double+ -- ^ Standard deviation of the Gaussian spread used to build the+ -- initial population around the initial candidate (@>= 0@).+ , gaBounds :: Maybe (U.Vector (Double, Double))+ -- ^ Optional per-gene @(low, high)@ box constraints. Must have the+ -- same length as the chromosome; every generated gene (initial+ -- spread and mutation) is clamped into its interval.+ , gaSeed :: Int+ -- ^ Seed of the solver's internal random generator.+ }++-- | A reasonable starting configuration: minimization, population 50,+-- 100 generations, crossover rate 0.9, per-gene mutation rate 0.1 with+-- scale 0.1, tournament size 3, 2 elites, initial spread 1.0, no+-- bounds, seed 42. Override fields as needed.+defaultGAConfig :: GAConfig+defaultGAConfig = GAConfig+ { gaDirection = Minimize+ , gaPopulationSize = 50+ , gaGenerations = 100+ , gaCrossoverRate = 0.9+ , gaMutationRate = 0.1+ , gaMutationScale = 0.1+ , gaTournamentSize = 3+ , gaEliteCount = 2+ , gaInitSpread = 1.0+ , gaBounds = Nothing+ , gaSeed = 42+ }++instance Solver GA where+ type Candidate GA = U.Vector Double+ type Config GA = GAConfig++ optimize _ cfg obj x0 = do+ validateConfig cfg x0+ let x0' = clampTo (gaBounds cfg) x0+ (rest, g1) = initialPopulation cfg x0' (mkStdGen (gaSeed cfg))+ pop0 <- mapM evalOne (x0' : rest)+ let best0 = L.minimumBy (comparing snd) pop0+ (bc, be) <- go 1 g1 pop0 best0+ return (bc, orient dir be)+ where+ dir = gaDirection cfg++ evalOne c = do+ s <- obj c+ if isNaN s || isInfinite s+ then error ("ExchangeAlgebra.Optimize.GA: objective returned "+ ++ "a non-finite score (" ++ show s ++ ")")+ else return (c, orient dir s)++ go !gen !g !pop !best+ | gen > gaGenerations cfg = return best+ | otherwise = do+ let sorted = L.sortBy (comparing snd) pop+ elites = take (gaEliteCount cfg) sorted+ nChildren = gaPopulationSize cfg - gaEliteCount cfg+ pool = V.fromList sorted+ (childs, g') = makeChildren cfg pool nChildren g+ children <- mapM evalOne childs+ let pop' = elites ++ children+ best' = L.minimumBy (comparing snd) (best : pop')+ go (gen + 1) g' pop' best'++-- ------------------------------------------------------------------+-- Pure genetics (random generator threaded explicitly)+-- ------------------------------------------------------------------++-- | Initial population (without the seed candidate itself): Gaussian+-- perturbations of the seed candidate, clamped to the bounds.+initialPopulation :: GAConfig -> U.Vector Double -> StdGen+ -> ([U.Vector Double], StdGen)+initialPopulation cfg x0 = goN (gaPopulationSize cfg - 1)+ where+ goN 0 g = ([], g)+ goN k g = let (v, g1) = perturbAll x0 g+ (vs, g2) = goN (k - 1) g1+ in (v : vs, g2)+ perturbAll v g =+ let (genes, g') = threadGenes step (U.toList v) g+ in (clampTo (gaBounds cfg) (U.fromList genes), g')+ where+ step x gg = let (z, gg') = gauss gg+ in (x + gaInitSpread cfg * z, gg')++-- | Produce @k@ children by tournament selection, uniform crossover and+-- Gaussian mutation.+makeChildren :: GAConfig -> V.Vector (U.Vector Double, Double) -> Int -> StdGen+ -> ([U.Vector Double], StdGen)+makeChildren cfg pool = goN+ where+ goN 0 g = ([], g)+ goN k g = let (c, g1) = makeOne g+ (cs, g2) = goN (k - 1) g1+ in (c : cs, g2)++ makeOne g0 =+ let (p1, g1) = tournament g0+ (p2, g2) = tournament g1+ (u, g3) = uniformR (0 :: Double, 1) g2+ (raw, g4) = if u < gaCrossoverRate cfg+ then crossover p1 p2 g3+ else (p1, g3)+ (mut, g5) = mutate raw g4+ in (clampTo (gaBounds cfg) mut, g5)++ -- Tournament selection: best (lowest energy) of k uniform picks.+ tournament g0 = go (gaTournamentSize cfg) g0 Nothing+ where+ go 0 g acc = case acc of+ Just (c, _) -> (c, g)+ Nothing -> error "ExchangeAlgebra.Optimize.GA: empty tournament"+ go k g acc =+ let (i, g') = uniformR (0, V.length pool - 1) g+ cand = pool V.! i+ acc' = case acc of+ Just (_, e) | e <= snd cand -> acc+ _ -> Just cand+ in go (k - 1 :: Int) g' acc'++ -- Uniform crossover: each gene comes from either parent with p = 1/2.+ crossover p1 p2 g0 =+ let (genes, g') = crossGenes (U.toList p1) (U.toList p2) g0+ in (U.fromList genes, g')+ crossGenes [] _ g = ([], g)+ crossGenes _ [] g = ([], g)+ crossGenes (a:as) (b:bs) g =+ let (u, g1) = uniformR (0 :: Double, 1) g+ (rest, g2) = crossGenes as bs g1+ in ((if u < 0.5 then a else b) : rest, g2)++ -- Per-gene Gaussian mutation.+ mutate v g0 =+ let (genes, g') = threadGenes step (U.toList v) g0+ in (U.fromList genes, g')+ where+ step x g =+ let (u, g1) = uniformR (0 :: Double, 1) g+ in if u < gaMutationRate cfg+ then let (z, g2) = gauss g1+ in (x + gaMutationScale cfg * z, g2)+ else (x, g1)++-- | Thread the generator through a per-gene transformation.+threadGenes :: (Double -> StdGen -> (Double, StdGen))+ -> [Double] -> StdGen -> ([Double], StdGen)+threadGenes _ [] g = ([], g)+threadGenes f (x:xs) g =+ let (y, g1) = f x g+ (ys, g2) = threadGenes f xs g1+ in (y : ys, g2)++-- | One standard-normal draw via Box–Muller. The first uniform is lifted+-- away from zero so @log u1@ stays finite. (The unguarded @normal@ in+-- "ExchangeAlgebra.Simulate" has a @log 0@ failure mode and must not be+-- copied here.)+gauss :: StdGen -> (Double, StdGen)+gauss g0 =+ let (u1raw, g1) = uniformR (0 :: Double, 1) g0+ u1 = max 2.2250738585072014e-308 u1raw -- smallest normal+ (u2, g2) = uniformR (0 :: Double, 1) g1+ in (sqrt (-2 * log u1) * cos (2 * pi * u2), g2)++-- | Clamp each gene into its bound interval (identity without bounds).+clampTo :: Maybe (U.Vector (Double, Double)) -> U.Vector Double -> U.Vector Double+clampTo Nothing v = v+clampTo (Just bs) v = U.zipWith (\(lo, hi) x -> max lo (min hi x)) bs v++-- ------------------------------------------------------------------+-- Fail-fast configuration validation (see ExchangeAlgebra.Optimize)+-- ------------------------------------------------------------------++validateConfig :: Monad m => GAConfig -> U.Vector Double -> m ()+validateConfig cfg x0+ | gaPopulationSize cfg < 1+ = bad ("gaPopulationSize must be >= 1, got " ++ show (gaPopulationSize cfg))+ | gaGenerations cfg < 0+ = bad ("gaGenerations must be >= 0, got " ++ show (gaGenerations cfg))+ | gaEliteCount cfg < 0 || gaEliteCount cfg > gaPopulationSize cfg+ = bad ("gaEliteCount must be in [0, gaPopulationSize], got "+ ++ show (gaEliteCount cfg))+ | gaTournamentSize cfg < 1+ = bad ("gaTournamentSize must be >= 1, got " ++ show (gaTournamentSize cfg))+ | badRate (gaCrossoverRate cfg)+ = bad ("gaCrossoverRate must be a finite value in [0,1], got "+ ++ show (gaCrossoverRate cfg))+ | badRate (gaMutationRate cfg)+ = bad ("gaMutationRate must be a finite value in [0,1], got "+ ++ show (gaMutationRate cfg))+ | badScale (gaMutationScale cfg)+ = bad ("gaMutationScale must be finite and >= 0, got "+ ++ show (gaMutationScale cfg))+ | badScale (gaInitSpread cfg)+ = bad ("gaInitSpread must be finite and >= 0, got "+ ++ show (gaInitSpread cfg))+ | U.null x0+ = bad "the initial candidate (chromosome) must be non-empty"+ | Just bs <- gaBounds cfg, U.length bs /= U.length x0+ = bad ("gaBounds length (" ++ show (U.length bs)+ ++ ") does not match the chromosome length (" ++ show (U.length x0) ++ ")")+ | Just bs <- gaBounds cfg+ , Just (i, (lo, hi)) <- badBound bs+ = bad ("gaBounds at index " ++ show i ++ " is invalid: "+ ++ show (lo, hi) ++ " (need finite lo <= hi)")+ | otherwise = return ()+ where+ bad msg = error ("ExchangeAlgebra.Optimize.GA: " ++ msg)+ badRate r = isNaN r || isInfinite r || r < 0 || r > 1+ badScale s = isNaN s || isInfinite s || s < 0+ badBound bs = U.ifoldr+ (\i b acc -> if invalid b then Just (i, b) else acc) Nothing bs+ where invalid (lo, hi) =+ isNaN lo || isInfinite lo || isNaN hi || isInfinite hi || lo > hi
+ src/ExchangeAlgebra/Reporting/Group.hs view
@@ -0,0 +1,409 @@+{-# LANGUAGE OverloadedStrings #-}+{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}++{- |+Module : ExchangeAlgebra.Reporting.Group+Description : Presentation groups and the contra netting policy (Definition 7, Land 3).++A /presentation group/ is a statement block made of one or more __gross__+account titles and the __contra__ (評価勘定) titles that are deducted from+them, rendered as @gross lines → deduction lines → net line@. It is the+mechanism the Definition 7 contra amendment left open: Land 2 fixed the+classification (contra assets are @Assets@ with @isContra = True@) but kept a+display-compatibility shim so that the contra accounts still /appeared/ in the+liability column; this module supplies the real deduction presentation and+lets that shim be removed.++This module is deliberately free of both the CSV writers and the validated+reporting pipeline: it is the __single implementation__ of grouping and+netting, and "ExchangeAlgebra.Write" ('ExchangeAlgebra.Write.bsRows',+'ExchangeAlgebra.Write.plRows') is its consumer. Placing it here rather than+inside "ExchangeAlgebra.Reporting.Presentation" is forced by the type of the+legacy writers: they are total, base-polymorphic functions+(@ExBaseClass b => Alg n b -> [[Text]]@), whereas @present@ is specialised to+@HatBase AccountTitles@, is gated by an opaque+@ValidatedTrialBalance@, and answers in @Either@. Routing @bsRows@ through+@present@ would therefore change it from a pure formatter into a partial,+validation-gated one; duplicating the netting rule in both places is exactly+what this module exists to prevent.++=== Value domain++Statement amounts are held as a @(debit gross, credit gross)@ pair and only+netted at the last moment, into a t'RelativeAmount' carrying a __non-negative__+magnitude plus a direction flag. Stored and rendered magnitudes therefore stay+in \(\mathbb{R}_0^+\), including for soft-invariant value types such as+@MoneyDecimal@. A minus sign exists only in rendered text, never in a stored+t'RelativeAmount'.++=== Block invariant++Within one block the displayed amounts sum to the net line:++> Σ (gross rows) + Σ (subgroup rows) + Σ (deduction rows) = net row++A deduction row is stated /relative to the group's normal side/, so an+ordinary contra balance is already negative there and the sum above is a plain+addition, not a subtraction. A contra that exceeds its gross members simply+makes the net row negative (that is the @contra > parent@ edge case); a group+whose gross members are absent entirely nets to the negated deduction (the+@parent absent@ edge case). Both are handled by the same arithmetic.+-}+module ExchangeAlgebra.Reporting.Group+ ( -- * Groups+ PresentationGroup(..)+ , PresentationGroupDef(..)+ , defaultPresentationGrouping+ , presentationGroupOf+ , lookupGroupDef+ , groupNormalSide+ , groupingForDivisions+ -- * Amounts+ , RelativeAmount(..)+ , relativeTo+ , addGross+ -- * Grouped rows+ , GroupRowKind(..)+ , GroupRow(..)+ , GroupedPresentation(..)+ , presentGroups+ ) where++import qualified Data.List as L+import qualified Data.Map.Strict as M+import Data.Map.Strict (Map)+import qualified Data.Set as S+import Data.Set (Set)+import Data.Text (Text)++import ExchangeAlgebra.Algebra (HatVal(zeroValue), nearlyEqScaled)+import ExchangeAlgebra.Algebra.Base+ ( AccountDivision(..)+ , AccountTitles(..)+ , Side(..)+ , defaultSide+ )++------------------------------------------------------------------+-- Groups+------------------------------------------------------------------++-- | Identity of a statement presentation block. These are reporting+-- identities, never posting coordinates: they name a group of account titles,+-- not an account.+data PresentationGroup+ = TradeReceivablesGroup -- ^ 売上債権 − 貸倒引当金+ | DepreciableAssetsGroup -- ^ 償却性有形固定資産 − 減価償却累計額+ | NetSalesGroup -- ^ 総売上高 − 売上割戻+ | NetPurchasesGroup -- ^ 総仕入高 − 仕入割戻+ | IncomeTaxesGroup -- ^ 法人税等 − 還付法人税等+ deriving (Show, Eq, Ord, Enum, Bounded)++-- | One block of a statement: the gross titles, the contra titles deducted+-- from them, and (optionally) the group this block rolls up into.+--+-- @pgGross@ and @pgDeductions@ are ordered; rows are emitted in that order so+-- that the output is deterministic. Every @pgDeductions@ member is expected to+-- be a registry contra account of the same division as @pgDivision@, and no+-- @pgGross@ member is; the test-suite pins both, together with the disjointness+-- of the membership sets and the fact that every registry contra account+-- belongs to exactly one group.+data PresentationGroupDef = PresentationGroupDef+ { pgKey :: PresentationGroup+ , pgDivision :: AccountDivision+ -- ^ Statement column the block belongs to. Fixes the group's normal+ -- side via 'groupNormalSide'.+ , pgLabel :: Text+ -- ^ Label of the net line (and of the carried subtotal when this block+ -- rolls up into another one).+ , pgGross :: [AccountTitles]+ , pgDeductions :: [AccountTitles]+ , pgParent :: Maybe PresentationGroup+ -- ^ When set, this block's net is carried into the named block as a+ -- 'SubgroupRow' and only the outermost block contributes to the column+ -- total. The default grouping is flat; nesting exists so that a caller+ -- can state a multi-level statement without a second netting rule.+ }+ deriving (Show, Eq)++-- | Normal (deduction-free) side of a group, i.e. the side its net line is+-- stated against.+groupNormalSide :: PresentationGroupDef -> Side+groupNormalSide = defaultSide . pgDivision++-- | The grouping applied by 'ExchangeAlgebra.Write.bsRows' and+-- 'ExchangeAlgebra.Write.plRows' unless a caller supplies its own.+--+-- The membership lists are presentation policy, not algebra: they say which+-- gross titles a single undifferentiated valuation account is deducted from+-- under JGAAP's indirect method (間接控除法). The registry keeps exactly five+-- contra accounts, so there are exactly five blocks. All blocks are flat —+-- a nested default would assert a subtotal structure that the chart does not+-- yet carry.+defaultPresentationGrouping :: [PresentationGroupDef]+defaultPresentationGrouping =+ [ PresentationGroupDef+ { pgKey = TradeReceivablesGroup+ , pgDivision = Assets+ , pgLabel = "TradeReceivablesNet"+ , pgGross =+ [ NotesReceivable+ , AccountsReceivable+ , ElectronicallyRecordedReceivable+ , CreditCardReceivable+ ]+ , pgDeductions = [AllowanceForDoubtfulAccounts]+ , pgParent = Nothing+ }+ , PresentationGroupDef+ { pgKey = DepreciableAssetsGroup+ , pgDivision = Assets+ , pgLabel = "DepreciableAssetsNet"+ , pgGross =+ [ Building+ , Structures+ , Machinery+ , Vehicle+ , ToolsAndInstruments+ , Fixtures+ ]+ , pgDeductions = [AccumulatedDepreciation]+ , pgParent = Nothing+ }+ , PresentationGroupDef+ { pgKey = NetSalesGroup+ , pgDivision = Revenue+ , pgLabel = "NetSales"+ , pgGross = [Sales]+ , pgDeductions = [SalesRebates]+ , pgParent = Nothing+ }+ , PresentationGroupDef+ { pgKey = NetPurchasesGroup+ , pgDivision = Cost+ , pgLabel = "NetPurchases"+ , pgGross = [Purchases]+ , pgDeductions = [PurchaseRebates]+ , pgParent = Nothing+ }+ , PresentationGroupDef+ { pgKey = IncomeTaxesGroup+ , pgDivision = Cost+ , pgLabel = "IncomeTaxesNet"+ , pgGross = [CorporateIncomeTaxes, AdditionalIncomeTaxesForPriorPeriods]+ , pgDeductions = [RefundOfIncomeTaxes]+ , pgParent = Nothing+ }+ ]++-- | Block a title belongs to under 'defaultPresentationGrouping', if any.+presentationGroupOf :: AccountTitles -> Maybe PresentationGroup+presentationGroupOf title = fmap pgKey (L.find member defaultPresentationGrouping)+ where+ member def = title `elem` pgGross def || title `elem` pgDeductions def++-- | Look one block up in the grouping in force.+lookupGroupDef :: [PresentationGroupDef] -> PresentationGroup -> Maybe PresentationGroupDef+lookupGroupDef defs key = L.find ((== key) . pgKey) defs++-- | Restrict a grouping to the statement columns a writer renders. A balance+-- sheet must not net revenue accounts, and a profit and loss statement must+-- not net asset accounts.+groupingForDivisions :: [AccountDivision] -> [PresentationGroupDef] -> [PresentationGroupDef]+groupingForDivisions divisions = filter ((`elem` divisions) . pgDivision)++------------------------------------------------------------------+-- Amounts+------------------------------------------------------------------++-- | An amount stated relative to a group's normal side. @raMagnitude@ is+-- always non-negative; @raBelowZero@ says whether the amount sits on the+-- opposite side (a deduction, or a net that its deductions pushed past zero).+-- Callers render the direction — as a leading @-@, a triangle (△), or a move+-- to the other column — and never negate the value itself.+data RelativeAmount v = RelativeAmount+ { raBelowZero :: !Bool+ , raMagnitude :: !v+ }+ deriving (Show, Eq)++-- | Net a @(debit gross, credit gross)@ pair against a side. Uses the same+-- scale-aware near-equality as @ExchangeAlgebra.Write.netGross@\/@diffRL@, so+-- a balance that nets to zero within tolerance reports a zero magnitude+-- rather than floating-point dust.+relativeTo :: HatVal v => Side -> (v, v) -> RelativeAmount v+relativeTo side (debit, credit)+ | nearlyEqScaled debit credit = RelativeAmount False zeroValue+ | otherwise = case side of+ Credit -> orient credit debit+ -- 'defaultSide' only ever answers Debit or Credit, so the wildcard+ -- cannot arise from a group; orient it like Debit rather than+ -- inventing a third direction.+ Debit -> orient debit credit+ Side -> orient debit credit+ where+ orient toward away+ | toward >= away = RelativeAmount False (toward - away)+ | otherwise = RelativeAmount True (away - toward)++-- | Accumulate @(debit gross, credit gross)@ pairs.+addGross :: Num v => (v, v) -> (v, v) -> (v, v)+addGross (leftDebit, leftCredit) (rightDebit, rightCredit) =+ (leftDebit + rightDebit, leftCredit + rightCredit)++------------------------------------------------------------------+-- Grouped rows+------------------------------------------------------------------++-- | What one row of a block states.+data GroupRowKind+ = GrossRow AccountTitles+ -- ^ A constituent account, before deduction.+ | SubgroupRow PresentationGroup+ -- ^ The net of a nested block, carried into this one.+ | DeductionRow AccountTitles+ -- ^ A contra account. Its amount is already stated relative to the+ -- group's normal side, so it is normally 'raBelowZero'.+ | NetRow PresentationGroup+ -- ^ The block's net. Equals the sum of the rows above it.+ deriving (Show, Eq)++data GroupRow v = GroupRow+ { grKind :: GroupRowKind+ , grAmount :: RelativeAmount v+ }+ deriving (Show, Eq)++-- | Result of applying a grouping to a set of per-title balances.+data GroupedPresentation v = GroupedPresentation+ { gpBlocks :: [(PresentationGroupDef, [GroupRow v])]+ -- ^ Blocks in render order: nested blocks precede the block they roll+ -- up into, roots follow the order of the grouping list.+ , gpRootTotals :: Map AccountDivision (v, v)+ -- ^ Per-division @(debit, credit)@ contribution of the __outermost__+ -- blocks only. A caller adds this to the ungrouped part of the column;+ -- adding nested blocks as well would double count.+ , gpConsumed :: Set AccountTitles+ -- ^ Titles the blocks have taken over. The caller must not render these+ -- again through its ordinary per-entry path.+ }+ deriving (Show, Eq)++-- | Apply a grouping to per-title @(debit gross, credit gross)@ totals.+--+-- A block is /active/ when one of its own contra titles carries non-zero gross+-- activity, or when a nested block is active: a group with nothing to deduct is+-- not a group, and leaving it inactive is what keeps output identical to the+-- pre-amendment behaviour for charts that contain no contra posting. Testing+-- gross activity rather than the net balance also keeps a fully offset contra+-- title inside its group instead of leaking one side into the ordinary rows.+--+-- Complexity: O(g · m + g²) for @g@ blocks of @m@ members (the grouping is a+-- fixed, small table).+presentGroups+ :: HatVal v+ => [PresentationGroupDef]+ -> Map AccountTitles (v, v)+ -> GroupedPresentation v+presentGroups defs balances = GroupedPresentation+ { gpBlocks = blocks+ , gpRootTotals = rootTotals+ , gpConsumed = consumed+ }+ where+ grossOf title = M.findWithDefault (zeroValue, zeroValue) title balances+ hasBalance title = not (uncurry nearlyEqScaled (grossOf title))+ hasActivity title = case M.lookup title balances of+ Nothing -> False+ Just (debit, credit) ->+ not (nearlyEqScaled debit zeroValue)+ || not (nearlyEqScaled credit zeroValue)++ memberTitles def = pgGross def ++ pgDeductions def++ -- Children of a block, in grouping order. 'pgParent' is followed with a+ -- visited set so that a malformed (cyclic) grouping is truncated instead+ -- of diverging.+ childrenOf visited key =+ [ def+ | def <- defs+ , pgParent def == Just key+ , not (S.member (pgKey def) visited)+ ]++ ownPair def = L.foldl' addGross (zeroValue, zeroValue)+ (map grossOf (memberTitles def))++ subtreePair visited def = L.foldl' addGross (ownPair def)+ (map (subtreePair visited') (activeChildrenOf visited' def))+ where+ visited' = S.insert (pgKey def) visited++ ownActive def = any hasActivity (pgDeductions def)++ isActive visited def = ownActive def+ || any (isActive visited') (childrenOf visited' (pgKey def))+ where+ visited' = S.insert (pgKey def) visited++ activeChildrenOf visited def =+ [ child+ | child <- childrenOf visited (pgKey def)+ , pgDivision child == pgDivision def+ , isActive visited child+ ]++ -- A block is a root when it has no usable parent, or when it is the stable+ -- representative of a malformed parent cycle. Cross-division parents are+ -- unusable because a carried subtotal and its root total must share a+ -- statement column.+ isRoot def = case pgParent def of+ Nothing -> True+ Just parent -> case lookupGroupDef defs parent of+ Nothing -> True+ Just parentDef+ | pgDivision parentDef /= pgDivision def -> True+ | otherwise -> case parentCycle def of+ Nothing -> False+ Just keys -> pgKey def == minimum keys++ parentCycle def = follow [] (pgKey def)+ where+ follow path key+ | key `elem` path = Just (dropWhile (/= key) path)+ | otherwise = case lookupGroupDef defs key >>= pgParent of+ Nothing -> Nothing+ Just parent -> follow (path ++ [key]) parent++ activeRoots = [def | def <- defs, isRoot def, isActive S.empty def]++ -- Depth-first: nested blocks are rendered before the block that carries+ -- their net, so a reader meets a subtotal only after its constituents.+ blocksOf visited def =+ concatMap (blocksOf visited') activeChildren+ ++ [(def, rowsOf visited' def activeChildren)]+ where+ visited' = S.insert (pgKey def) visited+ activeChildren = activeChildrenOf visited' def++ rowsOf visited def activeChildren =+ [ GroupRow (GrossRow title) (relative (grossOf title))+ | title <- pgGross def, hasBalance title ]+ +++ [ GroupRow (SubgroupRow (pgKey child)) (relative (subtreePair visited child))+ | child <- activeChildren ]+ +++ [ GroupRow (DeductionRow title) (relative (grossOf title))+ | title <- pgDeductions def, hasBalance title ]+ +++ [ GroupRow (NetRow (pgKey def)) (relative (subtreePair visited def)) ]+ where+ relative = relativeTo (groupNormalSide def)++ blocks = concatMap (blocksOf S.empty) activeRoots++ rootTotals = M.fromListWith addGross+ [ (pgDivision def, subtreePair S.empty def) | def <- activeRoots ]++ consumed = S.fromList+ [ title | (def, _) <- blocks, title <- memberTitles def ]
+ src/ExchangeAlgebra/Reporting/Metric.hs view
@@ -0,0 +1,182 @@+{-# LANGUAGE FlexibleContexts #-}+{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}++{- |+Module : ExchangeAlgebra.Reporting.Metric+Description : Typed, read-only metrics derived from exchange-algebra coordinates.++Derived metrics are not posting coordinates. In particular, profit and loss+are two directions of one period-result identity, represented by+'PeriodProfit' and 'PeriodLoss' with non-negative values. The legacy+@AccountTitles@ constructors remain available for the established transfer+pipeline, but new reporting code should derive values without inserting those+coordinates.+-}+module ExchangeAlgebra.Reporting.Metric+ ( MetricId+ , mkMetricId+ , metricIdText+ , DerivedMetric(..)+ , PeriodResult(..)+ , MetricError(..)+ , metricForLegacyTitle+ , legacyTitlesForMetric+ , periodResultOfAlg+ , periodResultOf+ ) where++import qualified Data.Map.Strict as M+import Data.Text (Text)+import qualified Data.Text as T++import ExchangeAlgebra.Algebra+ ( Alg, HatVal(isErrorValue), foldEntries )+import ExchangeAlgebra.Algebra.Base+ ( AccountDivision(..)+ , AccountSemantics(asemDivisionSemantics)+ , AccountTitles(..)+ , DivisionSemantics(..)+ , ExBaseClass(getAccountTitle, whichSide)+ , Hat(..)+ , HatBaseClass(hat)+ , Side(..)+ , accountSemantics+ )+import qualified ExchangeAlgebra.TrialBalance.Validation as TB++-- | Stable identity for a caller-defined metric. Display labels are supplied+-- separately by a reporting context.+newtype MetricId = MetricId Text+ deriving (Show, Eq, Ord)++-- | Construct a non-blank custom metric identity.+mkMetricId :: Text -> Maybe MetricId+mkMetricId raw+ | T.null normalized = Nothing+ | otherwise = Just (MetricId normalized)+ where+ normalized = T.strip raw++metricIdText :: MetricId -> Text+metricIdText (MetricId value) = value++-- | Identity of a value derived for reporting. Profit versus loss is carried+-- by the result value, not duplicated as two metric identities.+data DerivedMetric+ = PeriodResultMetric+ | GrossProfitMetric+ | OrdinaryProfitMetric+ | CustomMetric MetricId+ deriving (Show, Eq, Ord)++-- | A period result with structural direction and a non-negative amount.+data PeriodResult v+ = PeriodProfit v+ | PeriodLoss v+ | PeriodBreakEven+ deriving (Show, Eq)++data MetricError+ = MetricNotAvailableAtStage TB.TrialBalanceStage+ | ResidualDerivedCoordinate AccountTitles+ | WildcardMetricSide AccountTitles+ | InvalidMetricValue AccountTitles+ deriving (Show, Eq)++-- | Map an engine-only legacy coordinate to its reporting identity.+metricForLegacyTitle :: AccountTitles -> Maybe DerivedMetric+metricForLegacyTitle title = case title of+ NetIncome -> Just PeriodResultMetric+ NetLoss -> Just PeriodResultMetric+ GrossProfit -> Just GrossProfitMetric+ OrdinaryProfit -> Just OrdinaryProfitMetric+ _ -> Nothing++-- | Legacy coordinates retained for one metric. This is a migration aid,+-- not a list of statement lines.+legacyTitlesForMetric :: DerivedMetric -> [AccountTitles]+legacyTitlesForMetric metric = case metric of+ PeriodResultMetric -> [NetIncome, NetLoss]+ GrossProfitMetric -> [GrossProfit]+ OrdinaryProfitMetric -> [OrdinaryProfit]+ CustomMetric _ -> []++-- | Derive period profit or loss without adding a balancing coordinate.+-- Only genuine statement-classified Cost and Revenue titles participate;+-- direction-encoding legacy coordinates are therefore excluded structurally.+-- Invalid values and wildcard sides are returned as explicit errors. For+-- externally sourced trial balances, prefer 'periodResultOf'.+periodResultOfAlg+ :: (HatVal v, ExBaseClass b)+ => Alg v b+ -> Either MetricError (PeriodResult v)+periodResultOfAlg alg+ | (_, base) : _ <- invalidEntries =+ Left (InvalidMetricValue (getAccountTitle base))+ | (_, base) : _ <- wildcardEntries =+ Left (WildcardMetricSide (getAccountTitle base))+ | credit == debit = Right PeriodBreakEven+ | credit > debit = Right (PeriodProfit (credit - debit))+ | otherwise = Right (PeriodLoss (debit - credit))+ where+ entries = foldEntries (\current value base -> (value, base) : current) [] alg+ invalidEntries = filter (isErrorValue . fst) entries+ wildcardEntries = filter ((== HatNot) . hat . snd) entries+ (debit, credit) = foldEntries collect (0, 0) alg+ collect totals value base+ | isStatementNominal (getAccountTitle base) =+ addOnSide (whichSide base) value totals+ | otherwise = totals++-- | Derive a period result from an accepted before-closing trial balance.+-- A non-zero legacy derived coordinate is rejected to prevent a caller from+-- treating an already-derived balancing item as another nominal account.+-- Consolidation attribution coordinates do not participate, so the result is+-- the period result before attribution to owners and non-controlling interests.+periodResultOf+ :: HatVal v+ => TB.ValidatedTrialBalance v+ -> Either MetricError (PeriodResult v)+periodResultOf validated+ | TB.validatedStage validated /= TB.BeforeClosing =+ Left (MetricNotAvailableAtStage (TB.validatedStage validated))+ | legacy : _ <- nonZeroLegacyTitles alg =+ Left (ResidualDerivedCoordinate legacy)+ | otherwise = periodResultOfAlg alg+ where+ alg = TB.validatedTrialBalance validated++isStatementNominal :: AccountTitles -> Bool+isStatementNominal title = case accountSemantics title of+ Just semantics -> case asemDivisionSemantics semantics of+ StatementDivision Cost -> True+ StatementDivision Revenue -> True+ _ -> False+ Nothing -> False++addOnSide :: Num v => Side -> v -> (v, v) -> (v, v)+addOnSide Debit value (debit, credit) = (debit + value, credit)+addOnSide Credit value (debit, credit) = (debit, credit + value)+addOnSide Side _ totals = totals++nonZeroLegacyTitles+ :: (HatVal v, ExBaseClass b)+ => Alg v b+ -> [AccountTitles]+nonZeroLegacyTitles alg =+ [ title+ | (title, (debit, credit)) <- M.toList totals+ , debit /= credit+ ]+ where+ totals = foldEntries collect M.empty alg+ collect current value base = case metricForLegacyTitle title of+ Nothing -> current+ Just _ -> M.insertWith add title (sidePair (whichSide base) value) current+ where+ title = getAccountTitle base+ add (newDebit, newCredit) (oldDebit, oldCredit) =+ (newDebit + oldDebit, newCredit + oldCredit)+ sidePair Debit value = (value, 0)+ sidePair Credit value = (0, value)+ sidePair Side _ = (0, 0)
+ src/ExchangeAlgebra/Reporting/Presentation.hs view
@@ -0,0 +1,788 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}++{- |+Module : ExchangeAlgebra.Reporting.Presentation+Description : Context-sensitive JGAAP presentation from validated trial balances.++This module is the reporting boundary: presentation accepts only an opaque+'TB.ValidatedTrialBalance'. Bookkeeping coordinates remain unchanged; all+eliminations, relabelings, maturity allocations, netting decisions, and+subtotals are recorded as presentation audit events.+-}+module ExchangeAlgebra.Reporting.Presentation+ ( AccountingFramework(..)+ , ReportingScope(..)+ , PresentationProfile(..)+ , StatementSection(..)+ , StatementLine(..)+ , PresentationAllocation(..)+ , PresentationRelabel(..)+ , MaterialityTreatment(..)+ , MaterialityDecision(..)+ , ContraPresentationRule(..)+ , CustomMetricLabel(..)+ , SubtotalCoverage(..)+ , SubtotalDefinition(..)+ , StatementSubtotal(..)+ , ReportingContext(..)+ , jcciSecondGradeContext+ , PresentationAuditEvent(..)+ , PresentationIssue(..)+ , FinancialStatements(..)+ , presentationLabel+ , metricLabel+ , present+ ) where++import Data.List.NonEmpty (NonEmpty(..))+import qualified Data.Map.Strict as M+import Data.Map.Strict (Map)+import qualified Data.Set as S+import Data.Set (Set)+import Data.Text (Text)+import qualified Data.Text as T++import ExchangeAlgebra.Algebra (HatVal)+import ExchangeAlgebra.Algebra.Base+ ( AccountDivision(..)+ , AccountRole(..)+ , AccountSemantics(asemDivisionSemantics,+ asemReportingEligibility, asemRoles)+ , AccountTitles(..)+ , DivisionSemantics(..)+ , FixedCurrent(..)+ , ReportingEligibility(..)+ , Side(..)+ )+import qualified ExchangeAlgebra.Algebra.Base.Account.Registry as Registry+import ExchangeAlgebra.Reporting.Metric+ ( DerivedMetric(..), MetricId )+import qualified ExchangeAlgebra.TrialBalance.Validation as TB+import ExchangeAlgebra.TrialBalance.Balance+ ( accountBalances+ , balanceAmount+ , balanceFor+ , balancePair+ , balanceSide+ , combineBalances+ , netPair+ )++-- | Land 4 deliberately supports JGAAP only.+data AccountingFramework = JGAAP+ deriving (Show, Eq)++data ReportingScope = Standalone | Combined+ deriving (Show, Eq)++data PresentationProfile+ = JcciSecondGradeReport+ | CanonicalJapanese+ | CanonicalEnglish+ deriving (Show, Eq)++data StatementSection+ = CurrentAssetsSection+ | NoncurrentAssetsSection+ | CurrentLiabilitiesSection+ | NoncurrentLiabilitiesSection+ | EquitySection+ | RevenueSection+ | ExpenseSection+ | ContextualDebitSection+ | ContextualCreditSection+ deriving (Show, Eq)++data StatementLine v = StatementLine+ { _lineAccount :: AccountTitles+ , _lineLabel :: Text+ , _lineSection :: StatementSection+ , _lineSide :: Side+ , _lineAmount :: v+ , _lineIsDeduction :: Bool+ }+ deriving (Show, Eq)++-- | Evidence-backed split of one title without creating new bookkeeping+-- coordinates. Current and non-current parts must sum exactly to its balance.+data PresentationAllocation v = PresentationAllocation+ { _allocationAccount :: AccountTitles+ , _allocationCurrent :: v+ , _allocationNoncurrent :: v+ , _allocationEvidence :: Text+ }+ deriving (Show, Eq)++data PresentationRelabel = PresentationRelabel+ { _relabelSource :: AccountTitles+ , _relabelTarget :: AccountTitles+ , _relabelRationale :: Text+ }+ deriving (Show, Eq)++data MaterialityTreatment+ = PresentGross+ | PresentSeparately+ | NetAgainst AccountTitles+ deriving (Show, Eq)++data MaterialityDecision = MaterialityDecision+ { _materialityAccount :: AccountTitles+ , _materialityTreatment :: MaterialityTreatment+ , _materialityRationale :: Text+ }+ deriving (Show, Eq)++data ContraPresentationRule+ = PresentContraSeparately AccountTitles Text+ | NetContraAgainst AccountTitles AccountTitles Text+ deriving (Show, Eq)++-- | Profile-specific display labels for a caller-defined metric. The metric+-- identity remains separate from these labels.+data CustomMetricLabel = CustomMetricLabel+ { _customMetricIdentity :: MetricId+ , _customMetricLabelJapanese :: Text+ , _customMetricLabelEnglish :: Text+ }+ deriving (Show, Eq)++data SubtotalCoverage+ = RequireAllTitlesPresent+ | TreatAbsentAsZero+ deriving (Show, Eq)++-- | A subtotal is a typed reporting definition, never an account-basis title.+data SubtotalDefinition = SubtotalDefinition+ { _subtotalMetric :: DerivedMetric+ , _subtotalCreditTitles :: [AccountTitles]+ , _subtotalDebitTitles :: [AccountTitles]+ , _subtotalCoverage :: SubtotalCoverage+ }+ deriving (Show, Eq)++data StatementSubtotal v = StatementSubtotal+ { _statementSubtotalMetric :: DerivedMetric+ , _statementSubtotalLabel :: Text+ , _statementSubtotalBalance :: TB.AccountBalance v+ }+ deriving (Show, Eq)++data ReportingContext v = ReportingContext+ { _reportingFramework :: AccountingFramework+ , _reportingScope :: ReportingScope+ , _presentationProfile :: PresentationProfile+ , _maturitySensitiveTitles :: Set AccountTitles+ , _presentationAllocations :: [PresentationAllocation v]+ , _presentationRelabels :: [PresentationRelabel]+ , _materialityDecisions :: [MaterialityDecision]+ , _contraPresentationRules :: [ContraPresentationRule]+ , _subtotalDefinitions :: [SubtotalDefinition]+ , _customMetricLabels :: [CustomMetricLabel]+ }+ deriving (Show, Eq)++jcciSecondGradeContext :: ReportingScope -> ReportingContext v+jcciSecondGradeContext scope = ReportingContext+ { _reportingFramework = JGAAP+ , _reportingScope = scope+ , _presentationProfile = JcciSecondGradeReport+ , _maturitySensitiveTitles = S.empty+ , _presentationAllocations = []+ , _presentationRelabels = []+ , _materialityDecisions = []+ , _contraPresentationRules = []+ , _subtotalDefinitions = []+ , _customMetricLabels = []+ }++data PresentationAuditEvent v+ = ReciprocalAccountsEliminated+ (TB.AccountBalance v) (TB.AccountBalance v)+ | AccountRelabeled+ AccountTitles AccountTitles (TB.AccountBalance v) Text+ | BalanceAllocated AccountTitles v v Text+ | MaterialityApplied+ AccountTitles MaterialityTreatment (TB.AccountBalance v) Text+ | ContraPresentationApplied+ AccountTitles (Maybe AccountTitles) (TB.AccountBalance v) Text+ | SubtotalCalculated DerivedMetric (TB.AccountBalance v)+ | LabelOverridden AccountTitles Text Text+ deriving (Show, Eq)++data PresentationIssue v+ = ValidationFindingBlocks (TB.TBFinding v)+ | MissingPresentationAllocation AccountTitles+ | DuplicatePresentationAllocation AccountTitles+ | InvalidPresentationAllocation+ AccountTitles (TB.AccountBalance v) v v+ | BlankPresentationEvidence AccountTitles+ | UnexpectedPresentationAllocation AccountTitles+ | ConflictingPresentationInstruction AccountTitles+ | MissingPresentationAccount AccountTitles+ | BlankPresentationRationale AccountTitles+ | UnpresentableBalance AccountTitles (TB.AccountBalance v)+ | InvalidSubtotalDefinition DerivedMetric+ | DuplicateMetricIdentity DerivedMetric+ | UnlabelledCustomMetric MetricId+ | UnreconciledPresentation v v+ deriving (Show, Eq)++data FinancialStatements v = FinancialStatements+ { _statementFramework :: AccountingFramework+ , _statementScope :: ReportingScope+ , _statementProfile :: PresentationProfile+ , _statementTrialBalanceStage :: TB.TrialBalanceStage+ , _statementLines :: [StatementLine v]+ , _statementSubtotals :: [StatementSubtotal v]+ , _presentationAudit :: [PresentationAuditEvent v]+ }+ deriving (Show, Eq)++-- | Profile-specific Japanese display-label overrides.+jcciSecondGradeLabelOverrides :: Map AccountTitles Text+jcciSecondGradeLabelOverrides = M.fromList+ [ (AdvancesReceived, T.pack "契約負債")+ ]++-- | Profile-specific display label. Japanese profiles use the registry's+-- cleaned statement label, with the JCCI grade-2 overrides applied first.+presentationLabel :: PresentationProfile -> AccountTitles -> Text+presentationLabel profile title = case Registry.accountSpec title of+ Nothing -> T.pack (show title)+ Just spec -> case profile of+ JcciSecondGradeReport -> M.findWithDefault+ (Registry.asLabelJa spec) title jcciSecondGradeLabelOverrides+ CanonicalJapanese -> Registry.asLabelJa spec+ CanonicalEnglish -> Registry.asNameEn spec++-- | Resolve a metric's display label independently of its stable identity.+-- Profit/loss wording follows the structural balance direction.+metricLabel+ :: ReportingContext v+ -> DerivedMetric+ -> TB.AccountBalance v+ -> Maybe Text+metricLabel context metric balance = case metric of+ PeriodResultMetric -> Just (builtinLabel+ "当期純損益" "当期純利益" "当期純損失"+ "Net result" "Net income" "Net loss")+ GrossProfitMetric -> Just (builtinLabel+ "売上総損益" "売上総利益" "売上総損失"+ "Gross result" "Gross profit" "Gross loss")+ OrdinaryProfitMetric -> Just (builtinLabel+ "経常損益" "経常利益" "経常損失"+ "Ordinary result" "Ordinary profit" "Ordinary loss")+ CustomMetric metricId -> customLabel metricId+ where+ english = _presentationProfile context == CanonicalEnglish+ builtinLabel neutralJa creditJa debitJa neutralEn creditEn debitEn =+ case (english, balance) of+ (False, TB.NoBalance) -> neutralJa+ (False, TB.CreditBalance _) -> creditJa+ (False, TB.DebitBalance _) -> debitJa+ (True, TB.NoBalance) -> neutralEn+ (True, TB.CreditBalance _) -> creditEn+ (True, TB.DebitBalance _) -> debitEn+ customLabel metricId = case filter+ ((== metricId) . _customMetricIdentity)+ (_customMetricLabels context) of+ [label] -> Just (if english+ then _customMetricLabelEnglish label+ else _customMetricLabelJapanese label)+ _ -> Nothing++present+ :: HatVal v+ => ReportingContext v+ -> TB.ValidatedTrialBalance v+ -> Either (NonEmpty (PresentationIssue v)) (FinancialStatements v)+present context validated = case issues of+ issue : rest -> Left (issue :| rest)+ [] -> Right FinancialStatements+ { _statementFramework = framework+ , _statementScope = _reportingScope context+ , _statementProfile = _presentationProfile context+ , _statementTrialBalanceStage = TB.validatedStage validated+ , _statementLines = statementLines+ , _statementSubtotals = subtotals+ , _presentationAudit = eliminationAudit+ ++ relabelAudit ++ materialityAudit ++ contraAudit+ ++ allocationAudit ++ subtotalAudit ++ labelAudit+ }+ where+ framework = case _reportingFramework context of+ JGAAP -> JGAAP+ initial = accountBalances (TB.validatedTrialBalance validated)+ gatePolicy = case _reportingScope context of+ Standalone -> TB.standaloneTrialBalancePolicy+ Combined -> TB.strictTrialBalancePolicy+ gateIssues = map ValidationFindingBlocks+ (filter (TB.findingBlocksPresentation gatePolicy)+ (TB.validatedFindings validated))+ (eliminated, eliminationAudit) = eliminateReciprocals context initial+ (relabeled, relabelAudit) = applyRelabels context eliminated+ (materialized, materialityAudit) = applyMateriality context relabeled+ (transformed, contraAudit) = applyContraRules context materialized+ explicitRequired = S.union+ (TB.validatedMaturityRequiredTitles validated)+ (_maturitySensitiveTitles context)+ requiredMaturity = S.union explicitRequired+ (implicitMaturityTitles transformed)+ statementLines = renderLines context transformed+ contextIssues = instructionIssues explicitRequired context initial+ ++ allocationIssues requiredMaturity context transformed+ ++ subtotalIssues context initial transformed+ ++ coverageIssues transformed+ ++ reconciliationIssues statementLines+ issues = gateIssues ++ contextIssues+ allocationAudit =+ [ BalanceAllocated title current noncurrent evidence+ | PresentationAllocation title current noncurrent evidence+ <- _presentationAllocations context+ , balanceFor title transformed /= TB.NoBalance+ ]+ subtotals =+ [ StatementSubtotal metric label balance+ | SubtotalDefinition metric credits debits _ <-+ _subtotalDefinitions context+ , let balance = subtotalBalance transformed credits debits+ , Just label <- [metricLabel context metric balance]+ ]+ subtotalAudit =+ [ SubtotalCalculated metric balance+ | StatementSubtotal metric _ balance <- subtotals+ ]+ labelAudit =+ [ LabelOverridden title canonical displayed+ | _presentationProfile context == JcciSecondGradeReport+ , (title, _) <- M.toList jcciSecondGradeLabelOverrides+ , title `elem` map _lineAccount statementLines+ , let canonical = presentationLabel CanonicalJapanese title+ , let displayed = presentationLabel JcciSecondGradeReport title+ , canonical /= displayed+ ]++eliminateReciprocals+ :: Eq v => ReportingContext v+ -> Map AccountTitles (TB.AccountBalance v)+ -> (Map AccountTitles (TB.AccountBalance v), [PresentationAuditEvent v])+eliminateReciprocals context balances = case _reportingScope context of+ Standalone -> (balances, [])+ Combined ->+ ( M.delete BranchCurrentAccount+ (M.delete HeadOfficeCurrentAccount balances)+ , [ ReciprocalAccountsEliminated branch headOffice+ | branch /= TB.NoBalance || headOffice /= TB.NoBalance+ ]+ )+ where+ branch = balanceFor BranchCurrentAccount balances+ headOffice = balanceFor HeadOfficeCurrentAccount balances++applyRelabels+ :: (Ord v, Num v)+ => ReportingContext v+ -> Map AccountTitles (TB.AccountBalance v)+ -> (Map AccountTitles (TB.AccountBalance v), [PresentationAuditEvent v])+applyRelabels context balances0 =+ Prelude.foldl applyOne (balances0, []) (_presentationRelabels context)+ where+ applyOne (balances, events) (PresentationRelabel source target rationale) =+ let sourceBalance = balanceFor source balances+ targetBalance = balanceFor target balances+ next = M.insert target (combineBalances targetBalance sourceBalance)+ (M.delete source balances)+ in (next, events +++ [AccountRelabeled source target sourceBalance rationale])++applyMateriality+ :: (Ord v, Num v)+ => ReportingContext v+ -> Map AccountTitles (TB.AccountBalance v)+ -> (Map AccountTitles (TB.AccountBalance v), [PresentationAuditEvent v])+applyMateriality context balances0 =+ Prelude.foldl applyOne (balances0, []) (_materialityDecisions context)+ where+ applyOne (balances, events) decision =+ let source = _materialityAccount decision+ treatment = _materialityTreatment decision+ next = case treatment of+ PresentGross -> balances+ PresentSeparately -> balances+ NetAgainst target -> moveBalance source target balances+ event = MaterialityApplied source treatment+ (balanceFor source balances) (_materialityRationale decision)+ in (next, events ++ [event])++applyContraRules+ :: (Ord v, Num v)+ => ReportingContext v+ -> Map AccountTitles (TB.AccountBalance v)+ -> (Map AccountTitles (TB.AccountBalance v), [PresentationAuditEvent v])+applyContraRules context balances0 =+ Prelude.foldl applyOne (balances0, [])+ (_contraPresentationRules context)+ where+ applyOne (balances, events) rule = case rule of+ PresentContraSeparately source rationale ->+ (balances, events ++ [ContraPresentationApplied source Nothing+ (balanceFor source balances) rationale])+ NetContraAgainst source target rationale ->+ ( moveBalance source target balances+ , events ++ [ContraPresentationApplied source (Just target)+ (balanceFor source balances) rationale]+ )++moveBalance+ :: (Ord v, Num v)+ => AccountTitles -> AccountTitles+ -> Map AccountTitles (TB.AccountBalance v)+ -> Map AccountTitles (TB.AccountBalance v)+moveBalance source target balances =+ if source == target+ then balances+ else M.insert target+ (combineBalances (balanceFor target balances)+ (balanceFor source balances))+ (M.delete source balances)++allocationIssues+ :: HatVal v+ => Set AccountTitles+ -> ReportingContext v+ -> Map AccountTitles (TB.AccountBalance v)+ -> [PresentationIssue v]+allocationIssues required context balances = concatMap checkTitle checkedTitles+ ++ [ DuplicatePresentationAllocation title+ | title <- duplicateTitles (map _allocationAccount allocations)+ ]+ ++ map UnexpectedPresentationAllocation+ (S.toList (allocationTitles `S.difference` required))+ where+ allocations = _presentationAllocations context+ allocationTitles = S.fromList (map _allocationAccount allocations)+ checkedTitles = S.toList (required `S.union` allocationTitles)+ checkTitle title = case balanceFor title balances of+ TB.NoBalance+ | title `S.member` allocationTitles ->+ [MissingPresentationAccount title]+ | otherwise -> []+ balance -> case filter ((== title) . _allocationAccount) allocations of+ [] -> [MissingPresentationAllocation title]+ [allocation]+ | T.null (T.strip (_allocationEvidence allocation)) ->+ [BlankPresentationEvidence title]+ | allocationMatches balance allocation -> []+ | otherwise -> [InvalidPresentationAllocation title balance+ (_allocationCurrent allocation)+ (_allocationNoncurrent allocation)]+ _ -> []++allocationMatches+ :: (Ord v, Num v)+ => TB.AccountBalance v -> PresentationAllocation v -> Bool+allocationMatches TB.NoBalance _ = False+allocationMatches (TB.DebitBalance value) allocation =+ _allocationCurrent allocation >= 0+ && _allocationNoncurrent allocation >= 0+ && _allocationCurrent allocation + _allocationNoncurrent allocation == value+allocationMatches (TB.CreditBalance value) allocation =+ _allocationCurrent allocation >= 0+ && _allocationNoncurrent allocation >= 0+ && _allocationCurrent allocation + _allocationNoncurrent allocation == value++instructionIssues+ :: HatVal v+ => Set AccountTitles+ -> ReportingContext v+ -> Map AccountTitles (TB.AccountBalance v)+ -> [PresentationIssue v]+instructionIssues explicitRequired context balances =+ duplicateInstructionIssues+ ++ concatMap relabelIssue (_presentationRelabels context)+ ++ concatMap materialityIssue (_materialityDecisions context)+ ++ concatMap contraIssue (_contraPresentationRules context)+ where+ duplicateInstructionIssues =+ map ConflictingPresentationInstruction+ (duplicateTitles instructionSources)+ ++ [ ConflictingPresentationInstruction title+ | _reportingScope context == Combined+ , title <- [BranchCurrentAccount, HeadOfficeCurrentAccount]+ , title `elem` instructionSources+ ]+ instructionSources = concatMap relabelTitles (_presentationRelabels context)+ ++ concatMap materialityTitles (_materialityDecisions context)+ ++ concatMap contraTitles (_contraPresentationRules context)+ relabelIssue rule+ | T.null (T.strip (_relabelRationale rule)) =+ [BlankPresentationRationale (_relabelSource rule)]+ | balanceFor (_relabelSource rule) balances == TB.NoBalance =+ [MissingPresentationAccount (_relabelSource rule)]+ | _relabelSource rule `S.member` explicitRequired =+ [ConflictingPresentationInstruction (_relabelSource rule)]+ | not (presentableTarget (_relabelTarget rule)) =+ [ConflictingPresentationInstruction (_relabelTarget rule)]+ | otherwise = []+ materialityIssue decision+ | T.null (T.strip (_materialityRationale decision)) =+ [BlankPresentationRationale (_materialityAccount decision)]+ | balanceFor (_materialityAccount decision) balances == TB.NoBalance =+ [MissingPresentationAccount (_materialityAccount decision)]+ | otherwise = case _materialityTreatment decision of+ NetAgainst target+ | _materialityAccount decision `S.member` explicitRequired ->+ [ConflictingPresentationInstruction+ (_materialityAccount decision)]+ | target == _materialityAccount decision ->+ [ConflictingPresentationInstruction target]+ | not (presentableTarget target) ->+ [ConflictingPresentationInstruction target]+ | M.notMember target balances ->+ [MissingPresentationAccount target]+ _ -> []+ contraIssue rule = case rule of+ PresentContraSeparately source rationale+ | T.null (T.strip rationale) ->+ [BlankPresentationRationale source]+ | balanceFor source balances == TB.NoBalance ->+ [MissingPresentationAccount source]+ | otherwise -> []+ NetContraAgainst source target rationale+ | T.null (T.strip rationale) ->+ [BlankPresentationRationale source]+ | balanceFor source balances == TB.NoBalance ->+ [MissingPresentationAccount source]+ | source `S.member` explicitRequired ->+ [ConflictingPresentationInstruction source]+ | source == target ->+ [ConflictingPresentationInstruction source]+ | not (presentableTarget target) ->+ [ConflictingPresentationInstruction target]+ | M.notMember target balances ->+ [MissingPresentationAccount target]+ | otherwise -> []+ relabelTitles rule = [_relabelSource rule, _relabelTarget rule]+ materialityTitles decision = _materialityAccount decision : case+ _materialityTreatment decision of+ NetAgainst target -> [target]+ PresentGross -> []+ PresentSeparately -> []+ contraTitles (PresentContraSeparately source _) = [source]+ contraTitles (NetContraAgainst source target _) = [source, target]++duplicateTitles :: [AccountTitles] -> [AccountTitles]+duplicateTitles titles = S.toList+ (S.fromList [title | title <- titles, count title titles > 1])+ where+ count needle = length . filter (== needle)++presentableTarget :: AccountTitles -> Bool+presentableTarget title = case Registry.accountSemantics title of+ Just semantics -> asemReportingEligibility semantics `elem`+ [StatementEligible, ContextualPresentation]+ Nothing -> False++implicitMaturityTitles+ :: Map AccountTitles (TB.AccountBalance v) -> Set AccountTitles+implicitMaturityTitles balances = S.fromList+ [ title+ | (title, balance) <- M.toList balances+ , balanceSide balance /= Side+ , fixedCurrent title == Other+ , Just semantics <- [Registry.accountSemantics title]+ , StatementDivision division <- [asemDivisionSemantics semantics]+ , division `elem` [Assets, Liability]+ ]++coverageIssues+ :: Map AccountTitles (TB.AccountBalance v) -> [PresentationIssue v]+coverageIssues balances =+ [ UnpresentableBalance title balance+ | (title, balance) <- M.toList balances+ , balanceSide balance /= Side+ , not (presentableTarget title)+ ]++reconciliationIssues+ :: (Eq v, Num v) => [StatementLine v] -> [PresentationIssue v]+reconciliationIssues lines0 =+ [ UnreconciledPresentation debit credit | debit /= credit ]+ where+ debit = sum [_lineAmount line | line <- lines0, _lineSide line == Debit]+ credit = sum [_lineAmount line | line <- lines0, _lineSide line == Credit]++subtotalIssues+ :: HatVal v+ => ReportingContext v+ -> Map AccountTitles (TB.AccountBalance v)+ -> Map AccountTitles (TB.AccountBalance v)+ -> [PresentationIssue v]+subtotalIssues context initial balances = duplicateDefinitionIssues+ ++ duplicateCustomLabelIssues+ ++ concatMap check definitions+ where+ definitions = _subtotalDefinitions context+ definitionMetrics = map _subtotalMetric definitions+ duplicateDefinitionIssues =+ [ DuplicateMetricIdentity metric+ | metric <- duplicateMetrics definitionMetrics+ ]+ customLabelIds = map _customMetricIdentity (_customMetricLabels context)+ duplicateCustomLabelIssues =+ [ DuplicateMetricIdentity (CustomMetric metricId)+ | metricId <- duplicateMetricIds customLabelIds+ ]+ check (SubtotalDefinition metric credits debits coverage)+ | null credits && null debits = [InvalidSubtotalDefinition metric]+ | not (null (duplicateTitles (credits ++ debits))) =+ [InvalidSubtotalDefinition metric]+ | customMetricUnlabelled metric = [customMetricIssue metric]+ | coverage == RequireAllTitlesPresent+ && any (`M.notMember` balances) (credits ++ debits) =+ [InvalidSubtotalDefinition metric]+ | coverage == TreatAbsentAsZero+ && any removedNonZeroTitle (credits ++ debits) =+ [InvalidSubtotalDefinition metric]+ | any (wrongSide Credit coverage) credits =+ [InvalidSubtotalDefinition metric]+ | any (wrongSide Debit coverage) debits =+ [InvalidSubtotalDefinition metric]+ | otherwise = []+ removedNonZeroTitle title =+ balanceFor title initial /= TB.NoBalance && M.notMember title balances+ wrongSide expected coverage title = case balanceFor title balances of+ TB.NoBalance -> coverage == RequireAllTitlesPresent+ balance -> balanceSide balance /= expected+ customMetricUnlabelled metric = case metric of+ CustomMetric metricId -> case customLabels metricId of+ [label] -> T.null (T.strip (_customMetricLabelJapanese label))+ || T.null (T.strip (_customMetricLabelEnglish label))+ _ -> True+ _ -> False+ customMetricIssue metric = case metric of+ CustomMetric metricId -> UnlabelledCustomMetric metricId+ _ -> InvalidSubtotalDefinition metric+ customLabels metricId = filter+ ((== metricId) . _customMetricIdentity)+ (_customMetricLabels context)++duplicateMetrics :: [DerivedMetric] -> [DerivedMetric]+duplicateMetrics metrics = S.toList+ (S.fromList [metric | metric <- metrics, count metric metrics > 1])+ where+ count needle = length . filter (== needle)++duplicateMetricIds :: [MetricId] -> [MetricId]+duplicateMetricIds metricIds = S.toList+ (S.fromList [metricId | metricId <- metricIds, count metricId metricIds > 1])+ where+ count needle = length . filter (== needle)++renderLines+ :: (Eq v, Num v)+ => ReportingContext v+ -> Map AccountTitles (TB.AccountBalance v)+ -> [StatementLine v]+renderLines context balances = concatMap renderOne (M.toList balances)+ where+ renderOne (_, TB.NoBalance) = []+ renderOne (title, balance) = case allocationFor title of+ Just allocation -> allocatedLines context title balance allocation+ Nothing -> case reportingSection title balance of+ Nothing -> []+ Just section -> [lineFor context title section balance]+ allocationFor title = case filter ((== title) . _allocationAccount)+ (_presentationAllocations context) of+ allocation : _ -> Just allocation+ [] -> Nothing++allocatedLines+ :: (Eq v, Num v)+ => ReportingContext v -> AccountTitles -> TB.AccountBalance v+ -> PresentationAllocation v -> [StatementLine v]+allocatedLines context title balance allocation =+ [ StatementLine title (presentationLabel (_presentationProfile context) title)+ section side amount (isContraTitle title)+ | (section, amount) <-+ [ (currentSection title balance, _allocationCurrent allocation)+ , (noncurrentSection title balance, _allocationNoncurrent allocation)+ ]+ , amount /= 0+ ]+ where+ side = balanceSide balance++lineFor+ :: Num v => ReportingContext v -> AccountTitles -> StatementSection+ -> TB.AccountBalance v -> StatementLine v+lineFor context title section balance = StatementLine+ title (presentationLabel (_presentationProfile context) title)+ section (balanceSide balance) (balanceAmount balance) (isContraTitle title)++isContraTitle :: AccountTitles -> Bool+isContraTitle title = case Registry.accountSemantics title of+ Just semantics -> ContraAccount `elem` asemRoles semantics+ Nothing -> False++reportingSection+ :: AccountTitles -> TB.AccountBalance v -> Maybe StatementSection+reportingSection title balance = do+ semantics <- Registry.accountSemantics title+ case asemReportingEligibility semantics of+ DerivedPresentation -> Nothing+ NotPresented -> Nothing+ ContextualPresentation -> Just (contextualSection balance)+ StatementEligible -> case asemDivisionSemantics semantics of+ StatementDivision division -> Just (divisionSection title division)+ _ -> Nothing++divisionSection :: AccountTitles -> AccountDivision -> StatementSection+divisionSection title division = case division of+ Assets -> case fixedCurrent title of+ Fixed -> NoncurrentAssetsSection+ _ -> CurrentAssetsSection+ Liability -> case fixedCurrent title of+ Fixed -> NoncurrentLiabilitiesSection+ _ -> CurrentLiabilitiesSection+ Equity -> EquitySection+ Revenue -> RevenueSection+ Cost -> ExpenseSection++fixedCurrent :: AccountTitles -> FixedCurrent+fixedCurrent title = case Registry.accountSpec title of+ Just spec -> Registry.asFixedCurrent spec+ Nothing -> Other++contextualSection :: TB.AccountBalance v -> StatementSection+contextualSection (TB.DebitBalance _) = ContextualDebitSection+contextualSection (TB.CreditBalance _) = ContextualCreditSection+contextualSection TB.NoBalance = ContextualDebitSection++currentSection :: AccountTitles -> TB.AccountBalance v -> StatementSection+currentSection title balance = case Registry.accountSemantics title of+ Just semantics -> case asemDivisionSemantics semantics of+ StatementDivision Liability -> CurrentLiabilitiesSection+ StatementDivision Assets -> CurrentAssetsSection+ _ -> contextualSection balance+ Nothing -> contextualSection balance++noncurrentSection :: AccountTitles -> TB.AccountBalance v -> StatementSection+noncurrentSection title balance = case Registry.accountSemantics title of+ Just semantics -> case asemDivisionSemantics semantics of+ StatementDivision Liability -> NoncurrentLiabilitiesSection+ StatementDivision Assets -> NoncurrentAssetsSection+ _ -> contextualSection balance+ Nothing -> contextualSection balance++subtotalBalance+ :: (Ord v, Num v)+ => Map AccountTitles (TB.AccountBalance v)+ -> [AccountTitles] -> [AccountTitles] -> TB.AccountBalance v+subtotalBalance balances creditTitles debitTitles = netPair+ ( sum [balanceAmount (balanceFor title balances) | title <- debitTitles]+ , sum [balanceAmount (balanceFor title balances) | title <- creditTitles]+ )
src/ExchangeAlgebra/Simulate.hs view
@@ -76,7 +76,7 @@ ,update ,initAll ,updateAll- ,StateSpace(event,randomSeeds)+ ,StateSpace(event,randomSeeds,initT,lastT) ,normal ,normal' ,updateGen@@ -88,11 +88,17 @@ ,eventAll ,SpillOptions(..) ,SpillDeletePolicy(..)+ ,stepBackWith+ ,spillDeleteDecision ,mkSpillOptions ,mkBinarySpillOptions ,defaultSpillWriter ,defaultBinarySpillWriter+ ,SpillReadError(..)+ ,SpillRangeIssue(..)+ ,renderSpillReadError ,readBinarySpillFile+ ,readBinarySpillFileChecked ,runSimulation ,runSimulationWithSpill ,runScenarios@@ -112,11 +118,9 @@ import Data.Array.IO hiding (modifyArray) import Data.STRef import qualified Control.Monad as CM-import Data.Array import qualified Data.Map.Strict as M-import System.IO (Handle, IOMode(..), withFile, hPutStrLn, hPutStr)-import qualified Data.ByteString.Lazy as BL-import qualified Data.Binary as Binary+import System.IO (IOMode(..), withFile)+import ExchangeAlgebra.Simulate.Spill ------------------------------------------------------------------ -- | Type class defining the time axis for simulations.@@ -185,11 +189,11 @@ {-# INLINE copy #-} copy :: StdGen -> t -> v -> (a s) -> ST s ()- copy _ _ _ _ = undefined+ copy _ _ _ _ = error "Updatable.copy: default method not overridden" {-# INLINE modify #-} modify :: StdGen -> t -> v -> (a s) -> ST s ()- modify _ _ _ _ = undefined+ modify _ _ _ _ = error "Updatable.modify: default method not overridden" {-# INLINE update #-} update :: StdGen -> t -> v -> (a s) -> ST s ()@@ -217,6 +221,10 @@ -- -- _unwrapURef (SP x) = x -- -- _wrapURef x = SP x -- @+--+-- The underscore-prefixed methods are exported because defining a+-- (non-Coercible) instance requires naming them; they are instance wiring,+-- not a user-facing API — call 'readURef'\/'writeURef'\/'modifyURef' instead. class UpdatableSTRef wrapper s b | wrapper s -> b where -- | Unwrap to the inner @STRef@. -- For newtype wrappers, the @Coercible@ default is used.@@ -346,98 +354,6 @@ eventAll wld t = CM.forM_ [fstEvent .. lastEvent] $ \e -> event wld t e --- | Spill configuration for periodic external logging.--- `spillExtract` selects accounting payload from world.--- `spillWriteChunk` controls on-disk format.-data SpillOptions t a payload = SpillOptions- { spillEveryTerms :: !Int- , spillFilePath :: FilePath- , spillExtract :: a RealWorld -> ST RealWorld payload- , spillExtractChunk :: Maybe ((t, t) -> a RealWorld -> ST RealWorld payload)- , spillWriteChunk :: Handle -> (t, t) -> payload -> IO ()- , spillDeletePolicy :: SpillDeletePolicy t- , spillDeleteRange :: (t, t) -> a RealWorld -> ST RealWorld ()- }---- | Policy to decide which term range to evict after each spill.-data SpillDeletePolicy t- = NoDelete- | DeleteSpilledChunk- | KeepRecentTerms Int---- | Construct text-format SpillOptions.--- interval is the spill interval (in terms), path is the output file path.------ Complexity: O(1)-mkSpillOptions :: (Show t)- => Int- -> FilePath- -> (a RealWorld -> ST RealWorld String)- -> SpillOptions t a String-mkSpillOptions interval path extractF =- SpillOptions- { spillEveryTerms = max 1 interval- , spillFilePath = path- , spillExtract = extractF- , spillExtractChunk = Nothing- , spillWriteChunk = defaultSpillWriter- , spillDeletePolicy = NoDelete- , spillDeleteRange = \_ _ -> pure ()- }---- | Construct binary-format SpillOptions.--- Spills in a format that can be restored with 'readBinarySpillFile'.------ Complexity: O(1)-mkBinarySpillOptions :: (Binary.Binary t, Binary.Binary payload)- => Int- -> FilePath- -> (a RealWorld -> ST RealWorld payload)- -> SpillOptions t a payload-mkBinarySpillOptions interval path extractF =- SpillOptions- { spillEveryTerms = max 1 interval- , spillFilePath = path- , spillExtract = extractF- , spillExtractChunk = Nothing- , spillWriteChunk = defaultBinarySpillWriter- , spillDeletePolicy = NoDelete- , spillDeleteRange = \_ _ -> pure ()- }---- | Default text-format spill writer.--- Writes the chunk range and payload as text to the handle.-defaultSpillWriter :: (Show t) => Handle -> (t, t) -> String -> IO ()-defaultSpillWriter h (tStart, tEnd) payload = do- hPutStrLn h ("# chunk " ++ show tStart ++ " " ++ show tEnd)- hPutStr h payload- hPutStrLn h "\n# end-chunk"---- | Default binary-format spill writer.--- Writes the chunk range and payload to the handle using 'Binary.encode'.-defaultBinarySpillWriter :: (Binary.Binary t, Binary.Binary payload)- => Handle -> (t, t) -> payload -> IO ()-defaultBinarySpillWriter h range payload =- BL.hPut h $ Binary.encode (range, payload)---- | Read a binary spill file and return it as a list of chunks.--- Used to restore files written by 'defaultBinarySpillWriter'.--- When decoding fails, the remaining data is truncated.------ Complexity: O(file size)-readBinarySpillFile :: (Binary.Binary t, Binary.Binary payload)- => FilePath- -> IO [((t, t), payload)]-readBinarySpillFile path = do- bytes <- BL.readFile path- pure (go bytes)- where- go bs- | BL.null bs = []- | otherwise = case Binary.decodeOrFail bs of- Left _ -> []- Right (rest, _, entry) -> entry : go rest- -- | Simulation {-# INLINE simulate #-} simulate :: (StateSpace t v e a s)@@ -447,12 +363,12 @@ {-# INLINE loop #-} loop :: (StateSpace t v e a s) => StdGen -> a s -> t -> v -> ST s ()- loop g wld t v- | t == lastTerm = updateAll g t v wld >> eventAll wld t+ loop g' wld' t v'+ | t == lastTerm = updateAll g' t v' wld' >> eventAll wld' t | otherwise = do- updateAll g t v wld- eventAll wld t- loop g wld (nextTerm t) v+ updateAll g' t v' wld'+ eventAll wld' t+ loop g' wld' (nextTerm t) v' -- | Run a simulation from initialization through the final term. -- Build the world state with initAll, then repeat updateAll followed by eventAll for each term.@@ -485,25 +401,13 @@ shouldSpill chunkStart t isLast = isLast || (fromEnum t - fromEnum chunkStart + 1 >= spillEveryTerms opts) - backBy n x- | n <= 0 = x- | otherwise = backBy (n - 1) (prevTerm x)-- deleteRangeForChunk (chunkStart, chunkEnd) = case spillDeletePolicy opts of- NoDelete -> Nothing- DeleteSpilledChunk -> Just (chunkStart, chunkEnd)- KeepRecentTerms keepN ->- let deleteEnd = backBy keepN chunkEnd- in if deleteEnd < chunkStart- then Nothing- else Just (chunkStart, deleteEnd)- spillChunk h chunkStart t wld = do payload <- case spillExtractChunk opts of Just extractChunk -> stToIO $ extractChunk (chunkStart, t) wld Nothing -> stToIO $ spillExtract opts wld spillWriteChunk opts h (chunkStart, t) payload- case deleteRangeForChunk (chunkStart, t) of+ -- delete decision is single-sourced in 'spillDeleteDecision' (C4)+ case spillDeleteDecision prevTerm (spillDeletePolicy opts) (chunkStart, t) of Nothing -> pure () Just delRange -> stToIO $ spillDeleteRange opts delRange wld @@ -616,6 +520,9 @@ inverse :: IOArray (Int, Int) Double -> IO (IOArray (Int, Int) Double) inverse mat = do bnds <- getBounds mat+ -- 'inverse' is only ever called on a 1-indexed square matrix, so the bounds+ -- are @((1,1),(n,n))@; matching @((1,1),(n,_))@ is intentionally partial+ -- (audited invariant) — a non-1-indexed matrix is a programmer error here. let ((1,1),(n,_)) = bnds inv <- identity n
+ src/ExchangeAlgebra/Simulate/Lite.hs view
@@ -0,0 +1,742 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE ConstraintKinds #-}+{-# LANGUAGE QuantifiedConstraints #-}+{-# LANGUAGE UndecidableInstances #-}+{-# LANGUAGE AllowAmbiguousTypes #-}++{- |+ Module : ExchangeAlgebra.Simulate.Lite+ Copyright : (c) Kaya Akagi. 2018-2026+ Maintainer : yakagika@icloud.com++ Released under the OWL license++ == What this module is++ @Simulate.Lite@ is a small, additive front-end for agent-based bookkeeping+ simulations. It sits /beside/ the classic "ExchangeAlgebra.Simulate" engine+ (which is left completely unchanged) and trades some of its flexibility for+ much less boilerplate and a clearer, /bulk-synchronous parallel/ (BSP)+ semantics.++ A model is described by three things:++ 1. a __product-only HKD world__ @w@ (one record, sibling fields, no+ nesting, no sum types) whose fields are tagged by a /role/+ (@'InitT'@, @'RefT' s@, @'SnapT'@) via the 'HK' type family;+ 2. a list of __stages__ ('Stage'); each stage maps its agents to+ /messages/ (t'Journal') purely from a read-only world snapshot; and+ 3. a t'SimSpec' bundling the term range, seed, the ledger field selector,+ the stages, and a parallelism policy.++ 'runLite' drives the BSP loop: per term, per stage, it freezes the world to+ a snapshot, runs every agent against /that same snapshot/, and then commits+ the merged messages to the ledger in one shot.++ == Correspondence with the classic "ExchangeAlgebra.Simulate"++ +--------------------------+----------------------------------+----------------------------------++ | concept | classic @Simulate@ | @Simulate.Lite@ |+ +==========================+==================================+==================================++ | world state | @StateSpace@ + @Updatable@ | product-only HKD @w@ + 'HK' |+ | | instances per field | (no per-field class instances) |+ +--------------------------+----------------------------------+----------------------------------++ | mutable cell access | @UpdatableSTRef@ plumbed by hand | 'HK' @('RefT' s)@ = @STRef s@, |+ | | | generated by 'gInit' |+ +--------------------------+----------------------------------+----------------------------------++ | read-only view | read each ref where needed | one 'gFreeze' = @w 'SnapT'@ |+ +--------------------------+----------------------------------+----------------------------------++ | per-agent step | imperative @ST@ that may read | pure @w 'SnapT' -> ... -> |+ | | /and write/ shared refs | Journal@ (a /message/) |+ +--------------------------+----------------------------------+----------------------------------++ | term boundary update | bespoke @Updatable@ logic | declarative 'Field' rule |+ | | | (Carry\/ResetEach\/UpdateEach) |+ +--------------------------+----------------------------------+----------------------------------+++ == BSP semantics (differs from the classic engine)++ Within a single stage every agent observes the __same snapshot__, taken once+ at the start of the stage. An agent /cannot/ see the messages emitted by+ earlier agents in the same stage (intra-stage invisibility). Messages from a+ stage become visible only to /later/ stages (same term) and to later terms,+ because the ledger commit happens after the whole stage has run. This is the+ BSP "superstep" rule; the classic engine, by contrast, lets an imperative+ step read mutations made earlier in the same step. Models ported from the+ classic engine must respect this: split a read-then-write dependency into two+ stages.++ == Determinism++ Each agent's 'StdGen' is derived deterministically from+ @('specSeed', term index, stage index, agent index)@ only — never from+ wall-clock, thread scheduling, or the chosen 'Par' policy. Consequently a+ 'Sequential' run and a 'ParChunk' run of the same t'SimSpec' produce the same+ observable ledger (see the @DET-2@ test), and re-running is reproducible+ (@DET-1@).++ == Reading HKD type errors++ If a world field is given the wrong element type, GHC reports the mismatch+ with the 'HK' family already reduced to the field representation, e.g.+ annotating @wPrice :: 'HK' f Double@ but using it as a @String@ yields:++ > • Couldn't match type ‘[Char]’ with ‘Double’+ > Expected: HK SnapT String+ > Actual: Double+ > • In the ‘wPrice’ field of a record++ Read the @Expected@/@Actual@ lines as "this field is a @String@ here but a+ @Double@ was expected" — the family is shown reduced, so there is no+ instance-resolution wall to wade through.++ == Scope++ World records must be __product-only and non-nested__ (the generic traversal+ in this module only handles @M1@ \/ @:*:@ \/ @K1@). Ledger retention, spill+ and compaction are not decided in this module: they are supplied+ declaratively as a t'LedgerPolicy' ("ExchangeAlgebra.Simulate.Policy") and+ applied by 'runLiteWithPolicy'. Snapshot-dependent term-boundary+ recomputation and parallel speedup measurement remain out of scope.+-}++module ExchangeAlgebra.Simulate.Lite+ ( -- * Term-boundary field rules+ Field(..)+ , carry+ , resetEach+ , updateEach+ -- * Role tags and the HK field family+ , InitT+ , RefT+ , SnapT+ , HK+ -- * Generic world traversal+ -- The GLite* classes are exported name-only: their primed methods are the+ -- Generic-Rep plumbing (instances for M1/(:*:)/K1 live in this module);+ -- user code only ever names the classes in constraints.+ , GLiteInit+ , GLiteFreeze+ , GLiteCommit+ , LiteWorld+ , gInit+ , gFreeze+ , gCommit+ -- * Stages+ -- 'Stage' is exported name-only: build stages with the smart constructors+ -- ('stageFor'/'stage'/'stageOf') and read the name via 'stageName'.+ , Stage+ , stageFor+ , stage+ , stageOf+ , stageName+ -- * Simulation specification+ , Par(..)+ , SimSpec(..)+ , mkSimSpec+ -- * Runner+ , runLite+ -- * Policy-driven runner+ , runLiteWithPolicy+ ) where++import GHC.Generics+import Data.Kind (Type)+import Control.Monad (forM_, when)+import Control.Monad.ST (ST, runST, RealWorld, stToIO)+import Data.STRef (STRef, newSTRef, readSTRef, writeSTRef, modifySTRef')+import Data.Hashable (hash)+import Data.IORef (newIORef, readIORef, writeIORef)+import System.IO (Handle, IOMode(WriteMode), withFile)+import System.Random (StdGen, mkStdGen)+import Control.Parallel.Strategies (parListChunk, rdeepseq, using)+import Control.DeepSeq (deepseq)+import qualified Data.Binary as Binary+import qualified Data.HashMap.Strict as HM++import ExchangeAlgebra.Journal ( Journal+ , Note+ , HatVal+ , HatBaseClass+ , sigma+ , toMap+ , fromMap+ , (.|)+ , filterWithNote )+import ExchangeAlgebra.Algebra (Alg)+import qualified ExchangeAlgebra.Algebra as EA ((.+), compress)+import ExchangeAlgebra.Simulate (StateTime)+import ExchangeAlgebra.Simulate.Spill (defaultBinarySpillWriter, stepBackWith)+import ExchangeAlgebra.Simulate.Policy ( LedgerPolicy(..)+ , Retention(..)+ , Compaction(..)+ , HasTermAxis(..) )++------------------------------------------------------------------+-- * Term-boundary field rules+------------------------------------------------------------------++-- | How a world field is updated at a /term boundary/ (between superstep+-- sweeps of all stages). The constructor also carries the field's initial+-- value, so a single @w 'InitT'@ value fully describes both the starting world+-- and its boundary dynamics.+--+-- Note: within a term, a field only changes through the ledger commit of a+-- stage (for the ledger field) or not at all (for parameter fields). The+-- 'Field' rule fires exactly once per term, after every stage has run.+data Field a+ = Carry !a -- ^ Keep the current ref value across the boundary+ -- (the usual choice for an accumulating ledger).+ | ResetEach !a -- ^ Overwrite with this value at every boundary.+ | UpdateEach !a (a -> a) -- ^ Start from this value; apply the function at+ -- every boundary (e.g. a decaying price).++-- | Smart constructor for 'Carry'.+carry :: a -> Field a+carry = Carry++-- | Smart constructor for 'ResetEach'.+resetEach :: a -> Field a+resetEach = ResetEach++-- | Smart constructor for 'UpdateEach'.+updateEach :: a -> (a -> a) -> Field a+updateEach = UpdateEach++------------------------------------------------------------------+-- * Role tags and the HK field family+------------------------------------------------------------------++-- | Role tag: a field as an /initial value + boundary rule/ ('Field').+data InitT++-- | Role tag: a field as a mutable @'STRef' s@ cell during a run. The state+-- region @s@ lives only on this tag (snapshots and inits are region-free), so+-- stages stay plain (non-rank-2) functions.+data RefT s++-- | Role tag: a field as a bare, read-only value — the BSP snapshot view. No+-- @Identity@ wrapper, so @w 'SnapT'@ reads exactly like an ordinary record.+data SnapT++-- | @'HK' f a@ chooses the representation of a world field of element type @a@+-- under role tag @f@.+type family HK (f :: Type) a where+ HK InitT a = Field a+ HK (RefT s) a = STRef s a+ HK SnapT a = a++------------------------------------------------------------------+-- * Generic world traversal (product-only)+------------------------------------------------------------------+--+-- Three hand-written classes over the GHC.Generics product structure+-- (@M1@ / @:*:@ / @K1@ only — no @barbies@ or other HKD dependency). The+-- per-field leaf instances pattern-match on the concrete field shape+-- (@Field a@, @STRef s a@, bare @a@) so GHC never has to invert 'HK'.++-- | Build the @'RefT' s@ world from the @'InitT'@ world by allocating one+-- 'STRef' per field, seeded with the field's initial value.+class GLiteInit s i o where+ gInit' :: i x -> ST s (o x)++instance GLiteInit s i o => GLiteInit s (M1 t m i) (M1 t m o) where+ gInit' (M1 a) = M1 <$> gInit' a+instance (GLiteInit s i1 o1, GLiteInit s i2 o2)+ => GLiteInit s (i1 :*: i2) (o1 :*: o2) where+ gInit' (a :*: b) = (:*:) <$> gInit' a <*> gInit' b+instance GLiteInit s (K1 r (Field a)) (K1 r (STRef s a)) where+ gInit' (K1 fld) = K1 <$> newSTRef (initialOf fld)++-- | The starting value carried by a 'Field', regardless of its boundary rule.+initialOf :: Field a -> a+initialOf (Carry a) = a+initialOf (ResetEach a) = a+initialOf (UpdateEach a _) = a++-- | Freeze the @'RefT' s@ world to a bare-value snapshot (@'SnapT'@). Each+-- field is a single 'readSTRef'; immutable structures (arrays, maps) are shared+-- O(1), not copied.+class GLiteFreeze s i o where+ gFreeze' :: i x -> ST s (o x)++instance GLiteFreeze s i o => GLiteFreeze s (M1 t m i) (M1 t m o) where+ gFreeze' (M1 a) = M1 <$> gFreeze' a+instance (GLiteFreeze s i1 o1, GLiteFreeze s i2 o2)+ => GLiteFreeze s (i1 :*: i2) (o1 :*: o2) where+ gFreeze' (a :*: b) = (:*:) <$> gFreeze' a <*> gFreeze' b+instance GLiteFreeze s (K1 r (STRef s a)) (K1 r a) where+ gFreeze' (K1 ref) = K1 <$> readSTRef ref++-- | Apply each field's 'Field' boundary rule to the live refs, zipping the+-- @'InitT'@ structure (rules + initial values) against the @'RefT' s@ structure.+class GLiteCommit s i r where+ gCommit' :: i x -> r x -> ST s ()++instance GLiteCommit s i r => GLiteCommit s (M1 t m i) (M1 t m r) where+ gCommit' (M1 a) (M1 b) = gCommit' a b+instance (GLiteCommit s i1 r1, GLiteCommit s i2 r2)+ => GLiteCommit s (i1 :*: i2) (r1 :*: r2) where+ gCommit' (a :*: b) (c :*: d) = gCommit' a c >> gCommit' b d+instance GLiteCommit s (K1 k (Field a)) (K1 k (STRef s a)) where+ gCommit' (K1 fld) (K1 ref) =+ case fld of+ Carry _ -> pure ()+ ResetEach a0 -> writeSTRef ref a0+ UpdateEach _ f -> modifySTRef' ref f++-- | Constraint bundling everything a world type @w@ must satisfy to be driven+-- by 'runLite' in the @s@ region. It is a single (method-less) class so that it+-- can be used under a @forall s.@ quantified constraint in 'runLite' (a bare+-- @ConstraintKinds@ tuple synonym cannot). A product-only @deriving Generic@+-- on @w@ is all the user has to supply; the catch-all instance discharges the+-- rest automatically.+class ( Generic (w InitT)+ , Generic (w (RefT s))+ , Generic (w SnapT)+ , GLiteInit s (Rep (w InitT)) (Rep (w (RefT s)))+ , GLiteFreeze s (Rep (w (RefT s))) (Rep (w SnapT))+ , GLiteCommit s (Rep (w InitT)) (Rep (w (RefT s)))+ ) => LiteWorld w s++instance ( Generic (w InitT)+ , Generic (w (RefT s))+ , Generic (w SnapT)+ , GLiteInit s (Rep (w InitT)) (Rep (w (RefT s)))+ , GLiteFreeze s (Rep (w (RefT s))) (Rep (w SnapT))+ , GLiteCommit s (Rep (w InitT)) (Rep (w (RefT s)))+ ) => LiteWorld w s++-- | Allocate the live (ref) world from the initial world.+gInit :: (Generic (w InitT), Generic (w (RefT s)), GLiteInit s (Rep (w InitT)) (Rep (w (RefT s))))+ => w InitT -> ST s (w (RefT s))+gInit w = to <$> gInit' (from w)++-- | Take a read-only snapshot of the live world.+gFreeze :: (Generic (w (RefT s)), Generic (w SnapT), GLiteFreeze s (Rep (w (RefT s))) (Rep (w SnapT)))+ => w (RefT s) -> ST s (w SnapT)+gFreeze w = to <$> gFreeze' (from w)++-- | Apply every field's term-boundary 'Field' rule to the live world.+gCommit :: (Generic (w InitT), Generic (w (RefT s)), GLiteCommit s (Rep (w InitT)) (Rep (w (RefT s))))+ => w InitT -> w (RefT s) -> ST s ()+gCommit wi wr = gCommit' (from wi) (from wr)++------------------------------------------------------------------+-- * Stages+------------------------------------------------------------------++-- | A BSP stage: a named, pure mapping from agents to /messages/. Every agent+-- runs against the same snapshot @w 'SnapT'@, the current term @t@, and a+-- deterministically derived 'StdGen'. The agent element type @a@ is+-- existential, so different stages may use different agent populations.+--+-- There are two constructors:+--+-- * @StageFor@ — the original, fully general stage. Each agent emits a+-- t'Journal' directly, so the stage body is free to attach /any/ notes+-- (including several different notes from one agent). Built with 'stageFor'+-- or 'stage'.+--+-- * @StageTagged@ — a /note-tagged/ stage whose note type is fixed to+-- @(tag, t)@ by construction. Each agent emits a bare 'Alg' and the runner+-- attaches the single note @(stTag, t)@ in __one place__ (@runStage@). This+-- removes the write-site note duplication of @alg '.|' (Tag, t)@ and ties+-- the stage's tag to its note type at compile time. Built with 'stageOf'.+--+-- == Which constructor to use+--+-- Prefer 'stageOf' for a stage that emits __exactly one note tag__: the tag is+-- written once (as @stTag@), the runner supplies it, and a downstream+-- @projWithNote [(Tag, t)]@ that names the wrong constructor is a type error+-- rather than a silently empty projection. Use 'stageFor'\/'stage' when a single+-- stage must emit __several different notes__ (e.g. a closing stage that posts+-- both @(Closing, t)@ and @(Carryover, t+1)@): such a stage cannot be expressed+-- as a single auto-attached tag, so it keeps returning a t'Journal' itself.+--+-- The two constructors are observationally interchangeable for a one-note+-- stage: @'stageOf' tag as f@ produces the same messages as+-- @'stageFor' (show tag) as (\\v t g a -> f v t g a '.|' (tag, t))@ (asserted by+-- the @stageOf auto-note@ sentinel). @StageTagged@ only moves the @'.|' (tag, t)@+-- from the stage body into the runner.+data Stage w t n v b where+ StageFor :: { stName :: String+ , stAgents :: [a]+ , stRun :: w SnapT -> t -> StdGen -> a -> Journal n v b }+ -> Stage w t n v b+ StageTagged :: (Note tag)+ => { stTag :: tag+ , stAgentsT :: [a]+ , stRunAlg :: w SnapT -> t -> StdGen -> a -> Alg v b }+ -> Stage w t (tag, t) v b++-- | Build a stage that runs once per element of the given agent list, each+-- agent emitting a t'Journal' directly.+stageFor :: String+ -> [a]+ -> (w SnapT -> t -> StdGen -> a -> Journal n v b)+ -> Stage w t n v b+stageFor = StageFor++-- | Build a singleton stage with no per-agent fan-out and no random draw —+-- handy for aggregate/bookkeeping steps. Equivalent to+-- @'stageFor' name [()] (\\v t _ () -> f v t)@.+stage :: String+ -> (w SnapT -> t -> Journal n v b)+ -> Stage w t n v b+stage name f = StageFor name [()] (\v t _g () -> f v t)++-- | Build a __note-tagged__ stage: each agent emits a bare 'Alg' and the runner+-- attaches the single note @(stTag, t)@ once, in @runStage@. The note type is+-- fixed to @(tag, t)@ by the result type, so the write-site tag (the @tag@+-- argument) and any read-site @projWithNote [(tag, t)]@ are checked against the+-- same constructor by the type-checker — a stringly-typed mismatch becomes a+-- compile error instead of a silently empty projection.+--+-- Use this for any stage that emits __exactly one note tag__. For a stage that+-- must post __several different notes__ (e.g. @(Closing, t)@ together with+-- @(Carryover, t+1)@), keep using 'stageFor'\/'stage', which let the body return+-- a fully general t'Journal'.+--+-- Determinism is unaffected: the per-agent 'StdGen' is still derived from+-- @('specSeed', termIx, stageIx, agentIx)@ only (see @runStage@), and the note+-- attachment @'.|' (stTag, t)@ is a pure post-transform of each agent's 'Alg'.+stageOf :: (Note tag)+ => tag+ -> [a]+ -> (w SnapT -> t -> StdGen -> a -> Alg v b)+ -> Stage w t (tag, t) v b+stageOf = StageTagged++-- | The display name of a stage: @stName@ for @StageFor@, @show stTag@ for+-- @StageTagged@ (the tag's 'Show' comes from its 'Note' superclass). Use this+-- instead of @stName@ when a stage may be either constructor.+stageName :: Stage w t n v b -> String+stageName (StageFor nm _ _) = nm+stageName (StageTagged tg _ _) = show tg++------------------------------------------------------------------+-- * Simulation specification+------------------------------------------------------------------++-- | Parallelism policy for running a stage's agents. The chunk size is fixed+-- (input-size-only), so the partitioning — and therefore the result — does not+-- depend on the runtime scheduler (@DET-1@).+data Par = Sequential -- ^ Run agents left-to-right, no sparks.+ | ParChunk !Int -- ^ Evaluate agent messages in fixed-size chunks+ -- in parallel ('parListChunk').++-- | A complete simulation description.+data SimSpec w t n v b = SimSpec+ { specTerms :: (t, t)+ -- ^ Inclusive @(from, to)@ term range, a runtime value (no compile-time+ -- @lastTerm@ constant required).+ , specSeed :: Int+ -- ^ Master seed; all per-agent generators derive from it deterministically.+ , specLedger :: forall f. w f -> HK f (Journal n v b)+ -- ^ The ledger field selector (just the record accessor), used polymorphically+ -- across roles to read the snapshot ledger and to write the live one.+ --+ -- __Which field is the ledger is a model declaration, not inferred.__ The+ -- committed-ledger role is conferred by /this selector alone/: 'runLite'+ -- commits every stage's merged messages only to the field it returns, and+ -- retention\/spill\/compaction ('runLiteWithPolicy') all act on that same+ -- field. The product type only fixes the three roles+ -- (@'InitT'@\/@'RefT'@\/@'SnapT'@); it does /not/ mark one field as the+ -- ledger and the others as auxiliary — that split is a discipline this+ -- selector expresses, not a type-level guarantee.+ --+ -- Consequence: if the world has more than one @'Journal' n v b@ field,+ -- pointing the selector at the wrong one type-checks and fails __silently__.+ -- Commits, eviction and the final projection are then all consistently+ -- applied to the wrong ledger, so a test that reads back through the same+ -- selector can still pass. Prefer __exactly one__ @'Journal'@ field per+ -- world, or name fields so the intended ledger is unmistakable. (A future+ -- @newtype Ledger@ wrapper could make this distinction type-level; until+ -- then it is a convention.)+ , specStages :: [Stage w t n v b]+ -- ^ Stages, run in this declared order each term.+ , specParallel :: Par+ -- ^ Agent-level parallelism policy.+ }++-- | Smart constructor for t'SimSpec' defaulting to 'Sequential'. Prefer this over+-- the raw record so that future, additive t'SimSpec' fields stay non-breaking.+mkSimSpec :: (t, t)+ -> Int+ -> (forall f. w f -> HK f (Journal n v b))+ -> [Stage w t n v b]+ -> SimSpec w t n v b+mkSimSpec terms seed ledger stages = SimSpec+ { specTerms = terms+ , specSeed = seed+ , specLedger = ledger+ , specStages = stages+ , specParallel = Sequential+ }++------------------------------------------------------------------+-- * Runner (BSP)+------------------------------------------------------------------++-- | Run a t'SimSpec' over the given initial world and project the final+-- snapshot through a continuation.+--+-- The loop, per term @t@ in @['from'..'to']@ and per stage in declared order:+--+-- 1. take one snapshot @view@ of the live world ('gFreeze');+-- 2. run every agent against /that/ snapshot (intra-stage invisibility),+-- producing one message (t'Journal') each — sequentially or in fixed-size+-- parallel chunks per 'specParallel';+-- 3. /flatten-once commit/: fold all messages into a single journal once+-- with the public 'sigma' (which itself folds into a 'Data.HashMap',+-- skipping zero journals, then rebuilds one journal), and add it to the+-- ledger ref with a single 'modifySTRef''.+--+-- After all stages of a term, every field's term-boundary 'Field' rule fires+-- ('gCommit'). The final live world is frozen once and handed to the+-- continuation.+runLite :: forall w t n v b r.+ ( forall s. LiteWorld w s+ , HatVal v, HatBaseClass b, Note n, Enum t, Ord t )+ => SimSpec w t n v b+ -> w InitT+ -> (w SnapT -> r)+ -> r+runLite spec wInit k = runST $ do+ wr <- gInitR wInit+ let (from0, to0) = specTerms spec+ terms = enumFromThenToInclusive from0 to0+ stages = zip [0 ..] (specStages spec)+ forM_ (zip [0 ..] terms) $ \(termIx, t) -> do+ forM_ stages $ \(stageIx, st) -> do+ view <- gFreezeR wr+ let msgs = runStage spec view t termIx stageIx st+ delta = sigma msgs id :: Journal n v b+ modifySTRef' (specLedger spec wr) (\acc -> acc EA..+ delta)+ -- term boundary: fire the Field rules exactly once per term, AFTER+ -- all stages of the term have committed (BSP semantics, design S3).+ gCommitR wInit wr+ final <- gFreezeR wr+ pure (k final)+ where+ -- Pin the generic-traversal dictionaries at the @s@ chosen by 'runST'.+ gInitR :: forall s. LiteWorld w s => w InitT -> ST s (w (RefT s))+ gInitR = gInit+ gFreezeR :: forall s. LiteWorld w s => w (RefT s) -> ST s (w SnapT)+ gFreezeR = gFreeze+ gCommitR :: forall s. LiteWorld w s => w InitT -> w (RefT s) -> ST s ()+ gCommitR = gCommit++-- | Inclusive @[from .. to]@ for an 'Enum'/'Ord' term type. Empty when+-- @from > to@.+enumFromThenToInclusive :: (Enum t, Ord t) => t -> t -> [t]+enumFromThenToInclusive from0 to0+ | from0 > to0 = []+ | otherwise = [from0 .. to0]++-- | Produce the messages of one stage, applying the parallelism policy. Each+-- agent gets a 'StdGen' derived purely from+-- @('specSeed', termIx, stageIx, agentIx)@, so the result is independent of the+-- evaluation order or the 'Par' policy.+--+-- Under 'ParChunk' the FIRST message is forced to normal form in the calling+-- thread before the remaining messages are sparked. Every message reaches the+-- shared snapshot @view@ through lazily-built index structures (the Journal's+-- note-axis index, each Alg's base index); if many sparks race to force those+-- shared thunks, the RTS's duplicate-work suspension can re-enter its own+-- blackhole and abort with a spurious @<<loop>>@ (observed intermittently at+-- @-N4@; the thunk graph is acyclic — sequential evaluation never loops).+-- Forcing one message first materialises the shared structure in a single+-- thread, so the sparks only evaluate agent-local work. Pure values: the+-- result is unchanged (DET-2 asserts exact equality).+runStage :: forall w t n v b.+ (HatVal v, HatBaseClass b, Note n)+ => SimSpec w t n v b+ -> w SnapT+ -> t+ -> Int -- ^ term index (0-based)+ -> Int -- ^ stage index (0-based)+ -> Stage w t n v b+ -> [Journal n v b]+runStage spec view t termIx stageIx st =+ let seed0 = specSeed spec+ -- The per-agent messages, before applying the parallelism policy. Both+ -- constructors derive each generator from the same coordinates only, so+ -- DET is identical to the StageFor-only runner. 'StageTagged' attaches+ -- the single note @(stTag, t)@ here — the ONLY place auto-tagging+ -- happens — as a pure post-transform of each agent's bare 'Alg'.+ msgs = case st of+ StageFor _ agents f ->+ [ f view t (deriveGen seed0 termIx stageIx agentIx) a+ | (agentIx, a) <- zip [0 ..] agents ]+ StageTagged tg agents g ->+ [ g view t (deriveGen seed0 termIx stageIx agentIx) a .| (tg, t)+ | (agentIx, a) <- zip [0 ..] agents ]+ in case specParallel spec of+ Sequential -> msgs+ ParChunk c -> case msgs of+ [] -> []+ (m : ms) -> m `deepseq`+ (m : (ms `using` parListChunk (max 1 c) rdeepseq))++-- | Deterministically derive an agent's generator from the seed and the+-- @(term, stage, agent)@ coordinates only — never from scheduling. Uses a hash+-- of the tuple so that distinct coordinates almost never collide and the value+-- is stable across runs and 'Par' policies.+deriveGen :: Int -> Int -> Int -> Int -> StdGen+deriveGen seed0 termIx stageIx agentIx =+ mkStdGen (hash (seed0, termIx, stageIx, agentIx))+{-# INLINE deriveGen #-}++------------------------------------------------------------------+-- * Policy-driven runner+------------------------------------------------------------------++-- | Run a t'SimSpec' under a declarative t'LedgerPolicy', returning in 'IO'+-- (because spill writes a file). The BSP loop is /identical/ to 'runLite' — per+-- term, per stage: snapshot, run agents, flatten-once commit — and the term+-- range, seed, stages, parallelism and per-agent generators are all the same.+-- The only additions happen at each term boundary, /after/ the stages have+-- committed and the 'Field' rules have fired:+--+-- 1. __compaction__ — if @'compaction' = 'CompressClosedTerms'@, every entry+-- of a /closed/ term (term @<@ the current term @t@) is 'EA.compress'ed.+-- This is norm- and balance-preserving; only the within-term posting+-- sequence is collapsed. The in-progress term is never touched.+-- 2. __retention / spill__ — if @'retain' = 'RetainRecent' w@, terms with+-- @'termOf' n '<=' t - w@ are evicted from the in-memory ledger. If+-- @'spillTo' = 'Just' path@, each newly-closed-and-evicted term is first+-- appended to that binary file (compatible with 'defaultBinarySpillWriter',+-- so 'ExchangeAlgebra.Simulate.Policy.restoreLedger' can read it back).+--+-- Under 'ExchangeAlgebra.Simulate.Policy.defaultLedgerPolicy' nothing is+-- compacted, evicted or spilled, so the result is observationally equal to+-- @'runLite' spec wInit k@ (see the equivalence test).+--+-- __Data loss warning.__ @'spillTo' = 'Nothing'@ together with+-- @'RetainRecent' w@ /discards/ evicted terms — they are written nowhere and+-- cannot be restored. Use it only when the older history is genuinely not+-- needed; otherwise set @'spillTo' = 'Just' path@.+runLiteWithPolicy+ :: forall w t n v b r.+ ( forall s. LiteWorld w s+ , HatVal v, HatBaseClass b+ , HasTermAxis n, TermOf n ~ t+ , StateTime t+ , Binary.Binary t, Binary.Binary (Journal n v b) )+ => LedgerPolicy+ -> SimSpec w t n v b+ -> w InitT+ -> (w SnapT -> r)+ -> IO r+runLiteWithPolicy pol spec wInit k = do+ let (from0, to0) = specTerms spec+ terms = zip [0 :: Int ..] (enumFromThenToInclusive from0 to0)+ stages = zip [0 :: Int ..] (specStages spec)+ window = case retain pol of+ RetainAll -> Nothing+ RetainRecent w -> Just (max 0 w)+ -- High-water mark of the most recent term already spilled (so each closed+ -- term is written to disk at most once). 'Nothing' = nothing spilled yet.+ spilledRef <- newIORef (Nothing :: Maybe t)+ wr <- stToIO (gInitR wInit)+ let -- Run one full term (all stages, then Field rules). Mirrors 'runLite'.+ runTerm :: Int -> t -> ST RealWorld ()+ runTerm termIx t = do+ forM_ stages $ \(stageIx, st) -> do+ view <- gFreezeR wr+ let msgs = runStage spec view t termIx stageIx st+ delta = sigma msgs id :: Journal n v b+ modifySTRef' (specLedger spec wr) (\acc -> acc EA..+ delta)+ gCommitR wInit wr++ -- Apply 'CompressClosedTerms' to entries strictly before term @t@.+ compactClosed :: t -> ST RealWorld ()+ compactClosed t = case compaction pol of+ FullAudit -> pure ()+ CompressClosedTerms ->+ modifySTRef' (specLedger spec wr)+ (compressClosedTerms t)++ withMaybeSpillHandle (spillTo pol) $ \mh ->+ forM_ terms $ \(termIx, t) -> do+ stToIO (runTerm termIx t)+ stToIO (compactClosed t)+ -- retention / spill at the term boundary+ case window of+ Nothing -> pure ()+ Just w -> do+ -- Eviction boundary for a w-term resident window ending at t:+ -- evict every term <= t - w. The step-back arithmetic is+ -- single-sourced in ExchangeAlgebra.Simulate ('stepBackWith';+ -- design-review C4). When the boundary is below the spec's+ -- first term the delete predicate matches nothing — no+ -- clamping needed.+ let boundary = stepBackWith pred w t -- evict terms <= boundary+ spilledHi <- readIORef spilledRef+ -- spill newly-closed terms (spilledHi, boundary] before deleting+ case mh of+ Nothing -> pure ()+ Just h -> when (firstUnspilled spilledHi <= boundary) $ do+ ledger <- stToIO (readSTRef (specLedger spec wr))+ let lo = firstUnspilled spilledHi+ chunk = filterWithNote+ (\nn _ -> let tt = termOf nn+ in tt >= lo && tt <= boundary)+ ledger+ -- Suppress empty spill chunks: at early term boundaries the+ -- eviction window may not yet cover any resident term, so the+ -- filtered chunk is empty. Writing it would emit a zero-entry+ -- record to the binary file (restored as a no-op, but still a+ -- wasted write); skip it. The high-water mark is still advanced+ -- below, so a later non-empty term in (lo, boundary] is not lost.+ when (not (HM.null (toMap chunk))) $+ defaultBinarySpillWriter h (lo, boundary) chunk+ -- delete evicted terms from memory (whether or not spilled)+ stToIO $ modifySTRef' (specLedger spec wr)+ (filterWithNote (\nn _ -> termOf nn > boundary))+ writeIORef spilledRef (Just boundary)+ final <- stToIO (gFreezeR wr)+ pure (k final)+ where+ gInitR :: forall s. LiteWorld w s => w InitT -> ST s (w (RefT s))+ gInitR = gInit+ gFreezeR :: forall s. LiteWorld w s => w (RefT s) -> ST s (w SnapT)+ gFreezeR = gFreeze+ gCommitR :: forall s. LiteWorld w s => w InitT -> w (RefT s) -> ST s ()+ gCommitR = gCommit++ -- The first term that has not yet been spilled, given the high-water mark.+ firstUnspilled :: Maybe t -> t+ firstUnspilled Nothing = fst (specTerms spec)+ firstUnspilled (Just hi) = succ hi++-- | Open the spill file in 'WriteMode' if a path is given; otherwise run the+-- continuation with no handle. Truncate on open: one run writes one fresh file;+-- a stale file from an earlier run would otherwise be appended to and fail the+-- range checks in+-- 'ExchangeAlgebra.Simulate.Spill.readBinarySpillFileChecked'.+withMaybeSpillHandle :: Maybe FilePath -> (Maybe Handle -> IO a) -> IO a+withMaybeSpillHandle Nothing act = act Nothing+withMaybeSpillHandle (Just path) act = withFile path WriteMode (act . Just)++-- | Apply 'EA.compress' to the entry of every Note whose term is strictly+-- before @t@ (a /closed/ term), leaving the in-progress term untouched. Uses+-- 'toMap'\/'fromMap' so the traversal order does not affect the result.+compressClosedTerms+ :: (HasTermAxis n, TermOf n ~ t, Ord t, HatVal v, HatBaseClass b)+ => t -> Journal n v b -> Journal n v b+compressClosedTerms t j =+ fromMap (HM.mapWithKey+ (\nn alg -> if termOf nn < t then EA.compress alg else alg)+ (toMap j))
+ src/ExchangeAlgebra/Simulate/Network.hs view
@@ -0,0 +1,1229 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE OverloadedStrings #-}++{- |+ Module : ExchangeAlgebra.Simulate.Network+ Copyright : (c) Kaya Akagi. 2018-2026+ Maintainer : yakagika@icloud.com++ Released under the OWL license++ == What this module is++ A small, additive front-end that separates two concepts that the older+ examples conflated into a single dense @N×N@ coefficient matrix:++ 1. the __trade network__ (t'TradeNetwork') — /who may trade with whom/, a+ sparse directed relation; and+ 2. the __input coefficients__ (t'InputCoefficients') — /the technology/,+ a sparse map of per-edge coefficients @a_{ij}@.++ In the dense-matrix style the support (non-zero cells) of the coefficient+ matrix /was/ the trade relation, so sparsity was an accident of the data+ representation rather than a modelling choice. Splitting them lets a model+ pick its market structure (complete, @k@-regular, Erdős–Rényi, scale-free,+ sectoral) independently of the coefficients, and lets the summation++ @+ 'sigmaEdges' g f+ @++ run the familiar \"Σ\" notation over the /edges/ of @g@ (cost @O(E)@) instead+ of over all ordered pairs (cost @O(N²)@). With 'completeNetwork' the two+ coincide, so an existing all-pairs model can be ported without changing the+ notation (see 'sigmaEdges').++ The module also provides 'industrialNetwork' and 'industrialFlows': an+ ordered block-triangular, power-law trade graph and its exact-integer,+ demand-driven backward substitution.++ == Edge orientation++ An edge @(i, j)@ means \"@i@ is a /supplier/ of @j@\" (equivalently \"@j@ is a+ /buyer/ from @i@\"). The coefficient @a_{ij}@ attached to that edge is \"the+ amount of @i@ that one unit of @j@'s output requires\". This matches the+ long-form table layout @(from, to, coef)@ and the @(supplier, buyer)@ index+ order of the example input-coefficient tables.++ == Determinism++ Every generator is a pure function of an explicit 'StdGen' (it does not+ return a generator; split one yourself with 'System.Random.split' if you+ need an independent stream). The same seed always yields the same network,+ and all read-outs ('nodes', 'edges', 'suppliersOf', 'buyersOf', 'inputsOf')+ return their results in ascending 'Ord' order, never in hash-table order.++ == Internal representation is private++ t'TradeNetwork', t'InputCoefficients' and 'NetworkError' are abstract: their+ constructors are not exported, so the invariants (out\/in adjacency agree,+ @supp(A) ⊆ edges(G)@, no self-loops, non-negative coefficients) cannot be+ broken from outside. Build values with the smart constructors and the+ generators; read them with the accessors.++ == Using a network with the classic "ExchangeAlgebra.Simulate"++ This module deliberately provides /no/ @Updatable@ instance for the network+ types (the @Updatable t v a s | a s -> t v@ functional dependency makes it+ impossible for the library to fix the user's @(t, v)@). To carry a (read-only)+ network in a classic simulation, wrap it in your own @UpdatableSTRef@ cell:++ @+ newtype NetCell s = NetCell (Data.STRef.STRef s (TradeNetwork Int))+ instance UpdatableSTRef NetCell s (TradeNetwork Int)+ @++ and read it inside an event with @readURef@. In the newer+ "ExchangeAlgebra.Simulate.Lite" front-end the network is simply a @carry@+ field (it never changes during a run), with no instance at all.+-}++module ExchangeAlgebra.Simulate.Network+ ( -- * Types (abstract)+ TradeNetwork+ , InputCoefficients+ , NetworkError(..)+ -- * Smart constructors+ , tradeNetwork+ , inputCoefficients+ -- * Read-outs (deterministic, ascending order)+ , nodes+ , edges+ , suppliersOf+ , buyersOf+ , edgeCount+ , coefficient+ , inputsOf+ -- * Summation over edges+ , sigmaEdges+ -- * Network generators (deterministic)+ , completeNetwork+ , circulant+ , kRegular+ , erdosRenyi+ , scaleFree+ , sectorBlock+ -- * Ordered industrial networks and flows+ , IndustrialEconomy(..)+ , IndustrialOptions(..)+ , defaultIndustrialOptions+ , industrialNetwork+ , industrialNetworkWith+ , firms+ , industrialEdges+ , TaxRate(..)+ , taxOf+ , IndustrialFlows(..)+ , FlowOptions(..)+ , defaultFlowOptions+ , industrialFlows+ , industrialFlowsWith+ -- * Coefficient generation+ , CoefOptions(..)+ , defaultCoefOptions+ , randomCoefficients+ -- * Long-form table / matrix ingestion+ , networkFromTable+ , coefficientsFromTable+ , fromCoefficientMatrix+ -- * CSV (fixed schema, minimal self-contained parser)+ , parseEdgeCsv+ , parseCoefCsv+ , readEdgeCsv+ , readCoefCsv+ ) where++import Control.DeepSeq (NFData (..))+import Data.List (sortBy)+import qualified Data.Map.Strict as M+import Data.Map.Strict (Map)+import Data.Maybe (fromMaybe)+import qualified Data.Set as S+import Data.Set (Set)+import qualified Data.Text as T+import Data.Text (Text)+import qualified Data.Text.IO as TIO+import qualified Data.Vector as V+-- 'mkStdGen' is referenced only by the Haddock doctest examples (which run in+-- this module's import scope); 'randomR' drives the generators.+import System.Random (StdGen, mkStdGen, randomR)++import ExchangeAlgebra.Convert.Csv ( splitTrim )+import ExchangeAlgebra.Journal ( Journal, Note, HatVal, HatBaseClass )+import qualified ExchangeAlgebra.Journal as EJ++------------------------------------------------------------------+-- * Errors+------------------------------------------------------------------++-- | Why a smart constructor refused to build a value. All cases are reported+-- rather than silently repaired (in particular a duplicate edge is /not/ merged+-- and a coefficient outside the network is /not/ dropped).+data NetworkError+ = SelfLoop -- ^ An edge @(i, i)@ was supplied.+ | DuplicateEdge -- ^ The same ordered pair @(i, j)@ appeared twice.+ | CoefOutsideNetwork -- ^ A coefficient @(i, j, _)@ has no edge @(i, j)@ in the network.+ | NegativeCoefficient -- ^ A coefficient was negative (or a non-finite error value).+ | DuplicateCoefficient -- ^ The same @(i, j)@ coefficient appeared twice.+ deriving (Eq, Show)++instance NFData NetworkError where+ rnf x = x `seq` ()++------------------------------------------------------------------+-- * TradeNetwork+------------------------------------------------------------------++-- | A sparse directed trade relation over a fixed node set. Holds both the+-- out-adjacency (@supplier ↦ buyers@) and the in-adjacency+-- (@buyer ↦ suppliers@) so that 'suppliersOf' and 'buyersOf' are both+-- @O(log N + deg)@. The two indices are kept mutually consistent by+-- construction. Edges are stored as 'Data.Set.Set' so every read-out is in+-- ascending 'Ord' order.+data TradeNetwork k = TradeNetwork+ { tnNodes :: !(Set k)+ , tnOut :: !(Map k (Set k)) -- ^ supplier ↦ set of buyers+ , tnIn :: !(Map k (Set k)) -- ^ buyer ↦ set of suppliers+ }++instance Eq k => Eq (TradeNetwork k) where+ a == b = tnNodes a == tnNodes b && tnOut a == tnOut b && tnIn a == tnIn b++instance Show k => Show (TradeNetwork k) where+ showsPrec d g = showParen (d > 10)+ $ showString "TradeNetwork "+ . showsPrec 11 (S.toAscList (tnNodes g))+ . showString " "+ . showsPrec 11 (edges g)++instance NFData k => NFData (TradeNetwork k) where+ rnf (TradeNetwork ns o i) = rnf ns `seq` rnf o `seq` rnf i++-- | Build a network from a node list and a directed-edge list.+--+-- The node set is the union of the given nodes and every endpoint mentioned in+-- the edges (so missing nodes are added rather than rejected). Fails with+-- 'SelfLoop' on any @(i, i)@ edge and with 'DuplicateEdge' if the same ordered+-- pair appears twice (duplicates are never coalesced — that would silently sum+-- relations).+--+-- >>> let Right g = tradeNetwork [1,2,3] [(1,2),(1,3),(2,3)] :: Either NetworkError (TradeNetwork Int)+-- >>> edges g+-- [(1,2),(1,3),(2,3)]+-- >>> suppliersOf g 3+-- [1,2]+-- >>> buyersOf g 1+-- [2,3]+tradeNetwork :: Ord k => [k] -> [(k, k)] -> Either NetworkError (TradeNetwork k)+tradeNetwork ns es = do+ seen <- foldM' insertEdge S.empty es+ let nodeSet = S.unions+ [ S.fromList ns+ , S.fromList [ i | (i, _) <- es ]+ , S.fromList [ j | (_, j) <- es ] ]+ Right (buildNetwork nodeSet (S.toList seen))+ where+ insertEdge acc (i, j)+ | i == j = Left SelfLoop+ | (i, j) `S.member` acc = Left DuplicateEdge+ | otherwise = Right (S.insert (i, j) acc)++-- | Assemble both adjacency indices from a validated edge set.+buildNetwork :: Ord k => Set k -> [(k, k)] -> TradeNetwork k+buildNetwork nodeSet es = TradeNetwork nodeSet outM inM+ where+ outM = foldl' (\m (i, j) -> M.insertWith S.union i (S.singleton j) m) M.empty es+ inM = foldl' (\m (i, j) -> M.insertWith S.union j (S.singleton i) m) M.empty es++-- | The node set, ascending.+--+-- >>> let Right g = tradeNetwork [3,1,2] [] :: Either NetworkError (TradeNetwork Int)+-- >>> nodes g+-- [1,2,3]+nodes :: TradeNetwork k -> [k]+nodes = S.toAscList . tnNodes++-- | The edges @(supplier, buyer)@, ascending.+--+-- >>> let Right g = tradeNetwork [1,2] [(2,1),(1,2)] :: Either NetworkError (TradeNetwork Int)+-- >>> edges g+-- [(1,2),(2,1)]+edges :: TradeNetwork k -> [(k, k)]+edges g = [ (i, j) | (i, js) <- M.toAscList (tnOut g), j <- S.toAscList js ]++-- | The suppliers of a given buyer, ascending.+--+-- >>> let Right g = tradeNetwork [1,2,3] [(1,3),(2,3)] :: Either NetworkError (TradeNetwork Int)+-- >>> suppliersOf g 3+-- [1,2]+suppliersOf :: Ord k => TradeNetwork k -> k -> [k]+suppliersOf g j = S.toAscList (M.findWithDefault S.empty j (tnIn g))++-- | The buyers from a given supplier, ascending.+--+-- >>> let Right g = tradeNetwork [1,2,3] [(1,2),(1,3)] :: Either NetworkError (TradeNetwork Int)+-- >>> buyersOf g 1+-- [2,3]+buyersOf :: Ord k => TradeNetwork k -> k -> [k]+buyersOf g i = S.toAscList (M.findWithDefault S.empty i (tnOut g))++-- | The number of edges.+--+-- >>> let Right g = tradeNetwork [1,2,3] [(1,2),(1,3),(2,3)] :: Either NetworkError (TradeNetwork Int)+-- >>> edgeCount g+-- 3+edgeCount :: TradeNetwork k -> Int+edgeCount = M.foldr' (\s acc -> S.size s + acc) 0 . tnOut++------------------------------------------------------------------+-- * InputCoefficients+------------------------------------------------------------------++-- | A sparse, buyer-major table of input coefficients: @buyer ↦ (supplier ↦+-- coefficient)@. The buyer-major layout matches the dominant access pattern+-- (enumerate a fixed buyer's suppliers) and makes the per-buyer column sum (the+-- Hawkins–Simon productivity check) an @O(deg)@ scan. The invariant+-- @supp(A) ⊆ edges(G)@ is enforced by 'inputCoefficients'.+data InputCoefficients k v = InputCoefficients+ { icByBuyer :: !(Map k (Map k v)) -- ^ buyer ↦ supplier ↦ coefficient+ }++instance (Eq k, Eq v) => Eq (InputCoefficients k v) where+ a == b = icByBuyer a == icByBuyer b++instance (Show k, Show v) => Show (InputCoefficients k v) where+ showsPrec d a = showParen (d > 10)+ $ showString "InputCoefficients "+ . showsPrec 11+ [ (i, j, v)+ | (j, sup) <- M.toAscList (icByBuyer a)+ , (i, v) <- M.toAscList sup ]++instance (NFData k, NFData v) => NFData (InputCoefficients k v) where+ rnf (InputCoefficients m) = rnf m++-- | Build the coefficient table from a network and a long-form list of+-- @(supplier, buyer, coef)@ triples.+--+-- Fails with 'CoefOutsideNetwork' if a triple has no corresponding edge,+-- 'NegativeCoefficient' if a value is negative or a non-finite error value, and+-- 'DuplicateCoefficient' if the same @(supplier, buyer)@ pair appears twice+-- (duplicates are not summed — the redundant-algebra convention forbids silent+-- aggregation). Zero coefficients are kept as written (they do not create an+-- edge, but if the edge exists they are recorded as @0@).+--+-- >>> let Right g = tradeNetwork [1,2,3] [(1,3),(2,3)] :: Either NetworkError (TradeNetwork Int)+-- >>> let Right a = inputCoefficients g [(1,3,0.2),(2,3,0.5)] :: Either NetworkError (InputCoefficients Int Double)+-- >>> inputsOf a 3+-- [(1,0.2),(2,0.5)]+-- >>> coefficient a 1 3+-- Just 0.2+inputCoefficients :: (Ord k, HatVal v)+ => TradeNetwork k+ -> [(k, k, v)]+ -> Either NetworkError (InputCoefficients k v)+inputCoefficients g triples = do+ m <- foldM' step M.empty triples+ Right (InputCoefficients m)+ where+ edgeSet = S.fromList (edges g)+ step acc (i, j, v)+ | isBadValue v = Left NegativeCoefficient+ | not ((i, j) `S.member` edgeSet) = Left CoefOutsideNetwork+ | hasCoef j i acc = Left DuplicateCoefficient+ | otherwise = Right (M.insertWith M.union j (M.singleton i v) acc)+ hasCoef j i acc = maybe False (M.member i) (M.lookup j acc)++-- | A value that must not enter a coefficient table: negative, or a non-finite+-- error value (NaN\/Inf). Mirrors the non-negativity invariant enforced by the+-- algebra's @(.\@)@ smart constructor.+isBadValue :: HatVal v => v -> Bool+isBadValue v = v < 0 || EJ.isErrorValue v++-- | Look up the coefficient on edge @(supplier, buyer)@, if any.+--+-- >>> let Right g = tradeNetwork [1,2,3] [(1,3)] :: Either NetworkError (TradeNetwork Int)+-- >>> let Right a = inputCoefficients g [(1,3,0.7)] :: Either NetworkError (InputCoefficients Int Double)+-- >>> coefficient a 1 3+-- Just 0.7+-- >>> coefficient a 2 3+-- Nothing+coefficient :: Ord k => InputCoefficients k v -> k -> k -> Maybe v+coefficient a i j = M.lookup j (icByBuyer a) >>= M.lookup i++-- | The @(supplier, coefficient)@ inputs of a fixed buyer, ascending by+-- supplier.+--+-- >>> let Right g = tradeNetwork [1,2,3] [(1,3),(2,3)] :: Either NetworkError (TradeNetwork Int)+-- >>> let Right a = inputCoefficients g [(2,3,0.5),(1,3,0.2)] :: Either NetworkError (InputCoefficients Int Double)+-- >>> inputsOf a 3+-- [(1,0.2),(2,0.5)]+inputsOf :: Ord k => InputCoefficients k v -> k -> [(k, v)]+inputsOf a j = M.toAscList (M.findWithDefault M.empty j (icByBuyer a))++------------------------------------------------------------------+-- * Summation over edges+------------------------------------------------------------------++-- | Sum a per-edge journal builder over the edges of a network. This is the+-- network analogue of an all-pairs @Σ@: the notation stays \"Σ over the+-- relation\", but the set it runs over is the @O(E)@ edge list rather than the+-- @O(N²)@ ordered pairs.+--+-- @f i j@ is the journal contributed by the edge @(i, j)@ (supplier @i@, buyer+-- @j@). Edges are visited in ascending order, so for an exact value type the+-- result is order-independent and for 'Double' it is at least deterministic.+--+-- With 'completeNetwork' this is exactly the all-pairs sum over distinct+-- ordered pairs, i.e.+--+-- @'sigmaEdges' ('completeNetwork' ks) f == 'EJ.sigma2When' ks ks (/=) f@+--+-- so an all-pairs model ports to a sparse one by swapping the network, leaving+-- the @Σ@ call site unchanged.+--+-- >>> import ExchangeAlgebra.Journal+-- >>> type J = Journal (Int,Int) Double (HatBase CountUnit)+-- >>> let Right g = tradeNetwork [1,2,3] [(1,2),(1,3)] :: Either NetworkError (TradeNetwork Int)+-- >>> let f i j = (1.0 .@ Not:<Amount) .| (i,j) :: J+-- >>> norm (sigmaEdges g f)+-- 2.0+sigmaEdges :: (Note n, HatVal v, HatBaseClass b)+ => TradeNetwork k+ -> (k -> k -> Journal n v b)+ -> Journal n v b+sigmaEdges g f = EJ.sigma (edges g) (\(i, j) -> f i j)++------------------------------------------------------------------+-- * Network generators+------------------------------------------------------------------++-- | The complete directed network: every distinct ordered pair @(i, j)@,+-- @i /= j@, is an edge. @O(N²)@ edges — provided so an existing all-pairs model+-- can be expressed without changing its @Σ@ (see 'sigmaEdges').+--+-- >>> let g = completeNetwork [1,2,3] :: TradeNetwork Int+-- >>> edgeCount g+-- 6+-- >>> edges g+-- [(1,2),(1,3),(2,1),(2,3),(3,1),(3,2)]+completeNetwork :: Ord k => [k] -> TradeNetwork k+completeNetwork ks =+ buildNetwork nodeSet [ (i, j) | i <- xs, j <- xs, i /= j ]+ where+ nodeSet = S.fromList ks+ xs = S.toAscList nodeSet++-- | A deterministic circulant (ring-lattice) network: the nodes are taken in+-- ascending order and each buyer draws its @min k (N-1)@ suppliers from the @k@+-- nodes that follow it cyclically (@j+1, …, j+k@ mod @N@). Unlike 'kRegular' \/+-- 'erdosRenyi' it needs no 'StdGen' and is built in @O(kN)@ — it never scans the+-- @O(N²)@ ordered pairs — so it stays usable at the @N@ a market-scale run needs.+-- @|E| = min k (N-1) · N@ exactly, with no duplicate and no self edges.+--+-- >>> let g = circulant [1..6] 2 :: TradeNetwork Int+-- >>> edgeCount g+-- 12+-- >>> suppliersOf g 1+-- [2,3]+-- >>> suppliersOf g 6+-- [1,2]+-- >>> all (\j -> length (suppliersOf g j) == 2) (nodes g)+-- True+circulant :: Ord k => [k] -> Int -> TradeNetwork k+circulant ks k =+ buildNetwork nodeSet (concat [ zip (rot d xs) xs | d <- [1 .. deg] ])+ where+ nodeSet = S.fromList ks+ xs = S.toAscList nodeSet+ n = length xs+ deg = max 0 (min k (n - 1))+ rot d ys = drop d ys ++ take d ys++-- | A @k@-regular-in network: each buyer draws @min k (N-1)@ distinct suppliers+-- (sampling without replacement, excluding itself). Deterministic in the given+-- 'StdGen'.+--+-- >>> let g = kRegular (mkStdGen 1) [1..6] 2 :: TradeNetwork Int+-- >>> all (\j -> length (suppliersOf g j) == 2) (nodes g)+-- True+kRegular :: Ord k => StdGen -> [k] -> Int -> TradeNetwork k+kRegular gen ks k =+ buildNetwork nodeSet (concat (snd (foldl' pick (gen, []) xs)))+ where+ nodeSet = S.fromList ks+ xs = S.toAscList nodeSet+ deg = max 0 (min k (length xs - 1))+ pick (g0, acc) j =+ let candidates = filter (/= j) xs+ (chosen, g1) = sampleWithout g0 deg candidates+ in (g1, [ (i, j) | i <- chosen ] : acc)++-- | An Erdős–Rényi @G(n, p)@ directed network: each ordered pair @(i, j)@,+-- @i /= j@, becomes an edge independently with probability @p@. @p <= 0@ yields+-- the empty network, @p >= 1@ yields 'completeNetwork'. Deterministic in the+-- given 'StdGen'.+--+-- >>> edgeCount (erdosRenyi (mkStdGen 0) [1..5] 1.0 :: TradeNetwork Int)+-- 20+-- >>> edgeCount (erdosRenyi (mkStdGen 0) [1..5] 0.0 :: TradeNetwork Int)+-- 0+erdosRenyi :: Ord k => StdGen -> [k] -> Double -> TradeNetwork k+erdosRenyi gen ks p+ | p >= 1 = completeNetwork ks+ | p <= 0 = buildNetwork nodeSet []+ | otherwise = buildNetwork nodeSet (snd (foldl' step (gen, []) pairs))+ where+ nodeSet = S.fromList ks+ xs = S.toAscList nodeSet+ pairs = [ (i, j) | i <- xs, j <- xs, i /= j ]+ step (g0, acc) e =+ let (u, g1) = randomR (0, 1) g0 :: (Double, StdGen)+ in if u < p then (g1, e : acc) else (g1, acc)++-- | A scale-free network grown by Barabási–Albert preferential attachment: nodes+-- are added in ascending order; each new node attaches @m@ edges to existing+-- nodes chosen with probability proportional to their current degree (with a+-- uniform fallback while the graph is still empty). Orientation: the new node is+-- the /buyer/, the chosen existing nodes are its /suppliers/. Deterministic in+-- the given 'StdGen'.+--+-- The first @m@ nodes form a seed clique-ish core (each new seed node attaches+-- to all already-present nodes), so for @N > m@ the edge count is+-- @C(m+1, 2)·1 + (N - m - 1)·m@ counting the directed buyer→supplier edges as+-- one per attachment; the property test checks the exact value.+--+-- >>> let g = scaleFree (mkStdGen 7) [1..10] 2 :: TradeNetwork Int+-- >>> edgeCount g+-- 17+scaleFree :: Ord k => StdGen -> [k] -> Int -> TradeNetwork k+scaleFree gen ks m0 =+ buildNetwork nodeSet builtEdges+ where+ nodeSet = S.fromList ks+ xs = S.toAscList nodeSet+ m = max 1 m0+ -- State threads the generator, the edge accumulator and an incremental+ -- degree table (@node ↦ current degree@). The degree table replaces the+ -- O(E) rescan that the naive @degree i = length (filter …) acc@ performed+ -- for every candidate at every node: each added edge bumps both endpoints'+ -- degrees in O(log N), so the per-node weight list is built without ever+ -- touching @acc@. The weights produced are bit-for-bit identical to the+ -- rescan version, so the generated network is unchanged.+ (_, builtEdges0, _) = foldl' addNode (gen, [], M.empty) (zip [0 ..] xs)+ builtEdges = reverse builtEdges0++ -- addNode :: (StdGen, [(k,k)], Map k Int)+ -- -> (Int, k) -> (StdGen, [(k,k)], Map k Int)+ addNode st@(g0, acc, deg) (ix, j)+ | ix == 0 = st -- first node: nothing to attach to+ | ix <= m = -- seed phase: attach to all earlier nodes+ let suppliers = take ix xs+ newEdges = [ (i, j) | i <- suppliers ]+ in (g0, newEdges ++ acc, bumpEdges newEdges deg)+ | otherwise = -- preferential attachment+ let present = take ix xs -- nodes already added+ degree i = M.findWithDefault 0 i deg+ weighted = [ (i, fromIntegral (1 + degree i) :: Double) | i <- present ]+ (chosen, g1) = sampleWeightedWithout g0 m weighted+ newEdges = [ (i, j) | i <- chosen ]+ in (g1, newEdges ++ acc, bumpEdges newEdges deg)++ -- Increment both endpoints' degree counts for each newly added edge.+ bumpEdges es d = foldl' (\d' (i, t) -> bump i (bump t d')) d es+ bump k = M.insertWith (+) k 1++-- | A stochastic block network: each node carries a sector label, and an+-- ordered pair @(i, j)@ (@i /= j@) becomes an edge with probability+-- @p (sector i, sector j)@. Deterministic in the given 'StdGen'. Generalises+-- 'erdosRenyi' (a single block) and lets intra-\/inter-sector densities differ.+--+-- >>> let label n = if n <= 2 then 'A' else 'B'+-- >>> let p (a,b) = if a == b then 1.0 else 0.0+-- >>> let g = sectorBlock (mkStdGen 0) [(n, label n) | n <- [1..4]] p :: TradeNetwork Int+-- >>> edges g+-- [(1,2),(2,1),(3,4),(4,3)]+sectorBlock :: (Ord k, Ord s)+ => StdGen -> [(k, s)] -> ((s, s) -> Double) -> TradeNetwork k+sectorBlock gen labelled p =+ buildNetwork nodeSet (snd (foldl' step (gen, []) pairs))+ where+ secMap = M.fromList labelled+ nodeSet = S.fromList (map fst labelled)+ xs = S.toAscList nodeSet+ pairs = [ (i, j) | i <- xs, j <- xs, i /= j ]+ step (g0, acc) (i, j) =+ let pr = fromMaybe 0 $ do+ si <- M.lookup i secMap+ sj <- M.lookup j secMap+ pure (p (si, sj))+ (u, g1) = randomR (0, 1) g0 :: (Double, StdGen)+ in if pr >= 1 then (g1, (i, j) : acc)+ else if pr <= 0 then (g1, acc)+ else if u < pr then (g1, (i, j) : acc) else (g1, acc)++------------------------------------------------------------------+-- * Ordered industrial networks and demand-driven flows+------------------------------------------------------------------++-- | A block-triangular industrial economy. Sector @0@ is the most upstream+-- sector and larger sector numbers are progressively downstream. Every edge is+-- @(supplier, buyer)@; generated edges satisfy @sector supplier <= sector buyer@,+-- and an intra-sector edge additionally satisfies @supplier < buyer@.+data IndustrialEconomy k = IndustrialEconomy+ { ieNetwork :: !(TradeNetwork k)+ , ieSector :: !(Map k Int)+ , ieSize :: !(Map k Double)+ } deriving (Eq, Show)++instance NFData k => NFData (IndustrialEconomy k) where+ rnf (IndustrialEconomy g s w) = rnf g `seq` rnf s `seq` rnf w++-- | Options for 'industrialNetworkWith'. The flow function is consulted only+-- for ordered sector pairs @(upstream, downstream)@. Non-positive and+-- non-finite values make that sector pair ineligible.+data IndustrialOptions = IndustrialOptions+ { ioExponent :: !Double+ -- ^ Pareto exponent @gamma > 1@.+ , ioFlow :: Int -> Int -> Double+ -- ^ Sector-flow weight @B[s,s']@.+ }++-- | Pareto exponent @2.5@ and a uniform positive sector-flow matrix.+defaultIndustrialOptions :: IndustrialOptions+defaultIndustrialOptions = IndustrialOptions+ { ioExponent = 2.5+ , ioFlow = \_ _ -> 1+ }++-- | Build the paper's deterministic block-triangular, power-law industrial+-- network from @seed N K m@. Unlike 'sectorBlock', this generator is+-- deterministic from an integer seed, ordered by sector, power-law weighted,+-- and avoids an all-pairs scan: expected construction cost is+-- @O(N*K + |E|*(K + log N))@, or @O(N*K + |E|*log N)@ for fixed @K@.+--+-- The requested edge count is @m*N@. It is exact whenever the eligible+-- supplier capacity is at least that large; otherwise all eligible pairs are+-- used and @|E| < m*N@. @N <= 0@ produces an empty economy, @m <= 0@ produces+-- no edges, and @K <= 0@ or an exponent not greater than @1@ is an error.+--+-- >>> let e = industrialNetwork 7 6 2 1+-- >>> firms e+-- [1,2,3,4,5,6]+-- >>> all (\(i,j) -> let s = ieSector e in s M.! i < s M.! j || (s M.! i == s M.! j && i < j)) (industrialEdges e)+-- True+industrialNetwork :: Int -> Int -> Int -> Int -> IndustrialEconomy Int+industrialNetwork = industrialNetworkWith defaultIndustrialOptions++-- | Configurable form of 'industrialNetwork'. Supplier selection is weighted+-- by @B[sector i,sector j] * w_i@ without replacement. It first chooses a+-- sector in @O(K)@ and then a firm by binary search over that sector's+-- cumulative-size vector in @O(log N)@. Duplicate draws use bounded rejection;+-- the deterministic fallback fills the remaining eligible candidates in+-- sector/id order. Sector choice is @O(K)@ per draw; @K@ is normally a small+-- fixed model parameter.+industrialNetworkWith+ :: IndustrialOptions -> Int -> Int -> Int -> Int -> IndustrialEconomy Int+industrialNetworkWith opts seed n0 k m0+ | k <= 0 = error "industrialNetworkWith: K must be positive"+ | not (finitePositive gamma) || gamma <= 1 =+ error "industrialNetworkWith: ioExponent must be finite and greater than 1"+ | n <= 0 = IndustrialEconomy (buildNetwork S.empty []) M.empty M.empty+ | otherwise = IndustrialEconomy network sectors sizes+ where+ n = max 0 n0+ gamma = ioExponent opts+ firmIds = [1 .. n]+ (_, sectors, sizes, sectorRev) = foldl' drawFirm (mkStdGen seed, M.empty, M.empty, M.empty) firmIds+ sectorLists = M.map reverse sectorRev+ pools = M.fromList+ [ (s, mkSectorPool (M.findWithDefault [] s sectorLists) sizes)+ | s <- [0 .. k - 1] ]+ ranks = M.fromList+ [ (i, r)+ | (_, pool) <- M.toAscList pools+ , (r, i) <- zip [0 ..] (V.toList (spFirms pool)) ]+ capacities =+ [ (j, candidateCapacity opts sectors pools ranks j)+ | j <- firmIds ]+ capacityTotal = sum (map snd capacities)+ requestedInteger = toInteger (max 0 m0) * toInteger n+ target = fromInteger (min requestedInteger (toInteger capacityTotal))+ degrees = apportionCapped target+ [ (j, cap, M.findWithDefault 1 j sizes) | (j, cap) <- capacities ]+ (_, edgeChunks) = foldl' drawBuyer (mkStdGen (seed + 104729), []) firmIds+ network = buildNetwork (S.fromList firmIds) (concat (reverse edgeChunks))++ drawFirm (g0, sm, wm, groups) i =+ let (s, g1) = randomR (0, k - 1) g0+ (u0, g2) = randomR (0, 1) g1 :: (Double, StdGen)+ -- randomR's interval is closed; cap its upper endpoint so Pareto+ -- inversion remains finite while retaining the seed-derived draw.+ u = min (1 - 2.220446049250313e-16) (max 0 u0)+ logWeight = - log (1 - u) / (gamma - 1)+ -- The mathematical Pareto draw can exceed Double's range as gamma+ -- approaches 1. Saturate only that unrepresentable tail, keeping+ -- ieSize and every sampling table finite and consistent.+ w = exp (min (log maxIndustrialSize) logWeight)+ in ( g2+ , M.insert i s sm+ , M.insert i w wm+ , M.insertWith (++) s [i] groups )++ drawBuyer (g0, acc) j =+ let d = M.findWithDefault 0 j degrees+ (chosen, g1) = chooseIndustrialSuppliers opts sectors pools ranks j d g0+ in (g1, [ (i, j) | i <- chosen ] : acc)++-- | Firms in ascending order, equivalent to @nodes . ieNetwork@.+firms :: IndustrialEconomy k -> [k]+firms = nodes . ieNetwork++-- | Industrial edges in ascending @(supplier,buyer)@ order, equivalent to+-- @edges . ieNetwork@.+industrialEdges :: IndustrialEconomy k -> [(k, k)]+industrialEdges = edges . ieNetwork++-- | An exact rational tax rate @numerator / denominator@.+data TaxRate = TaxRate+ { taxNumerator :: !Integer+ , taxDenominator :: !Integer+ } deriving (Eq, Show)++instance NFData TaxRate where+ rnf (TaxRate num den) = rnf num `seq` rnf den++-- | Integer tax on an integer amount. Generated industrial flows are aligned+-- to the denominator, so this division is exact for their amounts.+taxOf :: TaxRate -> Integer -> Integer+taxOf (TaxRate num den) amount+ | den <= 0 = error "taxOf: denominator must be positive"+ | num < 0 = error "taxOf: numerator must be non-negative"+ | amount < 0 = error "taxOf: amount must be non-negative"+ | otherwise = amount * num `div` den++-- | One-period demand-driven monetary flows for an industrial economy.+data IndustrialFlows k = IndustrialFlows+ { flowTrade :: !(Map (k, k) Integer)+ , flowOutput :: !(Map k Integer)+ , flowInput :: !(Map k Integer)+ , flowValueAdded :: !(Map k Integer)+ , flowFinalDemand :: !(Map k Integer)+ } deriving (Eq, Show)++instance NFData k => NFData (IndustrialFlows k) where+ rnf (IndustrialFlows z x inp va f) =+ rnf z `seq` rnf x `seq` rnf inp `seq` rnf va `seq` rnf f++-- | Options for the demand-driven backward substitution.+data FlowOptions = FlowOptions+ { foMeanFinalDemand :: !Integer+ , foInputShare :: !Double+ } deriving (Eq, Show)++instance NFData FlowOptions where+ rnf (FlowOptions f a) = rnf f `seq` rnf a++-- | Mean final demand @1,000,000@ yen and intermediate-input share @0.5@.+defaultFlowOptions :: FlowOptions+defaultFlowOptions = FlowOptions+ { foMeanFinalDemand = 1000000+ , foInputShare = 0.5+ }++-- | Generate one-period flows with 'defaultFlowOptions'.+industrialFlows :: Ord k+ => TaxRate -> IndustrialEconomy k -> IndustrialFlows k+industrialFlows = industrialFlowsWith defaultFlowOptions++-- | Generate exact integer flows by a single downstream-to-upstream backward+-- substitution. Final demand and every trade amount are positive-denominator+-- multiples. Trade amounts may be zero when a buyer's input units are fewer+-- than its suppliers. For an economy produced by 'industrialNetworkWith', the+-- identities @x_j = sum_i z_ij + v_j = sum_m z_jm + f_j@ and+-- @sum_j v_j = sum_j f_j@ hold exactly. Complexity is+-- @O(N*log N + |E|*log N)@ with ordered 'Map' updates.+industrialFlowsWith+ :: Ord k+ => FlowOptions -> TaxRate -> IndustrialEconomy k -> IndustrialFlows k+industrialFlowsWith opts (TaxRate num den) economy+ | den <= 0 = error "industrialFlowsWith: tax denominator must be positive"+ | num < 0 = error "industrialFlowsWith: tax numerator must be non-negative"+ | not (a >= 0 && a < 1) || isNaN a || isInfinite a =+ error "industrialFlowsWith: foInputShare must be finite and in [0,1)"+ | any (not . validOrderedEdge) (industrialEdges economy) =+ error "industrialFlowsWith: economy contains an edge outside the ordered sector DAG"+ | otherwise = IndustrialFlows zMap xMap inputMap vaMap finalMap+ where+ a = foInputShare opts+ validOrderedEdge (i, j) =+ case (M.lookup i (ieSector economy), M.lookup j (ieSector economy)) of+ (Just si, Just sj) -> (si, i) < (sj, j)+ _ -> False+ ks = firms economy+ count = length ks+ sizeOf j = let w = M.findWithDefault 1 j (ieSize economy)+ in if finitePositive w then w else 1+ meanSize = if count == 0+ then 1+ else sum (map sizeOf ks) / fromIntegral count+ meanFinal = max 0 (foMeanFinalDemand opts)+ finalMap = M.fromList+ [ (j, den * max 1 (round (fromIntegral meanFinal * sizeOf j+ / meanSize / fromIntegral den)))+ | j <- ks ]+ order = sortBy downstreamFirst ks+ downstreamFirst i j =+ compare (M.findWithDefault 0 j (ieSector economy), j)+ (M.findWithDefault 0 i (ieSector economy), i)+ (_, zMap, xMap, inputMap, vaMap) =+ foldl' solveFirm (M.empty, M.empty, M.empty, M.empty, M.empty) order++ solveFirm (orders, zs, xs, ins, vas) j =+ let revenue = M.findWithDefault 0 j orders+ finalD = M.findWithDefault den j finalMap+ output = revenue + finalD+ suppliers = suppliersOf (ieNetwork economy) j+ input+ | null suppliers = 0+ | otherwise = den * floor (a * fromIntegral output / fromIntegral den)+ units = input `div` den+ allocations = apportionInteger units [ (i, sizeOf i) | i <- suppliers ]+ zs' = foldl' (\m i -> M.insert (i, j) (den * M.findWithDefault 0 i allocations) m)+ zs suppliers+ orders' = foldl'+ (\m i -> M.insertWith (+) i (den * M.findWithDefault 0 i allocations) m)+ orders suppliers+ valueAdded = output - input+ in ( orders'+ , zs'+ , M.insert j output xs+ , M.insert j input ins+ , M.insert j valueAdded vas )++-- | Per-sector cumulative weights used by two-level supplier sampling.+data SectorPool = SectorPool+ { spFirms :: !(V.Vector Int)+ , spCumulative :: !(V.Vector Double)+ }++mkSectorPool :: [Int] -> Map Int Double -> SectorPool+mkSectorPool ids weights = SectorPool firmVector cumulative+ where+ firmVector = V.fromList ids+ cumulative = V.fromList (drop 1 (scanl (+) 0 [ M.findWithDefault 1 i weights | i <- ids ]))++candidateCapacity+ :: IndustrialOptions+ -> Map Int Int+ -> Map Int SectorPool+ -> Map Int Int+ -> Int+ -> Int+candidateCapacity opts sectors pools ranks j =+ sum [ eligibleCount s | s <- [0 .. buyerSector] ]+ where+ buyerSector = M.findWithDefault 0 j sectors+ buyerRank = M.findWithDefault 0 j ranks+ eligibleCount s+ | not (finitePositive (ioFlow opts s buyerSector)) = 0+ | s == buyerSector = buyerRank+ | otherwise = maybe 0 (V.length . spFirms) (M.lookup s pools)++chooseIndustrialSuppliers+ :: IndustrialOptions+ -> Map Int Int+ -> Map Int SectorPool+ -> Map Int Int+ -> Int+ -> Int+ -> StdGen+ -> ([Int], StdGen)+chooseIndustrialSuppliers opts sectors pools ranks buyer wanted g0 =+ go g0 S.empty [] 0+ where+ buyerSector = M.findWithDefault 0 buyer sectors+ buyerRank = M.findWithDefault 0 buyer ranks+ attemptLimit = max 64 (wanted * 32)+ sectorChoices =+ [ ((s, pool, limit), ioFlow opts s buyerSector * prefixWeight pool limit)+ | s <- [0 .. buyerSector]+ , finitePositive (ioFlow opts s buyerSector)+ , Just pool <- [M.lookup s pools]+ , let limit = if s == buyerSector then buyerRank else V.length (spFirms pool)+ , limit > 0+ , finitePositive (prefixWeight pool limit) ]++ go g selected acc attempts+ | S.size selected >= wanted = (reverse acc, g)+ | attempts >= attemptLimit =+ let remaining = take (wanted - S.size selected)+ [ i+ | ((_, pool, limit), _) <- sectorChoices+ , i <- V.toList (V.take limit (spFirms pool))+ , i `S.notMember` selected ]+ in (reverse acc ++ remaining, g)+ | otherwise =+ let ((_, pool, limit), g1) = weightedChoice g sectorChoices+ total = prefixWeight pool limit+ (u, g2) = randomR (0, total) g1 :: (Double, StdGen)+ ix = cumulativeLowerBound (spCumulative pool) limit u+ supplier = spFirms pool V.! ix+ in if supplier `S.member` selected+ then go g2 selected acc (attempts + 1)+ else go g2 (S.insert supplier selected) (supplier : acc) (attempts + 1)++prefixWeight :: SectorPool -> Int -> Double+prefixWeight _ limit | limit <= 0 = 0+prefixWeight pool limit = spCumulative pool V.! (limit - 1)++cumulativeLowerBound :: V.Vector Double -> Int -> Double -> Int+cumulativeLowerBound cumulative limit target = go 0 (limit - 1)+ where+ go lo hi+ | lo >= hi = lo+ | cumulative V.! mid >= target = go lo mid+ | otherwise = go (mid + 1) hi+ where mid = (lo + hi) `div` 2++weightedChoice :: StdGen -> [(a, Double)] -> (a, StdGen)+weightedChoice _ [] = error "weightedChoice: empty positive-weight population"+weightedChoice g choices = (pick u choices, g1)+ where+ total = sum (map snd choices)+ (u, g1) = randomR (0, total) g :: (Double, StdGen)+ pick _ [(x, _)] = x+ pick r ((x, w) : rest)+ | r <= w = x+ | otherwise = pick (r - w) rest+ pick _ [] = error "weightedChoice: unreachable"++finitePositive :: Double -> Bool+finitePositive x = x > 0 && not (isNaN x) && not (isInfinite x)++-- | Numerical ceiling for the unrepresentable far tail of a Pareto draw. It is+-- far above any economically meaningful relative size while leaving+-- market-scale sector sums finite.+maxIndustrialSize :: Double+maxIndustrialSize = 1e100++-- | Largest-remainder allocation with per-recipient caps. Continuous weighted+-- water-filling finds the cap-saturation threshold in one sorted pass, then a+-- largest-remainder step integerises the result. @O(N log N)@.+apportionCapped :: Int -> [(Int, Int, Double)] -> Map Int Int+apportionCapped requested rows+ | target <= 0 = M.fromList [ (key, 0) | (key, _, _) <- rows ]+ | remainder > length ranked =+ error "apportionCapped: numerical instability in largest-remainder allocation"+ | otherwise = foldl' addRemainder bases (take remainder ranked)+ where+ normalised =+ [ (key, max 0 cap, if finitePositive weight then weight else 1)+ | (key, cap, weight) <- rows ]+ target = min (max 0 requested) (sum [ cap | (_, cap, _) <- normalised ])+ active = sortBy compareThreshold [ row | row@(_, cap, _) <- normalised, cap > 0 ]+ lambda = waterLevel target (sum [ weight | (_, _, weight) <- active ]) active+ quotas =+ [ (key, cap, min (fromIntegral cap) (lambda * weight))+ | (key, cap, weight) <- normalised ]+ floors =+ [ (key, cap, floor quota, quota - fromIntegral (floor quota :: Int))+ | (key, cap, quota) <- quotas ]+ bases = M.fromList [ (key, base) | (key, _, base, _) <- floors ]+ remainder = max 0 (target - sum [ base | (_, _, base, _) <- floors ])+ ranked = map (\(key, _, _) -> key) $ sortBy compareRemainder+ [ (key, cap, frac) | (key, cap, base, frac) <- floors, base < cap ]+ compareThreshold (keyA, capA, weightA) (keyB, capB, weightB) =+ compare (fromIntegral capA / weightA) (fromIntegral capB / weightB)+ <> compare keyA keyB+ compareRemainder (keyA, _, fracA) (keyB, _, fracB) =+ compare fracB fracA <> compare keyA keyB+ addRemainder m key = M.insertWith (+) key 1 m+ waterLevel amount weightTotal candidates = case candidates of+ [] -> 0+ (_, cap, weight) : rest+ | weightTotal <= 0 -> 0+ | level <= fromIntegral cap / weight -> level+ | otherwise -> waterLevel (amount - cap) (weightTotal - weight) rest+ where level = fromIntegral amount / weightTotal++apportionInteger :: Ord k => Integer -> [(k, Double)] -> Map k Integer+apportionInteger amount rows+ | amount <= 0 || null rows = M.fromList [ (key, 0) | (key, _) <- rows ]+ | otherwise = foldl' addRemainder bases (take (fromInteger remainder) ranked)+ where+ positiveRows = [ (key, if finitePositive weight then weight else 1) | (key, weight) <- rows ]+ total = sum (map snd positiveRows)+ quotas = [ (key, fromIntegral amount * weight / total) | (key, weight) <- positiveRows ]+ floors = [ (key, floor quota, quota - fromIntegral (floor quota :: Integer)) | (key, quota) <- quotas ]+ bases = M.fromList [ (key, base) | (key, base, _) <- floors ]+ remainder = max 0 (amount - sum [ base | (_, base, _) <- floors ])+ ranked = map (\(key, _, _) -> key) $ sortBy compareRemainder floors+ compareRemainder (keyA, _, fracA) (keyB, _, fracB) =+ compare fracB fracA <> compare keyA keyB+ addRemainder m key = M.insertWith (+) key 1 m++------------------------------------------------------------------+-- * Coefficient generation+------------------------------------------------------------------++-- | Options for 'randomCoefficients'.+data CoefOptions = CoefOptions+ { coefRange :: !(Double, Double)+ -- ^ Inclusive @(lo, hi)@ range each raw coefficient is drawn from.+ , hawkinsSimon :: !Bool+ -- ^ When 'True', each buyer's column (its suppliers' coefficients) is+ -- kept strictly below a column sum of @1@, guaranteeing productivity (a+ -- sufficient Hawkins–Simon condition). The rescaling is __shrink-only__:+ -- a column whose raw sum is already @< 1@ is left untouched, and only a+ -- column whose raw sum is @>= 1@ is scaled down (to @0.95@ of its sum).+ -- This preserves the heterogeneity of the raw draws instead of forcing+ -- every column to one common sum. When 'False' the raw draws are used+ -- as-is.+ } deriving (Eq, Show)++instance NFData CoefOptions where+ rnf (CoefOptions r h) = rnf r `seq` rnf h++-- | @(0, 1)@ range with the Hawkins–Simon rescaling on.+defaultCoefOptions :: CoefOptions+defaultCoefOptions = CoefOptions { coefRange = (0, 1), hawkinsSimon = True }++-- | Draw a coefficient for every edge of a network, deterministically from the+-- given 'StdGen'. Values are drawn in 'Double' and converted via 'realToFrac'+-- (so the value type @v@ does not need a @Random@ instance). With+-- 'hawkinsSimon' on, the rescaling is __shrink-only__: a column whose raw sum+-- is already strictly below @1@ is kept as drawn, and only a column whose raw+-- sum reaches @1@ is scaled down to @0.95@ of that sum. Either way every column+-- sum ends strictly below @1@, a sufficient condition for the Leontief system+-- to be productive, while the natural spread of the raw draws is preserved+-- (columns are /not/ all forced to one common sum).+--+-- The result satisfies @supp(A) = edges(G)@ by construction, so it always+-- round-trips back through 'inputCoefficients' without error.+--+-- >>> let g = completeNetwork [1,2,3] :: TradeNetwork Int+-- >>> let a = randomCoefficients (mkStdGen 5) defaultCoefOptions g :: InputCoefficients Int Double+-- >>> all (\j -> sum (map snd (inputsOf a j)) < 1.0) (nodes g)+-- True+randomCoefficients :: forall k v. (Ord k, HatVal v)+ => StdGen -> CoefOptions -> TradeNetwork k -> InputCoefficients k v+randomCoefficients gen opts g =+ InputCoefficients (normalise (snd (foldl' drawCol (gen, M.empty) buyers)))+ where+ (lo, hi) = coefRange opts+ buyers = nodes g+ -- one column per buyer: draw a raw coefficient per supplier+ drawCol (g0, acc) j =+ let sup = suppliersOf g j+ (row, g1) = foldl' drawOne ([], g0) sup+ in (g1, if null row then acc else M.insert j (M.fromList row) acc)+ drawOne (row, g0) i =+ let (u, g1) = randomR (lo, hi) g0 :: (Double, StdGen)+ in ((i, u) : row, g1)++ normalise :: Map k (Map k Double) -> Map k (Map k v)+ normalise+ | hawkinsSimon opts = M.map rescaleCol+ | otherwise = M.map (M.map realToFrac)+ -- Shrink-only Hawkins–Simon: leave a productive column (raw sum < 1)+ -- untouched, and only scale a column down when its raw sum reaches 1.+ rescaleCol col =+ let total = sum (M.elems col)+ in if total < 1+ then M.map realToFrac col+ else M.map (\x -> realToFrac (x / total * target)) col+ target = 0.95 :: Double++------------------------------------------------------------------+-- * Table / matrix ingestion+------------------------------------------------------------------++-- | Build a network from a long-form edge list, deriving the node set from the+-- rows. A thin alias for @'tradeNetwork' []@ (which already unions in every+-- endpoint).+--+-- >>> let Right g = networkFromTable [(1,2),(2,3)] :: Either NetworkError (TradeNetwork Int)+-- >>> nodes g+-- [1,2,3]+-- >>> edges g+-- [(1,2),(2,3)]+networkFromTable :: Ord k => [(k, k)] -> Either NetworkError (TradeNetwork k)+networkFromTable = tradeNetwork []++-- | Build both the network and the coefficient table from a long-form+-- @(supplier, buyer, coef)@ table in one step. The network's edges are exactly+-- the table's @(supplier, buyer)@ pairs, so the @supp(A) ⊆ edges(G)@ invariant+-- holds automatically.+--+-- >>> let Right (g, a) = coefficientsFromTable [(1,3,0.2),(2,3,0.5)] :: Either NetworkError (TradeNetwork Int, InputCoefficients Int Double)+-- >>> edges g+-- [(1,3),(2,3)]+-- >>> inputsOf a 3+-- [(1,0.2),(2,0.5)]+coefficientsFromTable :: (Ord k, HatVal v)+ => [(k, k, v)]+ -> Either NetworkError (TradeNetwork k, InputCoefficients k v)+coefficientsFromTable triples = do+ g <- tradeNetwork [] [ (i, j) | (i, j, _) <- triples ]+ a <- inputCoefficients g triples+ Right (g, a)++-- | Build a network and coefficient table from a /dense/ matrix presented as a+-- node list plus a lookup function @a i j@ (= the coefficient on edge+-- @(supplier i, buyer j)@). The support is derived by dropping zero (and+-- error-valued) cells, mirroring the older dense-matrix examples in one call.+-- Self-cells @(i, i)@ are skipped. Total, so it returns the pair directly+-- (no 'NetworkError': the support is consistent by construction).+--+-- >>> let m i j = if i < j then fromIntegral (i + j) else 0 :: Double+-- >>> let (g, a) = fromCoefficientMatrix [1,2,3] m+-- >>> edges g+-- [(1,2),(1,3),(2,3)]+-- >>> coefficient a 1 3+-- Just 4.0+fromCoefficientMatrix :: (Ord k, HatVal v)+ => [k] -> (k -> k -> v) -> (TradeNetwork k, InputCoefficients k v)+fromCoefficientMatrix ks a =+ (buildNetwork nodeSet es, InputCoefficients byBuyer)+ where+ nodeSet = S.fromList ks+ xs = S.toAscList nodeSet+ cells = [ (i, j, v)+ | i <- xs, j <- xs, i /= j+ , let v = a i j+ , not (EJ.isZeroValue v), not (EJ.isErrorValue v) ]+ es = [ (i, j) | (i, j, _) <- cells ]+ byBuyer = foldl' (\m (i, j, v) -> M.insertWith M.union j (M.singleton i v) m) M.empty cells++------------------------------------------------------------------+-- * CSV (fixed schema, minimal self-contained parser)+------------------------------------------------------------------+--+-- A deliberately tiny CSV reader: comma-separated, no quoting, blank lines and+-- lines whose first non-space character is @#@ are skipped, surrounding+-- whitespace on each field is trimmed. The first non-skipped line must be the+-- header. Avoids a @cassava@ dependency for these fixed schemas. Line splitting+-- is shared with "ExchangeAlgebra.Convert.Csv" through 'splitTrim'.++-- | Parse an edge CSV with header @from,to@ into @(from, to)@ pairs.+--+-- >>> parseEdgeCsv (T.pack "from,to\na,b\nb,c\n")+-- Right [("a","b"),("b","c")]+parseEdgeCsv :: Text -> Either String [(Text, Text)]+parseEdgeCsv txt =+ case dataRows ["from", "to"] txt of+ Left e -> Left e+ Right rows -> traverse row rows+ where+ row [a, b] = Right (a, b)+ row r = Left ("edge row expected 2 fields, got " ++ show (length r))++-- | Parse a coefficient CSV with header @from,to,coef@ into+-- @(from, to, coef)@ triples (coefficient read as 'Double').+--+-- >>> parseCoefCsv (T.pack "from,to,coef\na,b,0.5\n")+-- Right [("a","b",0.5)]+parseCoefCsv :: Text -> Either String [(Text, Text, Double)]+parseCoefCsv txt =+ case dataRows ["from", "to", "coef"] txt of+ Left e -> Left e+ Right rows -> traverse row rows+ where+ row [a, b, c] = case reads (T.unpack c) of+ [(d, "")] -> Right (a, b, d)+ _ -> Left ("coef field not a number: " ++ show c)+ row r = Left ("coef row expected 3 fields, got " ++ show (length r))++-- | Read an edge CSV file into a t'TradeNetwork'. Combines parse and validation+-- errors into the @Left@ string.+readEdgeCsv :: FilePath -> IO (Either String (TradeNetwork Text))+readEdgeCsv fp = do+ txt <- TIO.readFile fp+ pure $ case parseEdgeCsv txt of+ Left e -> Left e+ Right es -> either (Left . show) Right (networkFromTable es)++-- | Read a coefficient CSV file into a @(t'TradeNetwork', t'InputCoefficients')@+-- pair. Combines parse and validation errors into the @Left@ string.+readCoefCsv :: FilePath -> IO (Either String (TradeNetwork Text, InputCoefficients Text Double))+readCoefCsv fp = do+ txt <- TIO.readFile fp+ pure $ case parseCoefCsv txt of+ Left e -> Left e+ Right trips -> either (Left . show) Right (coefficientsFromTable trips)++-- | Split CSV text into trimmed data-field rows, after checking the header.+dataRows :: [Text] -> Text -> Either String [[Text]]+dataRows expectedHeader txt =+ case keptLines of+ [] -> Left "empty CSV (no header)"+ (h : body)+ | splitTrim h == expectedHeader -> Right (map splitTrim body)+ | otherwise -> Left ("unexpected header: " ++ show (splitTrim h)+ ++ ", expected " ++ show expectedHeader)+ where+ keptLines = filter keep (T.lines txt)+ keep l =+ let s = T.strip l+ in not (T.null s) && not ("#" `T.isPrefixOf` s)++------------------------------------------------------------------+-- * Internal sampling helpers+------------------------------------------------------------------++-- | Uniform sampling without replacement of @n@ elements from a list,+-- deterministic in the 'StdGen'. Returns the chosen elements (in the order they+-- were drawn) and the advanced generator. If @n >= length xs@ the whole list is+-- returned.+sampleWithout :: StdGen -> Int -> [a] -> ([a], StdGen)+sampleWithout g0 n xs0 = go g0 (max 0 n) xs0 []+ where+ go g _ [] acc = (reverse acc, g)+ go g k xs acc+ | k <= 0 = (reverse acc, g)+ | otherwise =+ let (ix, g1) = randomR (0, length xs - 1) g+ (chosen, rest) = pick ix xs+ in go g1 (k - 1) rest (chosen : acc)+ pick i ys = case splitAt i ys of+ (pre, y : post) -> (y, pre ++ post)+ (pre, []) -> (last pre, init pre) -- unreachable (ix in range)++-- | Weighted sampling without replacement: pick @n@ elements, each draw+-- proportional to its weight, deterministic in the 'StdGen'.+sampleWeightedWithout :: StdGen -> Int -> [(a, Double)] -> ([a], StdGen)+sampleWeightedWithout g0 n xs0 = go g0 (max 0 n) xs0 []+ where+ go g _ [] acc = (reverse acc, g)+ go g k ws acc+ | k <= 0 = (reverse acc, g)+ | otherwise =+ let total = sum (map snd ws)+ (u, g1) = randomR (0, total) g :: (Double, StdGen)+ (chosen, rest) = drawAt u ws+ in go g1 (k - 1) rest (chosen : acc)+ drawAt u ws = walk u ws []+ where+ walk _ [] seen = case reverse seen of+ ((a, _) : _) -> (a, []) -- exhausted (shouldn't happen)+ [] -> error "sampleWeightedWithout: empty"+ walk acc ((a, w) : rest) seen+ | acc <= w = (a, reverse seen ++ rest)+ | otherwise = walk (acc - w) rest ((a, w) : seen)++------------------------------------------------------------------+-- * Small utilities+------------------------------------------------------------------++-- | Strict left @foldM@ over 'Either', short-circuiting on the first 'Left'.+foldM' :: (b -> a -> Either e b) -> b -> [a] -> Either e b+foldM' f = go+ where+ go !acc [] = Right acc+ go !acc (x : xs) = case f acc x of+ Left e -> Left e+ Right b' -> go b' xs
+ src/ExchangeAlgebra/Simulate/Policy.hs view
@@ -0,0 +1,278 @@+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE MultiParamTypeClasses #-}++{- |+ Module : ExchangeAlgebra.Simulate.Policy+ Copyright : (c) Kaya Akagi. 2018-2026+ Maintainer : yakagika@icloud.com++ Released under the OWL license++ == What this module is++ @Simulate.Policy@ is a small, /additive/ vocabulary for declaring — once, at+ the point a ledger is built — how a long simulation should manage the size of+ its audit trail. Instead of hand-wiring+ t'ExchangeAlgebra.Simulate.Spill.SpillOptions'+ (seven fields plus 'filterWithNote' calls for term extraction and eviction),+ the user declares a t'LedgerPolicy' and lets the plumbing be /derived from the+ Note's term axis/.++ A policy answers three orthogonal questions:++ * __retention__ ('Retention'): how many recent terms to keep in memory+ (@'RetainAll'@ or @'RetainRecent' w@);+ * __spill__ ('spillTo'): whether evicted terms are first written to a+ binary file (so they can be restored losslessly) or simply discarded;+ * __compaction__ ('Compaction'): whether /closed/ terms keep their full+ per-entry audit sequence (@'FullAudit'@) or are @compress@ed to one+ Hat/Not pair per base (@'CompressClosedTerms'@).++ The three are independent. In particular @'spillTo' = 'Nothing'@ combined+ with @'RetainRecent' w@ means __evicted terms are destroyed, not written+ anywhere__ — there is no way to recover them. This is deliberate (it makes a+ bounded-memory run with no disk cost possible), but it is /lossy/; see+ t'LedgerPolicy'.++ == Tuning long simulations++ 'FullAudit' is the default because it preserves the complete posting+ sequence: every entry remains available for inspection, replay, and external+ audit. In a long simulation that also means already-closed terms keep growing+ monotonically with their historical @seq@ data, which can dominate residency.++ 'CompressClosedTerms' is the explicit opt-in for that case. It is applied+ only to /closed/ terms, never to the in-progress term, and it preserves+ @norm@ and balance while discarding redundant within-term sequence detail.+ Because the choice is named in t'LedgerPolicy', it is not an implicit+ @bar@\/@compress@ hidden inside another operation.++ A common long-horizon setting is to keep the current and previous closed+ terms resident, spill older terms to a restorable binary file, and compress+ only the closed terms that remain in memory:++ @+ longRunPolicy :: LedgerPolicy+ longRunPolicy = LedgerPolicy+ { retain = RetainRecent 2+ , spillTo = Just "ledger.spill"+ , compaction = CompressClosedTerms+ }+ @++ In local measurements this pattern, used with @'RetainRecent' 2@ and+ @'spillTo'@, reduced residency by about 15x compared with retaining the full+ uncompressed audit sequence.++ == Relation to the classic engine and to Lite++ This module only carries the /types/ and the two bridge helpers+ ('policySpillOptions', 'restoreLedger'). The actual term-boundary application+ of a policy in the BSP loop lives in "ExchangeAlgebra.Simulate.Lite"+ (@runLiteWithPolicy@), and the classic @runSimulationWithSpill@ can be driven+ from a policy via 'policySpillOptions'. Nothing here changes the behaviour or+ signatures of the existing spill API; it is a thin declarative front-end over+ it.++ == The term axis++ A policy needs to know /which term/ a 'Note' belongs to. Because a tuple+ Note carries no intrinsic notion of "which component is the period", the+ convention is fixed by the 'HasTermAxis' class: __the last component of the+ Note is the term__. The library ships instances for @(e, t)@ and+ @(e1, e2, t)@; a bespoke Note needs a one-line instance.+-}++module ExchangeAlgebra.Simulate.Policy+ ( -- * Policy vocabulary+ Retention(..)+ , Compaction(..)+ , LedgerPolicy(..)+ , defaultLedgerPolicy+ -- * Term axis convention+ , HasTermAxis(..)+ -- * Bridges to the classic spill engine+ , policySpillOptions+ , restoreLedger+ ) where++import qualified Data.Binary as Binary++import ExchangeAlgebra.Journal ( Journal+ , Note+ , HatVal+ , HatBaseClass+ , filterWithNote )+import ExchangeAlgebra.Simulate (StateTime)+import ExchangeAlgebra.Simulate.Spill ( SpillOptions(..)+ , SpillDeletePolicy(..)+ , defaultBinarySpillWriter+ , restoreJournalFromBinarySpill )+import Control.Monad.ST (ST, RealWorld)++------------------------------------------------------------------+-- * Policy vocabulary+------------------------------------------------------------------++-- | How much of the ledger's term history is kept resident in memory.+--+-- 'RetainAll' is the full audit trail (the default, equivalent to the classic+-- engine's behaviour). @'RetainRecent' w@ keeps only the most recent @w@ terms+-- resident; older terms are evicted at the term boundary (and, if 'spillTo' is+-- set, written to disk first — see t'LedgerPolicy').+data Retention = RetainAll | RetainRecent !Int+ deriving (Eq, Show)++-- | How /closed/ (no-longer-advancing) terms are stored.+--+-- 'FullAudit' (the default) preserves every posting in its original sequence —+-- the complete audit trail. 'CompressClosedTerms' applies @compress@ to the+-- entries of each closed term, collapsing the redundant per-base posting+-- sequence to a single Hat/Not pair per base. This is /norm-preserving and+-- balance-preserving/ (only the within-term @seq@ redundancy is lost) and is+-- only ever applied to closed terms; the in-progress term always keeps its full+-- audit trail.+--+-- This is the "closing the books" operation of bookkeeping practice. Per the+-- library's prohibition on /implicit/ @bar@\/@compress@, it is reachable only+-- through this named policy, never silently inside another function.+data Compaction = FullAudit | CompressClosedTerms+ deriving (Eq, Show)++-- | A declarative ledger-management policy, fixed once when the ledger is+-- created. The three fields are orthogonal.+--+-- __Data loss warning.__ When @'spillTo' = 'Nothing'@ and @'retain'@ is+-- @'RetainRecent' w@, terms older than the window are __deleted with no+-- backup__: they are not written to any file and cannot be recovered. Set+-- @'spillTo' = 'Just' path@ to keep a restorable copy (see 'restoreLedger').+data LedgerPolicy = LedgerPolicy+ { retain :: !Retention -- ^ Resident-history policy (default 'RetainAll').+ , spillTo :: !(Maybe FilePath) -- ^ Optional binary spill file (default 'Nothing'); orthogonal to 'retain'.+ , compaction :: !Compaction -- ^ Closed-term storage (default 'FullAudit').+ } deriving (Eq, Show)++-- | The default policy: keep everything, spill nowhere, never compress. This is+-- exactly the classic full-audit behaviour, so a run under 'defaultLedgerPolicy'+-- is observationally equal to one with no policy at all.+--+-- >>> defaultLedgerPolicy+-- LedgerPolicy {retain = RetainAll, spillTo = Nothing, compaction = FullAudit}+defaultLedgerPolicy :: LedgerPolicy+defaultLedgerPolicy = LedgerPolicy+ { retain = RetainAll+ , spillTo = Nothing+ , compaction = FullAudit+ }++------------------------------------------------------------------+-- * Term axis convention+------------------------------------------------------------------++-- | The convention that fixes /which/ part of a 'Note' is the simulation term.+--+-- A policy must map a Note to its term to decide which entries belong to a+-- closed period. Since a tuple Note has no intrinsic "term" component, the rule+-- is fixed here: __the term is the last component of the Note__. The shipped+-- instances follow this rule; a custom Note type provides a one-line instance.+--+-- >>> termOf ("buy", 7 :: Int)+-- 7+--+-- >>> termOf ("buy", "shopA", 3 :: Int)+-- 3+class (Note n, Ord (TermOf n)) => HasTermAxis n where+ -- | The term type extracted from the Note.+ type TermOf n+ -- | Extract the term (last Note component, by the library convention).+ termOf :: n -> TermOf n++-- | Pair Note: the term is the second (last) component.+instance (Note e, Note t) => HasTermAxis (e, t) where+ type TermOf (e, t) = t+ termOf = snd++-- | Triple Note: the term is the third (last) component.+instance (Note e1, Note e2, Note t) => HasTermAxis (e1, e2, t) where+ type TermOf (e1, e2, t) = t+ termOf (_, _, t) = t++------------------------------------------------------------------+-- * Bridges to the classic spill engine+------------------------------------------------------------------++-- | Build a binary t'ExchangeAlgebra.Simulate.SpillOptions' for the classic+-- @runSimulationWithSpill@ from+-- a t'LedgerPolicy', deriving the per-chunk extraction and the eviction range+-- from the Note's term axis ('termOf'). This replaces the ~20 lines of+-- hand-written 'filterWithNote' plumbing (cf. @simulateEx2@) with a single call.+--+-- The caller supplies @spillEvery@ (the chunk size in terms) and two accessors+-- for the world's ledger field: @readLedger@ reads the current ledger, and+-- @modifyLedger@ applies a journal transformation in place. The returned options+-- write each chunk with 'defaultBinarySpillWriter' (so 'restoreLedger' can read+-- them back) and, when @'retain' = 'RetainRecent' w@, evict terms whose term+-- index is at most @chunkEnd - w@.+--+-- When @'retain' = 'RetainAll'@ no eviction is wired ('NoDelete'); the chunks+-- are still written if you want an external log. The 'compaction' field has no+-- effect on this classic bridge (it is applied only in @runLiteWithPolicy@).+-- The eviction arithmetic itself (step-back and per-chunk delete decision) is+-- single-sourced in "ExchangeAlgebra.Simulate"+-- ('ExchangeAlgebra.Simulate.Spill.stepBackWith' /+-- 'ExchangeAlgebra.Simulate.Spill.spillDeleteDecision'); this bridge only maps+-- the policy vocabulary onto t'SpillOptions' (design-review C4).+policySpillOptions+ :: forall n v b t a.+ ( HasTermAxis n, TermOf n ~ t, StateTime t+ , HatVal v, HatBaseClass b+ , Binary.Binary t, Binary.Binary (Journal n v b) )+ => LedgerPolicy+ -> Int -- ^ chunk size in terms (@spillEvery@)+ -> (a RealWorld -> ST RealWorld (Journal n v b)) -- ^ read the world's ledger+ -> ((Journal n v b -> Journal n v b) -> a RealWorld -> ST RealWorld ())+ -- ^ modify the world's ledger in place+ -> SpillOptions t a (Journal n v b)+policySpillOptions pol spillEvery readLedger modifyLedger =+ SpillOptions+ { spillEveryTerms = max 1 spillEvery+ , spillFilePath = maybe "" id (spillTo pol)+ , spillExtract = readLedger+ , spillExtractChunk = Just $ \(chunkStart, chunkEnd) world -> do+ ledger <- readLedger world+ pure $ filterWithNote+ (\n _ -> let t = termOf n+ in t >= chunkStart && t <= chunkEnd)+ ledger+ , spillWriteChunk = defaultBinarySpillWriter+ , spillDeletePolicy = case retain pol of+ RetainAll -> NoDelete+ RetainRecent w -> KeepRecentTerms w+ , spillDeleteRange = \(_, deleteEnd) world ->+ modifyLedger+ (filterWithNote (\n _ -> termOf n > deleteEnd))+ world+ }++-- | Restore a full ledger from a policy-written binary spill file plus the+-- in-memory remainder (the most recent, un-evicted terms). The spilled chunks+-- and the remainder are merged with @('ExchangeAlgebra.Algebra..+')@, the+-- remainder narrowed to terms past the last spilled range so nothing is+-- double-counted.+--+-- This is the inverse of a @'RetainRecent' w@ + @'spillTo' ('Just' path)@ run:+-- the result is the same ledger a 'FullAudit' run would have produced (exact,+-- with an exact value type such as @MoneyDecimal@). With @'spillTo' = 'Nothing'@+-- there is nothing to restore — the evicted terms are gone.+restoreLedger+ :: ( HasTermAxis n, TermOf n ~ t, Ord t, Enum t, Show t+ , HatVal v, HatBaseClass b+ , Binary.Binary t, Binary.Binary (Journal n v b) )+ => FilePath -- ^ binary spill file written under the policy+ -> Journal n v b -- ^ in-memory remainder (recent terms)+ -> IO (Journal n v b)+restoreLedger path remainder =+ restoreJournalFromBinarySpill path termOf remainder
+ src/ExchangeAlgebra/Simulate/Spill.hs view
@@ -0,0 +1,324 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE StrictData #-}+{- |+ Module : ExchangeAlgebra.Simulate.Spill+ Copyright : (c) Kaya Akagi. 2018-2026+ Maintainer : yakagika@icloud.com+ Description : Spill-to-disk codec and restore for long simulations: chunk writers, checked readers, and ledger restoration. Shared by the classic engine ("ExchangeAlgebra.Simulate"), the Lite runner and the ledger policy layer; depends only on Algebra and Journal.++ Released under the OWL license+-}++module ExchangeAlgebra.Simulate.Spill+ ( SpillOptions(..)+ , SpillDeletePolicy(..)+ , stepBackWith+ , spillDeleteDecision+ , mkSpillOptions+ , mkBinarySpillOptions+ , defaultSpillWriter+ , defaultBinarySpillWriter+ , SpillReadError(..)+ , SpillRangeIssue(..)+ , renderSpillReadError+ , readBinarySpillFile+ , readBinarySpillFileChecked+ , restoreJournalFromBinarySpill+ , restoreJournalFromBinarySpillChecked+ ) where++import Control.Monad.ST (RealWorld, ST)+import qualified Data.Binary as Binary+import qualified Data.ByteString.Lazy as BL+import Data.Int (Int64)+import qualified Data.List as L+import System.IO (Handle, hPutStr, hPutStrLn)++import ExchangeAlgebra.Algebra ((.+), HatBaseClass, HatVal)+import qualified ExchangeAlgebra.Journal as EJ++-- | Spill configuration for periodic external logging.+-- `spillExtract` selects accounting payload from world.+-- `spillWriteChunk` controls on-disk format.+data SpillOptions t a payload = SpillOptions+ { spillEveryTerms :: !Int+ , spillFilePath :: FilePath+ , spillExtract :: a RealWorld -> ST RealWorld payload+ , spillExtractChunk :: Maybe ((t, t) -> a RealWorld -> ST RealWorld payload)+ , spillWriteChunk :: Handle -> (t, t) -> payload -> IO ()+ , spillDeletePolicy :: SpillDeletePolicy t+ , spillDeleteRange :: (t, t) -> a RealWorld -> ST RealWorld ()+ }++-- | Policy to decide which term range to evict after each spill.+data SpillDeletePolicy t+ = NoDelete+ | DeleteSpilledChunk+ | KeepRecentTerms Int++-- | Step a term back @n@ times with the supplied step function (the classic+-- engine's previous-term operation, or 'pred' in "ExchangeAlgebra.Simulate.Lite"'s+-- retention loop). This is the __single definition__ of the eviction-window+-- arithmetic that was previously duplicated in both engines (design-review+-- C4). @n <= 0@ returns the term unchanged.+--+-- Complexity: O(n)+{-# INLINE stepBackWith #-}+stepBackWith :: (t -> t) -> Int -> t -> t+stepBackWith step = go+ where+ go n x | n <= 0 = x+ | otherwise = go (n - 1) (step x)++-- | The per-chunk delete decision, as a pure function of the+-- 'SpillDeletePolicy' -- the single source of "which term range is evicted+-- after a chunk @(chunkStart, chunkEnd)@ is spilled" (design-review C4):+--+-- * 'NoDelete' -- evict nothing.+-- * 'DeleteSpilledChunk' -- evict exactly the spilled chunk.+-- * @'KeepRecentTerms' n@ -- evict the chunk except the trailing @n@ terms+-- (the resident window); evict nothing when the window covers the chunk.+--+-- The step function abstracts the engine's notion of "previous term"+-- (the classic engine's previous-term operation;+-- "ExchangeAlgebra.Simulate.Lite" uses 'pred').+--+-- Complexity: O(n) for @'KeepRecentTerms' n@, O(1) otherwise.+spillDeleteDecision :: Ord t => (t -> t) -> SpillDeletePolicy t -> (t, t) -> Maybe (t, t)+spillDeleteDecision step policy (chunkStart, chunkEnd) = case policy of+ NoDelete -> Nothing+ DeleteSpilledChunk -> Just (chunkStart, chunkEnd)+ KeepRecentTerms keepN ->+ let deleteEnd = stepBackWith step keepN chunkEnd+ in if deleteEnd < chunkStart+ then Nothing+ else Just (chunkStart, deleteEnd)++-- | Construct text-format SpillOptions.+-- interval is the spill interval (in terms), path is the output file path.+--+-- Complexity: O(1)+mkSpillOptions :: Show t+ => Int+ -> FilePath+ -> (a RealWorld -> ST RealWorld String)+ -> SpillOptions t a String+mkSpillOptions interval path extractF =+ SpillOptions+ { spillEveryTerms = max 1 interval+ , spillFilePath = path+ , spillExtract = extractF+ , spillExtractChunk = Nothing+ , spillWriteChunk = defaultSpillWriter+ , spillDeletePolicy = NoDelete+ , spillDeleteRange = \_ _ -> pure ()+ }++-- | Construct binary-format SpillOptions.+-- Spills in a format that can be restored with 'readBinarySpillFile'.+--+-- Complexity: O(1)+mkBinarySpillOptions :: (Binary.Binary t, Binary.Binary payload)+ => Int+ -> FilePath+ -> (a RealWorld -> ST RealWorld payload)+ -> SpillOptions t a payload+mkBinarySpillOptions interval path extractF =+ SpillOptions+ { spillEveryTerms = max 1 interval+ , spillFilePath = path+ , spillExtract = extractF+ , spillExtractChunk = Nothing+ , spillWriteChunk = defaultBinarySpillWriter+ , spillDeletePolicy = NoDelete+ , spillDeleteRange = \_ _ -> pure ()+ }++-- | Default text-format spill writer.+-- Writes the chunk range and payload as text to the handle.+defaultSpillWriter :: Show t => Handle -> (t, t) -> String -> IO ()+defaultSpillWriter h (tStart, tEnd) payload = do+ hPutStrLn h ("# chunk " ++ show tStart ++ " " ++ show tEnd)+ hPutStr h payload+ hPutStrLn h "\n# end-chunk"++-- | Default binary-format spill writer.+-- Writes the chunk range and payload to the handle using 'Binary.encode'.+defaultBinarySpillWriter :: (Binary.Binary t, Binary.Binary payload)+ => Handle -> (t, t) -> payload -> IO ()+defaultBinarySpillWriter h termRange payload =+ BL.hPut h $ Binary.encode (termRange, payload)++-- | Why a binary spill file could not be read back as a well-formed chunk sequence.+data SpillReadError t+ = SpillDecodeFailure+ { spillErrorOffset :: !Int64 -- ^ Byte offset at which decoding failed.+ , spillErrorChunks :: !Int -- ^ Chunks decoded successfully before the failure.+ , spillErrorMessage :: String -- ^ Message from 'Binary.decodeOrFail'.+ }+ | SpillRangeError+ { spillRangeIssue :: !SpillRangeIssue+ , spillRangePrevious :: (t, t) -- ^ The chunk range decoded just before.+ , spillRangeCurrent :: (t, t) -- ^ The offending chunk range.+ }+ | SpillEmptyRange+ { spillRangeCurrent :: (t, t) -- ^ A chunk whose start is after its end.+ }+ deriving (Eq, Show)++-- | The relationship by which a chunk range fails to follow its predecessor.+data SpillRangeIssue+ = ChunkOutOfOrder+ | ChunkOverlap+ | ChunkGap+ deriving (Eq, Show, Enum, Bounded)++-- | Render a spill read error for an exception or diagnostic message.+renderSpillReadError :: Show t => SpillReadError t -> String+renderSpillReadError err = case err of+ SpillDecodeFailure offset chunks message ->+ "binary spill decode failure at byte offset " ++ show offset+ ++ " after " ++ show chunks ++ " chunks: " ++ message+ SpillRangeError issue previous current ->+ "binary spill range error (" ++ show issue ++ "): chunk "+ ++ show current ++ " follows " ++ show previous+ SpillEmptyRange current ->+ "binary spill empty range: " ++ show current++-- | Read a binary spill file and return it as a list of chunks.+-- Used to restore files written by 'defaultBinarySpillWriter'.+-- Raises an error at the first undecodable chunk; no partial result is returned.+--+-- Complexity: O(file size)+readBinarySpillFile :: (Binary.Binary t, Binary.Binary payload)+ => FilePath+ -> IO [((t, t), payload)]+readBinarySpillFile path = do+ bytes <- BL.readFile path+ case decodeBinarySpillChunks bytes of+ Left (offset, chunks, message) ->+ let err = SpillDecodeFailure offset chunks message :: SpillReadError ()+ in error (renderSpillReadError err)+ Right chunks -> pure chunks++-- | Read and validate every chunk in a binary spill file.+--+-- In addition to decode failures, this rejects empty, overlapping,+-- out-of-order, and gapped ranges. Gaps are errors because restoring around a+-- gap would discard the corresponding terms from the in-memory remainder and+-- silently turn missing spill data into an apparently complete ledger.+-- An empty file is a valid spill containing no chunks.+--+-- Complexity: O(file size + number of chunks)+readBinarySpillFileChecked+ :: (Binary.Binary t, Binary.Binary payload, Ord t, Enum t)+ => FilePath+ -> IO (Either (SpillReadError t) [((t, t), payload)])+readBinarySpillFileChecked path = do+ bytes <- BL.readFile path+ pure $ case decodeBinarySpillChunks bytes of+ Left (offset, chunks, message) ->+ Left (SpillDecodeFailure offset chunks message)+ Right chunks -> validateChunkRanges chunks++-- Decode once for both public readers. The offset from 'Binary.decodeOrFail' is+-- relative to the current suffix, so add the bytes consumed by prior chunks.+decodeBinarySpillChunks+ :: (Binary.Binary t, Binary.Binary payload)+ => BL.ByteString+ -> Either (Int64, Int, String) [((t, t), payload)]+decodeBinarySpillChunks = go 0 0+ where+ go _ _ bs | BL.null bs = Right []+ go offset decoded bs = case Binary.decodeOrFail bs of+ Left (_, localOffset, message) ->+ Left (offset + localOffset, decoded, message)+ Right (rest, consumed, entry) ->+ (entry :) <$> go (offset + consumed) (decoded + 1) rest++validateChunkRanges+ :: (Ord t, Enum t)+ => [((t, t), payload)]+ -> Either (SpillReadError t) [((t, t), payload)]+validateChunkRanges chunks = go Nothing chunks >> Right chunks+ where+ go _ [] = Right ()+ go previous (((s, e), _) : rest)+ | s > e = Left (SpillEmptyRange (s, e))+ | otherwise = case previous of+ Nothing -> go (Just (s, e)) rest+ Just prior@(ps, pe)+ | s <= pe && e >= ps ->+ Left (SpillRangeError ChunkOverlap prior (s, e))+ | e < ps ->+ Left (SpillRangeError ChunkOutOfOrder prior (s, e))+ | s /= succ pe ->+ Left (SpillRangeError ChunkGap prior (s, e))+ | otherwise -> go (Just (s, e)) rest++-- | Restore a complete Journal from spilled binary chunks and the current in-memory Journal.+-- The in-memory portion is narrowed to only terms after the last spill range,+-- so duplicate terms are not double-counted.+-- A malformed or stale spill file is not restored: this function raises an+-- error instead. Use 'restoreJournalFromBinarySpillChecked' when the caller+-- needs the failure represented as 'Either'.+--+-- Complexity: O(file size + number of chunks * union cost)+restoreJournalFromBinarySpill+ :: ( Binary.Binary t+ , Ord t+ , Enum t+ , Show t+ , Binary.Binary (EJ.Journal n v b)+ , EJ.Note n+ , HatVal v+ , HatBaseClass b+ )+ => FilePath+ -> (n -> t)+ -> EJ.Journal n v b+ -> IO (EJ.Journal n v b)+restoreJournalFromBinarySpill spillPath noteToTerm currentLedger = do+ restored <- restoreJournalFromBinarySpillChecked+ spillPath noteToTerm currentLedger+ case restored of+ Left err -> error (renderSpillReadError err)+ Right ledger -> pure ledger++-- | Checked form of 'restoreJournalFromBinarySpill'.+-- The current ledger is merged only after the entire spill file has decoded+-- and its chunk ranges have passed the continuity checks.+--+-- Complexity: O(file size + number of chunks * union cost)+restoreJournalFromBinarySpillChecked+ :: ( Binary.Binary t+ , Ord t+ , Enum t+ , Binary.Binary (EJ.Journal n v b)+ , EJ.Note n+ , HatVal v+ , HatBaseClass b+ )+ => FilePath+ -> (n -> t)+ -> EJ.Journal n v b+ -> IO (Either (SpillReadError t) (EJ.Journal n v b))+restoreJournalFromBinarySpillChecked spillPath noteToTerm currentLedger = do+ result <- readBinarySpillFileChecked spillPath+ pure $ fmap restore result+ where+ restore chunks =+ let spilled = L.foldl' (\acc (_, j) -> acc .+ j) mempty chunks+ latestEnd = L.foldl'+ (\acc ((_, tEnd), _) ->+ case acc of+ Nothing -> Just tEnd+ Just x -> Just (max x tEnd)+ )+ Nothing+ chunks+ remainder = case latestEnd of+ Nothing -> currentLedger+ Just tEnd ->+ EJ.filterWithNote (\n _ -> noteToTerm n > tEnd) currentLedger+ in spilled .+ remainder
src/ExchangeAlgebra/Simulate/Visualize.hs view
@@ -42,8 +42,6 @@ import qualified Data.List as L import Graphics.Rendering.Chart.Easy hiding ( (:<),(.~)) import Graphics.Rendering.Chart.Backend.Cairo-import Graphics.Rendering.Chart.Axis-import Graphics.Rendering.Chart.Axis.Int import Graphics.Rendering.Chart.Grid import qualified Control.Monad as CM import Control.Monad.ST@@ -112,13 +110,13 @@ -- | Plot the time series data contained in each column createColumn seriesGroup = layoutToGrid $ execEC $ do- CM.forM_ seriesGroup $ \(label, seriesData) ->- plot $ linePlot label seriesData+ CM.forM_ seriesGroup $ \(seriesLabel, seriesData) ->+ plot $ linePlot seriesLabel seriesData -- | Render a single time series as a line plot- linePlot label seriesData = liftEC $ do+ linePlot seriesLabel seriesData = liftEC $ do plot_lines_values .= [concat seriesData]- plot_lines_title .= label+ plot_lines_title .= seriesLabel plot_lines_style . line_color .= opaque blue -- | Set the graph title@@ -232,13 +230,13 @@ -- | Plot multiple time series in each column with color coding createColumn seriesGroup = layoutToGrid $ execEC $- CM.forM_ (zip [0..] seriesGroup) $ \(index, (label, seriesData)) ->- plot $ linePlot index label seriesData+ CM.forM_ (zip [0..] seriesGroup) $ \(seriesIdx, (seriesLabel, seriesData)) ->+ plot $ linePlot seriesIdx seriesLabel seriesData -- | Render time series data as a colored line plot- linePlot idx label seriesData = liftEC $ do+ linePlot idx seriesLabel seriesData = liftEC $ do plot_lines_values .= [concat seriesData]- plot_lines_title .= label+ plot_lines_title .= seriesLabel plot_lines_style . line_color .= colorPalette !! (idx `mod` length colorPalette) -- | Set the graph title@@ -344,13 +342,13 @@ -- | Plot the time series data contained in each column createColumn seriesGroup = layoutToGrid $ execEC $ do- CM.forM_ seriesGroup $ \(label, seriesData) ->- plot $ linePlot label seriesData+ CM.forM_ seriesGroup $ \(seriesLabel, seriesData) ->+ plot $ linePlot seriesLabel seriesData -- | Render a single time series as a line plot- linePlot label seriesData = liftEC $ do+ linePlot seriesLabel seriesData = liftEC $ do plot_lines_values .= [concat seriesData]- plot_lines_title .= label+ plot_lines_title .= seriesLabel plot_lines_style . line_color .= opaque blue -- | Set the graph title@@ -452,7 +450,7 @@ where -- flattenIndex iCount iIndex tIndex = tIndex * iCount + iIndex flattenIndex :: Int -> Int -> Int -> Int- flattenIndex width x y = y * width + x+ flattenIndex w x y = y * w + x -------------------------------------------------------------------------------- -- 3. Function to draw (i, t) line graphs using a Vector-based approach@@ -491,12 +489,12 @@ -- | Plot multiple TimeSeries contained in one column (TimeSerieses t) createColumn seriesGroup = layoutToGrid $ execEC $ do- CM.forM_ seriesGroup $ \(label, seriesData) ->- plot $ linePlot label seriesData+ CM.forM_ seriesGroup $ \(seriesLabel, seriesData) ->+ plot $ linePlot seriesLabel seriesData - linePlot label seriesData = liftEC $ do+ linePlot seriesLabel seriesData = liftEC $ do plot_lines_values .= [concat seriesData]- plot_lines_title .= label+ plot_lines_title .= seriesLabel plot_lines_style . line_color .= opaque blue createTitle name =@@ -552,11 +550,11 @@ -> a RealWorld -> FilePath -> IO ()-writeFuncResults funcs range wld path =+writeFuncResults funcs termRange wld path = writeFuncResultsWithContext (\_ t -> return t) (map (\(header, f) -> (header, \t -> f wld t)) funcs)- range+ termRange wld path
+ src/ExchangeAlgebra/TrialBalance/Balance.hs view
@@ -0,0 +1,96 @@+{-# LANGUAGE FlexibleContexts #-}++{- |+Module : ExchangeAlgebra.TrialBalance.Balance+Description : Shared account-balance representation and netting primitives for trial balances, presentation and consolidation. One type, one pair order (debit, credit), one netting rule.++Account balances retain their debit or credit direction while keeping scalar+values non-negative. All pair-valued functions in this module use debit first+and credit second.+-}+module ExchangeAlgebra.TrialBalance.Balance+ ( AccountBalance(..)+ , balancePair+ , addPair+ , netPair+ , combineBalances+ , balanceFor+ , balanceSide+ , balanceAmount+ , accountBalances+ ) where++import qualified Data.Map.Strict as M+import Data.Map.Strict (Map)++import ExchangeAlgebra.Algebra (Alg, HatVal, foldEntries)+import ExchangeAlgebra.Algebra.Base+ ( AccountTitles+ , ExBaseClass(getAccountTitle, whichSide)+ , Side(..)+ )++-- | Net balance direction remains structural; values stay non-negative.+data AccountBalance v+ = NoBalance+ | DebitBalance v+ | CreditBalance v+ deriving (Show, Eq)++-- | Convert a balance to a pair ordered as debit, then credit.+balancePair :: Num v => AccountBalance v -> (v, v)+balancePair NoBalance = (0, 0)+balancePair (DebitBalance value) = (value, 0)+balancePair (CreditBalance value) = (0, value)++-- | Add two debit-credit pairs componentwise.+addPair :: Num v => (v, v) -> (v, v) -> (v, v)+addPair (leftDebit, leftCredit) (rightDebit, rightCredit) =+ (leftDebit + rightDebit, leftCredit + rightCredit)++-- | Net a debit-credit pair into one structural balance.+netPair :: (Ord v, Num v) => (v, v) -> AccountBalance v+netPair (debit, credit)+ | debit == credit = NoBalance+ | debit > credit = DebitBalance (debit - credit)+ | otherwise = CreditBalance (credit - debit)++-- | Combine two balances using the common netting rule.+combineBalances+ :: (Ord v, Num v)+ => AccountBalance v -> AccountBalance v -> AccountBalance v+combineBalances left right = netPair+ (addPair (balancePair left) (balancePair right))++-- | Look up an account balance, treating an absent account as balanced.+balanceFor :: AccountTitles -> Map AccountTitles (AccountBalance v)+ -> AccountBalance v+balanceFor title = M.findWithDefault NoBalance title++-- | Recover the structural side of a balance.+balanceSide :: AccountBalance v -> Side+balanceSide NoBalance = Side+balanceSide (DebitBalance _) = Debit+balanceSide (CreditBalance _) = Credit++-- | Recover the non-negative scalar amount of a balance.+balanceAmount :: Num v => AccountBalance v -> v+balanceAmount NoBalance = 0+balanceAmount (DebitBalance value) = value+balanceAmount (CreditBalance value) = value++-- | Aggregate postings by account and net each debit-credit total.+accountBalances+ :: (HatVal v, ExBaseClass b)+ => Alg v b+ -> Map AccountTitles (AccountBalance v)+accountBalances = M.map netPair . foldEntries collect M.empty+ where+ collect totals value base =+ M.insertWith addPair (getAccountTitle base)+ (sidePair (whichSide base) value) totals++sidePair :: Num v => Side -> v -> (v, v)+sidePair Debit value = (value, 0)+sidePair Credit value = (0, value)+sidePair Side _ = (0, 0)
+ src/ExchangeAlgebra/TrialBalance/Validation.hs view
@@ -0,0 +1,432 @@+{-# LANGUAGE FlexibleContexts #-}+{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}++{- |+Module : ExchangeAlgebra.TrialBalance.Validation+Description : Explicit findings and policy gate between trial balances and reporting.++An exactly balanced trial balance can still be unsuitable for financial+statement preparation. This module detects reciprocal-account mismatches,+temporary-account residuals, closing-device residuals, abnormal-side balances,+and missing classification evidence independently of the double-entry balance+check.++Detection and acceptance are deliberately separate. 'trialBalanceFindings'+always reports the facts it can observe; 'validateTrialBalance' applies a+t'TrialBalancePolicy' and hides the v'ValidatedTrialBalance' constructor. No+finding performs a reclassification automatically. A caller must record and+apply a transfer, then validate the resulting trial balance again.++Validation uses exact equality. Prefer an exact value type such as+@MoneyDecimal@ for deterministic accounting gates.+-}+module ExchangeAlgebra.TrialBalance.Validation+ ( TrialBalanceStage(..)+ , ReciprocalPolicy(..)+ , TemporaryBalancePolicy(..)+ , TrialBalancePolicy(..)+ , strictTrialBalancePolicy+ , standaloneTrialBalancePolicy+ , AccountBalance(..)+ , ReclassificationRule(..)+ , TrialBalanceInput(..)+ , TBFinding(..)+ , trialBalanceFindings+ , findingBlocksPresentation+ , ValidatedTrialBalance+ , validateTrialBalance+ , validatedTrialBalance+ , validatedFindings+ , validatedPolicy+ , validatedStage+ , validatedMaturityRequiredTitles+ ) where++import Data.List.NonEmpty (NonEmpty(..))+import qualified Data.List.NonEmpty as NE+import qualified Data.Map.Strict as M+import Data.Map.Strict (Map)+import qualified Data.Set as S+import Data.Set (Set)+import Data.Text (Text)+import qualified Data.Text as T++import ExchangeAlgebra.Algebra+ ( Alg+ , Exchange(decL, decR)+ , HatVal(..)+ , Redundant(norm)+ , bases+ , foldEntries+ )+import ExchangeAlgebra.Algebra.Base+ ( AccountDivision(..)+ , AccountRole(..)+ , AccountSemantics(asemDivisionSemantics,+ asemHomeSideSemantics, asemRoles)+ , AccountTitles(..)+ , DivisionSemantics(..)+ , Hat(..)+ , HatBase((:<))+ , HomeSideSemantics(..)+ , Side(..)+ , accountSemantics+ , concreteAccountTitles+ )+import ExchangeAlgebra.TrialBalance.Balance+ ( AccountBalance(..)+ , accountBalances+ , balanceFor+ , balanceSide+ )++-- | Processing point of the supplied trial balance.+data TrialBalanceStage+ = BeforeClosing+ | AfterClosing+ deriving (Show, Eq)++-- | Whether reciprocal accounts must tie in this validation context.+data ReciprocalPolicy+ = RequireReciprocalMatch+ | PermitStandaloneReciprocalBalance+ deriving (Show, Eq)++-- | Whether an explained temporary balance may pass the validation gate.+data TemporaryBalancePolicy+ = RequireTemporaryAccountsCleared+ | PermitExplainedTemporaryBalances+ deriving (Show, Eq)++-- | Policy applied after the complete finding list has been constructed.+data TrialBalancePolicy = TrialBalancePolicy+ { _reciprocalPolicy :: ReciprocalPolicy+ , _temporaryBalancePolicy :: TemporaryBalancePolicy+ }+ deriving (Show, Eq)++-- | Combined/reporting default: reciprocal accounts must tie and temporary+-- accounts must be cleared.+strictTrialBalancePolicy :: TrialBalancePolicy+strictTrialBalancePolicy = TrialBalancePolicy+ RequireReciprocalMatch RequireTemporaryAccountsCleared++-- | Standalone default: a branch or head-office control balance may remain,+-- while an explained temporary balance is retained as an auditable finding.+standaloneTrialBalancePolicy :: TrialBalancePolicy+standaloneTrialBalancePolicy = TrialBalancePolicy+ PermitStandaloneReciprocalBalance PermitExplainedTemporaryBalances++-- | A rule identifies a condition and possible target titles. It supplies an+-- instruction, not an automatic mutation.+data ReclassificationRule+ = SideReclassificationRule+ AccountTitles+ Side+ (NonEmpty AccountTitles)+ | MaturityEvidenceRequired AccountTitles+ deriving (Show, Eq)++-- | Unvalidated trial-balance input and its external evidence.+data TrialBalanceInput v = TrialBalanceInput+ { _trialBalanceElement :: Alg v (HatBase AccountTitles)+ , _trialBalanceStage :: TrialBalanceStage+ , _temporaryBalanceExplanations :: Map AccountTitles Text+ , _reclassificationRules :: [ReclassificationRule]+ , _maturityEvidenceTitles :: Set AccountTitles+ }+ deriving (Show)++-- | Facts detected before financial-statement presentation.+data TBFinding v+ = InvalidTrialBalanceValue v+ | WildcardTrialBalanceAccount+ | WildcardTrialBalanceSide+ | UnbalancedTrialBalance v v+ | ReciprocalMismatch+ (AccountBalance v)+ (AccountBalance v)+ | StandaloneReciprocalBalance AccountTitles (AccountBalance v)+ | UnresolvedTemporaryBalance AccountTitles (AccountBalance v)+ | ExplainedTemporaryBalance AccountTitles (AccountBalance v) Text+ | BlankTemporaryExplanation AccountTitles (AccountBalance v)+ | ClosingDeviceResidual AccountTitles (AccountBalance v)+ | DerivedCoordinateResidual AccountTitles (AccountBalance v)+ | UnclosedNominalBalance AccountTitles (AccountBalance v)+ | UnexplainedAbnormalBalance+ AccountTitles Side (AccountBalance v)+ | AbnormalBalanceWithReclassificationRule+ AccountTitles (AccountBalance v) AccountTitles+ | AmbiguousReclassification+ AccountTitles (AccountBalance v) (NonEmpty AccountTitles)+ | MissingMaturityEvidence AccountTitles+ | InapplicableReclassificationRule ReclassificationRule+ deriving (Show, Eq)++-- | Detect every applicable finding. Structural errors suppress any operation+-- that would call the partial legacy @whichSide@ function on wildcard bases.+trialBalanceFindings :: HatVal v => TrialBalanceInput v -> [TBFinding v]+trialBalanceFindings input+ | not (null structuralFindings) = structuralFindings+ | otherwise = balanceFindings+ ++ reciprocalFindings balances+ ++ temporaryFindings input balances+ ++ closingFindings input balances+ ++ abnormalFindings input balances+ ++ maturityFindings input balances+ ++ ruleConfigurationFindings input+ where+ alg = _trialBalanceElement input+ structuralFindings =+ [ InvalidTrialBalanceValue value+ | value <- valuesOf alg+ , isErrorValue value+ ]+ ++ [ WildcardTrialBalanceAccount+ | _ :< AccountTitle <- bases alg+ ]+ ++ [ WildcardTrialBalanceSide+ | HatNot :< _ <- bases alg+ ]+ debit = norm (decL alg)+ credit = norm (decR alg)+ balanceFindings =+ [ UnbalancedTrialBalance debit credit | debit /= credit ]+ balances = accountBalances alg++-- | Decide whether one finding blocks presentation under a chosen policy.+findingBlocksPresentation :: TrialBalancePolicy -> TBFinding v -> Bool+findingBlocksPresentation policy finding = case finding of+ ExplainedTemporaryBalance _ _ _ ->+ _temporaryBalancePolicy policy == RequireTemporaryAccountsCleared+ StandaloneReciprocalBalance _ _ ->+ _reciprocalPolicy policy == RequireReciprocalMatch+ ReciprocalMismatch _ _ -> True+ InvalidTrialBalanceValue _ -> True+ WildcardTrialBalanceAccount -> True+ WildcardTrialBalanceSide -> True+ UnbalancedTrialBalance _ _ -> True+ UnresolvedTemporaryBalance _ _ -> True+ BlankTemporaryExplanation _ _ -> True+ ClosingDeviceResidual _ _ -> True+ DerivedCoordinateResidual _ _ -> True+ UnclosedNominalBalance _ _ -> True+ UnexplainedAbnormalBalance _ _ _ -> True+ AbnormalBalanceWithReclassificationRule _ _ _ -> True+ AmbiguousReclassification _ _ _ -> True+ MissingMaturityEvidence _ -> True+ InapplicableReclassificationRule _ -> True++-- | Trial balance accepted by a stated policy. The constructor is hidden.+data ValidatedTrialBalance v = ValidatedTrialBalance+ (Alg v (HatBase AccountTitles))+ [TBFinding v]+ TrialBalancePolicy+ TrialBalanceStage+ (Set AccountTitles)++-- | Apply a policy to the complete finding list.+validateTrialBalance+ :: HatVal v+ => TrialBalancePolicy+ -> TrialBalanceInput v+ -> Either (NonEmpty (TBFinding v)) (ValidatedTrialBalance v)+validateTrialBalance policy input =+ case filter (findingBlocksPresentation policy) findings of+ [] -> Right (ValidatedTrialBalance+ (_trialBalanceElement input) findings policy+ (_trialBalanceStage input) (maturityRequiredTitles input))+ blocker : blockers -> Left (blocker :| blockers)+ where+ findings = trialBalanceFindings input++-- | Recover the accepted algebra element.+validatedTrialBalance+ :: ValidatedTrialBalance v -> Alg v (HatBase AccountTitles)+validatedTrialBalance (ValidatedTrialBalance alg _ _ _ _) = alg++-- | Recover both blocking-policy-independent facts and permitted warnings.+validatedFindings :: ValidatedTrialBalance v -> [TBFinding v]+validatedFindings (ValidatedTrialBalance _ findings _ _ _) = findings++-- | Recover the policy that admitted this trial balance.+validatedPolicy :: ValidatedTrialBalance v -> TrialBalancePolicy+validatedPolicy (ValidatedTrialBalance _ _ policy _ _) = policy++-- | Recover the processing stage at which the trial balance was admitted.+validatedStage :: ValidatedTrialBalance v -> TrialBalanceStage+validatedStage (ValidatedTrialBalance _ _ _ stage _) = stage++-- | Maturity-allocation obligations that crossed the validation boundary.+validatedMaturityRequiredTitles+ :: ValidatedTrialBalance v -> Set AccountTitles+validatedMaturityRequiredTitles (ValidatedTrialBalance _ _ _ _ titles) = titles++maturityRequiredTitles :: TrialBalanceInput v -> Set AccountTitles+maturityRequiredTitles input = S.fromList+ [ title+ | MaturityEvidenceRequired title <- _reclassificationRules input+ ]++valuesOf :: HatVal v => Alg v (HatBase AccountTitles) -> [v]+valuesOf = foldEntries (\values value _ -> value : values) []++reciprocalFindings+ :: (Eq v)+ => Map AccountTitles (AccountBalance v)+ -> [TBFinding v]+reciprocalFindings balances =+ case (branch, headOffice) of+ (NoBalance, NoBalance) -> []+ (NoBalance, balance) ->+ [StandaloneReciprocalBalance HeadOfficeCurrentAccount balance]+ (balance, NoBalance) ->+ [StandaloneReciprocalBalance BranchCurrentAccount balance]+ _ | reciprocalBalancesMatch branch headOffice -> []+ | otherwise -> [ReciprocalMismatch branch headOffice]+ where+ branch = balanceFor BranchCurrentAccount balances+ headOffice = balanceFor HeadOfficeCurrentAccount balances++reciprocalBalancesMatch :: Eq v => AccountBalance v -> AccountBalance v -> Bool+reciprocalBalancesMatch NoBalance NoBalance = True+reciprocalBalancesMatch (DebitBalance left) (CreditBalance right) = left == right+reciprocalBalancesMatch (CreditBalance left) (DebitBalance right) = left == right+reciprocalBalancesMatch _ _ = False++temporaryFindings+ :: TrialBalanceInput v+ -> Map AccountTitles (AccountBalance v)+ -> [TBFinding v]+temporaryFindings input balances = concatMap finding temporaryTitles+ where+ temporaryTitles = case _trialBalanceStage input of+ BeforeClosing -> titlesWithRole SuspenseOrClearingAccount+ AfterClosing -> filter (/= CashOverShort)+ (titlesWithRole SuspenseOrClearingAccount)+ finding title = case balanceFor title balances of+ NoBalance -> []+ balance -> case M.lookup title (_temporaryBalanceExplanations input) of+ Nothing -> [UnresolvedTemporaryBalance title balance]+ Just explanation+ | T.null (T.strip explanation) ->+ [BlankTemporaryExplanation title balance]+ | otherwise ->+ [ExplainedTemporaryBalance title balance explanation]++closingFindings+ :: TrialBalanceInput v+ -> Map AccountTitles (AccountBalance v)+ -> [TBFinding v]+closingFindings input balances = case _trialBalanceStage input of+ BeforeClosing -> []+ AfterClosing ->+ concatMap residual (CashOverShort : titlesWithRole ClosingDevice)+ ++ concatMap derivedResidual+ (titlesWithRole PeriodResult ++ titlesWithRole ReportingSubtotal)+ ++ [ UnclosedNominalBalance title balance+ | (title, balance) <- M.toList balances+ , hasBalance balance+ , isNominal title+ ]+ where+ residual title = case balanceFor title balances of+ NoBalance -> []+ balance -> [ClosingDeviceResidual title balance]+ derivedResidual title = case balanceFor title balances of+ NoBalance -> []+ balance -> [DerivedCoordinateResidual title balance]+ isNominal title = case accountSemantics title of+ Just semantics -> case asemDivisionSemantics semantics of+ StatementDivision Cost -> True+ StatementDivision Revenue -> True+ _ -> False+ Nothing -> False+ hasBalance NoBalance = False+ hasBalance _ = True++abnormalFindings+ :: TrialBalanceInput v+ -> Map AccountTitles (AccountBalance v)+ -> [TBFinding v]+abnormalFindings input balances = concatMap finding (M.toList balances)+ where+ finding (_, NoBalance) = []+ finding (title, balance) = case expectedSide title of+ Nothing -> []+ Just expected+ | balanceSide balance == expected -> []+ | otherwise -> classifyRule title expected balance+ classifyRule title expected balance = case ruleTargets title (balanceSide balance)+ (_reclassificationRules input) of+ [] -> [UnexplainedAbnormalBalance+ title expected balance]+ [target] -> [AbnormalBalanceWithReclassificationRule+ title balance target]+ target : targets -> [AmbiguousReclassification+ title balance (target :| targets)]++maturityFindings+ :: Eq v+ => TrialBalanceInput v+ -> Map AccountTitles (AccountBalance v)+ -> [TBFinding v]+maturityFindings input balances =+ [ MissingMaturityEvidence title+ | MaturityEvidenceRequired title <- _reclassificationRules input+ , balanceFor title balances /= NoBalance+ , S.notMember title (_maturityEvidenceTitles input)+ ]++expectedSide :: AccountTitles -> Maybe Side+expectedSide title = do+ semantics <- accountSemantics title+ if OrdinaryAccount `elem` asemRoles semantics+ then case asemHomeSideSemantics semantics of+ FixedHomeSide side -> Just side+ ContextDependentHomeSide -> Nothing+ NoFixedHomeSide -> Nothing+ NoPostingSide -> Nothing+ else Nothing++ruleTargets+ :: AccountTitles+ -> Side+ -> [ReclassificationRule]+ -> [AccountTitles]+ruleTargets title side = deduplicate S.empty . concatMap targets+ where+ deduplicate _ [] = []+ deduplicate seen (candidate : candidates)+ | candidate `S.member` seen = deduplicate seen candidates+ | otherwise = candidate+ : deduplicate (S.insert candidate seen) candidates+ targets (SideReclassificationRule source trigger candidates)+ | source == title && trigger == side = NE.toList candidates+ | otherwise = []+ targets (MaturityEvidenceRequired _) = []++ruleConfigurationFindings :: TrialBalanceInput v -> [TBFinding v]+ruleConfigurationFindings input =+ [ InapplicableReclassificationRule rule+ | rule <- _reclassificationRules input+ , not (applicable rule)+ ]+ where+ applicable (MaturityEvidenceRequired title) = title /= AccountTitle+ applicable (SideReclassificationRule source trigger candidates) =+ source /= AccountTitle+ && trigger /= Side+ && expectedSide source /= Nothing+ && expectedSide source /= Just trigger+ && all (targetAccepts trigger) (NE.toList candidates)+ targetAccepts trigger target =+ target /= AccountTitle && expectedSide target == Just trigger++titlesWithRole :: AccountRole -> [AccountTitles]+titlesWithRole role =+ [ title+ | title <- concreteAccountTitles+ , Just semantics <- [accountSemantics title]+ , role `elem` asemRoles semantics+ ]
+ src/ExchangeAlgebra/Value.hs view
@@ -0,0 +1,184 @@+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE DerivingStrategies #-}++{- |+ Module : ExchangeAlgebra.Value+ Copyright : (c) Kaya Akagi. 2018-2026+ Maintainer : yakagika@icloud.com++ Released under the OWL license++ Exact, non-negative decimal value type (t'MoneyDecimal') for use as the @v@+ parameter of @Alg v b@ / @Journal n v b@.++ == Why this exists (DESIGN, 2026-06-06)++ The accounting value type is selectable:++ * @Double@ — fast IEEE-754; the default. Addition is /non-associative/,+ so the order in which same-base postings are summed (which depends on how a+ value was /constructed/) can change @norm@ / @bar@ results. In the+ agent-based simulations this manifested as a ~3% swing in a stock value when+ the construction order changed (see plans/in-progress/LAZY_EVAL_AUDIT.md and+ FP_SUMMATION_SURVEY.md). This is acceptable for relative-price ABM work where+ speed matters, but it is not deterministic/auditable.++ * @MoneyDecimal@ — exact base-10 fixed-point (a non-negative 'Data.Decimal').+ Addition is /exact and associative/, so results are independent of+ construction order: the fromList fold direction, parallel merges, etc. no+ longer change the answer. This is the right choice for audited ledgers and+ for making the construction-order optimizations (fromList O(N)) safe.++ @Integer@ (minimal-currency-unit) is intentionally NOT offered: ABM simulations+ use relative prices with base unit 1 and sub-unit fractional prices, which an+ integer cannot represent.++ == Ergonomics++ Numeric literals work without wrapping, because 'Num'/'Fractional' are derived:++ > type Ledger = Journal Term MoneyDecimal (HatBase AccountTitles)+ > entry = 10.5 :@ Hat:<Cash .+ 2 :@ Not:<Sales -- 10.5 and 2 are MoneyDecimal literals++ == Rounding++ The core algebra only adds/subtracts, which is exact for t'MoneyDecimal' and needs no+ rounding. Rounding is only needed by /multiplication and division/ (tax ratios,+ proration, scalar product) at the point a monetary amount is /finalised/. Use+ 'bankersRound': it rounds half-to-even (the unbiased financial default; also GHC's+ 'Prelude.round' and IEEE-754's default mode). A ceiling variant ('ceilingRound') is+ provided for the previous @rounding = ceiling@ behaviour and for jurisdictions whose+ rules differ. There is no single correct rule (e.g. Japanese consumption tax rounding+ varies by company), so the rounding function is explicit and swappable.+-}+module ExchangeAlgebra.Value+ ( MoneyDecimal(..)+ , toDecimal+ , bankersRound+ , ceilingRound+ , MoneyDouble(..)+ , toDouble+ ) where++import ExchangeAlgebra.Algebra (HatVal (..), Nearly (..))+import Data.Decimal (Decimal, DecimalRaw (Decimal), roundTo')+import Data.Word (Word8)+import Data.Hashable (Hashable (..))+import Control.DeepSeq (NFData (..))+import qualified Data.Binary as Binary++-- | A non-negative exact decimal value (wraps 'Data.Decimal.Decimal').+--+-- Non-negativity is a /soft/ invariant, the same as for the @Double@ instance:+-- it is not enforced by the constructor (intermediate subtraction inside @bar@/@(.-)@+-- can produce negatives), but 'isErrorValue' reports @x < 0@ so the @(.@)@ smart+-- constructor rejects negative postings.+newtype MoneyDecimal = MoneyDecimal Decimal+ -- Num/Fractional are derived so numeric literals (@10.5@, @0.08@) work directly,+ -- with no @MoneyDecimal@ wrapper at use sites. Show/Eq/Ord delegate to t'Decimal'.+ -- 'Real' (and thus 'toRational') is derived so values can be converted to/from+ -- @Double@ via 'realToFrac' at the simulation boundary: ABM parameters, input+ -- coefficients and random draws stay 'Double', and are converted to t'MoneyDecimal'+ -- only where they enter a ledger; final stock/profit amounts convert back for+ -- reporting. The ledger arithmetic in between is exact.+ deriving newtype (Eq, Ord, Show, Num, Fractional, Real)++-- | Project out the underlying t'Decimal'.+toDecimal :: MoneyDecimal -> Decimal+toDecimal (MoneyDecimal d) = d++-- 'Nearly': for an exact type there is no rounding noise to tolerate, so the+-- tolerance argument is ignored and equality is exact. (Contrast the @Double@+-- instance, which uses a scale-aware tolerance.)+instance Nearly MoneyDecimal where+ {-# INLINE isNearly #-}+ isNearly x y _ = x == y++instance HatVal MoneyDecimal where+ {-# INLINE zeroValue #-}+ zeroValue = MoneyDecimal 0+ -- Exact decimals have no NaN/Infinity; the only "error value" is a negative+ -- amount, which violates the non-negativity invariant of the algebra.+ {-# INLINE isErrorValue #-}+ isErrorValue (MoneyDecimal x) = x < 0+ -- Render exactly (e.g. "0.3", "12.34"); unlike the Double instance there is no+ -- fixed-2-decimal formatting, because the decimal value is already exact.+ {-# INLINE showValue #-}+ showValue (MoneyDecimal x) = show x++-- 'Binary'/'Hashable' are defined here (not orphan) because 'Data.Decimal' ships+-- neither, and 'Alg'/t'Journal' serialisation and the binary spill path require+-- @Binary v@. Both go through the (places, mantissa) structure of t'Decimal'.+instance Binary.Binary MoneyDecimal where+ {-# INLINE put #-}+ put (MoneyDecimal (Decimal places mantissa)) = do+ Binary.put (places :: Word8)+ Binary.put (mantissa :: Integer)+ {-# INLINE get #-}+ get = do+ places <- Binary.get :: Binary.Get Word8+ mantissa <- Binary.get :: Binary.Get Integer+ pure (MoneyDecimal (Decimal places mantissa))++instance Hashable MoneyDecimal where+ {-# INLINE hashWithSalt #-}+ hashWithSalt s (MoneyDecimal (Decimal places mantissa)) =+ s `hashWithSalt` places `hashWithSalt` mantissa++instance NFData MoneyDecimal where+ {-# INLINE rnf #-}+ rnf (MoneyDecimal (Decimal places mantissa)) = rnf places `seq` rnf mantissa++------------------------------------------------------------------+-- * MoneyDouble — fast IEEE-754 value type+------------------------------------------------------------------++-- | A fast IEEE-754 money value (wraps 'Prelude.Double').+--+-- This is the @newtype@ counterpart of the bare-@Double@ instance: a dedicated,+-- domain-specific money type so a ledger value cannot be silently confused with+-- an ABM coefficient, a random draw, or any other raw 'Double'. Every instance+-- it needs is owned here (via @deriving newtype@), so — exactly like+-- t'MoneyDecimal' — there are no orphan instances. Use t'MoneyDouble' for the same+-- speed as bare 'Double' while keeping the value type distinct in signatures.+--+-- Trade-off vs t'MoneyDecimal': addition is /non-associative/ (FP), so @norm@ \/+-- @bar@ can differ in the last ULP depending on construction order. It is fast+-- and runs everywhere bare 'Double' does (subtraction is signed, so the+-- intermediate negatives that arise inside @bar@\/@(.-)@ are fine — unlike+-- @Number.NonNegative.Double@, whose @(-)@ /errors/ on a negative result).+--+-- Non-negativity is the same /soft/ invariant as for t'MoneyDecimal' and bare+-- 'Double': not enforced by the constructor, but 'isErrorValue' reports+-- @isNaN x || isInfinite x || x < 0@, so the @(.\@)@ smart constructor and+-- @(.*)@ reject negative\/non-finite values.+newtype MoneyDouble = MoneyDouble Double+ -- All instances are coerced from the existing bare-'Double' instances+ -- ('Nearly'/'HatVal' live in "ExchangeAlgebra.Algebra"; 'Binary'/'Hashable'/+ -- 'NFData' come from the binary/hashable/deepseq packages), so t'MoneyDouble'+ -- is a zero-cost wrapper with identical numeric behaviour and 2-decimal+ -- 'showValue' formatting.+ deriving newtype ( Eq, Ord, Show, Num, Fractional, Real, RealFrac+ , Nearly, HatVal, Hashable, NFData, Binary.Binary )++-- | Project out the underlying 'Prelude.Double'.+toDouble :: MoneyDouble -> Double+toDouble (MoneyDouble d) = d++-- | Round a value to @n@ decimal places using /banker's rounding/+-- (round-half-to-even): the unbiased financial default. Ties go to the nearest+-- even digit (@2.5 -> 2@, @3.5 -> 4@, @0.125 -> 0.12@), so repeated rounding over+-- many transactions does not drift the total upward the way half-up does. This is+-- 'Prelude.round' applied per 'Data.Decimal.roundTo''.+--+-- Apply at the point a monetary amount is finalised after multiplication/division+-- (tax, proration, scalar product). The core algebra (add/subtract) is exact and+-- needs no rounding.+bankersRound :: Word8 -> MoneyDecimal -> MoneyDecimal+bankersRound places (MoneyDecimal d) = MoneyDecimal (roundTo' round places d)++-- | Round a value to @n@ decimal places by rounding /up/ (ceiling). This preserves+-- the previous library default (@rounding = ceiling@) and suits jurisdictions whose+-- rules round up. Prefer 'bankersRound' unless a ceiling rule is specifically required.+ceilingRound :: Word8 -> MoneyDecimal -> MoneyDecimal+ceilingRound places (MoneyDecimal d) = MoneyDecimal (roundTo' ceiling places d)
src/ExchangeAlgebra/Write.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE BangPatterns #-} {- | Module : ExchangeAlgebra.Write Copyright : (c) Kaya Akagi. 2018-2026@@ -23,16 +24,30 @@ , csvTranspose -- * Balance Sheet / P&L / Journal output , writeBS+ , bsRows , writePL+ , plRows , writeJournal+ , journalRows , writeAccountOf+ , writeAccountOfJournal+ , accountLedgerRowsJournal , writeCompoundTrialBalance+ , compoundTrialBalanceRows+ -- * Closing documents (決算書類)+ , writeWorksheet+ , worksheetRows+ , writePostClosingTrialBalance+ , postClosingTrialBalanceRows+ , accountLedgerRows -- * Simulation output , writeTermIO , writeIOMatrix -- * Spill / Restore , restoreJournalFromBinarySpill+ , restoreJournalFromBinarySpillChecked -- * Helpers+ , balanceOf , tshow , toSameLength ) where@@ -40,17 +55,22 @@ import qualified ExchangeAlgebra.Algebra as EA import ExchangeAlgebra.Algebra import qualified ExchangeAlgebra.Journal as EJ+import ExchangeAlgebra.Journal ((.|)) import qualified ExchangeAlgebra.Algebra.Transfer as ET+import qualified ExchangeAlgebra.Reporting.Group as RG -import ExchangeAlgebra.Simulate+import ExchangeAlgebra.Simulate.Spill+ ( restoreJournalFromBinarySpill+ , restoreJournalFromBinarySpillChecked ) import qualified Data.List as L import qualified Data.Text as T-import qualified Data.Binary as Binary import Control.Monad import qualified Data.Set as Set+import qualified Data.HashMap.Strict as Map+import qualified Data.Map.Strict as OMap import Data.Array.IO import Data.Time (Day) import System.IO (openFile, IOMode(WriteMode), hClose)@@ -82,51 +102,250 @@ tshow :: (Show a) => a -> T.Text tshow = T.pack . show --- | Output a Balance Sheet in CSV format.--- Internally applies @finalStockTransfer@, then decomposes into assets, liabilities, and equity for output.+-- | Render one 'RG.RelativeAmount' as a statement cell. The magnitude is+-- always non-negative (see "ExchangeAlgebra.Reporting.Group"); a deduction, or+-- a net that its deductions pushed past zero, is shown with a leading @-@.+-- The minus sign exists only here, in the rendered text — never in a value. --+-- Complexity: O(show cost)+renderRelative :: (HatVal n) => RG.RelativeAmount n -> T.Text+renderRelative amount+ | RG.raBelowZero amount = T.cons '-' magnitude+ | otherwise = magnitude+ where+ magnitude = tshow (RG.raMagnitude amount)++-- | Flatten presentation blocks into @(label, value)@ statement cells.+-- Gross and deduction rows are labelled by their account title, subtotal and+-- net rows by the group's label.+--+-- Complexity: O(r) (r = number of rows in the given blocks)+groupCells :: (HatVal n)+ => [RG.PresentationGroupDef]+ -> [(RG.PresentationGroupDef, [RG.GroupRow n])]+ -> [(T.Text, T.Text)]+groupCells defs blocks =+ [ (label (RG.grKind row), renderRelative (RG.grAmount row))+ | (_, rows) <- blocks, row <- rows ]+ where+ label kind = case kind of+ RG.GrossRow t -> tshow t+ RG.DeductionRow t -> tshow t+ RG.SubgroupRow key -> groupLabel key+ RG.NetRow key -> groupLabel key+ groupLabel key = maybe (tshow key) RG.pgLabel (RG.lookupGroupDef defs key)++-- | Build the rows of a Balance Sheet, as a pure value (the part 'writeBS'+-- renders to CSV).+--+-- Internally applies @'ET.finalStockTransfer'@ -- the only netting on the+-- /algebra/ this function does -- which closes every 'Cost'\/'Revenue'+-- account into 'RetainedEarnings' via @('.-')@\/@bar@. The closed algebra is+-- then partitioned by 'whichSide'\/'whatDiv' into assets (debit side) and+-- liability\/equity (credit side, further split by division);+-- 'decL'\/'decR'\/'EA.filter' only select entries, they do not aggregate them.+--+-- __Contra accounts (Definition 7 amendment, Land 3).__ Contra assets+-- (@whatDiv == Assets && isContra@, e.g. 貸倒引当金\/減価償却累計額) sit on+-- the credit side, so a plain side partition would either drop them from the+-- statement or file them under Liability. They are instead collected into the+-- presentation groups of "ExchangeAlgebra.Reporting.Group" and shown as a+-- real deduction — @gross lines → deduction lines → net line@ — inside the+-- Asset column, replacing Land 2's temporary placement in the Liability+-- column. A+-- group is formed only when one of its contra accounts actually carries gross+-- activity, so a chart that contains no contra posting keeps its ordinary+-- rows. Deduction+-- and negative-net cells carry a leading @-@; values themselves stay in+-- \(\mathbb{R}_0^+\).+--+-- Column totals use the ordinary cells plus each outermost group's net, so+-- nested subtotal rows are not counted twice. __Known limitation:__ an+-- ungrouped non-contra asset whose net balance is+-- on the credit side (an abnormal balance) is still not displayed, and is now+-- excluded from the totals as well; the side-versus-division placement of+-- abnormal balances is a separate pre-existing issue, not part of the contra+-- amendment.+-- Presentation groups are financial-statement aggregates keyed by account+-- title. With a multi-axis base, grouped titles therefore aggregate across+-- the remaining axes; ungrouped titles retain the legacy per-entry layout.+--+-- Layout:+--+-- > Asset | <titles...> | Total+-- > | <values...> | <asset total>+-- > Liability | <titles...> | Equity | <titles...> | Total+-- > | <values...> | | <values...> | <liability+equity total>+--+-- ==== __Examples__+--+-- Cash 100 (asset), a loan 60 (liability), capital 40 (equity); no+-- cost\/revenue accounts, so @'ET.finalStockTransfer'@ is a no-op, and no+-- contra account is present, so no presentation group is formed:+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let alg = (100 .@ Not:<Cash) .+ (60 .@ Not:<LoansPayable) .+ (40 .@ Not:<CapitalStock) :: T+-- >>> mapM_ print (bsRows alg)+-- ["Asset","","Liability",""]+-- ["Cash","100.0","LoansPayable","60.0"]+-- ["Total","100.0","Equity",""]+-- ["","","CapitalStock","40.0"]+-- ["","","Total","100.0"]+--+-- Receivables 1000 with an allowance of 100 against them, and capital 900.+-- The allowance is deducted from the receivables and the asset total is the+-- net 900, not the gross 1000:+--+-- >>> let contra = (1000 .@ Not:<AccountsReceivable) .+ (100 .@ Not:<AllowanceForDoubtfulAccounts) .+ (900 .@ Not:<CapitalStock) :: T+-- >>> mapM_ print (bsRows contra)+-- ["Asset","","Liability",""]+-- ["AccountsReceivable","1000.0","Equity",""]+-- ["AllowanceForDoubtfulAccounts","-100.0","CapitalStock","900.0"]+-- ["TradeReceivablesNet","900.0","Total","900.0"]+-- ["Total","900.0","",""]+-- -- Complexity: O(s) (s = total number of scalar entries)-writeBS :: (HatVal n, HatBaseClass b, ExBaseClass b) => FilePath -> Alg n b -> IO ()-writeBS path alg = writeCSV path result+bsRows :: (HatVal n, HatBaseClass b, ExBaseClass b) => Alg n b -> [[T.Text]]+bsRows alg = result where transferred = ET.finalStockTransfer alg- debitSide = decR transferred- creditSide = decL transferred- assets = creditSide- liability = EA.filter (\x -> whatDiv (_hatBase x) == Liability) debitSide- equity = EA.filter (\x -> whatDiv (_hatBase x) == Equity) debitSide- debitTotal = tshow (EA.norm debitSide)- creditTotal = tshow (EA.norm creditSide)+ grouping = RG.groupingForDivisions [Assets, Liability, Equity]+ RG.defaultPresentationGrouping+ grouped = RG.presentGroups grouping (accountGrossTotals transferred)+ consumed = RG.gpConsumed grouped+ ungrouped = EA.filter+ (\x -> not (Set.member (getAccountTitle (_hatBase x)) consumed)) transferred+ creditSide = decR ungrouped+ debitSide = decL ungrouped+ assets = debitSide+ liability = EA.filter (\x -> whatDiv (_hatBase x) == Liability) creditSide+ equity = EA.filter (\x -> whatDiv (_hatBase x) == Equity) creditSide+ blocksIn divisions =+ [block | block@(def, _) <- RG.gpBlocks grouped+ , RG.pgDivision def `elem` divisions]+ rootOf division = OMap.findWithDefault (zeroValue, zeroValue) division+ (RG.gpRootTotals grouped)+ -- Totals are the sum of the displayed cells: the ungrouped entries of the+ -- column plus each group's net (counted once, at its outermost block).+ assetGross = RG.addGross (EA.norm assets, zeroValue) (rootOf Assets)+ creditGross = RG.addGross+ (zeroValue, EA.norm liability + EA.norm equity)+ (RG.addGross (rootOf Liability) (rootOf Equity))+ assetTotal = renderRelative (RG.relativeTo Debit assetGross)+ creditTotal = renderRelative (RG.relativeTo Credit creditGross)+ (assetGroupText, assetGroupValue) =+ unzip (groupCells grouping (blocksIn [Assets]))+ (liabilityGroupText, liabilityGroupValue) =+ unzip (groupCells grouping (blocksIn [Liability]))+ (equityGroupText, equityGroupValue) =+ unzip (groupCells grouping (blocksIn [Equity])) assetsText = L.map (tshow . getAccountTitle . _hatBase) (EA.toList assets)- assetsValue = L.map (tshow . _val) (EA.toList assets)+ ++ assetGroupText+ assetsValue = L.map (tshow . _val) (EA.toList assets) ++ assetGroupValue liabilityText = L.map (tshow . getAccountTitle . _hatBase) (EA.toList liability)- liabilityValue = L.map (tshow . _val) (EA.toList liability)+ ++ liabilityGroupText+ liabilityValue = L.map (tshow . _val) (EA.toList liability) ++ liabilityGroupValue equityText = L.map (tshow . getAccountTitle . _hatBase) (EA.toList equity)- equityValue = L.map (tshow . _val) (EA.toList equity)+ ++ equityGroupText+ equityValue = L.map (tshow . _val) (EA.toList equity) ++ equityGroupValue result = csvTranspose [ [T.pack "Asset"] ++ assetsText ++ [T.pack "Total"]- , [T.empty] ++ assetsValue ++ [creditTotal]+ , [T.empty] ++ assetsValue ++ [assetTotal] , [T.pack "Liability"] ++ liabilityText ++ [T.pack "Equity"] ++ equityText ++ [T.pack "Total"]- , [T.empty] ++ liabilityValue ++ [T.empty] ++ equityValue ++ [debitTotal]+ , [T.empty] ++ liabilityValue ++ [T.empty] ++ equityValue ++ [creditTotal] ] --- | Output a Profit and Loss Statement in CSV format.--- Decomposes into costs and revenues for output.+-- | Output a Balance Sheet in CSV format. Pure layout is delegated to+-- 'bsRows'; this function only writes the file. See 'bsRows' for the+-- @'ET.finalStockTransfer'@ closing semantics and the column layout. --+-- Complexity: O(s) (s = total number of scalar entries; see 'bsRows')+writeBS :: (HatVal n, HatBaseClass b, ExBaseClass b) => FilePath -> Alg n b -> IO ()+writeBS path alg = writeCSV path (bsRows alg)++-- | Build the rows of a Profit and Loss Statement, as a pure value (the part+-- 'writePL' renders to CSV).+--+-- No closing is applied here (contrast 'bsRows', which applies+-- @'ET.finalStockTransfer'@) -- this decomposes the algebra /as given/ into+-- cost and revenue entries by 'whichSide'\/'whatDiv' ('decL'\/'decR'\/'EA.filter'+-- only select, they do not aggregate).+--+-- __Contra accounts (Definition 7 amendment, Land 3).__ A contra revenue+-- (売上割戻) sits on the debit side and a contra cost (仕入割戻, 還付法人税等)+-- on the credit side, so the side partition above would drop all three from+-- the statement entirely. They are instead collected into the presentation+-- groups of "ExchangeAlgebra.Reporting.Group" and shown as a real deduction+-- (@gross lines → deduction lines → net line@) inside their own column, on+-- the same terms as 'bsRows'. A group is formed only when one of its contra+-- accounts carries gross activity, so a statement containing no contra+-- posting keeps its ordinary rows. The column totals keep their historical cross-placement+-- (the Cost column's total cell states the revenue total and vice versa), but+-- are now the sum of the cells displayed in the other column.+--+-- Layout:+--+-- > Cost | <titles...> | Total+-- > | <values...> | <revenue total>+-- > Revenue | <titles...> | Total+-- > | <values...> | <cost total>+--+-- ==== __Examples__+--+-- A single sale of 500 (revenue) against its cost of 300; no contra account,+-- so no presentation group is formed:+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let alg = (500 .@ Not:<Sales) .+ (300 .@ Not:<SalesCost) :: T+-- >>> mapM_ print (plRows alg)+-- ["Cost","","Revenue",""]+-- ["SalesCost","300.0","Sales","500.0"]+-- ["Total","500.0","Total","300.0"]+--+-- The same sale with a rebate of 50 granted on it. Gross sales stay visible,+-- the rebate is deducted, and net sales carry into the total:+--+-- >>> let rebated = alg .+ (50 .@ Not:<SalesRebates) :: T+-- >>> mapM_ print (plRows rebated)+-- ["Cost","","Revenue",""]+-- ["SalesCost","300.0","Sales","500.0"]+-- ["","","SalesRebates","-50.0"]+-- ["","","NetSales","450.0"]+-- ["Total","450.0","Total","300.0"]+-- -- Complexity: O(s) (s = total number of scalar entries)-writePL :: (HatVal n, HatBaseClass b, ExBaseClass b) => FilePath -> Alg n b -> IO ()-writePL path alg = writeCSV path result+plRows :: (HatVal n, HatBaseClass b, ExBaseClass b) => Alg n b -> [[T.Text]]+plRows alg = result where- debitSide = decR alg- creditSide = decL alg- cost = EA.filter (\x -> whatDiv (_hatBase x) == Cost) creditSide- revenue = EA.filter (\x -> whatDiv (_hatBase x) == Revenue) debitSide- debitTotal = tshow (EA.norm cost)- creditTotal = tshow (EA.norm revenue)+ grouping = RG.groupingForDivisions [Cost, Revenue] RG.defaultPresentationGrouping+ grouped = RG.presentGroups grouping (accountGrossTotals alg)+ consumed = RG.gpConsumed grouped+ ungrouped = EA.filter+ (\x -> not (Set.member (getAccountTitle (_hatBase x)) consumed)) alg+ creditSide = decR ungrouped+ debitSide = decL ungrouped+ cost = EA.filter (\x -> whatDiv (_hatBase x) == Cost) debitSide+ revenue = EA.filter (\x -> whatDiv (_hatBase x) == Revenue) creditSide+ blocksIn division =+ [block | block@(def, _) <- RG.gpBlocks grouped+ , RG.pgDivision def == division]+ rootOf division = OMap.findWithDefault (zeroValue, zeroValue) division+ (RG.gpRootTotals grouped)+ costGross = RG.addGross (EA.norm cost, zeroValue) (rootOf Cost)+ revenueGross = RG.addGross (zeroValue, EA.norm revenue) (rootOf Revenue)+ -- Historical cross-placement preserved: the Cost column's total cell+ -- states the revenue total, and the Revenue column's the cost total.+ debitTotal = renderRelative (RG.relativeTo Debit costGross)+ creditTotal = renderRelative (RG.relativeTo Credit revenueGross)+ (costGroupText, costGroupValue) = unzip (groupCells grouping (blocksIn Cost))+ (revenueGroupText, revenueGroupValue) =+ unzip (groupCells grouping (blocksIn Revenue)) costText = L.map (tshow . getAccountTitle . _hatBase) (EA.toList cost)- costValue = L.map (tshow . _val) (EA.toList cost)+ ++ costGroupText+ costValue = L.map (tshow . _val) (EA.toList cost) ++ costGroupValue revenueText = L.map (tshow . getAccountTitle . _hatBase) (EA.toList revenue)- revenueValue = L.map (tshow . _val) (EA.toList revenue)+ ++ revenueGroupText+ revenueValue = L.map (tshow . _val) (EA.toList revenue) ++ revenueGroupValue (ct, rt) = toSameLength costText revenueText (cv, rv) = toSameLength costValue revenueValue result = csvTranspose@@ -136,6 +355,13 @@ , [T.empty] ++ rv ++ [debitTotal] ] +-- | Output a Profit and Loss Statement in CSV format. Pure layout is+-- delegated to 'plRows'; this function only writes the file.+--+-- Complexity: O(s) (s = total number of scalar entries; see 'plRows')+writePL :: (HatVal n, HatBaseClass b, ExBaseClass b) => FilePath -> Alg n b -> IO ()+writePL path alg = writeCSV path (plRows alg)+ -- | Pad two lists to the same length. Appends empty text to the shorter list. -- -- Complexity: O(max(|xs|, |ys|))@@ -149,85 +375,339 @@ lx = Prelude.length xs ly = Prelude.length ys +-- | Build the rows of a journal (仕訳帳), as a pure value (the part+-- 'writeJournal' renders to CSV).+--+-- Entries are grouped by date (via @f@) into a deterministic, deduplicated,+-- ascending day sequence; within a day the debit and credit postings are+-- listed by 'decL'\/'decR' and padded to equal length with 'toSameLength' --+-- no aggregation\/@bar@ is applied, so a day with more debit than credit+-- postings (or vice versa) simply gets blank cells on the shorter side.+--+-- ==== __Examples__+--+-- Three days: a capital contribution, a cash sale, and a day with two debit+-- postings against one credit posting (the debit column is one line longer,+-- and the credit/date cells of that extra line are padded blank):+--+-- >>> import Data.Time (fromGregorian)+-- >>> type T = Alg Double (HatBase (AccountTitles, Day))+-- >>> let d1 = fromGregorian 2024 4 1+-- >>> let d2 = fromGregorian 2024 4 2+-- >>> let d3 = fromGregorian 2024 4 3+-- >>> let getDay (_ :< (_, d)) = d+-- >>> let alg = (100 .@ Not:<(Cash,d1)) .+ (100 .@ Not:<(CapitalStock,d1)) .+ (50 .@ Not:<(Cash,d2)) .+ (50 .@ Not:<(Sales,d2)) .+ (30 .@ Not:<(Cash,d3)) .+ (10 .@ Not:<(AccountsReceivable,d3)) .+ (40 .@ Not:<(Sales,d3)) :: T+-- >>> mapM_ print (journalRows alg getDay)+-- ["Day","Debit","Amount","Credit","Amount"]+-- ["2024-04-01","Cash","100.0","CapitalStock","100.0"]+-- ["2024-04-02","Cash","50.0","Sales","50.0"]+-- ["2024-04-03","AccountsReceivable","10.0","Sales","40.0"]+-- ["","Cash","30.0","",""]+--+-- Complexity: O(s * log d) (s = number of entries, d = number of distinct dates)+journalRows :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => Alg n b+ -> (b -> Day)+ -> [[T.Text]]+journalRows alg f = csvTranspose [ds, dt, dv, ct, cv]+ where+ days = L.sort $ Set.toList . Set.fromList $ L.map (f . _hatBase) $ EA.toList alg+ rows = L.map perDay days+ perDay d =+ let da = EA.filter (\y -> (f . _hatBase) y == d) alg+ dl = decL da+ dr = decR da+ dlTexts = L.map (tshow . getAccountTitle . _hatBase) (EA.toList dl)+ drTexts = L.map (tshow . getAccountTitle . _hatBase) (EA.toList dr)+ dlValues = L.map (tshow . _val) (EA.toList dl)+ drValues = L.map (tshow . _val) (EA.toList dr)+ (dt', ct') = toSameLength dlTexts drTexts+ (dv', cv') = toSameLength dlValues drValues+ (ds', _) = toSameLength [tshow d] cv'+ in (ds', dt', dv', ct', cv')+ ds = [T.pack "Day"] ++ concatMap (\(a,_,_,_,_) -> a) rows+ dt = [T.pack "Debit"] ++ concatMap (\(_,a,_,_,_) -> a) rows+ dv = [T.pack "Amount"] ++ concatMap (\(_,_,a,_,_) -> a) rows+ ct = [T.pack "Credit"] ++ concatMap (\(_,_,_,a,_) -> a) rows+ cv = [T.pack "Amount"] ++ concatMap (\(_,_,_,_,a) -> a) rows+ -- | Output journal entries in CSV format. -- Groups by date and records the debit/credit account titles and amounts for each day.+-- Pure layout is delegated to 'journalRows'; this function only writes the file. ----- Complexity: O(s * log d) (s = number of entries, d = number of distinct dates)+-- Complexity: O(s * log d) (s = number of entries, d = number of distinct dates; see 'journalRows') writeJournal :: (HatVal n, HatBaseClass b, ExBaseClass b) => FilePath -> Alg n b -> (b -> Day) -> IO ()-writeJournal path alg f = do- let days = L.sort $ Set.toList . Set.fromList $ L.map (f . _hatBase) $ EA.toList alg- rows <- forM days $ \d -> do- let da = EA.filter (\y -> (f . _hatBase) y == d) alg- let dl = decL da- let dr = decR da- let dlTexts = L.map (tshow . getAccountTitle . _hatBase) (EA.toList dl)- let drTexts = L.map (tshow . getAccountTitle . _hatBase) (EA.toList dr)- let dlValues = L.map (tshow . _val) (EA.toList dl)- let drValues = L.map (tshow . _val) (EA.toList dr)- let (dt', ct') = toSameLength dlTexts drTexts- let (dv', cv') = toSameLength dlValues drValues- let (ds', _) = toSameLength [tshow d] cv'- pure (ds', dt', dv', ct', cv')- let ds = [T.pack "Day"] ++ concatMap (\(a,_,_,_,_) -> a) rows- let dt = [T.pack "Debit"] ++ concatMap (\(_,a,_,_,_) -> a) rows- let dv = [T.pack "Amount"] ++ concatMap (\(_,_,a,_,_) -> a) rows- let ct = [T.pack "Credit"] ++ concatMap (\(_,_,_,a,_) -> a) rows- let cv = [T.pack "Amount"] ++ concatMap (\(_,_,_,_,a) -> a) rows- writeCSV path (csvTranspose [ds, dt, dv, ct, cv])+writeJournal path alg f = writeCSV path (journalRows alg f) --- | Output account ledgers in CSV format.+-- | Build the rows of a general ledger (総勘定元帳 / T-account) for the given+-- account titles, as a pure value (the part 'writeAccountOf' renders to CSV). ----- __Note__: Not yet implemented. Calling this will raise an exception.+-- The ledger is the audit-trail view of an algebra: for each requested account+-- title, every posting touching that title is listed __individually in date+-- order, with no aggregation__ — the redundant sequence (seq) of the algebra is+-- preserved verbatim. (Contrast with 'writeCompoundTrialBalance', which is the+-- aggregated, @bar@\/@norm@-netted view.) This is the showcase of the algebra's+-- redundancy: each historical posting is kept as a separate ledger line.+--+-- For each title the layout is a two-sided T-account:+--+-- > <Title>+-- > Date | Debit | Date | Credit+--+-- Debit-side postings (@'whichSide' == 'Debit'@) go on the left, credit-side+-- postings on the right; the two columns are padded to equal length. Within a+-- side, postings are sorted by date and otherwise keep their original order+-- (so the line count equals the number of postings — no netting).+--+-- ==== __Examples__+--+-- Two separate debit postings to Cash are kept as two ledger lines (the seq is+-- /not/ aggregated into a single 30; the count of postings is preserved):+--+-- >>> import Data.Time (fromGregorian)+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let led = (10 .@ Not:<Cash) .+ (20 .@ Not:<Cash) .+ (5 .@ Hat:<Cash) :: T+-- >>> let d = fromGregorian 2024 4 1+-- >>> mapM_ print (accountLedgerRows [Cash] led (const d))+-- ["Cash","","",""]+-- ["Date","Debit","Date","Credit"]+-- ["2024-04-01","20.0","2024-04-01","5.0"]+-- ["2024-04-01","10.0","",""]+--+-- Complexity: O(t * s) (t = number of titles, s = number of entries).+accountLedgerRows :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => [AccountTitles]+ -> Alg n b+ -> (b -> Day)+ -> [[T.Text]]+accountLedgerRows titles alg f =+ concatMap titleBlock titles+ where+ titleBlock t =+ let xs = projByAccountTitle t alg+ debits = sortByDay (EA.toList (decL xs))+ credits = sortByDay (EA.toList (decR xs))+ dDates = L.map (tshow . f . _hatBase) debits+ dVals = L.map (tshow . _val) debits+ cDates = L.map (tshow . f . _hatBase) credits+ cVals = L.map (tshow . _val) credits+ (dDates', cDates') = toSameLength dDates cDates+ (dVals', cVals') = toSameLength dVals cVals+ header = [tshow t, T.empty, T.empty, T.empty]+ sub = [T.pack "Date", T.pack "Debit", T.pack "Date", T.pack "Credit"]+ body = L.zipWith4 (\a b c d -> [a,b,c,d]) dDates' dVals' cDates' cVals'+ in header : sub : body+ sortByDay = L.sortBy (\x y -> compare ((f . _hatBase) x) ((f . _hatBase) y))++-- | Output general ledgers (総勘定元帳) for the given account titles in CSV+-- format. Pure layout is delegated to 'accountLedgerRows'; this function only+-- performs the file write.+--+-- Each requested title is rendered as a two-sided T-account whose postings are+-- listed __individually, without aggregation__ (the seq redundancy is the audit+-- trail — see 'accountLedgerRows').+--+-- Complexity: O(t * s) (t = number of titles, s = number of entries). writeAccountOf :: (HatVal n, HatBaseClass b, ExBaseClass b) => [AccountTitles] -> FilePath -> Alg n b -> (b -> Day) -> IO ()-writeAccountOf _ _ _ _ = undefined+writeAccountOf titles path alg f = writeCSV path (accountLedgerRows titles alg f) +-- | Build the rows of general ledgers (総勘定元帳) from a 'EJ.Journal', as a+-- pure value (the part 'writeAccountOfJournal' renders to CSV), carrying the+-- per-posting note (摘要) as an extra column.+--+-- As with 'accountLedgerRows', postings are listed __individually, without+-- aggregation__ — the redundant sequence is the audit trail. Because each+-- posting carries its own note, this version uses a flat detail layout rather+-- than the two-sided T-account:+--+-- > <Title>+-- > Note | Debit | Credit+-- > <note> | <amount> | -- debit-side posting+-- > <note> | | <amount> -- credit-side posting+--+-- The note is rendered with 'show'. Postings are emitted in note order+-- (the 'EJ.Journal' is keyed by note); within a note the algebra's own seq+-- order is preserved. No 'EA.bar' \/ aggregation is applied.+--+-- ==== __Examples__+--+-- Two Cash postings under different notes; note order ("pay" < "sale")+-- determines the row order, not posting order:+--+-- >>> let jrn = ((100 .@ Not:<Cash) .| "sale") .+ ((40 .@ Hat:<Cash) .| "pay") :: EJ.Journal String Double (HatBase AccountTitles)+-- >>> mapM_ print (accountLedgerRowsJournal [Cash] jrn)+-- ["Cash","",""]+-- ["Note","Debit","Credit"]+-- ["\"pay\"","","40.0"]+-- ["\"sale\"","100.0",""]+--+-- Complexity: O(t * s) (t = number of titles, s = number of postings).+accountLedgerRowsJournal :: (HatVal n, HatBaseClass b, ExBaseClass b, EJ.Note note)+ => [AccountTitles]+ -> EJ.Journal note n b+ -> [[T.Text]]+accountLedgerRowsJournal titles j =+ concatMap titleBlock titles+ where+ pairs = L.sortBy (\(a,_) (b,_) -> compare a b) (Map.toList (EJ.toMap j))+ titleBlock t =+ let header = [tshow t, T.empty, T.empty]+ sub = [T.pack "Note", T.pack "Debit", T.pack "Credit"]+ body = concatMap (noteRows t) pairs+ in header : sub : body+ noteRows t (note, alg) =+ let xs = projByAccountTitle t alg+ mkRow x =+ let amt = tshow (_val x)+ in if (whichSide . _hatBase) x == Debit+ then [tshow note, amt, T.empty]+ else [tshow note, T.empty, amt]+ in L.map mkRow (EA.toList (EA.filter (\x -> x /= EA.Zero) xs)) --- | Output a Compound Trial Balance in CSV format.--- Calculates the debit total, credit total, and balance for each account title and outputs as a table.+-- | Output general ledgers (総勘定元帳) from a 'EJ.Journal' in CSV format,+-- carrying the per-posting note (摘要) as an extra column. Pure layout is+-- delegated to 'accountLedgerRowsJournal'; this function only writes the+-- file. See 'accountLedgerRowsJournal' for the flat note-detail layout. ----- Complexity: O(s * a) (s = number of entries, a = number of distinct account titles)-writeCompoundTrialBalance :: (HatVal n, HatBaseClass b, ExBaseClass b)- => FilePath- -> Alg n b- -> IO ()-writeCompoundTrialBalance path alg = do- let header = [T.pack "Debit Balance"- ,T.pack "Debit Total"- ,T.pack "Account Title"- ,T.pack "Credit Total"- ,T.pack "Credit Balance"]- let accounts = L.sort- $ Set.toList . Set.fromList- $ L.map (getAccountTitle . _hatBase)- $ EA.toList alg- let (lines', debitBalanceTotal, debitTotal, creditBalanceTotal, creditTotal) =- L.foldl' step ([], zeroValue, zeroValue, zeroValue, zeroValue) accounts- let totalLine = [ tshow debitBalanceTotal- , tshow creditTotal- , T.pack "Total"- , tshow debitTotal- , tshow creditBalanceTotal- ]- writeCSV path (header : lines' ++ [totalLine])+-- Complexity: O(t * s) (t = number of titles, s = number of postings; see+-- 'accountLedgerRowsJournal').+writeAccountOfJournal :: (HatVal n, HatBaseClass b, ExBaseClass b, EJ.Note note)+ => [AccountTitles]+ -> FilePath+ -> EJ.Journal note n b+ -> IO ()+writeAccountOfJournal titles path j =+ writeCSV path (accountLedgerRowsJournal titles j)+++------------------------------------------------------------------+-- Trial-balance aggregation (non-negative single-pass)+------------------------------------------------------------------++-- | Per-account gross debit/credit totals, accumulated in a __single pass__ over+-- the algebra's scalar entries (O(s)), keyed by 'AccountTitles' in 'Ord' order.+--+-- Each entry contributes its (non-negative) value to either the debit or the+-- credit total of its account title, classified by 'whichSide' (which already+-- folds in the Hat\/Not reversal) — exactly the @Debit@\/@Credit@ partition that+-- 'decL'\/'decR' produce. Summing per side reproduces @'norm' . 'decL'@ (debit+-- gross) and @'norm' . 'decR'@ (credit gross) without any signed netting, so the+-- value-domain invariant ($\\mathbb{R}_0^+$) is preserved: only non-negative+-- magnitudes are stored.+--+-- 'foldEntries' already skips zero values, matching the @x /= 'Zero'@ guard in+-- 'decL'\/'decR'. The resulting 'OMap.Map' iterates titles in ascending order,+-- matching the previous @L.sort . Set.toList . Set.fromList@ title enumeration.+--+-- Complexity: O(s) (s = number of scalar entries).+accountGrossTotals :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => Alg n b -> OMap.Map AccountTitles (n, n)+accountGrossTotals = EA.foldEntries step OMap.empty where- step (accLines, dbt, dt, cbt, ct) a =- let xs = projByAccountTitle a alg- xr = norm (decR xs)- xl = norm (decL xs)- (dc, diff) = diffRL xs+ -- (debit gross, credit gross)+ step acc v b =+ let !t = getAccountTitle b+ !pair = case whichSide b of+ Debit -> (v, zeroValue)+ Credit -> (zeroValue, v)+ Side -> (zeroValue, zeroValue)+ in OMap.insertWith addPair t pair acc+ addPair (d1, c1) (d2, c2) = (d1 + d2, c1 + c2)++-- | Net a @(debit gross, credit gross)@ pair into a @(Side, magnitude)@ balance,+-- reproducing 'diffRL' exactly. 'diffRL' compares @r = 'norm' . 'decR'@ (credit)+-- against @l = 'norm' . 'decL'@ (debit) with the scale-aware tolerance, so the+-- same comparison is applied here: near-equal sides report @(v'Side', 0)@,+-- otherwise the larger side wins with the non-negative difference.+--+-- Complexity: O(1).+netGross :: (HatVal n) => (n, n) -> (Side, n)+netGross (l, r) -- l = debit gross, r = credit gross+ | nearlyEqScaled r l = (Side, zeroValue)+ | r > l = (Credit, r - l)+ | otherwise = (Debit, l - r)++-- | Build the rows of a Compound Trial Balance (合計残高試算表), as a pure+-- value (the part 'writeCompoundTrialBalance' renders to CSV). Calculates the+-- debit total, credit total, and balance for each account title, aggregated+-- via the single-pass @accountGrossTotals@\/@netGross@ (the same+-- @diffRL@-equivalent netting as 'balanceOf'; no implicit @bar@). Layout:+--+-- > Debit Balance | Debit Total | Account Title | Credit Total | Credit Balance+--+-- __Legacy column-placement quirk (preserved verbatim):__ unlike+-- 'worksheetRows'\/'postClosingTrialBalanceRows' (which route a @(side,mag)@+-- balance through @sideCells@, putting a Debit balance in the Debit cell and+-- a Credit balance in the Credit cell), this layout places the balance+-- figure in the column pair /opposite/ the netted side: a debit-heavy+-- account's balance lands in the __Credit Balance__ (rightmost) column, and a+-- credit-heavy account's balance lands in the __Debit Balance__ (leftmost)+-- column — see the example below. Reusing @sideCells@ here would require+-- flipping 'Debit'\/'Credit' first, which is no clearer than the explicit+-- case analysis in @step@ below, so this was kept as-is rather than+-- consolidated (design-review C7) to guarantee output is unchanged.+--+-- ==== __Examples__+--+-- Cash is debit-heavy (gross debit 100, credit 0); CapitalStock and+-- LoansPayable are credit-heavy. Note where each balance figure lands:+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let alg = (100 .@ Not:<Cash) .+ (60 .@ Not:<LoansPayable) .+ (40 .@ Not:<CapitalStock) :: T+-- >>> mapM_ print (compoundTrialBalanceRows alg)+-- ["Debit Balance","Debit Total","Account Title","Credit Total","Credit Balance"]+-- ["","100.0","Cash","0.0","100.0"]+-- ["40.0","0.0","CapitalStock","40.0",""]+-- ["60.0","0.0","LoansPayable","60.0",""]+-- ["100.0","100.0","Total","100.0","100.0"]+--+-- Complexity: O(s) (single pass over s scalar entries; see @accountGrossTotals@)+compoundTrialBalanceRows :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => Alg n b -> [[T.Text]]+compoundTrialBalanceRows alg =+ header : lines' ++ [totalLine]+ where+ header = [T.pack "Debit Balance"+ ,T.pack "Debit Total"+ ,T.pack "Account Title"+ ,T.pack "Credit Total"+ ,T.pack "Credit Balance"]+ -- Single pass (O(s)): gross debit/credit totals per title, in Ord order.+ accounts = OMap.toList (accountGrossTotals alg)+ (lines', debitBalanceTotal, debitTotal, creditBalanceTotal, creditTotal) =+ L.foldl' step ([], zeroValue, zeroValue, zeroValue, zeroValue) accounts+ totalLine = [ tshow debitBalanceTotal+ , tshow creditTotal+ , T.pack "Total"+ , tshow debitTotal+ , tshow creditBalanceTotal+ ]+ step (accLines, dbt, dt, cbt, ct) (a, gross) =+ let xl = fst gross -- norm (decL xs) : debit gross+ xr = snd gross -- norm (decR xs) : credit gross+ (dc, diff) = netGross gross+ -- 'netGross'/'diffRL' returns the wildcard v'Side' with a zero+ -- difference when an account nets to zero (e.g. a fully-cleared+ -- suspense account). Treat that as no balance on either side+ -- (cf. 'sideCells'). (dbt', cbt') = case dc of Credit -> (dbt + diff, cbt) Debit -> (dbt, cbt + diff)+ Side -> (dbt, cbt)+ -- See the Haddock above: the Credit case places 'diff' in the+ -- *Debit Balance* column (position 0) and the Debit case places+ -- it in the *Credit Balance* column (position 4) -- opposite of+ -- 'sideCells' -- a legacy layout kept verbatim. line = case dc of Credit -> [ tshow diff , tshow xl@@ -241,10 +721,290 @@ , tshow xr , tshow diff ]+ Side -> [ T.empty+ , tshow xl+ , tshow a+ , tshow xr+ , T.empty+ ] in (accLines ++ [line], dbt', dt + xr, cbt', ct + xl) +-- | Output a Compound Trial Balance in CSV format.+-- Calculates the debit total, credit total, and balance for each account+-- title and outputs as a table. Pure layout is delegated to+-- 'compoundTrialBalanceRows'; this function only writes the file. See+-- 'compoundTrialBalanceRows' for the column layout (including the legacy+-- Debit\/Credit Balance placement quirk).+--+-- Complexity: O(s) (single pass over s scalar entries; see+-- 'compoundTrialBalanceRows')+writeCompoundTrialBalance :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => FilePath+ -> Alg n b+ -> IO ()+writeCompoundTrialBalance path alg =+ writeCSV path (compoundTrialBalanceRows alg) + ------------------------------------------------------------------+-- Closing documents (決算書類): worksheet & post-closing trial balance+------------------------------------------------------------------++-- | Per-account net balance: @('diffRL' . 'projByAccountTitle' title)@ on a+-- single-title restriction returns @(side, magnitude)@ where @side@ is the+-- balance side (Debit or Credit) after netting and @magnitude@ is the+-- (non-negative) amount. This is the explicit aggregation used by both the+-- worksheet trial-balance column and the post-closing trial balance (the+-- analogous netting that 'writeCompoundTrialBalance' performs).+balanceOf :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => AccountTitles -> Alg n b -> (Side, n)+balanceOf t = diffRL . projByAccountTitle t++-- | Place a @(side, magnitude)@ balance into a (debit, credit) text column pair.+-- A zero magnitude produces two empty cells.+sideCells :: (HatVal n) => (Side, n) -> (T.Text, T.Text)+sideCells (side, mag)+ | mag == zeroValue = (T.empty, T.empty)+ -- 'diffRL' returns the wildcard v'Side' only when the net magnitude is zero,+ -- which the guard above has already handled; a non-zero balance is always+ -- 'Debit' or 'Credit'. The non-exhaustive @case@ is by design (audited).+ | otherwise = case side of+ Debit -> (tshow mag, T.empty)+ Credit -> (T.empty, tshow mag)++-- | Build the rows of an 8-column worksheet (8 桁精算表), as a pure value+-- (the part 'writeWorksheet' renders to CSV).+--+-- Columns (matching the lecture's 精算表): per account title there are four+-- debit\/credit column pairs —+--+-- > Account Title+-- > | Trial Balance (Debit, Credit) -- 残高試算表+-- > | Adjustments (Debit, Credit) -- 整理記入+-- > | Profit & Loss (Debit, Credit) -- 損益計算書+-- > | Balance Sheet (Debit, Credit) -- 貸借対照表+--+-- For each account title:+--+-- * the __Trial Balance__ pair is the net balance of the+-- /pre-adjustment ledger/ (@'balanceOf' title pre@ — an explicit+-- @diffRL@ netting, no implicit @bar@);+-- * the __Adjustments__ pair is the net balance of the /adjustment entries/+-- (@'balanceOf' title adj@);+-- * the __final balance__ is the net balance of @pre '.+' adj@, routed by+-- 'whatDiv': 'Cost'\/'Revenue' titles go to the __Profit & Loss__ columns,+-- 'Assets'\/'Liability'\/'Equity' titles go to the __Balance Sheet__+-- columns.+--+-- The penultimate row is the column totals of the trial-balance, P\/L and B\/S+-- column pairs. The final row is the profit\/loss balancing figure+-- (当期純利益\/純損失): it is the amount that makes each of the P\/L and B\/S+-- column pairs balance. By construction (the homomorphism @norm@) the P\/L+-- imbalance equals the B\/S imbalance — that equality is the worksheet's own+-- self-check (精算表の貸借差額の一致). __This function does not enforce the+-- equality__; if the inputs are inconsistent it still emits both figures so the+-- discrepancy is visible (rather than raising an error).+--+-- Account titles are listed in 'Ord' order so the output is deterministic.+--+-- ==== __Examples__+--+-- Opening capital 100 + a cash sale of 50, with no adjustments. Cash (150) and+-- CapitalStock (100) go to the Balance Sheet; Sales (50) goes to Profit & Loss.+-- The net income of 50 appears on the P\/L debit and B\/S credit, making both+-- statement column pairs balance at 50 and 150 respectively:+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let pre = (150 .@ Not:<Cash) .+ (100 .@ Not:<CapitalStock) .+ (50 .@ Not:<Sales) :: T+-- >>> mapM_ print (worksheetRows pre (zeroValue .@ Not:<Cash))+-- ["Account Title","Trial Balance","","Adjustments","","Profit & Loss","","Balance Sheet",""]+-- ["","Debit","Credit","Debit","Credit","Debit","Credit","Debit","Credit"]+-- ["Cash","150.0","","","","","","150.0",""]+-- ["CapitalStock","","100.0","","","","","","100.0"]+-- ["Sales","","50.0","","","","50.0","",""]+-- ["Subtotal","150.0","150.0","","","0.0","50.0","150.0","100.0"]+-- ["Net Income","","","","","50.0","","","50.0"]+-- ["Total","","","","","50.0","50.0","150.0","150.0"]+--+-- Complexity: O(s) (single pass per algebra over s scalar entries;+-- see @accountGrossTotals@).+worksheetRows :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => Alg n b -- ^ pre-adjustment ledger (決算整理前残高)+ -> Alg n b -- ^ adjustment entries (決算整理仕訳)+ -> [[T.Text]]+worksheetRows pre adj =+ header : sub : bodyRows ++ [tbTotalRow, netRow, grandTotalRow]+ where+ header =+ [ T.pack "Account Title"+ , T.pack "Trial Balance", T.empty+ , T.pack "Adjustments", T.empty+ , T.pack "Profit & Loss", T.empty+ , T.pack "Balance Sheet", T.empty ]+ sub =+ [ T.empty+ , T.pack "Debit", T.pack "Credit"+ , T.pack "Debit", T.pack "Credit"+ , T.pack "Debit", T.pack "Credit"+ , T.pack "Debit", T.pack "Credit" ]+ combined = pre .+ adj+ -- Single pass per algebra (O(s)): gross debit/credit totals per title.+ -- 'balanceOf t alg' = 'diffRL (projByAccountTitle t alg)' is reproduced as+ -- 'netGross' of the per-title gross pair; a title absent from a map (e.g.+ -- only in 'adj', not 'pre') yields '(0,0)', i.e. 'diffRL Zero = (Side, 0)'.+ preTotals = accountGrossTotals pre+ adjTotals = accountGrossTotals adj+ combTotals = accountGrossTotals combined+ grossOf m t = OMap.findWithDefault (zeroValue, zeroValue) t m+ -- Title enumeration: the keys of 'combined' in Ord order (= the previous+ -- 'L.sort . Set.toList . Set.fromList' over 'EA.toList combined').+ titles = OMap.keys combTotals+ -- per-title row + accumulate the four debit/credit column totals+ (bodyRows, tbD, tbC, plD, plC, bsD, bsC) =+ L.foldl' step ([], zeroValue, zeroValue, zeroValue, zeroValue, zeroValue, zeroValue) titles+ step (rows, tbd, tbc, pld, plc, bsd, bsc) t =+ let tb = netGross (grossOf preTotals t)+ (tbDc, tbCc) = sideCells tb+ adjPair = netGross (grossOf adjTotals t)+ (adjDc, adjCc) = sideCells adjPair+ finalPair@(finalSide, finalMag) = netGross (grossOf combTotals t)+ div' = classifyAccountDivision t+ isPL = div' == Cost || div' == Revenue+ (plDc, plCc, bsDc, bsCc)+ | isPL = let (d,c) = sideCells finalPair in (d, c, T.empty, T.empty)+ | otherwise = let (d,c) = sideCells finalPair in (T.empty, T.empty, d, c)+ row = [ tshow t, tbDc, tbCc, adjDc, adjCc, plDc, plCc, bsDc, bsCc ]+ -- column totals+ addCol side mag d c = case side of+ Debit -> (d + mag, c)+ Credit -> (d, c + mag)+ _ -> (d, c) -- wildcard v'Side' (balanced to zero): adds nothing+ (ntbd, ntbc) = let (s,m) = tb in addCol s m tbd tbc+ (npld, nplc) | isPL = addCol finalSide finalMag pld plc+ | otherwise = (pld, plc)+ (nbsd, nbsc) | not isPL = addCol finalSide finalMag bsd bsc+ | otherwise = (bsd, bsc)+ in (rows ++ [row], ntbd, ntbc, npld, nplc, nbsd, nbsc)+ -- trial-balance totals row (整理記入 totals are intentionally omitted: the+ -- adjustment column does not have a meaningful single grand total in the+ -- lecture's layout; the self-check is on P/L and B/S).+ tbTotalRow =+ [ T.pack "Subtotal"+ , tshow tbD, tshow tbC+ , T.empty, T.empty+ , tshow plD, tshow plC+ , tshow bsD, tshow bsC ]+ -- profit/loss balancing figure (当期純利益 / 純損失).+ -- P/L imbalance: if credit (revenue) side exceeds debit (cost) side, the+ -- difference is net income, recorded on the P/L debit side and the B/S+ -- credit side (it increases equity). The reverse is a net loss.+ plDiff = absDiff plD plC+ bsDiff = absDiff bsD bsC+ netProfit = plC > plD -- revenue side larger => profit+ (netLabel, netPlD, netPlC, netBsD, netBsC)+ | plC == plD = (T.pack "Net Income/Loss", T.empty, T.empty, T.empty, T.empty)+ | netProfit = (T.pack "Net Income", tshow plDiff, T.empty, T.empty, tshow bsDiff)+ | otherwise = (T.pack "Net Loss", T.empty, tshow plDiff, tshow bsDiff, T.empty)+ netRow =+ [ netLabel+ , T.empty, T.empty+ , T.empty, T.empty+ , netPlD, netPlC+ , netBsD, netBsC ]+ -- grand totals after adding the balancing figure (both sides now equal).+ plTotal = max plD plC+ bsTotal = max bsD bsC+ grandTotalRow =+ [ T.pack "Total"+ , T.empty, T.empty+ , T.empty, T.empty+ , tshow plTotal, tshow plTotal+ , tshow bsTotal, tshow bsTotal ]+ absDiff a b = if a >= b then a - b else b - a++-- | Output an 8-column worksheet (8 桁精算表) in CSV format.+-- Pure layout is delegated to 'worksheetRows'; this function only writes the+-- file. See 'worksheetRows' for the column structure and the self-check+-- (P\/L vs B\/S balancing figure) semantics.+--+-- Complexity: O(s) (single pass per algebra; see 'worksheetRows').+writeWorksheet :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => FilePath+ -> Alg n b -- ^ pre-adjustment ledger+ -> Alg n b -- ^ adjustment entries+ -> IO ()+writeWorksheet path pre adj = writeCSV path (worksheetRows pre adj)++-- | Build the rows of a post-closing trial balance (繰越試算表), as a pure+-- value (the part 'writePostClosingTrialBalance' renders to CSV).+--+-- After the closing transfers, only the /real/ (permanent) accounts remain on+-- the ledger. This lists the net balance of every 'Assets'\/'Liability'\/+-- 'Equity' account (the carried-forward balances, 次期繰越); 'Cost'\/'Revenue'+-- (nominal) accounts are __excluded by construction__, since they have been+-- closed out to the income summary. Layout:+--+-- > Debit | Account Title | Credit+--+-- The net balance per title is computed with an explicit @diffRL@ netting+-- (the same aggregation as 'writeCompoundTrialBalance'); no implicit @bar@.+-- The final row gives the debit and credit totals (which must agree).+--+-- Titles are listed in 'Ord' order for deterministic output.+--+-- ==== __Examples__+--+-- A ledger with cash, a loan (liability) and a sale (revenue). Only the real+-- accounts (Cash, LoansPayable) survive; the nominal Sales account is dropped.+--+-- >>> type T = Alg Double (HatBase AccountTitles)+-- >>> let led = (100 .@ Not:<Cash) .+ (40 .@ Hat:<Cash) .+ (60 .@ Not:<LoansPayable) .+ (100 .@ Not:<Sales) :: T+-- >>> mapM_ print (postClosingTrialBalanceRows led)+-- ["Debit","Account Title","Credit"]+-- ["60.0","Cash",""]+-- ["","LoansPayable","60.0"]+-- ["60.0","Total","60.0"]+--+-- Complexity: O(s) (single pass over s scalar entries; see @accountGrossTotals@).+postClosingTrialBalanceRows :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => Alg n b -> [[T.Text]]+postClosingTrialBalanceRows alg =+ header : bodyRows ++ [totalRow]+ where+ header = [T.pack "Debit", T.pack "Account Title", T.pack "Credit"]+ -- Single pass (O(s)): gross debit/credit totals per title, in Ord order;+ -- keep only real (Assets/Liability/Equity) titles.+ totals = accountGrossTotals alg+ titles = L.filter isReal (OMap.keys totals)+ isReal t = let d = classifyAccountDivision t+ in d == Assets || d == Liability || d == Equity+ (bodyRows, debitTotal, creditTotal) =+ L.foldl' step ([], zeroValue, zeroValue) titles+ step (rows, dt, ct) t =+ let (side, mag) = netGross (OMap.findWithDefault (zeroValue, zeroValue) t totals)+ (dCell, cCell) = sideCells (side, mag)+ (dt', ct') = case side of+ Debit | mag /= zeroValue -> (dt + mag, ct)+ Credit | mag /= zeroValue -> (dt, ct + mag)+ _ -> (dt, ct)+ row = [dCell, tshow t, cCell]+ in (rows ++ [row], dt', ct')+ totalRow = [tshow debitTotal, T.pack "Total", tshow creditTotal]++-- | Output a post-closing trial balance (繰越試算表) in CSV format.+-- Pure layout is delegated to 'postClosingTrialBalanceRows'; this function only+-- writes the file. Only 'Assets'\/'Liability'\/'Equity' (real) accounts appear;+-- 'Cost'\/'Revenue' accounts are excluded — see 'postClosingTrialBalanceRows'.+--+-- Complexity: O(s) (single pass; see 'postClosingTrialBalanceRows').+writePostClosingTrialBalance :: (HatVal n, HatBaseClass b, ExBaseClass b)+ => FilePath+ -> Alg n b+ -> IO ()+writePostClosingTrialBalance path alg =+ writeCSV path (postClosingTrialBalanceRows alg)++------------------------------------------------------------------ -- Write Functions for Simulation ------------------------------------------------------------------ @@ -252,15 +1012,15 @@ -- Outputs a slice of the specified term from a 3D array (term, row industry, column industry). -- -- Complexity: O(r * c) (r = number of rows, c = number of columns)-writeTermIO :: (HatVal n,BaseClass b, StateTime t, Ix b, Ix t, Enum b)+writeTermIO :: (HatVal n,BaseClass b, Ix t, Ix b, Enum b) => FilePath -> t -> IOArray (t, b, b) n -> IO () writeTermIO path t arr = do ((_, c1Min, c2Min), (_, c1Max, c2Max)) <- getBounds arr let rows = [c1Min .. c1Max] let cols = [c2Min .. c2Max] body <- forM rows $ \r -> do- vals <- forM cols $ \c -> tshow <$> readArray arr (t, r, c)- pure (tshow r : vals)+ cells <- forM cols $ \c -> tshow <$> readArray arr (t, r, c)+ pure (tshow r : cells) writeCSV path ((T.pack "" : L.map tshow cols) : body) -- | Output a 2D IOArray (Input-Output Table or ripple effect matrix) in CSV format.@@ -272,46 +1032,6 @@ let rows = [r1 .. r2] let cols = [c1 .. c2] body <- forM rows $ \r -> do- vals <- forM cols $ \c -> tshow <$> readArray arr (r, c)- pure (tshow r : vals)+ cells <- forM cols $ \c -> tshow <$> readArray arr (r, c)+ pure (tshow r : cells) writeCSV path ((T.pack "" : L.map tshow cols) : body)----------------------------------------------------------------------- Spill Restore Utilities----------------------------------------------------------------------- | Restore a complete Journal from spilled binary chunks and the current in-memory Journal.--- The in-memory portion is narrowed to only terms after the last spill range,--- so duplicate terms are not double-counted.------ Complexity: O(file size + number of chunks * union cost)-restoreJournalFromBinarySpill- :: ( Binary.Binary t- , Ord t- , Binary.Binary (EJ.Journal n v b)- , EJ.Note n- , HatVal v- , HatBaseClass b- )- => FilePath- -> (n -> t)- -> EJ.Journal n v b- -> IO (EJ.Journal n v b)-restoreJournalFromBinarySpill spillPath noteToTerm currentLedger = do- chunks <- readBinarySpillFile spillPath- let spilled = L.foldl' (\acc (_, j) -> acc .+ j) mempty chunks- latestEnd = L.foldl'- (\acc ((_, tEnd), _) ->- case acc of- Nothing -> Just tEnd- Just x -> Just (max x tEnd)- )- Nothing- chunks- remainder = case latestEnd of- Nothing -> currentLedger- Just tEnd ->- EJ.filterWithNote (\n _ -> noteToTerm n > tEnd) currentLedger- pure (spilled .+ remainder)--------------------------------------------------------------------
test/Spec.hs view
@@ -7,836 +7,6816 @@ module Main (main) where import ExchangeAlgebra.Journal-import qualified ExchangeAlgebra.Algebra as EA-import qualified ExchangeAlgebra.Algebra.Transfer as EAT-import qualified ExchangeAlgebra.Journal as EJ-import qualified ExchangeAlgebra.Journal.Transfer as EJT-import qualified ExchangeAlgebra.Simulate as ES-import ExchangeAlgebra.Simulate-import qualified ExchangeAlgebra.Write as EW-import ExchangeAlgebra.Write--import qualified Data.HashMap.Strict as HM-import qualified Data.Map.Strict as M-import qualified Data.List as L-import qualified Data.Text as T-import qualified Data.Text.IO as TIO-import Control.Monad (forM_)-import Control.Monad.ST-import Data.Array.ST-import Data.STRef-import System.Exit (exitFailure)-import System.IO (IOMode(WriteMode), withFile)-import System.Directory (removeFile)-import System.Random (StdGen, mkStdGen, randomR)-import Control.Monad (replicateM)-import Control.Monad.State (runState, state)---- ================================================================--- Unit test helpers--- ================================================================--eps :: Double-eps = 1e-9--assertEqual :: (Eq a, Show a) => String -> a -> a -> IO ()-assertEqual label expected actual- | expected == actual = putStrLn ("[PASS] " ++ label)- | otherwise = do- putStrLn ("[FAIL] " ++ label)- putStrLn (" expected: " ++ show expected)- putStrLn (" actual : " ++ show actual)- exitFailure--assertNear :: String -> Double -> Double -> IO ()-assertNear label expected actual- | abs (expected - actual) <= eps = putStrLn ("[PASS] " ++ label)- | otherwise = do- putStrLn ("[FAIL] " ++ label)- putStrLn (" expected: " ++ show expected)- putStrLn (" actual : " ++ show actual)- exitFailure---- ================================================================--- Existing pure algebra tests--- ================================================================--type TestAlg = EA.Alg Double (HatBase CountUnit)-type TestJournal = EJ.Journal String Double (HatBase CountUnit)-type AxisJournal = EJ.Journal (String, Int) Double (HatBase CountUnit)--algSample :: TestAlg-algSample =- (1 :@ (Hat :< Yen))- .+ (1 :@ (Not :< Amount))- .+ (2 :@ (Not :< Yen))- .+ (2 :@ (Hat :< Amount))- .+ (3 :@ (Hat :< Yen))--journalSample :: TestJournal-journalSample = EJ.fromList [x, y, z]- where- x = ((1 :@ (Hat :< Yen)) .+ (1 :@ (Not :< Amount))) .| "cat" :: TestJournal- y = ((2 :@ (Not :< Yen)) .+ (2 :@ (Hat :< Amount))) .| "dog" :: TestJournal- z = ((3 :@ (Hat :< Yen)) .+ (3 :@ (Not :< Amount))) .| "fish" :: TestJournal--testProjMultiPatternOnePass :: IO ()-testProjMultiPatternOnePass = do- let qs :: [HatBase CountUnit]- qs = [Hat :< Yen, HatNot :< Amount, Hat :< Yen]- expected = L.foldl' (\acc q -> acc .+ EA.proj [q] algSample) EA.Zero qs- actual = EA.proj qs algSample- assertEqual "Alg.proj multi-pattern preserves behavior" expected actual--testProjNormFastPath :: IO ()-testProjNormFastPath = do- let qs :: [HatBase CountUnit]- qs = [Hat :< Yen, HatNot :< Amount, Hat :< Yen]- expected = norm $ (.-) $ EA.proj qs algSample- actual = EA.projNorm qs algSample- assertNear "Alg.projNorm fast path matches existing semantics" expected actual--testProjWithBaseNorm :: IO ()-testProjWithBaseNorm = do- let bs :: [HatBase CountUnit]- bs = [Not :< Amount]- expected = norm $ EJ.projWithBase bs journalSample- actual = EJ.projWithBaseNorm bs journalSample- assertNear "Journal.projWithBaseNorm matches norm . projWithBase" expected actual--testProjWithNoteNorm :: IO ()-testProjWithNoteNorm = do- let bs :: [HatBase CountUnit]- bs = [HatNot :< Amount, Hat :< Yen]- ns1 = ["dog", "cat"]- ns2 = [plank]- expected1 = norm $ EJ.projWithNoteBase ns1 bs journalSample- actual1 = EJ.projWithNoteNorm ns1 bs journalSample- expected2 = norm $ EJ.projWithNoteBase ns2 bs journalSample- actual2 = EJ.projWithNoteNorm ns2 bs journalSample- 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---- | Regression test for the @union@ zero-singleton base-relabel bug--- (Algebra.hs). When one operand of @(.+)@ is a /zero-valued/ singleton on base--- @b1@ and the other a /real/ singleton on a different base @b2@, the result must--- keep the real value on its OWN base (@v2:@b2@), not relabel it onto the zero--- posting's base. The old code returned @v2:@b1@ / @v1:@b2@, which silently moved--- a value to the wrong base — preserving @norm@ but corrupting per-base projection.-testUnionZeroSingletonBase :: IO ()-testUnionZeroSingletonBase = do- let zb = 0 :@ (Hat :< Yen) :: TestAlg -- zero value, base Yen- rb = 5 :@ (Hat :< Amount) :: TestAlg -- real value, base Amount- assertEqual "union zero(.+)real keeps real value on its own base"- rb (EA.proj [Hat :< Amount] (zb .+ rb))- assertEqual "union real(.+)zero keeps real value on its own base"- rb (EA.proj [Hat :< Amount] (rb .+ zb))- assertEqual "union zero(.+)real: nothing relabeled onto the zero's base"- (EA.Zero :: TestAlg) (EA.proj [Hat :< Yen] (zb .+ rb))- assertEqual "union real(.+)zero: nothing relabeled onto the zero's base"- (EA.Zero :: TestAlg) (EA.proj [Hat :< Yen] (rb .+ zb))--testSigmaMergePath :: IO ()-testSigmaMergePath = do- let xs = [1 .. 5 :: Int]- f :: Int -> TestAlg- f i- | i == 3 = EA.Zero- | odd i = fromIntegral i :@ (Hat :< Yen)- | otherwise = fromIntegral i :@ (Not :< Amount)- expected :: TestAlg- expected = EA.unionsMerge (L.map f xs)- actual :: TestAlg- actual = EA.sigma xs f- assertEqual "Alg.sigma bulk-merge path matches unionsMerge" expected actual--testSigma2When :: IO ()-testSigma2When = do- let xs = [1 .. 3 :: Int]- ys = [1 .. 4 :: Int]- cond i j = i /= j && even (i + j)- f :: Int -> Int -> TestAlg- f i j =- let v = fromIntegral (i * 10 + j)- in if odd i- then v :@ (Hat :< Yen)- else v :@ (Not :< Amount)- expected :: TestAlg- expected =- EA.unionsMerge- [ f i j- | i <- xs- , j <- ys- , cond i j- ]- actual :: TestAlg- actual = EA.sigma2When xs ys cond f- assertEqual "Alg.sigma2When matches list-comprehension sum" expected actual--testSigmaFromMap :: IO ()-testSigmaFromMap = do- let kvs = M.fromList- [ ((1, 2), 5.0)- , ((2, 3), 0.0)- , ((3, 1), 7.0)- ] :: M.Map (Int, Int) Double- f :: (Int, Int) -> Double -> TestAlg- f (i, j) v- | i < j = v :@ (Hat :< Yen)- | otherwise = v :@ (Not :< Amount)- expected :: TestAlg- expected = EA.unionsMerge- [ f (1, 2) 5.0- , f (3, 1) 7.0- ]- actual :: TestAlg- actual = EA.sigmaFromMap kvs f- assertEqual "Alg.sigmaFromMap iterates non-zero map entries only" expected actual--testJournalSigmaMergePath :: IO ()-testJournalSigmaMergePath = do- let xs = [1 .. 4 :: Int]- f :: Int -> TestJournal- f i = case i of- 1 -> (1 :@ (Hat :< Yen)) .| "A"- 2 -> EJ.Zero- 3 -> (EA.Zero :: TestAlg) .| "A"- _ -> (2 :@ (Not :< Amount)) .| "B"- expected :: TestJournal- expected = EJ.fromMap $ HM.fromList- [ ("A", 1 :@ (Hat :< Yen))- , ("B", 2 :@ (Not :< Amount))- ]- actual = EJ.sigma xs f- assertEqual "Journal.sigma bulk-merge path skips zero postings" (EJ.toMap expected) (EJ.toMap actual)--testJournalSigma2When :: IO ()-testJournalSigma2When = do- let xs = [1 .. 3 :: Int]- ys = [1 .. 3 :: Int]- cond i j = i < j- f :: Int -> Int -> TestJournal- f i j- | i == 1 && j == 2 = (EA.Zero :: TestAlg) .| "N"- | odd (i + j) = (fromIntegral (i + j) :@ (Hat :< Yen)) .| "N"- | otherwise = EJ.Zero- expected :: TestJournal- expected = EJ.fromMap $ HM.fromList [("N", 5 :@ (Hat :< Yen))]- actual = EJ.sigma2When xs ys cond f- assertEqual "Journal.sigma2When matches filtered pair sum" (EJ.toMap expected) (EJ.toMap actual)--testJournalSigmaOn :: IO ()-testJournalSigmaOn = do- let xs = [1 .. 4 :: Int]- f :: Int -> TestAlg- f i- | i <= 2 = EA.Zero- | otherwise = fromIntegral i :@ (Hat :< Yen)- expected :: TestJournal- expected = (EA.sigma xs f) .| "SalesPurchase"- actual :: TestJournal- actual = EJ.sigmaOn "SalesPurchase" xs f- zeroExpected = EJ.Zero :: TestJournal- zeroActual = EJ.sigmaOn "SalesPurchase" xs (\_ -> EA.Zero :: TestAlg)- assertEqual "Journal.sigmaOn attaches note after EA.sigma" (EJ.toMap expected) (EJ.toMap actual)- assertEqual "Journal.sigmaOn returns Zero when EA.sigma is Zero" (EJ.toMap zeroExpected) (EJ.toMap zeroActual)--testJournalSigmaOnFromMap :: IO ()-testJournalSigmaOnFromMap = do- let kvs = M.fromList- [ ((1, 2), 4.0)- , ((2, 3), 0.0)- , ((2, 1), 6.0)- ] :: M.Map (Int, Int) Double- f :: (Int, Int) -> Double -> TestAlg- f (i, j) v- | i < j = v :@ (Hat :< Yen)- | otherwise = v :@ (Not :< Amount)- expected :: TestJournal- expected = (EA.sigmaFromMap kvs f) .| "SalesPurchase"- actual :: TestJournal- actual = EJ.sigmaOnFromMap "SalesPurchase" kvs f- zeroActual :: TestJournal- zeroActual = EJ.sigmaOnFromMap "SalesPurchase" (M.singleton (1, 1) 0.0) f- assertEqual "Journal.sigmaOnFromMap matches EA.sigmaFromMap + note" (EJ.toMap expected) (EJ.toMap actual)- assertEqual "Journal.sigmaOnFromMap returns Zero for empty-effective map" (EJ.toMap (EJ.Zero :: TestJournal)) (EJ.toMap zeroActual)--testFilterByAxisEquivalent :: IO ()-testFilterByAxisEquivalent = do- let ledger :: AxisJournal- ledger = EJ.fromList- [ (10 :@ (Hat :< Yen)) .| ("A", 1)- , (20 :@ (Not :< Amount)) .| ("B", 1)- , (30 :@ (Hat :< Yen)) .| ("A", 2)- ]- expected = EJ.filterWithNote (\(_, t') _ -> t' == 1) ledger- actual = EJ.filterByAxis 1 (EJ.NoteAxisKey (1 :: Int)) ledger- mismatch = EJ.filterByAxis 1 (EJ.NoteAxisKey ("1" :: String)) ledger- assertEqual "Journal.filterByAxis matches filterWithNote on axis=1"- (EJ.toMap expected)- (EJ.toMap actual)- assertEqual "Journal.filterByAxis type mismatch returns empty"- (EJ.toMap (EJ.Zero :: AxisJournal))- (EJ.toMap mismatch)--testFilterByAxisWithDeltaUpdates :: IO ()-testFilterByAxisWithDeltaUpdates = do- let base :: AxisJournal- base = EJ.fromMap $ HM.fromList- [ (("A", 1), 10 :@ (Hat :< Yen))- , (("C", 2), 5 :@ (Not :< Amount))- ]- rhs :: AxisJournal- rhs = EJ.fromMap $ HM.fromList- [ (("A", 1), 3 :@ (Not :< Amount))- , (("B", 1), 7 :@ (Hat :< Yen))- ]- ledger = base .+ rhs- expected = EJ.filterWithNote (\(_, t') _ -> t' == 1) ledger- actual = EJ.filterByAxis 1 (EJ.NoteAxisKey (1 :: Int)) ledger- assertEqual "Journal.filterByAxis works after append updates"- (EJ.toMap expected)- (EJ.toMap actual)---- ================================================================--- Transfer regression tests--- ================================================================--type TransferAlg = EA.Alg Double SimHatBase2-type TransferJournal = EJ.Journal String Double SimHatBase2--transferAlgSample :: TransferAlg-transferAlgSample = EA.fromList- [ 7 :@ Not :<(WageExpenditure, 1, 1, Yen)- , 3 :@ Hat :<(Depreciation, 2, 2, Yen)- , 11 :@ Not :<(Purchases, 3, 3, Yen)- , 13 :@ Not :<(ValueAdded, 1, 2, Yen)- , 17 :@ Hat :<(Sales, 2, 1, Yen)- , 19 :@ Not :<(InterestEarned, 4, 4, Yen)- , 23 :@ Hat :<(InterestExpense, 5, 5, Yen)- , 29 :@ Not :<(TaxesRevenue, 2, 2, Yen)- , 31 :@ Hat :<(TaxesExpense, 3, 3, Yen)- , 37 :@ Not :<(WageEarned, 6, 6, Yen)- , 41 :@ Hat :<(ConsumptionExpenditure, 6, 6, Yen)- , 43 :@ Not :<(CentralBankPaymentIncome, 1, 1, Yen)- , 47 :@ Hat :<(CentralBankPaymentExpense, 1, 1, Yen)- , 53 :@ Not :<(GrossProfit, 7, 7, Yen)- , 59 :@ Hat :<(OrdinaryProfit, 8, 8, Yen)- , 61 :@ Not :<(Cash, 1, 1, Yen)- ]--transferJournalSample :: TransferJournal-transferJournalSample = EJ.fromList- [ transferAlgSample .| "A"- , ((5 :@ Not :<(Sales, 2, 1, Yen)) .+ (2 :@ Hat :<(WageExpenditure, 1, 1, Yen))) .| "B"- , ((3 :@ Hat :<(TaxesExpense, 3, 3, Yen)) .+ (4 :@ Not :<(InterestEarned, 4, 4, Yen))) .| "C"- ]--testFinalStockTransferAlgEquivalence :: IO ()-testFinalStockTransferAlgEquivalence = do- let ref =- (.-)- . EAT.retainedEarningTransfer- . EAT.ordinaryProfitTransfer- . EAT.grossProfitTransfer- $ transferAlgSample- actual = EAT.finalStockTransfer transferAlgSample- assertEqual "Algebra.finalStockTransfer matches composed transfer" ref actual--testFinalStockTransferJournalEquivalence :: IO ()-testFinalStockTransferJournalEquivalence = do- let ref =- (.-)- . EJT.retainedEarningTransfer- . EJT.ordinaryProfitTransfer- . EJT.grossProfitTransfer- $ transferJournalSample- actual = EJT.finalStockTransfer transferJournalSample- assertEqual "Journal.finalStockTransfer matches composed transfer" (EJ.toMap ref) (EJ.toMap actual)---- A ledger whose Debit and Credit sides are equal (net income is zero).--- @diffRL@ reports the wildcard 'Side' here, which used to crash--- @incomeSummaryAccount@ with "Non-exhaustive patterns" (regression for 0.4.1.2).-balancedTransferAlg :: TransferAlg-balancedTransferAlg = EA.fromList- [ 5 :@ Not :<(Cash, 1, 1, Yen) -- Assets -> Debit side- , 5 :@ Not :<(Sales, 2, 1, Yen) -- Revenue -> Credit side- ]--balancedTransferJournal :: TransferJournal-balancedTransferJournal = balancedTransferAlg .| "balanced"---- | Regression: a balanced ledger (credit == debit) must not crash--- @incomeSummaryAccount@; it returns the input unchanged.-testIncomeSummaryBalancedAlg :: IO ()-testIncomeSummaryBalancedAlg = do- let actual = EAT.incomeSummaryAccount balancedTransferAlg- assertEqual- "Algebra.incomeSummaryAccount on balanced ledger returns input unchanged"- (EA.toList balancedTransferAlg)- (EA.toList actual)--testIncomeSummaryBalancedJournal :: IO ()-testIncomeSummaryBalancedJournal = do- let actual = EJT.incomeSummaryAccount balancedTransferJournal- assertEqual- "Journal.incomeSummaryAccount on balanced ledger returns input unchanged"- (EJ.toMap balancedTransferJournal)- (EJ.toMap actual)--type SpillRestoreJournal = EJ.Journal (String, Int) Double (HatBase CountUnit)--testRestoreJournalFromBinarySpill :: IO ()-testRestoreJournalFromBinarySpill = do- let spillPath = "/tmp/exchangealgebra_spill_restore_test.bin"- chunk1 :: SpillRestoreJournal- chunk1 = EJ.fromList- [ (1 :@ (Hat :< Yen)) .| ("A", 1)- , (2 :@ (Not :< Amount)) .| ("B", 2)- ]- chunk2 :: SpillRestoreJournal- chunk2 = (3 :@ (Hat :< Yen)) .| ("C", 3)- currentLedger :: SpillRestoreJournal- currentLedger = EJ.fromList- [ (4 :@ (Not :< Amount)) .| ("Tail", 4)- , (8 :@ (Hat :< Yen)) .| ("AlreadySpilled", 2)- ]- expected :: SpillRestoreJournal- expected = chunk1 .+ chunk2 .+ ((4 :@ (Not :< Amount)) .| ("Tail", 4))-- withFile spillPath WriteMode $ \h -> do- ES.defaultBinarySpillWriter h (1 :: Int, 2 :: Int) chunk1- ES.defaultBinarySpillWriter h (3 :: Int, 3 :: Int) chunk2-- actual <- restoreJournalFromBinarySpill spillPath snd currentLedger- assertEqual "Write.restoreJournalFromBinarySpill merges spill + tail remainder"- (EJ.toMap expected)- (EJ.toMap actual)---- ================================================================--- SimulateEx1 reproduction (default scenario only, no parallelism)--- ================================================================--type SimTerm = Int--instance StateTime SimTerm where- initTerm = 1- lastTerm = 100-data SimInitVar = SimInitVar- { _simInitStock :: Double- , _simSteadyProduction :: Double- , _simInhouseRatio :: Double- } deriving (Eq, Show)--instance InitVariables SimInitVar where--data SimEvent- = SimSalesPurchase- | SimProduction- | SimPlank- deriving (Ord, Show, Enum, Eq, Bounded, Generic)--instance Hashable SimEvent where--instance Note SimEvent where- plank = SimPlank--instance Event SimEvent where--type SimCompany = Int--instance Element SimCompany where- wiledcard = -1--instance BaseClass SimCompany where--simFstC, simLastC :: SimCompany-simFstC = 1-simLastC = 6--simCompanies :: [SimCompany]-simCompanies = [simFstC .. simLastC]--type SimHatBase2 = HatBase (AccountTitles, SimCompany, SimCompany, CountUnit)--instance ExBaseClass SimHatBase2 where- getAccountTitle (h :< (a, _, _, _)) = a- setAccountTitle (h :< (_, c, e, u)) b = h :< (b, c, e, u)--type SimTransaction = EJ.Journal (SimEvent, SimTerm) Double SimHatBase2--simCompressPreviousTerm :: SimTerm -> SimTransaction -> SimTransaction-simCompressPreviousTerm t le =- EJ.fromMap $- L.foldl' (\acc ev -> HM.adjust compress (ev, t) acc)- (EJ.toMap le)- [fstEvent .. lastEvent]--newtype SimLedger s = SimLedger (STRef s SimTransaction)--instance UpdatableSTRef SimLedger s SimTransaction where- _unwrapURef (SimLedger x) = x- _wrapURef x = SimLedger x--simInitLedger :: Double -> ST s (SimLedger s)-simInitLedger d = newURef $ EJ.fromList- [ d :@ Not :<(Products, e, e, Amount) .| (plank, initTerm)- | e <- simCompanies- ]--instance Updatable SimTerm SimInitVar SimLedger s where- type Inner SimLedger s = STRef s SimTransaction- unwrap = _unwrapURef- initialize _ _ e = simInitLedger (_simInitStock e)- updatePattern _ = return Modify- modify _ t _ x = do- le <- readURef x- let added = EJ.gather (plank, t)- $ EJT.finalStockTransfer- $ (.-) $ simTermJournal (t - 1) le- next = simCompressPreviousTerm (t - 1) (le .+ added)- writeURef x next--type SimInputCoefficient = Double--newtype SimICTable s = SimICTable (STArray s (SimCompany, SimCompany) SimInputCoefficient)--instance UpdatableSTArray SimICTable s (SimCompany, SimCompany) SimInputCoefficient where- _unwrapUArray (SimICTable arr) = arr- _wrapUArray arr = SimICTable arr--simGenerateRandomList :: StdGen -> Int -> ([Double], StdGen)-simGenerateRandomList g n =- let (xs, g') = runState (replicateM n (state (randomR (0, 1.0))))- (updateGen g 1000)- ys = L.map (\v -> if v < 0.1 then 0 else v) xs- in (ys, g')--simInitTermCoefficients :: StdGen -> Double -> M.Map SimCompany [SimInputCoefficient]-simInitTermCoefficients g inhouseRatio =- fst $ L.foldl' buildRow (M.empty, g) simCompanies- where- buildRow (acc, g0) c2 =- let (row, g1) = generateRow g0- in (M.insert c2 row acc, g1)- generateRow g0 =- let (vals, g1) = simGenerateRandomList g0 simLastC- total = sum vals- normalized = L.map (\v -> (v / total) * inhouseRatio) vals- in (normalized, g1)--simInitICTables :: StdGen -> Double -> ST s (SimICTable s)-simInitICTables g inhouseRatio = do- arr <- newUArray ((simFstC, simFstC), (simLastC, simLastC)) 0- let termCoefficients = simInitTermCoefficients g inhouseRatio- forM_ simCompanies $ \c2 -> do- let row = termCoefficients M.! c2- forM_ (zip simCompanies row) $ \(c1, coef) ->- writeUArray arr (c1, c2) coef- return arr--instance Updatable SimTerm SimInitVar SimICTable s where- type Inner SimICTable s = STArray s (SimCompany, SimCompany) SimInputCoefficient- unwrap (SimICTable a) = a- initialize g _ e = simInitICTables g (_simInhouseRatio e)- updatePattern _ = return DoNothing--type SimSteadyProd = Double--newtype SimSP s = SimSP (STRef s SimSteadyProd)--instance UpdatableSTRef SimSP s SimSteadyProd where- _unwrapURef (SimSP x) = x- _wrapURef x = SimSP x--instance Updatable SimTerm SimInitVar SimSP s where- type Inner SimSP s = STRef s SimSteadyProd- unwrap = _unwrapURef- initialize _ _ e = newURef (_simSteadyProduction e)- updatePattern _ = return DoNothing--data SimWorld s = SimWorld- { _simLedger :: SimLedger s- , _simIcs :: SimICTable s- , _simSp :: SimSP s- } deriving (Generic)---- helper functions--simTermJournal :: SimTerm -> SimTransaction -> SimTransaction-simTermJournal t = EJ.filterWithNote (\(_, t') _ -> t' == t)--simGetOneProduction :: SimWorld s -> SimTerm -> SimCompany -> ST s SimTransaction-simGetOneProduction wld t c = do- let arr = _simIcs wld- inputs <- mapM (\c2 -> do- coef <- readUArray arr (c2, c)- return $ coef :@ Hat :<(Products, c2, c, Amount) .| (SimProduction, t)- ) simCompanies- let totalInput = EJ.fromList inputs- result = (1 :@ Not :<(Products, c, c, Amount) .| (SimProduction, t)) .+ totalInput- return result--simJournal :: SimWorld s -> SimTransaction -> ST s ()-simJournal _ Zero = return ()-simJournal wld js = modifyURef (_simLedger wld) (\x -> x .+ js)--simBuildShortageMap :: SimTerm -> SimTransaction -> M.Map (SimCompany, SimCompany) Double-simBuildShortageMap t le =- let termAlg = EJ.toAlg $ (.-) $ simTermJournal t le- in L.foldl' go M.empty (EA.toList termAlg)- where- go acc (v :@ (Hat :< (Products, j, i, Amount))) = M.insertWith (+) (i, j) v acc- go acc _ = acc--simPurchases :: SimTerm -> SimWorld s -> ST s SimTransaction-simPurchases t wld = do- le <- readURef (_simLedger wld)- let shortageMap = simBuildShortageMap t le- o i j = M.findWithDefault 0 (i, j) shortageMap- return $ sigma simCompanies $ \i- -> sigma (simCompanies L.\\ [i]) $ \j- -> (o i j) :@ Not :<(Products, j, i, Amount)- .+ (o i j) :@ Hat :<(Cash, (.#), i, Yen)- .+ (o i j) :@ Not :<(Purchases, (.#), i, Yen)- .+ (o i j) :@ Not :<(Cash, (.#), j, Yen)- .+ (o i j) :@ Not :<(Sales, (.#), j, Yen)- .+ (o i j) :@ Hat :<(Products, j, j, Amount)- .| (SimSalesPurchase, t)--instance StateSpace SimTerm SimInitVar SimEvent SimWorld s where- event = simEvent--simEvent :: SimWorld s -> SimTerm -> SimEvent -> ST s ()--simEvent wld t SimSalesPurchase = do- toAdd <- simPurchases t wld- simJournal wld toAdd--simEvent wld t SimProduction = do- sp <- readURef (_simSp wld)- forM_ simCompanies $ \e1 -> do- op <- simGetOneProduction wld t e1- simJournal wld (sp .* op)--simEvent _ _ SimPlank = return ()--simGetTermStock :: SimWorld s -> SimTerm -> SimCompany -> ST s Double-simGetTermStock wld t e = do- le <- readURef (_simLedger wld)- let tj = (.-) $ simTermJournal t le- plusStock = norm $ EJ.projWithBase [Not :<(Products, e, e, Amount)] tj- minusStock = norm $ EJ.projWithBase [Hat :<(Products, e, e, Amount)] tj- return $ plusStock - minusStock--simGetTermGrossProfit :: SimWorld s -> SimTerm -> SimCompany -> ST s Double-simGetTermGrossProfit wld t e = do- le <- readURef (_simLedger wld)- let termTr = simTermJournal t le- tr = EJT.grossProfitTransfer termTr- plus = norm $ EJ.projWithBase [Not :<(GrossProfit, (.#), e, Yen)] tr- minus = norm $ EJ.projWithBase [Hat :<(GrossProfit, (.#), e, Yen)] tr- return (plus - minus)---- ================================================================--- Simulation integration test--- ================================================================--simEps :: Double-simEps = 1e-6--assertSimNear :: String -> Double -> Double -> IO ()-assertSimNear label expected actual- | abs (expected - actual) <= simEps = putStrLn ("[PASS] " ++ label)- | otherwise = do- putStrLn ("[FAIL] " ++ label)- putStrLn (" expected: " ++ show expected)- putStrLn (" actual : " ++ show actual)- exitFailure--testSimulateEx1Default :: IO ()-testSimulateEx1Default = do- let gen = mkStdGen 2025- defaultEnv = SimInitVar- { _simInitStock = 20- , _simInhouseRatio = 0.4- , _simSteadyProduction = 10- }-- wld <- ES.runSimulation gen defaultEnv-- -- Stock at term 1 for each company- stocks1 <- stToIO $ mapM (simGetTermStock wld 1) simCompanies- -- Stock at term 50 for each company- stocks50 <- stToIO $ mapM (simGetTermStock wld 50) simCompanies- -- Stock at term 100 for each company- stocks100 <- stToIO $ mapM (simGetTermStock wld 100) simCompanies- -- Gross profit at term 50 for each company- profits50 <- stToIO $ mapM (simGetTermGrossProfit wld 50) simCompanies-- -- Stock at t=1- assertSimNear "sim1 stock(t=1,c=1)" 28.487224703666264 (stocks1 !! 0)- assertSimNear "sim1 stock(t=1,c=3)" 30.0 (stocks1 !! 2)- assertSimNear "sim1 stock(t=1,c=6)" 30.0 (stocks1 !! 5) -- re-baselined: union zero-base fix removed a phantom self-input- -- Stock at t=50- assertSimNear "sim1 stock(t=50,c=1)" 304.9028131162567 (stocks50 !! 0)- assertSimNear "sim1 stock(t=50,c=4)" 292.4764622201871 (stocks50 !! 3)- -- Stock at t=100- assertSimNear "sim1 stock(t=100,c=1)" 586.9595359862476 (stocks100 !! 0)- assertSimNear "sim1 stock(t=100,c=6)" 767.960563480499 (stocks100 !! 5) -- re-baselined: union zero-base fix (bug compounded over terms)- -- Gross profit at t=50- assertSimNear "sim1 profit(t=50,c=1)" 0.35886554260018855 (profits50 !! 0)- assertSimNear "sim1 profit(t=50,c=2)" 1.572544209772035 (profits50 !! 1)---- ================================================================--- CSV Write tests--- ================================================================--testCsvTranspose :: IO ()-testCsvTranspose = do- -- Square matrix- let input1 = [ [T.pack "a", T.pack "b"]- , [T.pack "c", T.pack "d"] ]- expected1 = [ [T.pack "a", T.pack "c"]- , [T.pack "b", T.pack "d"] ]- assertEqual "CSV.transpose square matrix" expected1 (EW.csvTranspose input1)-- -- Ragged matrix (shorter rows padded with empty)- let input2 = [ [T.pack "a", T.pack "b", T.pack "c"]- , [T.pack "d"] ]- expected2 = [ [T.pack "a", T.pack "d"]- , [T.pack "b", T.empty]- , [T.pack "c", T.empty] ]- assertEqual "CSV.transpose ragged matrix" expected2 (EW.csvTranspose input2)-- -- Single row- let input3 = [[T.pack "x", T.pack "y", T.pack "z"]]- expected3 = [[T.pack "x"], [T.pack "y"], [T.pack "z"]]- assertEqual "CSV.transpose single row" expected3 (EW.csvTranspose input3)-- -- Empty- assertEqual "CSV.transpose empty" ([] :: [[T.Text]]) (EW.csvTranspose [])--testCsvWriteCSV :: IO ()-testCsvWriteCSV = do- let path = "/tmp/exchangealgebra_csv_test.csv"- input = [ [T.pack "Name", T.pack "Value"]- , [T.pack "Alice", T.pack "100"]- , [T.pack "Bob", T.pack "200"] ]- EW.writeCSV path input- raw <- readFileStrict path- -- Each cell should be quoted- let lns = lines raw- assertEqual "CSV writeCSV line count" 3 (length lns)- assertEqual "CSV writeCSV header" "\"Name\",\"Value\"" (lns !! 0)- assertEqual "CSV writeCSV row 1" "\"Alice\",\"100\"" (lns !! 1)- assertEqual "CSV writeCSV row 2" "\"Bob\",\"200\"" (lns !! 2)- removeFile path--testCsvWriteCSVWithQuotes :: IO ()-testCsvWriteCSVWithQuotes = do- let path = "/tmp/exchangealgebra_csv_quote_test.csv"- input = [[T.pack "say \"hello\"", T.pack "a,b"]]- EW.writeCSV path input- raw <- readFileStrict path- let lns = lines raw- -- Internal quotes should be escaped as ""- assertEqual "CSV writeCSV escapes quotes" "\"say \"\"hello\"\"\",\"a,b\"" (lns !! 0)- removeFile path--testCsvWriteCSVEmpty :: IO ()-testCsvWriteCSVEmpty = do- let path = "/tmp/exchangealgebra_csv_empty_test.csv"- input = [[T.pack "", T.pack "x"]]- EW.writeCSV path input- raw <- readFileStrict path- let lns = lines raw- 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- bs <- TIO.readFile p- return (T.unpack bs)---- ================================================================--- Main--- ================================================================--main :: IO ()-main = do- testProjMultiPatternOnePass- testProjNormFastPath- testProjWithBaseNorm- testProjWithNoteNorm- testBasesNotSideRegression- testNumericToleranceScaleAware- testUnionZeroSingletonBase- testSigmaMergePath- testSigma2When- testSigmaFromMap- testJournalSigmaMergePath- testJournalSigma2When- testJournalSigmaOn- testJournalSigmaOnFromMap- testFilterByAxisEquivalent- testFilterByAxisWithDeltaUpdates- testFinalStockTransferAlgEquivalence- testFinalStockTransferJournalEquivalence- testIncomeSummaryBalancedAlg- testIncomeSummaryBalancedJournal- testRestoreJournalFromBinarySpill- testSimulateEx1Default- testCsvTranspose- testCsvWriteCSV- testCsvWriteCSVWithQuotes- testCsvWriteCSVEmpty+import qualified ExchangeAlgebra.Convert as EC+import qualified ExchangeAlgebra.Convert.Checked as ECC+import qualified ExchangeAlgebra.Accounting.PostingPolicy as PP+import qualified ExchangeAlgebra.Consolidation.Worksheet as CW+import qualified ExchangeAlgebra.TrialBalance.Balance as TBB+import qualified ExchangeAlgebra.TrialBalance.Validation as TB+import qualified ExchangeAlgebra.Reporting.Presentation as RP+import qualified ExchangeAlgebra.Reporting.Metric as RM+import qualified ExchangeAlgebra.Reporting.Group as RG+import qualified ExchangeAlgebra.Convert.Csv as ECsv+import qualified ExchangeAlgebra.Assist as Assist+import qualified ExchangeAlgebra.Assist.Descriptions as AssistDesc+import qualified ExchangeAlgebra.Algebra.Base.Account.Registry as Registry+import qualified ExchangeAlgebra.Algebra as EA+import qualified ExchangeAlgebra.Algebra.Internal as EAI+import qualified ExchangeAlgebra.Algebra.Transfer as EAT+import qualified ExchangeAlgebra.Journal as EJ+import qualified ExchangeAlgebra.Journal.Transfer as EJT+import qualified ExchangeAlgebra.Bookkeeping as EB+import ExchangeAlgebra.Value (MoneyDecimal, bankersRound)+import qualified ExchangeAlgebra.Simulate as ES+import ExchangeAlgebra.Simulate+import qualified ExchangeAlgebra.Simulate.Lite as Lite+import ExchangeAlgebra.Simulate.Network+ ( TradeNetwork, InputCoefficients, NetworkError(..)+ , tradeNetwork, inputCoefficients+ , nodes, edges, suppliersOf, buyersOf, edgeCount+ , coefficient, inputsOf, sigmaEdges+ , completeNetwork, kRegular, erdosRenyi, scaleFree, sectorBlock+ , IndustrialEconomy(..), IndustrialOptions(..)+ , defaultIndustrialOptions, industrialNetwork, industrialNetworkWith+ , firms, industrialEdges+ , TaxRate(..), taxOf, IndustrialFlows(..)+ , FlowOptions(..), industrialFlows, industrialFlowsWith+ , CoefOptions(..), defaultCoefOptions, randomCoefficients+ , networkFromTable, coefficientsFromTable, fromCoefficientMatrix+ , parseEdgeCsv, parseCoefCsv )+import ExchangeAlgebra.Simulate.Lite+ ( InitT, RefT, SnapT, HK+ , Field(..), carry, resetEach, updateEach+ , Stage, stage, stageFor, stageOf+ , Par(..), SimSpec, mkSimSpec, runLite, runLiteWithPolicy )+import qualified ExchangeAlgebra.Simulate.Policy as Policy+import ExchangeAlgebra.Value (MoneyDouble)+import qualified ExchangeAlgebra.Write as EW+import ExchangeAlgebra.Write+import qualified ExchangeAlgebra.Optimize as O+import qualified ExchangeAlgebra.Optimize.Annealing as OA+import qualified ExchangeAlgebra.Optimize.GA as OG+import qualified Data.Vector.Unboxed as UV++import qualified Data.HashMap.Strict as HM+import qualified Data.Map.Strict as M+import qualified Data.List as L+import qualified Data.List.NonEmpty as NE+import qualified Data.Set as Set+import Data.Char (isAlpha, isAlphaNum, isAscii, isSpace)+import qualified Data.Binary as Binary+import qualified Data.Binary.Put as BinaryPut+import qualified Data.ByteString.Lazy as BL+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Numeric (showHex)+import Control.Monad (forM_)+import Control.Monad.ST+import Data.Array.ST+import Data.STRef+import System.Exit (exitFailure)+import System.IO (IOMode(WriteMode), withFile)+import Data.Time (Day, TimeOfDay(..), fromGregorian)+import System.Directory (removeFile)+import System.Random (StdGen, mkStdGen, randomR, split)+import Control.Monad (replicateM)+import Control.Monad.State (runState, state)+import Control.Exception (try, evaluate, ErrorCall, SomeException)+import Control.DeepSeq (force)+import Test.QuickCheck hiding (Fixed)+import GHC.Generics (Generic)+import System.Random (randomR)++-- ================================================================+-- Unit test helpers+-- ================================================================++eps :: Double+eps = 1e-9++assertEqual :: (Eq a, Show a) => String -> a -> a -> IO ()+assertEqual label expected actual+ | expected == actual = putStrLn ("[PASS] " ++ label)+ | otherwise = do+ putStrLn ("[FAIL] " ++ label)+ putStrLn (" expected: " ++ show expected)+ putStrLn (" actual : " ++ show actual)+ exitFailure++assertNear :: String -> Double -> Double -> IO ()+assertNear label expected actual+ | abs (expected - actual) <= eps = putStrLn ("[PASS] " ++ label)+ | otherwise = do+ putStrLn ("[FAIL] " ++ label)+ putStrLn (" expected: " ++ show expected)+ putStrLn (" actual : " ++ show actual)+ exitFailure++decodeAccountTitleOrFail :: BL.ByteString -> Either String AccountTitles+decodeAccountTitleOrFail bytes = case Binary.decodeOrFail bytes of+ Left (_, _, message) -> Left message+ Right (_, _, title) -> Right title++testAccountTitlesBinary :: IO ()+testAccountTitlesBinary = do+ let titles = [minBound .. maxBound] :: [AccountTitles]+ roundTripped = L.map (Binary.decode . Binary.encode) titles+ invalidTag = fromIntegral (fromEnum (maxBound :: AccountTitles) + 1)+ invalidBytes = BinaryPut.runPut (BinaryPut.putWord16be invalidTag)+ assertEqual "AccountTitles Binary covers all 236 constructors"+ 236 (L.length titles)+ assertEqual "AccountTitles Binary Word16be roundtrip"+ titles roundTripped+ assertEqual "AccountTitles Binary rejects out-of-range Word16"+ True (case decodeAccountTitleOrFail invalidBytes of+ Left _ -> True+ Right _ -> False)++-- ================================================================+-- AccountTitles classification exhaustiveness (Phase A)+-- ================================================================+--+-- Pins the (whatDiv, whichSide, fixedCurrent) classification of every+-- AccountTitles constructor against an explicit expected table that encodes+-- the Phase A design table. Any new constructor that is not added here makes+-- the test fail (the [minBound .. maxBound] traversal will hit a title absent+-- from the table), forcing the table to be kept in sync and guarding against+-- classifyAccountDivision's wildcard silently classifying a title as Assets.+--+-- whichSide is evaluated on the @Not :< title@ base (no Hat reversal), so it+-- equals the "home side" implied by whatDiv: Debit for Assets/Cost,+-- Credit for Liability/Equity/Revenue.++-- | Expected classification for every non-wildcard AccountTitles constructor.+-- (title, expected whatDiv, expected whichSide on Not-base, expected fixedCurrent)+accountTitleClassTable :: [(AccountTitles, AccountDivision, Side, FixedCurrent)]+accountTitleClassTable =+ -- Pre-existing titles+ [ (Cash, Assets, Debit, Current)+ , (Deposits, Assets, Debit, Current)+ , (CurrentDeposits, Assets, Debit, Current)+ , (Securities, Assets, Debit, Current)+ , (InvestmentSecurities, Assets, Debit, Fixed)+ , (LongTermNationalBonds, Assets, Debit, Fixed)+ , (ShortTermNationalBonds, Assets, Debit, Current)+ , (Products, Assets, Debit, Current)+ , (Machinery, Assets, Debit, Fixed)+ , (Building, Assets, Debit, Fixed)+ , (Vehicle, Assets, Debit, Fixed)+ , (StockInvestment, Assets, Debit, Other)+ , (EquipmentInvestment, Assets, Debit, Fixed)+ , (LongTermLoansReceivable, Assets, Debit, Fixed)+ , (AccountsReceivable, Assets, Debit, Current)+ , (ShortTermLoansReceivable, Assets, Debit, Current)+ , (ReserveDepositReceivable, Assets, Debit, Current)+ , (Gold, Assets, Debit, Fixed)+ , (GovernmentService, Assets, Debit, Current)+ , (CapitalStock, Equity, Credit, Other)+ , (RetainedEarnings, Equity, Credit, Other)+ , (LongTermLoansPayable, Liability, Credit, Fixed)+ , (ShortTermLoansPayable, Liability, Credit, Current)+ , (LoansPayable, Liability, Credit, Current)+ , (ReserveForDepreciation, Liability, Credit, Current)+ , (DepositPayable, Liability, Credit, Current)+ , (LongTermNationalBondsPayable, Liability, Credit, Fixed)+ , (ShortTermNationalBondsPayable, Liability, Credit, Current)+ , (ReserveDepositPayable, Liability, Credit, Current)+ , (CentralBankNotePayable, Liability, Credit, Current)+ , (Depreciation, Cost, Debit, Other)+ , (AmortizationExpense, Cost, Debit, Other)+ , (SalesCost, Cost, Debit, Other)+ , (BusinessTrip, Cost, Debit, Other)+ , (Commutation, Cost, Debit, Other)+ , (UtilitiesExpense, Cost, Debit, Other)+ , (RentExpense, Cost, Debit, Other)+ , (AdvertisingExpense, Cost, Debit, Other)+ , (DeliveryExpenses, Cost, Debit, Other)+ , (SuppliesExpenses, Cost, Debit, Other)+ , (MiscellaneousExpenses, Cost, Debit, Other)+ , (WageExpenditure, Cost, Debit, Other)+ , (InterestExpense, Cost, Debit, Other)+ , (TaxesExpense, Cost, Debit, Other)+ , (ConsumptionExpenditure, Cost, Debit, Other)+ , (SubsidyExpense, Cost, Debit, Other)+ , (CentralBankPaymentExpense, Cost, Debit, Other)+ , (Purchases, Cost, Debit, Other)+ , (NetIncome, Cost, Debit, Other)+ , (ValueAdded, Revenue, Credit, Other)+ , (SubsidyIncome, Revenue, Credit, Other)+ , (NationalBondInterestEarned, Revenue, Credit, Other)+ , (DepositInterestEarned, Revenue, Credit, Other)+ , (GrossProfit, Revenue, Credit, Other)+ , (OrdinaryProfit, Revenue, Credit, Other)+ , (InterestEarned, Revenue, Credit, Other)+ , (ReceiptFee, Revenue, Credit, Other)+ , (RentalIncome, Revenue, Credit, Other)+ , (WageEarned, Revenue, Credit, Other)+ , (TaxesRevenue, Revenue, Credit, Other)+ , (CentralBankPaymentIncome, Revenue, Credit, Other)+ , (Sales, Revenue, Credit, Other)+ , (NetLoss, Revenue, Credit, Other)+ -- Phase A additions: Assets (資産)+ , (PettyCash, Assets, Debit, Current)+ , (NotesReceivable, Assets, Debit, Current)+ , (ElectronicallyRecordedReceivable, Assets, Debit, Current)+ , (CreditCardReceivable, Assets, Debit, Current)+ , (NotesLoansReceivable, Assets, Debit, Current)+ , (MerchandiseInventory, Assets, Debit, Current)+ , (AdvancesPaid, Assets, Debit, Current)+ , (PrepaidExpenses, Assets, Debit, Current)+ , (AccruedRevenue, Assets, Debit, Current)+ , (OtherReceivables, Assets, Debit, Current)+ , (PaymentsOnBehalf, Assets, Debit, Current)+ , (SuspensePayments, Assets, Debit, Current)+ , (ConsumptionTaxPaid, Assets, Debit, Current)+ , (PrepaidCorporateIncomeTaxes, Assets, Debit, Current)+ , (Land, Assets, Debit, Fixed)+ , (Fixtures, Assets, Debit, Fixed)+ , (Patent, Assets, Debit, Fixed)+ , (Trademark, Assets, Debit, Fixed)+ , (Software, Assets, Debit, Fixed)+ , (CashOverShort, Assets, Debit, Other)+ -- Phase A additions: Liability (負債)+ , (AccountsPayable, Liability, Credit, Current)+ , (NotesPayable, Liability, Credit, Current)+ , (ElectronicallyRecordedObligations, Liability, Credit, Current)+ , (NotesLoansPayable, Liability, Credit, Current)+ , (BankOverdraft, Liability, Credit, Current)+ , (AdvancesReceived, Liability, Credit, Current)+ , (UnearnedRevenue, Liability, Credit, Current)+ , (AccruedExpenses, Liability, Credit, Current)+ , (OtherPayables, Liability, Credit, Current)+ , (DepositsReceived, Liability, Credit, Current)+ , (SuspenseReceipts, Liability, Credit, Current)+ , (ConsumptionTaxReceived, Liability, Credit, Current)+ , (AccruedConsumptionTax, Liability, Credit, Current)+ , (AccruedCorporateIncomeTaxes, Liability, Credit, Current)+ , (UnpaidDividends, Liability, Credit, Current)+ , (AllowanceForDoubtfulAccounts, Assets, Credit, Current) -- contra asset (isContra)+ , (AccumulatedDepreciation, Assets, Credit, Fixed) -- contra asset (isContra)+ -- Phase A additions: Equity (資本)+ , (LegalRetainedEarnings, Equity, Credit, Other)+ -- Phase A additions: Cost (費用)+ , (ProvisionForDoubtfulAccounts, Cost, Debit, Other)+ , (BadDebtLoss, Cost, Debit, Other)+ , (LossOnSalesOfFixedAssets, Cost, Debit, Other)+ , (LossOnSalesOfNotesReceivable, Cost, Debit, Other)+ , (PaymentFees, Cost, Debit, Other)+ , (MiscellaneousLoss, Cost, Debit, Other)+ , (CorporateIncomeTaxes, Cost, Debit, Other)+ , (CommunicationExpenses, Cost, Debit, Other)+ -- Phase A additions: Revenue (収益)+ , (GainOnSalesOfFixedAssets, Revenue, Credit, Other)+ , (RecoveryOfBadDebts, Revenue, Credit, Other)+ , (MiscellaneousIncome, Revenue, Credit, Other)+ -- Phase B addition: Revenue (収益)+ , (ReversalOfAllowanceForDoubtfulAccounts, Revenue, Credit, Other)+ -- T4b additions: equity-method accounts+ , (InvestmentInAssociate, Assets, Debit, Fixed)+ , (EquityInEarningsOfInvestee, Revenue, Credit, Other)+ -- FX library additions: OCI/capital accounts+ , (CumulativeTranslationAdjustment, Equity, Credit, Other)+ -- V-Land 2 additions+ , (TimeDeposits, Assets, Debit, Current)+ , (LoansReceivable, Assets, Debit, Current)+ , (GiftCertificatesReceived, Assets, Debit, Current)+ , (SecurityDepositsPaid, Assets, Debit, Fixed)+ , (SuppliesOnHand, Assets, Debit, Current)+ , (ContractAssets, Assets, Debit, Current)+ , (IncomeTaxesRefundReceivable, Assets, Debit, Current)+ , (WorkInProcess, Assets, Debit, Current)+ , (DeferredTaxAssets, Assets, Debit, Fixed)+ , (LeasedAssets, Assets, Debit, Fixed)+ , (ToolsAndInstruments, Assets, Debit, Fixed)+ , (ConstructionInProgress, Assets, Debit, Fixed)+ , (Goodwill, Assets, Debit, Fixed)+ , (SoftwareInProgress, Assets, Debit, Fixed)+ , (LongTermPrepaidExpenses, Assets, Debit, Fixed)+ , (DishonoredNotesReceivable, Assets, Debit, Current)+ , (PrepaidPensionCost, Assets, Debit, Fixed)+ , (NetDefinedBenefitAsset, Assets, Debit, Fixed)+ , (DepositsInSpecialAccounts, Assets, Debit, Current)+ , (Structures, Assets, Debit, Fixed)+ , (LeaseholdRights, Assets, Debit, Fixed)+ , (NonOperatingNotesReceivable, Assets, Debit, Current)+ , (NonOperatingElectronicallyRecordedReceivable, Assets, Debit, Current)+ , (RefundLiabilities, Liability, Credit, Current)+ , (NonOperatingNotesPayable, Liability, Credit, Current)+ , (NonOperatingElectronicallyRecordedObligations, Liability, Credit, Current)+ , (BonusesPayable, Liability, Credit, Current)+ , (AllowanceForRepairs, Liability, Credit, Current)+ , (AllowanceForProductWarranties, Liability, Credit, Current)+ , (AllowanceForBonuses, Liability, Credit, Current)+ , (DeferredTaxLiabilities, Liability, Credit, Fixed)+ , (LeaseObligations, Liability, Credit, Fixed)+ , (GuaranteeDepositsReceived, Liability, Credit, Fixed)+ , (AllowanceForRetirementBenefits, Liability, Credit, Fixed)+ , (LongTermOtherPayables, Liability, Credit, Fixed)+ , (NetDefinedBenefitLiability, Liability, Credit, Fixed)+ , (StockSubscriptionDeposits, Equity, Credit, Other)+ , (LegalCapitalSurplus, Equity, Credit, Other)+ , (OtherCapitalSurplus, Equity, Credit, Other)+ , (DividendEqualizationReserve, Equity, Credit, Other)+ , (RepairFundReserve, Equity, Credit, Other)+ , (ConstructionFundReserve, Equity, Credit, Other)+ , (GeneralReserve, Equity, Credit, Other)+ , (ValuationDifferenceOnOtherSecurities, Equity, Credit, Other)+ , (NonControllingInterests, Equity, Credit, Other)+ , (CapitalSurplus, Equity, Credit, Other)+ , (EarnedSurplus, Equity, Credit, Other)+ , (ServiceRevenue, Revenue, Credit, Other)+ , (OperatingRevenue, Revenue, Credit, Other)+ , (GainOnSalesOfSecurities, Revenue, Credit, Other)+ , (GainOnValuationOfSecurities, Revenue, Credit, Other)+ , (DividendsReceived, Revenue, Credit, Other)+ , (InterestOnSecurities, Revenue, Credit, Other)+ , (GainOnSalesOfInvestmentSecurities, Revenue, Credit, Other)+ , (InsuranceGain, Revenue, Credit, Other)+ , (GainOnBargainPurchase, Revenue, Credit, Other)+ , (ReversalOfAllowanceForRepairs, Revenue, Credit, Other)+ , (ReversalOfAllowanceForProductWarranties, Revenue, Credit, Other)+ , (GainOnDonationOfFixedAssets, Revenue, Credit, Other)+ , (GainOnNationalSubsidies, Revenue, Credit, Other)+ , (GainOnConstructionGrants, Revenue, Credit, Other)+ , (LandRentReceived, Revenue, Credit, Other)+ , (SalesRebates, Revenue, Debit, Other)+ , (CostOfServices, Cost, Debit, Other)+ , (OperatingExpenses, Cost, Debit, Other)+ , (InventoryShrinkageLoss, Cost, Debit, Other)+ , (LossOnValuationOfMerchandise, Cost, Debit, Other)+ , (Bonuses, Cost, Debit, Other)+ , (RetirementBenefitExpenses, Cost, Debit, Other)+ , (ProvisionForRepairs, Cost, Debit, Other)+ , (ProvisionForBonuses, Cost, Debit, Other)+ , (ProvisionForProductWarranties, Cost, Debit, Other)+ , (ResearchAndDevelopmentExpenses, Cost, Debit, Other)+ , (AmortizationOfGoodwill, Cost, Debit, Other)+ , (AmortizationOfSoftware, Cost, Debit, Other)+ , (AmortizationOfPatents, Cost, Debit, Other)+ , (LeaseExpenses, Cost, Debit, Other)+ , (IncorporationExpenses, Cost, Debit, Other)+ , (StockIssuanceCosts, Cost, Debit, Other)+ , (BusinessCommencementExpenses, Cost, Debit, Other)+ , (DevelopmentExpenses, Cost, Debit, Other)+ , (LossOnSalesOfElectronicallyRecordedReceivables, Cost, Debit, Other)+ , (LossOnSalesOfReceivables, Cost, Debit, Other)+ , (LossOnSalesOfSecurities, Cost, Debit, Other)+ , (LossOnValuationOfSecurities, Cost, Debit, Other)+ , (LossOnSalesOfInvestmentSecurities, Cost, Debit, Other)+ , (LossOnFire, Cost, Debit, Other)+ , (LossOnRetirementOfFixedAssets, Cost, Debit, Other)+ , (LossOnReductionOfFixedAssets, Cost, Debit, Other)+ , (AdditionalIncomeTaxesForPriorPeriods, Cost, Debit, Other)+ , (RefundOfIncomeTaxes, Cost, Credit, Other)+ , (PurchaseRebates, Cost, Credit, Other)+ , (WelfareExpenses, Cost, Debit, Other)+ , (MaintenanceExpenses, Cost, Debit, Other)+ , (StatutoryWelfareExpenses, Cost, Debit, Other)+ , (LandRentPaid, Cost, Debit, Other)+ , (InsuranceExpense, Cost, Debit, Other)+ , (RepairsExpense, Cost, Debit, Other)+ , (StorageExpenses, Cost, Debit, Other)+ , (MembershipFees, Cost, Debit, Other)+ , (IncomeSummary, Assets, Debit, Other)+ , (SuspenseAccount, Assets, Debit, Current)+ , (ForeignExchangeGains, Revenue, Credit, Other)+ , (ForeignExchangeLosses, Cost, Debit, Other)+ , (ContraAccountForGuaranteeObligations, Assets, Debit, Other)+ , (GuaranteeObligations, Liability, Credit, Other)+ , (IncomeTaxesAdjustment, Cost, Debit, Other)+ , (BranchCurrentAccount, Assets, Debit, Other)+ , (HeadOfficeCurrentAccount, Liability, Credit, Other)+ , (NetIncomeAttributableToNCI, Cost, Debit, Other)+ , (NetLossAttributableToNCI, Revenue, Credit, Other)+ , (TradingSecurities, Assets, Debit, Current)+ , (HeldToMaturityBonds, Assets, Debit, Fixed)+ , (SubsidiaryStocks, Assets, Debit, Fixed)+ , (AffiliateStocks, Assets, Debit, Fixed)+ , (AvailableForSaleSecurities, Assets, Debit, Fixed)+ , (ConsumptionTaxRefundReceivable, Assets, Debit, Current)+ , (PropertyTaxPayable, Liability, Credit, Current)+ , (DepositsReceivedFromOfficers, Liability, Credit, Current)+ ]++testAccountTitleClassification :: IO ()+testAccountTitleClassification = do+ -- All non-wildcard constructors, derived from Bounded/Enum.+ let allTitles = [ t | t <- [minBound .. maxBound], t /= AccountTitle ]+ tableMap = M.fromList [ (t, (d, s, fc)) | (t, d, s, fc) <- accountTitleClassTable ]+ -- A title is "covered" iff it appears in the expected table.+ missing = [ t | t <- allTitles, not (M.member t tableMap) ]+ extra = [ t | (t, _, _, _) <- accountTitleClassTable, t `notElem` allTitles ]+ -- Guard: the table must list exactly the non-wildcard constructors.+ assertEqual "AccountTitles class table covers every constructor (no missing)"+ ([] :: [AccountTitles]) missing+ assertEqual "AccountTitles class table has no stale entry (no extra)"+ ([] :: [AccountTitles]) extra+ -- Per-title classification must match the expected table.+ forM_ allTitles $ \t -> do+ let base = Not :< t :: HatBase AccountTitles+ actual = (whatDiv base, whichSide base, fixedCurrent base)+ case M.lookup t tableMap of+ Just expected ->+ assertEqual ("classification of " ++ show t) expected actual+ Nothing -> return () -- already reported by the "missing" guard++type TestAlg = EA.Alg Double (HatBase CountUnit)+type TestJournal = EJ.Journal String Double (HatBase CountUnit)+type AxisJournal = EJ.Journal (String, Int) Double (HatBase CountUnit)++algSample :: TestAlg+algSample =+ (1 :@ (Hat :< Yen))+ .+ (1 :@ (Not :< Amount))+ .+ (2 :@ (Not :< Yen))+ .+ (2 :@ (Hat :< Amount))+ .+ (3 :@ (Hat :< Yen))++journalSample :: TestJournal+journalSample = EJ.fromList [x, y, z]+ where+ x = ((1 :@ (Hat :< Yen)) .+ (1 :@ (Not :< Amount))) .| "cat" :: TestJournal+ y = ((2 :@ (Not :< Yen)) .+ (2 :@ (Hat :< Amount))) .| "dog" :: TestJournal+ z = ((3 :@ (Hat :< Yen)) .+ (3 :@ (Not :< Amount))) .| "fish" :: TestJournal++testReplaceNotesMatchesInsert :: IO ()+testReplaceNotesMatchesInsert = do+ let x = (10.00 .@ (Not :< Cash)) .| "A"+ :: EJ.Journal String Double (HatBase AccountTitles)+ y = (20.00 .@ (Not :< Cash)) .| "B"+ :: EJ.Journal String Double (HatBase AccountTitles)+ z = (30.00 .@ (Hat :< Cash)) .| "A"+ :: EJ.Journal String Double (HatBase AccountTitles)+ source = x .+ y+ expected = z .+ y+ assertEqual "Journal.replaceNotes replaces the complete matching Note"+ (EJ.toMap expected)+ (EJ.toMap (EJ.replaceNotes z source))+ assertEqual "Journal.replaceNotes matches insert"+ (EJ.toMap (EJ.insert z source))+ (EJ.toMap (EJ.replaceNotes z source))++testMapPosting :: IO ()+testMapPosting = do+ let source = (1 .@ (Hat :< Yen))+ .+ (2 .@ (Hat :< Yen))+ .+ (3 .@ (Not :< Amount))+ .+ (4 .@ (Hat :< Amount))+ :: TestAlg+ actual = EA.mapPosting (\v b -> (2 * v, b)) source+ assertNear "Algebra.mapPosting doubles norm"+ (2 * norm source) (norm actual)+ assertEqual "Algebra.mapPosting preserves posting count"+ (length (EA.toList source)) (length (EA.toList actual))+ assertEqual "Algebra.mapPosting preserves posting order"+ (EA.toList (2 .* source)) (EA.toList actual)++testMapMaybePosting :: IO ()+testMapMaybePosting = do+ let droppedBase = Not :< Amount :: HatBase CountUnit+ zeroedBase = Hat :< Yen :: HatBase CountUnit+ source = (1 .@ (Hat :< Yen))+ .+ (2 .@ (Hat :< Yen))+ .+ (3 .@ droppedBase)+ .+ (4 .@ (Hat :< Amount))+ :: TestAlg+ dropped = EA.mapMaybePosting+ (\v b -> if b == droppedBase then Nothing else Just (v, b))+ source+ zeroed = EA.mapMaybePosting+ (\v b -> Just (if b == zeroedBase then 0 else v, b))+ source+ without b = L.filter (\posting -> case posting of+ _ :@ b' -> b' /= b+ _ -> False)+ assertNear "Algebra.mapMaybePosting drop decreases norm"+ (norm source - 3) (norm dropped)+ assertEqual "Algebra.mapMaybePosting drop preserves remaining order"+ (without droppedBase (EA.toList source)) (EA.toList dropped)+ assertEqual "Algebra.mapMaybePosting normalises zero values away"+ (without zeroedBase (EA.toList source)) (EA.toList zeroed)++-- | Multi-pattern 'proj' uses __set__ semantics: a duplicated query selects the+-- same posting only once (no double counting). The de-duplicated query list and+-- its de-duplicated counterpart must give identical results.+testProjMultiPatternOnePass :: IO ()+testProjMultiPatternOnePass = do+ let qs, qsDedup :: [HatBase CountUnit]+ qs = [Hat :< Yen, HatNot :< Amount, Hat :< Yen] -- Hat:<Yen duplicated+ qsDedup = [Hat :< Yen, HatNot :< Amount]+ -- duplicate query does not change the projection (set semantics)+ assertEqual "Alg.proj treats query list as a set (duplicate exact)"+ (EA.proj qsDedup algSample) (EA.proj qs algSample)+ -- and the duplicated Hat:<Yen is counted once, not twice+ assertEqual "Alg.proj no double counting (single Hat:<Yen)"+ (EA.proj [Hat :< Yen] algSample)+ (EA.proj [Hat :< Yen, Hat :< Yen] algSample)++-- | 'projNetNorm' returns a bar-netted norm; the identity is+-- @projNetNorm bs x == norm (bar (proj bs x))@ (not @norm (proj bs x)@), and the+-- query list is a set (duplicates do not double count).+testProjNormFastPath :: IO ()+testProjNormFastPath = do+ let qs :: [HatBase CountUnit]+ qs = [Hat :< Yen, HatNot :< Amount, Hat :< Yen]+ expected = norm $ EA.bar $ EA.proj qs algSample+ actual = EA.projNetNorm qs algSample+ assertNear "Alg.projNetNorm == norm . bar . proj (set semantics)" expected actual++-- | R7 sentinel (a): a duplicated exact base must project the same as a single+-- copy (MoneyDecimal exact: no floating tolerance needed).+testProjDuplicateExact :: IO ()+testProjDuplicateExact = do+ let alg :: EA.Alg MoneyDecimal (HatBase CountUnit)+ alg = (10 :@ (Hat :< Yen))+ .+ (3 :@ (Not :< Amount))+ b = Hat :< Yen :: HatBase CountUnit+ assertEqual "proj [b,b] == proj [b] (duplicate exact, MoneyDecimal)"+ (EA.proj [b] alg) (EA.proj [b, b] alg)+ assertEqual "projNetNorm [b,b] == projNetNorm [b] (duplicate exact)"+ (EA.projNetNorm [b] alg) (EA.projNetNorm [b, b] alg)++-- | R7 sentinel (b): an exact base together with a wildcard query that subsumes+-- it must not double count the overlapping posting.+testProjExactWildcardOverlap :: IO ()+testProjExactWildcardOverlap = do+ let alg :: EA.Alg MoneyDecimal (HatBase CountUnit)+ alg = (10 :@ (Hat :< Yen))+ .+ (5 :@ (Not :< Amount))+ exact = Hat :< Yen :: HatBase CountUnit+ wild = Hat :< (.#) :: HatBase CountUnit -- subsumes Hat:<Yen+ -- the wildcard already selects everything the exact base does, so the union+ -- equals the wildcard alone (overlap counted once)+ assertEqual "proj [exact,wild] == proj [wild] (overlap, no double count)"+ (EA.proj [wild] alg) (EA.proj [exact, wild] alg)+ assertEqual "projNetNorm [exact,wild] == projNetNorm [wild] (overlap)"+ (EA.projNetNorm [wild] alg) (EA.projNetNorm [exact, wild] alg)++-- | R7 sentinel (c): the bar-netted identity @projNetNorm bs x == norm (bar (proj+-- bs x))@ holds on a base carrying both hat and not sides (where it differs from+-- @norm (proj bs x)@).+testProjNormBarIdentity :: IO ()+testProjNormBarIdentity = do+ let alg :: EA.Alg MoneyDecimal (HatBase CountUnit)+ alg = (10 :@ (Hat :< Yen)) -- Yen carries both sides+ .+ (4 :@ (Not :< Yen))+ .+ (7 :@ (Not :< Amount))+ bs = [HatNot :< Yen, HatNot :< Amount] :: [HatBase CountUnit]+ assertEqual "projNetNorm == norm . bar . proj (both-sided base, MoneyDecimal)"+ (norm (EA.bar (EA.proj bs alg))) (EA.projNetNorm bs alg)++testProjWithBaseNorm :: IO ()+testProjWithBaseNorm = do+ let bs :: [HatBase CountUnit]+ bs = [Not :< Amount]+ expected = norm $ EJ.projWithBase bs journalSample+ actual = EJ.projWithBaseNetNorm bs journalSample+ assertNear "Journal.projWithBaseNetNorm matches norm . projWithBase" expected actual++testProjWithNoteNorm :: IO ()+testProjWithNoteNorm = do+ let bs :: [HatBase CountUnit]+ bs = [HatNot :< Amount, Hat :< Yen]+ ns1 = ["dog", "cat"]+ ns2 = [plank]+ expected1 = norm $ EJ.projWithNoteBase ns1 bs journalSample+ actual1 = EJ.projWithNoteBaseNetNorm ns1 bs journalSample+ expected2 = norm $ EJ.projWithNoteBase ns2 bs journalSample+ actual2 = EJ.projWithNoteBaseNetNorm ns2 bs journalSample+ assertNear "Journal.projWithNoteBaseNetNorm (selected notes)" expected1 actual1+ assertNear "Journal.projWithNoteBaseNetNorm (plank wildcard)" expected2 actual2++-- | Sentinel for the REMOVED RULES rewrite+-- @norm (projWithBase bs js) = projWithBaseNetNorm bs js@ (and the note-base+-- analogue): the equation is false when a query selects both sides of one+-- base. 'EJ.projWithBaseNetNorm' \/ 'EJ.projWithNoteBaseNetNorm' are the /bar-netted/+-- read-outs (per base @|not - hat|@), while @norm . projWithBase@ is the+-- gross norm (sums both sides). Both values are pinned here so a future+-- \"optimization\" that silently nets the gross path fails loudly.+testProjWithBaseNormBothSided :: IO ()+testProjWithBaseNormBothSided = do+ let alg :: EA.Alg MoneyDecimal (HatBase CountUnit)+ alg = (10 :@ (Hat :< Yen)) -- Yen carries both sides+ .+ (4 :@ (Not :< Yen))+ .+ (7 :@ (Not :< Amount))+ js = alg .| "n" :: EJ.Journal String MoneyDecimal (HatBase CountUnit)+ bs = [HatNot :< Yen] :: [HatBase CountUnit]+ assertEqual "projWithBaseNetNorm nets both sides (HatNot query): |10-4|"+ 6 (EJ.projWithBaseNetNorm bs js)+ assertEqual "norm . projWithBase stays gross (no RULES rewrite): 10+4"+ 14 (norm (EJ.projWithBase bs js))+ assertEqual "projWithBaseNetNorm == norm . map bar . projWithBase"+ (norm (EJ.map EA.bar (EJ.projWithBase bs js)))+ (EJ.projWithBaseNetNorm bs js)+ assertEqual "projWithNoteBaseNetNorm nets both sides (HatNot query): |10-4|"+ 6 (EJ.projWithNoteBaseNetNorm ["n"] bs js)+ assertEqual "norm . projWithNoteBase stays gross (no RULES rewrite): 10+4"+ 14 (norm (EJ.projWithNoteBase ["n"] bs js))++-- | 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]+ f :: Int -> TestAlg+ f i+ | i == 3 = EA.Zero+ | odd i = fromIntegral i :@ (Hat :< Yen)+ | otherwise = fromIntegral i :@ (Not :< Amount)+ expected :: TestAlg+ expected = EA.unionsMerge (L.map f xs)+ actual :: TestAlg+ actual = EA.sigma xs f+ assertEqual "Alg.sigma bulk-merge path matches unionsMerge" expected actual++-- | Characterization: the same-base 'Seq' order is __construction-path+-- dependent__. The pairwise-union path ('EA.fromList' = 'mconcat') and the+-- bulk-merge path ('EA.sigma' \/ 'EA.unionsMerge') produce the same /multiset/+-- of postings but in different sequence orders, which 'Eq' \/ @Binary@ observe+-- (and 'Double' observes through the last ULP of 'norm'\/'bar' association).+-- This test pins the current orders so any change to either path is a+-- conscious decision; unifying the paths is tracked in the 0.5.0.0 cleanup+-- plan. For order-independent comparison use `MoneyDecimal` (exact) or compare+-- after 'EA.compress'\/'EA.bar'.+testSameBaseSeqOrderPathDependence :: IO ()+testSameBaseSeqOrderPathDependence = do+ let f :: Int -> TestAlg+ f i = fromIntegral i :@ (Hat :< Yen)+ xs = L.map f [1, 2, 3]+ viaFromList = EA.fromList xs+ viaSigma = EA.sigma [1, 2, 3] f+ viaMerge = EA.unionsMerge xs+ assertEqual "fromList same-base seq order (pairwise-union path)"+ [3, 1, 2] (EA.vals viaFromList)+ assertEqual "sigma same-base seq order (bulk-merge path)"+ [3, 2, 1] (EA.vals viaSigma)+ assertEqual "unionsMerge order matches sigma (same merge path)"+ (EA.vals viaSigma) (EA.vals viaMerge)+ -- same multiset, different order: Eq observes the redundancy order+ assertEqual "fromList /= sigma under Eq (order is observable)"+ False (viaFromList == viaSigma)+ -- the algebraic content is nevertheless identical+ assertNear "norm agrees across construction paths"+ (norm viaFromList) (norm viaSigma)+ assertEqual "bar agrees across construction paths"+ (EA.bar viaFromList) (EA.bar viaSigma)++testSigma2When :: IO ()+testSigma2When = do+ let xs = [1 .. 3 :: Int]+ ys = [1 .. 4 :: Int]+ cond i j = i /= j && even (i + j)+ f :: Int -> Int -> TestAlg+ f i j =+ let v = fromIntegral (i * 10 + j)+ in if odd i+ then v :@ (Hat :< Yen)+ else v :@ (Not :< Amount)+ expected :: TestAlg+ expected =+ EA.unionsMerge+ [ f i j+ | i <- xs+ , j <- ys+ , cond i j+ ]+ actual :: TestAlg+ actual = EA.sigma2When xs ys cond f+ assertEqual "Alg.sigma2When matches list-comprehension sum" expected actual++testSigmaFromMap :: IO ()+testSigmaFromMap = do+ let kvs = M.fromList+ [ ((1, 2), 5.0)+ , ((2, 3), 0.0)+ , ((3, 1), 7.0)+ ] :: M.Map (Int, Int) Double+ f :: (Int, Int) -> Double -> TestAlg+ f (i, j) v+ | i < j = v :@ (Hat :< Yen)+ | otherwise = v :@ (Not :< Amount)+ expected :: TestAlg+ expected = EA.unionsMerge+ [ f (1, 2) 5.0+ , f (3, 1) 7.0+ ]+ actual :: TestAlg+ actual = EA.sigmaFromMap kvs f+ assertEqual "Alg.sigmaFromMap iterates non-zero map entries only" expected actual++testJournalFromListStrict :: IO ()+testJournalFromListStrict = do+ -- fromList is now a strict left fold (L.foldl' (.+) mempty). Verify it still+ -- preserves the posting multiset by matching the old lazy right-fold reference+ -- (foldr (.+) mempty). Colliding note keys (i `mod` 30) force same-note/same-base+ -- postings into one Alg sequence, where the two folds accumulate in opposite+ -- order; with MoneyDecimal (exact, associative) the aggregate (norm) is identical.+ let mk i = ((fromIntegral (i `mod` 7 + 1) :: MoneyDecimal)+ :@ ((if even i then Hat else Not) :< ([Yen, Amount] !! (i `mod` 2))))+ .| show (i `mod` 30)+ xs :: [Journal String MoneyDecimal (HatBase CountUnit)]+ xs = [ mk i | i <- [1 .. 400 :: Int] ]+ strict = EJ.fromList xs+ lazyRef = foldr (.+) mempty xs+ -- exact value type ⇒ norm identical regardless of seq order (multiset preserved)+ assertEqual "Journal.fromList (strict): norm matches lazy foldr reference (MoneyDecimal exact)"+ (norm strict) (norm lazyRef)+ -- distinct note keys ⇒ no seq collision ⇒ exact structural equality with foldr+ let ys :: [Journal String MoneyDecimal (HatBase CountUnit)]+ ys = [ ((fromIntegral i :: MoneyDecimal) :@ (Not :< Yen)) .| show i+ | i <- [1 .. 20 :: Int] ]+ assertEqual "Journal.fromList (strict): structurally equal to foldr for distinct notes"+ (EJ.toMap (EJ.fromList ys)) (EJ.toMap (foldr (.+) mempty ys))++-- | Regression test for the @union@ zero-singleton base-relabel bug+-- (Algebra.hs). When one operand of @(.+)@ is a /zero-valued/ singleton on base+-- @b1@ and the other a /real/ singleton on a different base @b2@, the result must+-- keep the real value on its OWN base (@v2:@b2@), not relabel it onto the zero+-- posting's base. The old code returned @v2:@b1@ / @v1:@b2@, which silently moved+-- a value to the wrong base. It preserved @norm@ (total unchanged) but corrupted+-- per-base projection, and surfaced as construction-order-dependent simulation+-- results (sparsified coefficients build explicit @0:@base@ singletons via raw+-- @(:@)@). See plans/in-progress/SELECTABLE_VALUE_TYPE_PLAN.md (Stage D).+testUnionZeroSingletonBase :: IO ()+testUnionZeroSingletonBase = do+ let zb = 0 :@ (Hat :< Yen) :: TestAlg -- zero value, base Yen+ rb = 5 :@ (Hat :< Amount) :: TestAlg -- real value, base Amount+ -- both fold directions of the singleton/singleton union+ assertEqual "union zero(.+)real keeps real value on its own base"+ rb (EA.proj [Hat :< Amount] (zb .+ rb))+ assertEqual "union real(.+)zero keeps real value on its own base"+ rb (EA.proj [Hat :< Amount] (rb .+ zb))+ -- the real value must NOT appear on the zero posting's base+ assertEqual "union zero(.+)real: nothing relabeled onto the zero's base"+ (EA.Zero :: TestAlg) (EA.proj [Hat :< Yen] (zb .+ rb))+ assertEqual "union real(.+)zero: nothing relabeled onto the zero's base"+ (EA.Zero :: TestAlg) (EA.proj [Hat :< Yen] (rb .+ zb))++-- | Regression for audit divergence C: scalar product (.*) must reject a+-- negative / non-finite scalar instead of silently producing negative+-- (out-of-domain) postings. (Pre-fix, (.*) used raw (:@) and bypassed the+-- isErrorValue check that (.@) performs.)+testScalarRejectsNegative :: IO ()+testScalarRejectsNegative = do+ let xD = 10 :@ (Not :< Yen) :: TestAlg+ rD <- try (evaluate (norm ((-1) .* xD))) :: IO (Either SomeException Double)+ case rD of+ Left _ -> putStrLn "[PASS] (.*) rejects negative scalar (Double)"+ Right v -> do putStrLn ("[FAIL] (.*) negative scalar leaked (Double): " ++ show v); exitFailure+ let xN = 10 :@ (Not :< Yen) :: EA.Alg MoneyDecimal (HatBase CountUnit)+ rN <- try (evaluate (norm ((-1) .* xN))) :: IO (Either SomeException MoneyDecimal)+ case rN of+ Left _ -> putStrLn "[PASS] (.*) rejects negative scalar (MoneyDecimal)"+ Right v -> do putStrLn ("[FAIL] (.*) negative scalar leaked (MoneyDecimal): " ++ show v); exitFailure+ -- non-negative scalar still works+ assertNear "(.*) non-negative scalar works" 20.0 (norm (2 .* xD))++-- Step 1 (concrete projection keeps the axis index lazy): the module is compiled+-- @Strict@, so a concrete (non-wildcard) 'projNetNorm' must NOT force the lazy+-- @_axisPosting@ index (it should be a plain 'Map.lookup'); a wildcard 'projNetNorm'+-- must use (force) it. We poison the index fields with 'error' and check which+-- projection crashes. Guards the projExactMap/projWildMap split.+testProjConcreteNoIndexForce :: IO ()+testProjConcreteNoIndexForce = do+ let alg :: EA.Alg Double SimHatBase2+ alg = EA.fromList [ 10 :@ Not :< (Cash, 1, 1, Yen)+ , 20 :@ Not :< (Products, 2, 2, Amount)+ , 30 :@ Hat :< (Cash, 3, 3, Yen) ]+ case alg of+ EAI.Liner m _ _ _ _ _ -> do+ let poison = EAI.Liner m (error "POISON") (error "POISON")+ (error "POISON") (error "POISON") (error "POISON")+ rc <- try (evaluate (EA.projNetNorm [Not :< (Cash, 1, 1, Yen)] poison))+ :: IO (Either SomeException Double)+ case rc of+ Right v | v == 10.0 -> putStrLn "[PASS] concrete projNetNorm does not force the axis index"+ Right v -> do putStrLn ("[FAIL] concrete projNetNorm wrong value: " ++ show v); exitFailure+ Left _ -> do putStrLn "[FAIL] concrete projNetNorm forced the (poisoned) axis index"; exitFailure+ rw <- try (evaluate (EA.projNetNorm [Not :< (Cash, (.#), 1, Yen)] poison))+ :: IO (Either SomeException Double)+ case rw of+ Left _ -> putStrLn "[PASS] wildcard projNetNorm uses the axis index (forced, as required)"+ Right v -> do putStrLn ("[FAIL] wildcard projNetNorm did not use the index: " ++ show v); exitFailure+ _ -> do putStrLn "[FAIL] expected a Liner"; exitFailure++-- The Liner @_bpToId@ and @_nextBpId@ fields are reserved for the dormant P1a+-- incremental-id scheme and are not maintained by 'linerFromMap' (it leaves them+-- as lazy 'error' poison). This guards two invariants: (1) normal projection+-- (concrete + wildcard) never forces those poisoned fields, so 'projWildMap'+-- stays green; (2) forcing the unused fields fails loudly (as designed) rather+-- than returning a stale/empty value.+testLinerReservedFieldsPoisoned :: IO ()+testLinerReservedFieldsPoisoned = do+ let alg :: EA.Alg Double SimHatBase2+ alg = EA.fromList [ 10 :@ Not :< (Cash, 1, 1, Yen)+ , 20 :@ Not :< (Products, 2, 2, Amount)+ , 30 :@ Hat :< (Cash, 3, 3, Yen) ]+ -- projWildMap path (and concrete path) must stay green without forcing the+ -- reserved fields.+ assertNear "wildcard projNetNorm green with reserved fields unmaintained"+ 10.0 (EA.projNetNorm [Not :< (Cash, (.#), 1, Yen)] alg)+ assertNear "concrete projNetNorm green with reserved fields unmaintained"+ 10.0 (EA.projNetNorm [Not :< (Cash, 1, 1, Yen)] alg)+ -- Forcing _bpToId / _nextBpId must error (poison), proving they are not+ -- silently maintained.+ case alg of+ EAI.Liner _ _ bpToId _ nextBpId _ -> do+ rb <- (try (evaluate (HM.size bpToId)) :: IO (Either SomeException Int))+ case rb of+ Left _ -> putStrLn "[PASS] _bpToId is poisoned (forcing it errors as designed)"+ Right _ -> do putStrLn "[FAIL] _bpToId was forced without error (unexpectedly maintained)"; exitFailure+ rn <- (try (evaluate nextBpId) :: IO (Either SomeException Int))+ case rn of+ Left _ -> putStrLn "[PASS] _nextBpId is poisoned (forcing it errors as designed)"+ Right _ -> do putStrLn "[FAIL] _nextBpId was forced without error (unexpectedly maintained)"; exitFailure+ _ -> do putStrLn "[FAIL] expected a Liner"; exitFailure++testJournalSigmaMergePath :: IO ()+testJournalSigmaMergePath = do+ let xs = [1 .. 4 :: Int]+ f :: Int -> TestJournal+ f i = case i of+ 1 -> (1 :@ (Hat :< Yen)) .| "A"+ 2 -> EJ.Zero+ 3 -> (EA.Zero :: TestAlg) .| "A"+ _ -> (2 :@ (Not :< Amount)) .| "B"+ expected :: TestJournal+ expected = EJ.fromMap $ HM.fromList+ [ ("A", 1 :@ (Hat :< Yen))+ , ("B", 2 :@ (Not :< Amount))+ ]+ actual = EJ.sigma xs f+ assertEqual "Journal.sigma bulk-merge path skips zero postings" (EJ.toMap expected) (EJ.toMap actual)++testJournalSigma2When :: IO ()+testJournalSigma2When = do+ let xs = [1 .. 3 :: Int]+ ys = [1 .. 3 :: Int]+ cond i j = i < j+ f :: Int -> Int -> TestJournal+ f i j+ | i == 1 && j == 2 = (EA.Zero :: TestAlg) .| "N"+ | odd (i + j) = (fromIntegral (i + j) :@ (Hat :< Yen)) .| "N"+ | otherwise = EJ.Zero+ expected :: TestJournal+ expected = EJ.fromMap $ HM.fromList [("N", 5 :@ (Hat :< Yen))]+ actual = EJ.sigma2When xs ys cond f+ assertEqual "Journal.sigma2When matches filtered pair sum" (EJ.toMap expected) (EJ.toMap actual)++testJournalSigmaOn :: IO ()+testJournalSigmaOn = do+ let xs = [1 .. 4 :: Int]+ f :: Int -> TestAlg+ f i+ | i <= 2 = EA.Zero+ | otherwise = fromIntegral i :@ (Hat :< Yen)+ expected :: TestJournal+ expected = (EA.sigma xs f) .| "SalesPurchase"+ actual :: TestJournal+ actual = EJ.sigmaOn "SalesPurchase" xs f+ zeroExpected = EJ.Zero :: TestJournal+ zeroActual = EJ.sigmaOn "SalesPurchase" xs (\_ -> EA.Zero :: TestAlg)+ assertEqual "Journal.sigmaOn attaches note after EA.sigma" (EJ.toMap expected) (EJ.toMap actual)+ assertEqual "Journal.sigmaOn returns Zero when EA.sigma is Zero" (EJ.toMap zeroExpected) (EJ.toMap zeroActual)++testJournalSigmaOnFromMap :: IO ()+testJournalSigmaOnFromMap = do+ let kvs = M.fromList+ [ ((1, 2), 4.0)+ , ((2, 3), 0.0)+ , ((2, 1), 6.0)+ ] :: M.Map (Int, Int) Double+ f :: (Int, Int) -> Double -> TestAlg+ f (i, j) v+ | i < j = v :@ (Hat :< Yen)+ | otherwise = v :@ (Not :< Amount)+ expected :: TestJournal+ expected = (EA.sigmaFromMap kvs f) .| "SalesPurchase"+ actual :: TestJournal+ actual = EJ.sigmaOnFromMap "SalesPurchase" kvs f+ zeroActual :: TestJournal+ zeroActual = EJ.sigmaOnFromMap "SalesPurchase" (M.singleton (1, 1) 0.0) f+ assertEqual "Journal.sigmaOnFromMap matches EA.sigmaFromMap + note" (EJ.toMap expected) (EJ.toMap actual)+ assertEqual "Journal.sigmaOnFromMap returns Zero for empty-effective map" (EJ.toMap (EJ.Zero :: TestJournal)) (EJ.toMap zeroActual)++testFilterByAxisEquivalent :: IO ()+testFilterByAxisEquivalent = do+ let ledger :: AxisJournal+ ledger = EJ.fromList+ [ (10 :@ (Hat :< Yen)) .| ("A", 1)+ , (20 :@ (Not :< Amount)) .| ("B", 1)+ , (30 :@ (Hat :< Yen)) .| ("A", 2)+ ]+ expected = EJ.filterWithNote (\(_, t') _ -> t' == 1) ledger+ actual = EJ.filterByAxis 1 (EJ.NoteAxisKey (1 :: Int)) ledger+ mismatch = EJ.filterByAxis 1 (EJ.NoteAxisKey ("1" :: String)) ledger+ assertEqual "Journal.filterByAxis matches filterWithNote on axis=1"+ (EJ.toMap expected)+ (EJ.toMap actual)+ assertEqual "Journal.filterByAxis type mismatch returns empty"+ (EJ.toMap (EJ.Zero :: AxisJournal))+ (EJ.toMap mismatch)++testFilterByAxisWithDeltaUpdates :: IO ()+testFilterByAxisWithDeltaUpdates = do+ let base :: AxisJournal+ base = EJ.fromMap $ HM.fromList+ [ (("A", 1), 10 :@ (Hat :< Yen))+ , (("C", 2), 5 :@ (Not :< Amount))+ ]+ rhs :: AxisJournal+ rhs = EJ.fromMap $ HM.fromList+ [ (("A", 1), 3 :@ (Not :< Amount))+ , (("B", 1), 7 :@ (Hat :< Yen))+ ]+ ledger = base .+ rhs+ expected = EJ.filterWithNote (\(_, t') _ -> t' == 1) ledger+ actual = EJ.filterByAxis 1 (EJ.NoteAxisKey (1 :: Int)) ledger+ assertEqual "Journal.filterByAxis works after append updates"+ (EJ.toMap expected)+ (EJ.toMap actual)++-- ================================================================+-- Transfer regression tests+-- ================================================================++type TransferAlg = EA.Alg Double SimHatBase2+type TransferJournal = EJ.Journal String Double SimHatBase2++transferAlgSample :: TransferAlg+transferAlgSample = EA.fromList+ [ 7 :@ Not :<(WageExpenditure, 1, 1, Yen)+ , 3 :@ Hat :<(Depreciation, 2, 2, Yen)+ , 11 :@ Not :<(Purchases, 3, 3, Yen)+ , 13 :@ Not :<(ValueAdded, 1, 2, Yen)+ , 17 :@ Hat :<(Sales, 2, 1, Yen)+ , 19 :@ Not :<(InterestEarned, 4, 4, Yen)+ , 23 :@ Hat :<(InterestExpense, 5, 5, Yen)+ , 29 :@ Not :<(TaxesRevenue, 2, 2, Yen)+ , 31 :@ Hat :<(TaxesExpense, 3, 3, Yen)+ , 37 :@ Not :<(WageEarned, 6, 6, Yen)+ , 41 :@ Hat :<(ConsumptionExpenditure, 6, 6, Yen)+ , 43 :@ Not :<(CentralBankPaymentIncome, 1, 1, Yen)+ , 47 :@ Hat :<(CentralBankPaymentExpense, 1, 1, Yen)+ , 53 :@ Not :<(GrossProfit, 7, 7, Yen)+ , 59 :@ Hat :<(OrdinaryProfit, 8, 8, Yen)+ , 61 :@ Not :<(Cash, 1, 1, Yen)+ ]++transferJournalSample :: TransferJournal+transferJournalSample = EJ.fromList+ [ transferAlgSample .| "A"+ , ((5 :@ Not :<(Sales, 2, 1, Yen)) .+ (2 :@ Hat :<(WageExpenditure, 1, 1, Yen))) .| "B"+ , ((3 :@ Hat :<(TaxesExpense, 3, 3, Yen)) .+ (4 :@ Not :<(InterestEarned, 4, 4, Yen))) .| "C"+ ]++testFinalStockTransferAlgEquivalence :: IO ()+testFinalStockTransferAlgEquivalence = do+ let ref =+ (.-)+ . EAT.retainedEarningTransfer+ . EAT.ordinaryProfitTransfer+ . EAT.grossProfitTransfer+ $ transferAlgSample+ actual = EAT.finalStockTransfer transferAlgSample+ assertEqual "Algebra.finalStockTransfer matches composed transfer" ref actual++testFinalStockTransferJournalEquivalence :: IO ()+testFinalStockTransferJournalEquivalence = do+ let ref =+ (.-)+ . EJT.retainedEarningTransfer+ . EJT.ordinaryProfitTransfer+ . EJT.grossProfitTransfer+ $ transferJournalSample+ actual = EJT.finalStockTransfer transferJournalSample+ assertEqual "Journal.finalStockTransfer matches composed transfer" (EJ.toMap ref) (EJ.toMap actual)++testFinalStockTransferAggregatedAlias :: IO ()+testFinalStockTransferAggregatedAlias = do+ let twoNotes = EJ.fromList+ [ ((5 .@ (Not :< (Sales, 2, 1, Yen)))+ .+ (2 .@ (Hat :< (WageExpenditure, 1, 1, Yen)))) .| "A"+ , ((7 .@ (Hat :< (InterestExpense, 5, 5, Yen)))+ .+ (3 .@ (Not :< (InterestEarned, 4, 4, Yen)))) .| "B"+ ] :: TransferJournal+ assertEqual "Journal.finalStockTransferAggregated matches finalStockTransfer"+ (EJ.toMap (EJT.finalStockTransfer twoNotes))+ (EJ.toMap (EJT.finalStockTransferAggregated twoNotes))++type FinalStockProbe = EA.Alg Double (HatBase AccountTitles)++-- | Classify the observable image of the same one-posting probe used to+-- generate @test/fixtures/pre-vocab/finalstock.tsv@.+--+-- Complexity: O(1)+finalStockProbeRule :: AccountTitles -> String+finalStockProbeRule RetainedEarnings = "SELF"+finalStockProbeRule title+ | actual == show probe = "Nothing"+ | actual == show (1 .@ Not :< RetainedEarnings :: FinalStockProbe) = "Keep"+ | actual == show (1 .@ Hat :< RetainedEarnings :: FinalStockProbe) = "Flip"+ | otherwise = "UNEXPECTED:" ++ actual+ where+ probe = 1 .@ Not :< title :: FinalStockProbe+ actual = show (EAT.finalStockTransfer probe)++-- | The pre-vocabulary fixture is frozen. The only permitted behavioural+-- changes are the concrete Cost/Revenue accounts that the former SNA-era+-- title case split omitted. Aggregate NetIncome/NetLoss remain explicit+-- registry overrides and therefore do not occur in this list.+finalStockExpectedClosedDiff :: [AccountTitles]+finalStockExpectedClosedDiff =+ [ AmortizationExpense+ , SalesCost+ , BusinessTrip+ , Commutation+ , UtilitiesExpense+ , RentExpense+ , AdvertisingExpense+ , DeliveryExpenses+ , SuppliesExpenses+ , MiscellaneousExpenses+ , NationalBondInterestEarned+ , DepositInterestEarned+ , ReceiptFee+ , RentalIncome+ , EquityInEarningsOfInvestee+ , ProvisionForDoubtfulAccounts+ , BadDebtLoss+ , LossOnSalesOfFixedAssets+ , LossOnSalesOfNotesReceivable+ , PaymentFees+ , MiscellaneousLoss+ , CorporateIncomeTaxes+ , CommunicationExpenses+ , GainOnSalesOfFixedAssets+ , RecoveryOfBadDebts+ , MiscellaneousIncome+ , ReversalOfAllowanceForDoubtfulAccounts+ ]++-- V-Land 1: finalStockRule の全域を独立参照式 (division + contra を明示分岐)+-- と突き合わせ, 方向 (Keep/Flip) まで固定する。contra P/L (将来の売上割戻等)+-- では division 基準と逆になることをこの式が明文化する。+testFinalStockRuleReference :: IO ()+testFinalStockRuleReference = mapM_ check Registry.concreteAccountTitles+ where+ check RetainedEarnings = pure ()+ check t = assertEqual ("finalStockRule reference: " ++ show t)+ (expected t) (finalStockProbeRule t)+ expected t+ | t `L.elem`+ [ NetIncome+ , NetLoss+ , IncomeSummary+ , NetIncomeAttributableToNCI+ , NetLossAttributableToNCI+ ] = "Nothing"+ | contra && div_ == Revenue = "Flip"+ | contra && div_ == Cost = "Keep"+ | div_ == Revenue = "Keep"+ | div_ == Cost = "Flip"+ | otherwise = "Nothing"+ where+ div_ = classifyAccountDivision t+ contra = Registry.classifyAccountContra t++testFinalStockRegistryClosedDiff :: IO ()+testFinalStockRegistryClosedDiff = do+ fixture <- TIO.readFile "test/fixtures/pre-vocab/finalstock.tsv"+ let fixtureLines =+ [ line+ | line <- T.lines fixture+ , not (T.null line)+ , not (T.isPrefixOf (T.pack "#") line)+ ]+ parseFixtureLine line = case T.splitOn (T.pack "\t") line of+ [titleText, oldRule, _division] -> case EC.parseAccountTitle titleText of+ Right title -> (title, T.unpack oldRule)+ Left err -> error ("invalid final-stock fixture title: " ++ show err)+ fields -> error ("invalid final-stock fixture row: " ++ show fields)+ fixtureRows = L.map parseFixtureLine fixtureLines+ actualDiff =+ [ title+ | (title, oldRule) <- fixtureRows+ , title /= RetainedEarnings+ , finalStockProbeRule title /= oldRule+ ]+ assertEqual "final-stock fixture covers all 116 concrete account titles"+ 116 (L.length fixtureRows)+ assertEqual "final-stock registry closed diff = 27 formerly omitted accounts"+ finalStockExpectedClosedDiff actualDiff+ assertEqual "final-stock aggregate overrides remain open"+ ["Nothing", "Nothing", "Nothing", "Nothing", "Nothing"]+ [ finalStockProbeRule NetIncome+ , finalStockProbeRule NetLoss+ , finalStockProbeRule IncomeSummary+ , finalStockProbeRule NetIncomeAttributableToNCI+ , finalStockProbeRule NetLossAttributableToNCI+ ]++-- ================================================================+-- V-Land 2 scaffolding (語彙拡張の受理条件, レビュー非依存):+-- pre-vland2 fixture (tools/DumpVocabGolden.hs で生成, commit 85d6a7f に pin)+-- に対する Enum 挿入規律 pin と意味関数 closed-diff。+-- ================================================================++-- | V-Land 2 で既存意味が変わってよい科目の閉リスト。+-- scaffold 時点 (constructor 追加前) は空。外部レビュー裁定で既存科目の分類が+-- 変わる場合 (例: 有価証券 4 分類分解に伴う 'Securities' の再定義) は+-- ここに列挙して閉じる — 列挙外の意味変化は fail する。+vocabSemanticsExpectedClosedDiff :: [AccountTitles]+vocabSemanticsExpectedClosedDiff = []++-- | 挿入規律 pin: 語彙拡張は「既存 concrete constructor の Enum 序数を 1 つも+-- 動かさず, 新規は最大既存 concrete 序数と wildcard の間にのみ挿入し,+-- wildcard ('AccountTitle') は maxBound のまま」でなければならない+-- (Binary Word16 直列化互換と既存 fixture 世代の解釈可能性の要)。+testVocabOrdinalPin :: IO ()+testVocabOrdinalPin = do+ fixture <- TIO.readFile "test/fixtures/pre-vland2/ordinals.tsv"+ let parseOrd line = case T.splitOn (T.pack "\t") line of+ [name, ordText] -> (name, read (T.unpack ordText) :: Int)+ fields -> error ("invalid pre-vland2 ordinals row: " ++ show fields)+ rows =+ [ parseOrd line+ | line <- T.lines fixture+ , not (T.null line)+ , not (T.isPrefixOf (T.pack "#") line)+ ]+ current = M.fromList+ [ (T.pack (show t), fromEnum t)+ | t <- [minBound .. maxBound] :: [AccountTitles] ]+ wildcardName = T.pack (show (AccountTitle :: AccountTitles))+ pinnedConcrete = [ r | r@(n, _) <- rows, n /= wildcardName ]+ moved =+ [ (n, o, M.lookup n current)+ | (n, o) <- pinnedConcrete+ , M.lookup n current /= Just o ]+ assertEqual "vocab ordinal pin: fixture rows = 117 (116 concrete + wildcard)"+ 117 (L.length rows)+ assertEqual "vocab ordinal pin: no pinned concrete ordinal moved" [] moved+ assertEqual "vocab ordinal pin: wildcard is maxBound"+ (fromEnum (maxBound :: AccountTitles))+ (fromEnum (AccountTitle :: AccountTitles))+ let maxPinned = L.maximum [ o | (_, o) <- pinnedConcrete ]+ pinnedNames = M.fromList [ (n, ()) | (n, _) <- rows ]+ misplaced =+ [ (n, o)+ | (n, o) <- M.toList current+ , not (M.member n pinnedNames)+ , not (o > maxPinned && o < fromEnum (maxBound :: AccountTitles)) ]+ assertEqual "vocab ordinal pin: new constructors sit between max pinned and wildcard"+ [] misplaced+ assertEqual "vocab ordinal pin: Land 4a constructor ordinals and wildcard"+ [232, 233, 234, 235]+ (L.map fromEnum+ [ ConsumptionTaxRefundReceivable+ , PropertyTaxPayable+ , DepositsReceivedFromOfficers+ , AccountTitle+ ])+ -- concreteAccountTitles は wildcard 以外の全 constructor を被覆すること。+ -- 現行の hardcoded 上限 ([Cash .. ReversalOfAllowanceForDoubtfulAccounts]) は+ -- 挿入後に新規科目が漏れるため, この assert が V-Land 2 に+ -- filter (/= wildcard) [minBound ..] への導出化を強制する。+ assertEqual "vocab ordinal pin: concreteAccountTitles covers all non-wildcard constructors"+ (L.filter (/= (AccountTitle :: AccountTitles)) [minBound .. maxBound])+ Registry.concreteAccountTitles++-- | 意味関数 closed-diff: 既存 116 科目の (division / isContra / whichSide+-- Not\/Hat / whatPIMO / fixedCurrent / finalStock probe) は,+-- 'vocabSemanticsExpectedClosedDiff' に列挙された科目を除き+-- pre-vland2 fixture と行単位で一致しなければならない。+testPreVland2SemanticsClosedDiff :: IO ()+testPreVland2SemanticsClosedDiff = do+ fixture <- TIO.readFile "test/fixtures/pre-vland2/semantics.tsv"+ let byName = M.fromList+ [ (T.pack (show t), t)+ | t <- [minBound .. maxBound] :: [AccountTitles] ]+ currentRow t =+ let nb = Not :< t :: HatBase AccountTitles+ hb = Hat :< t :: HatBase AccountTitles+ in T.intercalate (T.pack "\t")+ [ T.pack (show t)+ , T.pack (show (whatDiv nb))+ , T.pack (show (Registry.classifyAccountContra t))+ , T.pack (show (whichSide nb))+ , T.pack (show (whichSide hb))+ , T.pack (show (whatPIMO nb))+ , T.pack (show (fixedCurrent nb))+ , T.pack (finalStockProbeRule t)+ ]+ rows =+ [ line+ | line <- T.lines fixture+ , not (T.null line)+ , not (T.isPrefixOf (T.pack "#") line) ]+ titleOf line = case T.splitOn (T.pack "\t") line of+ (name:_) -> case M.lookup name byName of+ Just t -> t+ Nothing -> error ("pre-vland2 semantics: unknown title " ++ T.unpack name)+ [] -> error "pre-vland2 semantics: empty row"+ actualDiff =+ [ titleOf line | line <- rows, currentRow (titleOf line) /= line ]+ assertEqual "pre-vland2 semantics fixture covers all 116 concrete account titles"+ 116 (L.length rows)+ assertEqual "pre-vland2 semantics closed diff"+ vocabSemanticsExpectedClosedDiff actualDiff++-- | R1 sentinel: a /balanced/ ledger (credit total == debit total, net income+-- zero) makes 'diffRL' report the wildcard 'Side'. Before the fix,+-- 'incomeSummaryAccount' matched only Credit/Debit and crashed with+-- "Non-exhaustive patterns". Run every closing-transfer function (Alg and+-- Journal) over a balanced ledger and force the result; none may throw.+--+-- The ledger pairs equal Sales (Revenue/Credit) and WageExpenditure+-- (Cost/Debit) amounts so @decR == decL@ (balanced).+balancedAlgSample :: TransferAlg+balancedAlgSample = EA.fromList+ [ 50 :@ Not :<(Sales, 1, 1, Yen) -- credit (revenue)+ , 50 :@ Not :<(WageExpenditure, 1, 1, Yen) -- debit (cost)+ , 20 :@ Not :<(Purchases, 2, 2, Yen) -- debit (cost)+ , 20 :@ Not :<(InterestEarned, 2, 2, Yen) -- credit (revenue)+ ]++balancedJournalSample :: TransferJournal+balancedJournalSample = EJ.fromList+ [ balancedAlgSample .| "A"+ , ((10 :@ Not :<(Sales, 3, 3, Yen)) .+ (10 :@ Not :<(Purchases, 3, 3, Yen))) .| "B"+ ]++testIncomeSummaryBalancedNoCrash :: IO ()+testIncomeSummaryBalancedNoCrash = do+ -- confirm the ledger really is balanced (triggers the wildcard Side)+ case EA.diffRL balancedAlgSample of+ (Side, _) -> return ()+ other -> do putStrLn ("[FAIL] balanced sample not balanced: " ++ show (fst other))+ exitFailure+ let algFns =+ [ ("incomeSummaryAccount", EAT.incomeSummaryAccount)+ , ("netIncomeTransfer", EAT.netIncomeTransfer)+ , ("grossProfitTransfer", EAT.grossProfitTransfer)+ , ("ordinaryProfitTransfer",EAT.ordinaryProfitTransfer)+ , ("retainedEarningTransfer",EAT.retainedEarningTransfer)+ , ("finalStockTransfer", EAT.finalStockTransfer)+ ]+ jFns =+ [ ("incomeSummaryAccount", EJT.incomeSummaryAccount)+ , ("netIncomeTransfer", EJT.netIncomeTransfer)+ , ("grossProfitTransfer", EJT.grossProfitTransfer)+ , ("ordinaryProfitTransfer",EJT.ordinaryProfitTransfer)+ , ("retainedEarningTransfer",EJT.retainedEarningTransfer)+ , ("finalStockTransfer", EJT.finalStockTransfer)+ ]+ forM_ algFns $ \(nm, f) -> do+ r <- try (evaluate (EA.norm (f balancedAlgSample)))+ :: IO (Either SomeException Double)+ case r of+ Right _ -> return ()+ Left e -> do putStrLn ("[FAIL] Alg." ++ nm ++ " threw on balanced ledger: " ++ show e)+ exitFailure+ forM_ jFns $ \(nm, f) -> do+ r <- try (evaluate (EA.norm (EJ.toAlg (f balancedJournalSample))))+ :: IO (Either SomeException Double)+ case r of+ Right _ -> return ()+ Left e -> do putStrLn ("[FAIL] Journal." ++ nm ++ " threw on balanced ledger: " ++ show e)+ exitFailure+ putStrLn "[PASS] all closing transfers identity-safe on balanced ledger (R1)"++type SpillRestoreJournal = EJ.Journal (String, Int) Double (HatBase CountUnit)++-- | Design-review C4: the spill/eviction decision logic is single-sourced in+-- 'ES.stepBackWith' / 'ES.spillDeleteDecision' (previously duplicated inline+-- in the classic spill loop and in Lite's retention loop). Pin the decision+-- table and the equivalence with Lite's former @backByTerms@.+testSpillDecisionSingleSource :: IO ()+testSpillDecisionSingleSource = do+ assertEqual "stepBackWith pred 3 10" (7 :: Int) (ES.stepBackWith pred 3 10)+ assertEqual "stepBackWith is id for n <= 0" (10 :: Int) (ES.stepBackWith pred 0 10)+ assertEqual "stepBackWith is id for negative n" (10 :: Int) (ES.stepBackWith pred (-1) 10)+ assertEqual "NoDelete evicts nothing"+ Nothing (ES.spillDeleteDecision pred (ES.NoDelete :: ES.SpillDeletePolicy Int) (1, 10))+ assertEqual "DeleteSpilledChunk evicts exactly the chunk"+ (Just (1, 10)) (ES.spillDeleteDecision pred ES.DeleteSpilledChunk (1 :: Int, 10))+ assertEqual "KeepRecentTerms 3 keeps the trailing window"+ (Just (1, 7)) (ES.spillDeleteDecision pred (ES.KeepRecentTerms 3) (1 :: Int, 10))+ assertEqual "KeepRecentTerms covering the chunk evicts nothing"+ Nothing (ES.spillDeleteDecision pred (ES.KeepRecentTerms 12) (1 :: Int, 10))+ -- Lite boundary equivalence: former backByTerms w t == stepBackWith pred w t+ let backByTermsRef w t = let go n x | n <= (0 :: Int) = x+ | otherwise = go (n - 1) (pred x)+ in go w t+ forM_ [(0, 5), (1, 5), (3, 5), (7, 5)] $ \(w, t) ->+ assertEqual ("Lite boundary equivalence w=" ++ show w)+ (backByTermsRef w t) (ES.stepBackWith pred w (t :: Int))++testRestoreJournalFromBinarySpill :: IO ()+testRestoreJournalFromBinarySpill = do+ let spillPath = "/tmp/exchangealgebra_spill_restore_test.bin"+ chunk1 :: SpillRestoreJournal+ chunk1 = EJ.fromList+ [ (1 .@ (Hat :< Yen)) .| ("A", 1)+ , (2 .@ (Not :< Amount)) .| ("B", 2)+ ]+ chunk2 :: SpillRestoreJournal+ chunk2 = (3 .@ (Hat :< Yen)) .| ("C", 3)+ currentLedger :: SpillRestoreJournal+ currentLedger = EJ.fromList+ [ (4 .@ (Not :< Amount)) .| ("Tail", 4)+ , (8 .@ (Hat :< Yen)) .| ("AlreadySpilled", 2)+ ]+ expected :: SpillRestoreJournal+ expected = chunk1 .+ chunk2 .+ ((4 .@ (Not :< Amount)) .| ("Tail", 4))++ removeSpillTestFile spillPath+ withFile spillPath WriteMode $ \h -> do+ ES.defaultBinarySpillWriter h (1 :: Int, 2 :: Int) chunk1+ ES.defaultBinarySpillWriter h (3 :: Int, 3 :: Int) chunk2++ actual <- restoreJournalFromBinarySpill spillPath snd currentLedger+ assertEqual "Write.restoreJournalFromBinarySpill merges spill + tail remainder"+ (EJ.toMap expected)+ (EJ.toMap actual)+ removeSpillTestFile spillPath++removeSpillTestFile :: FilePath -> IO ()+removeSpillTestFile path = do+ _ <- try (removeFile path) :: IO (Either SomeException ())+ pure ()++writeSpillTestChunks+ :: FilePath+ -> [((Int, Int), SpillRestoreJournal)]+ -> IO ()+writeSpillTestChunks path chunks = do+ removeSpillTestFile path+ withFile path WriteMode $ \h ->+ forM_ chunks $ \(termRange, chunk) ->+ ES.defaultBinarySpillWriter h termRange chunk++spillCheckedChunk1 :: SpillRestoreJournal+spillCheckedChunk1 = EJ.fromList+ [ (1 .@ (Hat :< Yen)) .| ("A", 1)+ , (2 .@ (Not :< Amount)) .| ("B", 2)+ ]++spillCheckedChunk2 :: SpillRestoreJournal+spillCheckedChunk2 = (3 .@ (Hat :< Yen)) .| ("C", 3)++spillCheckedCurrent :: SpillRestoreJournal+spillCheckedCurrent = EJ.fromList+ [ (4 .@ (Not :< Amount)) .| ("Tail", 4)+ , (8 .@ (Hat :< Yen)) .| ("AlreadySpilled", 2)+ ]++spillCheckedExpected :: SpillRestoreJournal+spillCheckedExpected =+ spillCheckedChunk1 .+ spillCheckedChunk2+ .+ ((4 .@ (Not :< Amount)) .| ("Tail", 4))++testSpillCheckedReaderWellFormed :: IO ()+testSpillCheckedReaderWellFormed = do+ let path = "/tmp/exchangealgebra_spill_checked_well_formed.bin"+ chunks = [((1, 2), spillCheckedChunk1), ((3, 3), spillCheckedChunk2)]+ writeSpillTestChunks path chunks+ readResult <- ES.readBinarySpillFileChecked path+ :: IO (Either (ES.SpillReadError Int) [((Int, Int), SpillRestoreJournal)])+ case readResult of+ Left err -> assertEqual "checked spill reader accepts well-formed chunks"+ "Right with two chunks" (ES.renderSpillReadError err)+ Right decoded -> assertEqual "checked spill reader returns both chunks"+ 2 (L.length decoded)+ restored <- restoreJournalFromBinarySpillChecked path snd spillCheckedCurrent+ case restored of+ Left err -> assertEqual "checked spill restore accepts well-formed chunks"+ "Right restored ledger" (ES.renderSpillReadError err)+ Right actual -> assertEqual "checked spill restore merges spill + tail remainder"+ (EJ.toMap spillCheckedExpected) (EJ.toMap actual)+ removeSpillTestFile path++testSpillCheckedReaderTruncated :: IO ()+testSpillCheckedReaderTruncated = do+ let path = "/tmp/exchangealgebra_spill_checked_truncated.bin"+ encodedChunk2 = Binary.encode+ ((3 :: Int, 3 :: Int), spillCheckedChunk2)+ truncatedChunk2 = BL.take (BL.length encodedChunk2 `div` 2) encodedChunk2+ removeSpillTestFile path+ withFile path WriteMode $ \h -> do+ ES.defaultBinarySpillWriter h (1 :: Int, 2 :: Int) spillCheckedChunk1+ BL.hPut h truncatedChunk2+ result <- ES.readBinarySpillFileChecked path+ :: IO (Either (ES.SpillReadError Int) [((Int, Int), SpillRestoreJournal)])+ case result of+ Left (ES.SpillDecodeFailure offset chunks _) -> do+ assertEqual "truncated spill failure follows first chunk" True (offset > 0)+ assertEqual "truncated spill reports decoded chunk count" 1 chunks+ other -> assertEqual "truncated spill is a decode failure"+ "SpillDecodeFailure" (show other)+ caught <- try+ (restoreJournalFromBinarySpill path snd (mempty :: SpillRestoreJournal))+ :: IO (Either ErrorCall SpillRestoreJournal)+ case caught of+ Left _ -> putStrLn "[PASS] unchecked spill restore raises ErrorCall"+ Right _ -> assertEqual "unchecked spill restore raises ErrorCall" True False+ removeSpillTestFile path++testSpillCheckedReaderStaleAppend :: IO ()+testSpillCheckedReaderStaleAppend = do+ let path = "/tmp/exchangealgebra_spill_checked_stale_append.bin"+ writeSpillTestChunks path+ [ ((1, 2), spillCheckedChunk1)+ , ((3, 3), spillCheckedChunk2)+ , ((1, 2), spillCheckedChunk1)+ ]+ result <- ES.readBinarySpillFileChecked path+ :: IO (Either (ES.SpillReadError Int) [((Int, Int), SpillRestoreJournal)])+ assertEqual "checked spill reader rejects stale append"+ (Left (ES.SpillRangeError ES.ChunkOutOfOrder (3, 3) (1, 2))) (fmap (fmap fst) result)+ removeSpillTestFile path++testSpillCheckedReaderOverlap :: IO ()+testSpillCheckedReaderOverlap = do+ let path = "/tmp/exchangealgebra_spill_checked_overlap.bin"+ writeSpillTestChunks path+ [((1, 3), spillCheckedChunk1), ((2, 4), spillCheckedChunk2)]+ result <- ES.readBinarySpillFileChecked path+ :: IO (Either (ES.SpillReadError Int) [((Int, Int), SpillRestoreJournal)])+ assertEqual "checked spill reader rejects overlap"+ (Left (ES.SpillRangeError ES.ChunkOverlap (1, 3) (2, 4))) (fmap (fmap fst) result)+ removeSpillTestFile path++testSpillCheckedReaderGap :: IO ()+testSpillCheckedReaderGap = do+ let path = "/tmp/exchangealgebra_spill_checked_gap.bin"+ writeSpillTestChunks path+ [((1, 2), spillCheckedChunk1), ((4, 4), spillCheckedChunk2)]+ result <- ES.readBinarySpillFileChecked path+ :: IO (Either (ES.SpillReadError Int) [((Int, Int), SpillRestoreJournal)])+ assertEqual "checked spill reader rejects gap"+ (Left (ES.SpillRangeError ES.ChunkGap (1, 2) (4, 4))) (fmap (fmap fst) result)+ removeSpillTestFile path++testSpillCheckedReaderEmptyRange :: IO ()+testSpillCheckedReaderEmptyRange = do+ let path = "/tmp/exchangealgebra_spill_checked_empty_range.bin"+ writeSpillTestChunks path [((3, 1), spillCheckedChunk1)]+ result <- ES.readBinarySpillFileChecked path+ :: IO (Either (ES.SpillReadError Int) [((Int, Int), SpillRestoreJournal)])+ assertEqual "checked spill reader rejects empty range"+ (Left (ES.SpillEmptyRange (3, 1))) (fmap (fmap fst) result)+ removeSpillTestFile path++testSpillCheckedReaderEmptyFile :: IO ()+testSpillCheckedReaderEmptyFile = do+ let path = "/tmp/exchangealgebra_spill_checked_empty_file.bin"+ removeSpillTestFile path+ withFile path WriteMode $ \_ -> pure ()+ result <- ES.readBinarySpillFileChecked path+ :: IO (Either (ES.SpillReadError Int) [((Int, Int), SpillRestoreJournal)])+ assertEqual "checked spill reader accepts empty file" (Right []) (fmap (fmap fst) result)+ removeSpillTestFile path++-- ================================================================+-- SimulateEx1 reproduction (default scenario only, no parallelism)+-- ================================================================++type SimTerm = Int++instance StateTime SimTerm where+ initTerm = 1+ lastTerm = 100+data SimInitVar = SimInitVar+ { _simInitStock :: Double+ , _simSteadyProduction :: Double+ , _simInhouseRatio :: Double+ } deriving (Eq, Show)++instance InitVariables SimInitVar where++data SimEvent+ = SimSalesPurchase+ | SimProduction+ | SimPlank+ deriving (Ord, Show, Enum, Eq, Bounded, Generic)++instance Hashable SimEvent where++instance Note SimEvent where+ plank = SimPlank++instance Event SimEvent where++type SimCompany = Int++instance Element SimCompany where+ wildcard = -1++instance BaseClass SimCompany where++simFstC, simLastC :: SimCompany+simFstC = 1+simLastC = 6++simCompanies :: [SimCompany]+simCompanies = [simFstC .. simLastC]++type SimHatBase2 = HatBase (AccountTitles, SimCompany, SimCompany, CountUnit)++instance ExBaseClass SimHatBase2 where+ getAccountTitle (h :< (a, _, _, _)) = a+ setAccountTitle (h :< (_, c, e, u)) b = h :< (b, c, e, u)++-- Accounting value type is MoneyDecimal (exact): ledger arithmetic is exact and+-- construction-order-independent. ABM parameters / input coefficients / random+-- draws remain Double and are converted (realToFrac) at the boundary where they+-- enter the ledger; reported stock/profit convert back to Double.+type SimTransaction = EJ.Journal (SimEvent, SimTerm) MoneyDecimal SimHatBase2++simCompressPreviousTerm :: SimTerm -> SimTransaction -> SimTransaction+simCompressPreviousTerm t le =+ EJ.fromMap $+ L.foldl' (\acc ev -> HM.adjust compress (ev, t) acc)+ (EJ.toMap le)+ [fstEvent .. lastEvent]++newtype SimLedger s = SimLedger (STRef s SimTransaction)++instance UpdatableSTRef SimLedger s SimTransaction where+ _unwrapURef (SimLedger x) = x+ _wrapURef x = SimLedger x++simInitLedger :: Double -> ST s (SimLedger s)+simInitLedger d = newURef $ EJ.fromList+ [ realToFrac d :@ Not :<(Products, e, e, Amount) .| (plank, initTerm) -- Double param -> MoneyDecimal+ | e <- simCompanies+ ]++instance Updatable SimTerm SimInitVar SimLedger s where+ type Inner SimLedger s = STRef s SimTransaction+ unwrap = _unwrapURef+ initialize _ _ e = simInitLedger (_simInitStock e)+ updatePattern _ = return Modify+ modify _ t _ x = do+ le <- readURef x+ let added = EJ.gather (plank, t)+ $ EJT.finalStockTransfer+ $ (.-) $ simTermJournal (t - 1) le+ next = simCompressPreviousTerm (t - 1) (le .+ added)+ writeURef x next++type SimInputCoefficient = Double++newtype SimICTable s = SimICTable (STArray s (SimCompany, SimCompany) SimInputCoefficient)++instance UpdatableSTArray SimICTable s (SimCompany, SimCompany) SimInputCoefficient where+ _unwrapUArray (SimICTable arr) = arr+ _wrapUArray arr = SimICTable arr++simGenerateRandomList :: StdGen -> Int -> ([Double], StdGen)+simGenerateRandomList g n =+ let (xs, g') = runState (replicateM n (state (randomR (0, 1.0))))+ (updateGen g 1000)+ ys = L.map (\v -> if v < 0.1 then 0 else v) xs+ in (ys, g')++simInitTermCoefficients :: StdGen -> Double -> M.Map SimCompany [SimInputCoefficient]+simInitTermCoefficients g inhouseRatio =+ fst $ L.foldl' buildRow (M.empty, g) simCompanies+ where+ buildRow (acc, g0) c2 =+ let (row, g1) = generateRow g0+ in (M.insert c2 row acc, g1)+ generateRow g0 =+ let (vals, g1) = simGenerateRandomList g0 simLastC+ total = sum vals+ normalized = L.map (\v -> (v / total) * inhouseRatio) vals+ in (normalized, g1)++simInitICTables :: StdGen -> Double -> ST s (SimICTable s)+simInitICTables g inhouseRatio = do+ arr <- newUArray ((simFstC, simFstC), (simLastC, simLastC)) 0+ let termCoefficients = simInitTermCoefficients g inhouseRatio+ forM_ simCompanies $ \c2 -> do+ let row = termCoefficients M.! c2+ forM_ (zip simCompanies row) $ \(c1, coef) ->+ writeUArray arr (c1, c2) coef+ return arr++instance Updatable SimTerm SimInitVar SimICTable s where+ type Inner SimICTable s = STArray s (SimCompany, SimCompany) SimInputCoefficient+ unwrap (SimICTable a) = a+ initialize g _ e = simInitICTables g (_simInhouseRatio e)+ updatePattern _ = return DoNothing++type SimSteadyProd = Double++newtype SimSP s = SimSP (STRef s SimSteadyProd)++instance UpdatableSTRef SimSP s SimSteadyProd where+ _unwrapURef (SimSP x) = x+ _wrapURef x = SimSP x++instance Updatable SimTerm SimInitVar SimSP s where+ type Inner SimSP s = STRef s SimSteadyProd+ unwrap = _unwrapURef+ initialize _ _ e = newURef (_simSteadyProduction e)+ updatePattern _ = return DoNothing++data SimWorld s = SimWorld+ { _simLedger :: SimLedger s+ , _simIcs :: SimICTable s+ , _simSp :: SimSP s+ } deriving (Generic)++-- helper functions++simTermJournal :: SimTerm -> SimTransaction -> SimTransaction+simTermJournal t = EJ.filterWithNote (\(_, t') _ -> t' == t)++simGetOneProduction :: SimWorld s -> SimTerm -> SimCompany -> ST s SimTransaction+simGetOneProduction wld t c = do+ let arr = _simIcs wld+ inputs <- mapM (\c2 -> do+ coef <- readUArray arr (c2, c)+ return $ realToFrac coef :@ Hat :<(Products, c2, c, Amount) .| (SimProduction, t) -- Double coef -> MoneyDecimal+ ) simCompanies+ let totalInput = EJ.fromList inputs+ result = (1 :@ Not :<(Products, c, c, Amount) .| (SimProduction, t)) .+ totalInput+ return result++simJournal :: SimWorld s -> SimTransaction -> ST s ()+simJournal _ Zero = return ()+simJournal wld js = modifyURef (_simLedger wld) (\x -> x .+ js)++-- Values come from the MoneyDecimal ledger (via EA.toList), so the shortage map is+-- MoneyDecimal-valued; no conversion is needed and the amounts re-enter the ledger exactly.+simBuildShortageMap :: SimTerm -> SimTransaction -> M.Map (SimCompany, SimCompany) MoneyDecimal+simBuildShortageMap t le =+ let termAlg = EJ.toAlg $ (.-) $ simTermJournal t le+ in L.foldl' go M.empty (EA.toList termAlg)+ where+ go acc (v :@ (Hat :< (Products, j, i, Amount))) = M.insertWith (+) (i, j) v acc+ go acc _ = acc++simPurchases :: SimTerm -> SimWorld s -> ST s SimTransaction+simPurchases t wld = do+ le <- readURef (_simLedger wld)+ let shortageMap = simBuildShortageMap t le+ o i j = M.findWithDefault 0 (i, j) shortageMap+ return $ sigma simCompanies $ \i+ -> sigma (simCompanies L.\\ [i]) $ \j+ -> (o i j) :@ Not :<(Products, j, i, Amount)+ .+ (o i j) :@ Hat :<(Cash, (.#), i, Yen)+ .+ (o i j) :@ Not :<(Purchases, (.#), i, Yen)+ .+ (o i j) :@ Not :<(Cash, (.#), j, Yen)+ .+ (o i j) :@ Not :<(Sales, (.#), j, Yen)+ .+ (o i j) :@ Hat :<(Products, j, j, Amount)+ .| (SimSalesPurchase, t)++instance StateSpace SimTerm SimInitVar SimEvent SimWorld s where+ event = simEvent++simEvent :: SimWorld s -> SimTerm -> SimEvent -> ST s ()++simEvent wld t SimSalesPurchase = do+ toAdd <- simPurchases t wld+ simJournal wld toAdd++simEvent wld t SimProduction = do+ sp <- readURef (_simSp wld)+ forM_ simCompanies $ \e1 -> do+ op <- simGetOneProduction wld t e1+ simJournal wld (realToFrac sp .* op) -- Double steady-production multiplier -> MoneyDecimal scalar++simEvent _ _ SimPlank = return ()++simGetTermStock :: SimWorld s -> SimTerm -> SimCompany -> ST s Double+simGetTermStock wld t e = do+ le <- readURef (_simLedger wld)+ let tj = (.-) $ simTermJournal t le+ plusStock = norm $ EJ.projWithBase [Not :<(Products, e, e, Amount)] tj+ minusStock = norm $ EJ.projWithBase [Hat :<(Products, e, e, Amount)] tj+ return $ realToFrac (plusStock - minusStock) -- exact MoneyDecimal stock -> Double for reporting++simGetTermGrossProfit :: SimWorld s -> SimTerm -> SimCompany -> ST s Double+simGetTermGrossProfit wld t e = do+ le <- readURef (_simLedger wld)+ let termTr = simTermJournal t le+ tr = EJT.grossProfitTransfer termTr+ plus = norm $ EJ.projWithBase [Not :<(GrossProfit, (.#), e, Yen)] tr+ minus = norm $ EJ.projWithBase [Hat :<(GrossProfit, (.#), e, Yen)] tr+ return $ realToFrac (plus - minus) -- exact MoneyDecimal -> Double for reporting++-- ================================================================+-- Simulation integration test+-- ================================================================++simEps :: Double+simEps = 1e-6++assertSimNear :: String -> Double -> Double -> IO ()+assertSimNear label expected actual+ | abs (expected - actual) <= simEps = putStrLn ("[PASS] " ++ label)+ | otherwise = do+ putStrLn ("[FAIL] " ++ label)+ putStrLn (" expected: " ++ show expected)+ putStrLn (" actual : " ++ show actual)+ exitFailure++testSimulateEx1Default :: IO ()+testSimulateEx1Default = do+ let gen = mkStdGen 2025+ defaultEnv = SimInitVar+ { _simInitStock = 20+ , _simInhouseRatio = 0.4+ , _simSteadyProduction = 10+ }++ wld <- ES.runSimulation gen defaultEnv++ -- Stock at term 1 for each company+ stocks1 <- stToIO $ mapM (simGetTermStock wld 1) simCompanies+ -- Stock at term 50 for each company+ stocks50 <- stToIO $ mapM (simGetTermStock wld 50) simCompanies+ -- Stock at term 100 for each company+ stocks100 <- stToIO $ mapM (simGetTermStock wld 100) simCompanies+ -- Gross profit at term 50 for each company+ profits50 <- stToIO $ mapM (simGetTermGrossProfit wld 50) simCompanies++ -- Stock at t=1+ assertSimNear "sim1 stock(t=1,c=1)" 28.487224703666264 (stocks1 !! 0)+ assertSimNear "sim1 stock(t=1,c=3)" 30.0 (stocks1 !! 2)+ assertSimNear "sim1 stock(t=1,c=6)" 30.0 (stocks1 !! 5) -- re-baselined: union zero-base fix removed a phantom self-input+ -- Stock at t=50+ assertSimNear "sim1 stock(t=50,c=1)" 304.9028131162567 (stocks50 !! 0)+ assertSimNear "sim1 stock(t=50,c=4)" 292.4764622201871 (stocks50 !! 3)+ -- Stock at t=100+ assertSimNear "sim1 stock(t=100,c=1)" 586.9595359862476 (stocks100 !! 0)+ assertSimNear "sim1 stock(t=100,c=6)" 767.9605634804993 (stocks100 !! 5) -- re-baselined: union zero-base fix (bug compounded over terms)+ -- Gross profit at t=50+ assertSimNear "sim1 profit(t=50,c=1)" 0.35886554260018855 (profits50 !! 0)+ assertSimNear "sim1 profit(t=50,c=2)" 1.572544209772035 (profits50 !! 1)++-- ================================================================+-- CSV Write tests+-- ================================================================++testCsvTranspose :: IO ()+testCsvTranspose = do+ -- Square matrix+ let input1 = [ [T.pack "a", T.pack "b"]+ , [T.pack "c", T.pack "d"] ]+ expected1 = [ [T.pack "a", T.pack "c"]+ , [T.pack "b", T.pack "d"] ]+ assertEqual "CSV.transpose square matrix" expected1 (EW.csvTranspose input1)++ -- Ragged matrix (shorter rows padded with empty)+ let input2 = [ [T.pack "a", T.pack "b", T.pack "c"]+ , [T.pack "d"] ]+ expected2 = [ [T.pack "a", T.pack "d"]+ , [T.pack "b", T.empty]+ , [T.pack "c", T.empty] ]+ assertEqual "CSV.transpose ragged matrix" expected2 (EW.csvTranspose input2)++ -- Single row+ let input3 = [[T.pack "x", T.pack "y", T.pack "z"]]+ expected3 = [[T.pack "x"], [T.pack "y"], [T.pack "z"]]+ assertEqual "CSV.transpose single row" expected3 (EW.csvTranspose input3)++ -- Empty+ assertEqual "CSV.transpose empty" ([] :: [[T.Text]]) (EW.csvTranspose [])++testCsvWriteCSV :: IO ()+testCsvWriteCSV = do+ let path = "/tmp/exchangealgebra_csv_test.csv"+ input = [ [T.pack "Name", T.pack "Value"]+ , [T.pack "Alice", T.pack "100"]+ , [T.pack "Bob", T.pack "200"] ]+ EW.writeCSV path input+ raw <- readFileStrict path+ -- Each cell should be quoted+ let lns = lines raw+ assertEqual "CSV writeCSV line count" 3 (length lns)+ assertEqual "CSV writeCSV header" "\"Name\",\"Value\"" (lns !! 0)+ assertEqual "CSV writeCSV row 1" "\"Alice\",\"100\"" (lns !! 1)+ assertEqual "CSV writeCSV row 2" "\"Bob\",\"200\"" (lns !! 2)+ removeFile path++testCsvWriteCSVWithQuotes :: IO ()+testCsvWriteCSVWithQuotes = do+ let path = "/tmp/exchangealgebra_csv_quote_test.csv"+ input = [[T.pack "say \"hello\"", T.pack "a,b"]]+ EW.writeCSV path input+ raw <- readFileStrict path+ let lns = lines raw+ -- Internal quotes should be escaped as ""+ assertEqual "CSV writeCSV escapes quotes" "\"say \"\"hello\"\"\",\"a,b\"" (lns !! 0)+ removeFile path++testCsvWriteCSVEmpty :: IO ()+testCsvWriteCSVEmpty = do+ let path = "/tmp/exchangealgebra_csv_empty_test.csv"+ input = [[T.pack "", T.pack "x"]]+ EW.writeCSV path input+ raw <- readFileStrict path+ let lns = lines raw+ assertEqual "CSV writeCSV empty cell" "\"\",\"x\"" (lns !! 0)+ removeFile path++-- ================================================================+-- Legacy-generation writer output-pinning tests (design-review C7)+--+-- These pin the exact CSV bytes produced, for small fixed inputs, by the+-- "legacy generation" writers (writeBS / writePL / writeJournal /+-- writeCompoundTrialBalance / writeAccountOfJournal) as computed by the+-- pre-refactor implementation. Their purpose is to let the "generation+-- unification" refactor (pure *Rows builder + thin IO wrapper, matching the+-- worksheetRows/postClosingTrialBalanceRows/accountLedgerRows style) be+-- verified to leave output bit-for-bit unchanged: these must stay green,+-- unmodified, across the refactor.+-- ================================================================++testWriteBSPinned :: IO ()+testWriteBSPinned = do+ let path = "/tmp/exchangealgebra_write_bs_pinned_test.csv"+ alg = (100 .@ Not :< Cash)+ .+ (60 .@ Not :< LoansPayable)+ .+ (40 .@ Not :< CapitalStock)+ :: EA.Alg Double (HatBase AccountTitles)+ EW.writeBS path alg+ raw <- readFileStrict path+ removeFile path+ let lns = lines raw+ assertEqual "writeBS pinned: line count" 5 (length lns)+ assertEqual "writeBS pinned: row0 (Asset/Liability headers)"+ "\"Asset\",\"\",\"Liability\",\"\"" (lns !! 0)+ assertEqual "writeBS pinned: row1 (Cash/LoansPayable)"+ "\"Cash\",\"100.0\",\"LoansPayable\",\"60.0\"" (lns !! 1)+ assertEqual "writeBS pinned: row2 (Total/Equity header)"+ "\"Total\",\"100.0\",\"Equity\",\"\"" (lns !! 2)+ assertEqual "writeBS pinned: row3 (CapitalStock)"+ "\"\",\"\",\"CapitalStock\",\"40.0\"" (lns !! 3)+ assertEqual "writeBS pinned: row4 (grand total)"+ "\"\",\"\",\"Total\",\"100.0\"" (lns !! 4)++testWritePLPinned :: IO ()+testWritePLPinned = do+ let path = "/tmp/exchangealgebra_write_pl_pinned_test.csv"+ alg = (500 .@ Not :< Sales)+ .+ (300 .@ Not :< SalesCost)+ :: EA.Alg Double (HatBase AccountTitles)+ EW.writePL path alg+ raw <- readFileStrict path+ removeFile path+ let lns = lines raw+ assertEqual "writePL pinned: line count" 3 (length lns)+ assertEqual "writePL pinned: row0 (Cost/Revenue headers)"+ "\"Cost\",\"\",\"Revenue\",\"\"" (lns !! 0)+ assertEqual "writePL pinned: row1 (SalesCost/Sales)"+ "\"SalesCost\",\"300.0\",\"Sales\",\"500.0\"" (lns !! 1)+ assertEqual "writePL pinned: row2 (totals)"+ "\"Total\",\"500.0\",\"Total\",\"300.0\"" (lns !! 2)++testWriteJournalPinned :: IO ()+testWriteJournalPinned = do+ let path = "/tmp/exchangealgebra_write_journal_pinned_test.csv"+ d1 = fromGregorian 2024 4 1+ d2 = fromGregorian 2024 4 2+ d3 = fromGregorian 2024 4 3+ getDay' :: HatBase (AccountTitles, Day) -> Day+ getDay' (_ :< (_, d)) = d+ alg = (100 .@ Not :< (Cash, d1))+ .+ (100 .@ Not :< (CapitalStock, d1))+ .+ (50 .@ Not :< (Cash, d2))+ .+ (50 .@ Not :< (Sales, d2))+ .+ (30 .@ Not :< (Cash, d3))+ .+ (10 .@ Not :< (AccountsReceivable, d3))+ .+ (40 .@ Not :< (Sales, d3))+ :: EA.Alg Double (HatBase (AccountTitles, Day))+ EW.writeJournal path alg getDay'+ raw <- readFileStrict path+ removeFile path+ let lns = lines raw+ assertEqual "writeJournal pinned: line count" 5 (length lns)+ assertEqual "writeJournal pinned: header"+ "\"Day\",\"Debit\",\"Amount\",\"Credit\",\"Amount\"" (lns !! 0)+ assertEqual "writeJournal pinned: day1"+ "\"2024-04-01\",\"Cash\",\"100.0\",\"CapitalStock\",\"100.0\"" (lns !! 1)+ assertEqual "writeJournal pinned: day2"+ "\"2024-04-02\",\"Cash\",\"50.0\",\"Sales\",\"50.0\"" (lns !! 2)+ assertEqual "writeJournal pinned: day3 line1 (2 debits vs 1 credit -> toSameLength padding)"+ "\"2024-04-03\",\"AccountsReceivable\",\"10.0\",\"Sales\",\"40.0\"" (lns !! 3)+ assertEqual "writeJournal pinned: day3 line2 (padded Day/Credit cells empty)"+ "\"\",\"Cash\",\"30.0\",\"\",\"\"" (lns !! 4)++testWriteCompoundTrialBalancePinned :: IO ()+testWriteCompoundTrialBalancePinned = do+ let path = "/tmp/exchangealgebra_write_ctb_pinned_test.csv"+ alg = (100 .@ Not :< Cash)+ .+ (60 .@ Not :< LoansPayable)+ .+ (40 .@ Not :< CapitalStock)+ :: EA.Alg Double (HatBase AccountTitles)+ EW.writeCompoundTrialBalance path alg+ raw <- readFileStrict path+ removeFile path+ let lns = lines raw+ assertEqual "writeCompoundTrialBalance pinned: line count" 5 (length lns)+ assertEqual "writeCompoundTrialBalance pinned: header"+ "\"Debit Balance\",\"Debit Total\",\"Account Title\",\"Credit Total\",\"Credit Balance\""+ (lns !! 0)+ -- NOTE (legacy layout quirk, preserved verbatim): a debit-balance account+ -- (Cash: gross debit 100 / credit 0) places its balance figure in the+ -- *Credit Balance* column (rightmost), not the *Debit Balance* column,+ -- and a credit-balance account (CapitalStock/LoansPayable) places it in+ -- *Debit Balance* (leftmost) -- the opposite of the (side,mag) ->+ -- (debitCell,creditCell) convention 'sideCells' uses elsewhere+ -- (worksheetRows / postClosingTrialBalanceRows). See the Haddock on+ -- 'compoundTrialBalanceRows' for why this was kept as explicit case+ -- analysis instead of being consolidated onto 'sideCells'.+ assertEqual "writeCompoundTrialBalance pinned: Cash (debit-heavy -> Credit Balance col)"+ "\"\",\"100.0\",\"Cash\",\"0.0\",\"100.0\"" (lns !! 1)+ assertEqual "writeCompoundTrialBalance pinned: CapitalStock (credit-heavy -> Debit Balance col)"+ "\"40.0\",\"0.0\",\"CapitalStock\",\"40.0\",\"\"" (lns !! 2)+ assertEqual "writeCompoundTrialBalance pinned: LoansPayable (credit-heavy -> Debit Balance col)"+ "\"60.0\",\"0.0\",\"LoansPayable\",\"60.0\",\"\"" (lns !! 3)+ assertEqual "writeCompoundTrialBalance pinned: totals"+ "\"100.0\",\"100.0\",\"Total\",\"100.0\",\"100.0\"" (lns !! 4)++testWriteAccountOfJournalPinned :: IO ()+testWriteAccountOfJournalPinned = do+ let path = "/tmp/exchangealgebra_write_aoj_pinned_test.csv"+ jrn = ((100 .@ Not :< Cash) .| "sale")+ .+ ((40 .@ Hat :< Cash) .| "pay")+ :: Journal String Double (HatBase AccountTitles)+ EW.writeAccountOfJournal [Cash] path jrn+ raw <- readFileStrict path+ removeFile path+ let lns = lines raw+ assertEqual "writeAccountOfJournal pinned: line count" 4 (length lns)+ assertEqual "writeAccountOfJournal pinned: title header"+ "\"Cash\",\"\",\"\"" (lns !! 0)+ assertEqual "writeAccountOfJournal pinned: sub header"+ "\"Note\",\"Debit\",\"Credit\"" (lns !! 1)+ assertEqual "writeAccountOfJournal pinned: note order (\"pay\" < \"sale\")"+ "\"\"\"pay\"\"\",\"\",\"40.0\"" (lns !! 2)+ assertEqual "writeAccountOfJournal pinned: sale posting"+ "\"\"\"sale\"\"\",\"100.0\",\"\"" (lns !! 3)++-- | 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))++-- | Smoke test for the exact non-negative decimal value type 'MoneyDecimal' (Stage B).+-- The point of an exact value type is that summation is associative, so @norm@ is+-- *independent of construction order* — the property that makes the fromList O(N)+-- optimization safe (Stage D). Note the raw @Seq@ order (and hence @toMap@/@Eq@)+-- still depends on construction; only the numeric results are order-independent.+testMoneyDecimalExactOrderIndependent :: IO ()+testMoneyDecimalExactOrderIndependent = do+ assertEqual "MoneyDecimal: 0.1 + 0.2 == 0.3 exactly"+ True (0.1 + 0.2 == (0.3 :: MoneyDecimal))+ let mk i = ((fromIntegral (i `mod` 7 + 1) :: MoneyDecimal)+ :@ ((if even i then Hat else Not) :< ([Yen, Amount] !! (i `mod` 2))))+ .| show (i `mod` 150)+ xs :: [Journal String MoneyDecimal (HatBase CountUnit)]+ xs = [ mk i | i <- [1 .. 400 :: Int] ]+ viaFoldr = foldr (.+) mempty xs+ viaFoldl = L.foldl' (.+) mempty xs+ -- exact ⇒ norm is identical for the two construction orders+ assertEqual "MoneyDecimal Journal: norm is construction-order-independent"+ (norm viaFoldr) (norm viaFoldl)+ -- banker's rounding (round half to even)+ assertEqual "bankersRound 0 2.5 = 2 (half to even)" (2 :: MoneyDecimal) (bankersRound 0 2.5)+ assertEqual "bankersRound 0 3.5 = 4 (half to even)" (4 :: MoneyDecimal) (bankersRound 0 3.5)+ assertEqual "bankersRound 2 0.125 = 0.12 (half to even)" (0.12 :: MoneyDecimal) (bankersRound 2 0.125)++-- | Strict file read helper for tests+readFileStrict :: FilePath -> IO String+readFileStrict p = do+ bs <- TIO.readFile p+ return (T.unpack bs)++-- ================================================================+-- Main+-- ================================================================++-- ================================================================+-- Redundant-algebra axiom property tests (QuickCheck)+--+-- Encodes the Definition 6 axioms (paper Appendix A) + derived lemmas as+-- QuickCheck properties, plus regression generalizations for the union+-- zero-base bug and construction-order independence. Property suite, additive.+-- ================================================================++type NNAlg = EA.Alg MoneyDecimal (HatBase CountUnit)++-- run a QuickCheck property in the existing IO-style harness+quickProp :: Testable p => String -> p -> IO ()+quickProp label p = do+ r <- quickCheckWithResult stdArgs { maxSuccess = 200, chatty = False } p+ if isSuccess r+ then putStrLn ("[PASS] " ++ label)+ else do putStrLn ("[FAIL] " ++ label); putStr (output r); exitFailure++-- generators: concrete (non-wildcard) bases, intentional collisions+genUnit :: Gen CountUnit+genUnit = elements [Yen, Dollar, Amount]++genSide :: Gen Hat+genSide = elements [Hat, Not]++genBase :: Gen (HatBase CountUnit)+genBase = (:<) <$> genSide <*> genUnit++genNNDouble :: Gen Double -- non-negative, finite+genNNDouble = do+ NonNegative x <- arbitrary+ if isNaN x || isInfinite x then genNNDouble else pure x++genAlgD :: Gen TestAlg+genAlgD = sized $ \n -> do+ k <- choose (0, min 40 n)+ ps <- vectorOf k ((,) <$> genNNDouble <*> genBase)+ pure (EA.fromList [ v .@ b | (v, b) <- ps ])++genAlgN :: Gen NNAlg+genAlgN = sized $ \n -> do+ k <- choose (0, min 40 n)+ ps <- vectorOf k ((,) <$> (realToFrac <$> genNNDouble) <*> genBase)+ pure (EA.fromList [ v .@ b | (v, b) <- ps ])++-- exact per-base signed net (Not +, Hat -) via Rational; the observable+-- accounting content. Robust to seq order; catches base misassociation.+netByBase :: (HatVal v, Real v) => EA.Alg v (HatBase CountUnit) -> M.Map CountUnit Rational+netByBase = EA.foldEntries step M.empty+ where+ step m v b = M.insertWith (+) (part b) (signed v b) m+ part (_ :< u) = u+ signed v b = if isHat b then negate (toRational v) else toRational v++-- ℘ observation: retain each full Hat/Not base and its value multiset, while+-- forgetting the representation-level order of the per-side Seq.+observe :: (Ord b, Ord v, HatVal v, HatBaseClass b)+ => EA.Alg v b -> M.Map b [v]+observe = fmap L.sort . EA.foldEntries step M.empty+ where+ step m v b = M.insertWith (++) b [v] m++epsEq :: Double -> Double -> Bool+epsEq a b = abs (a - b) <= 1e-9 * (1 + max (abs a) (abs b))++-- ================================================================+-- ExchangeAlgebra.Convert.Csv: generic journal CSV reader.+-- Read-only round-trip property: a generated list of postings rendered to a+-- fixed-schema CSV string parses back to exactly the term built directly by+-- journalFromSides (MoneyDecimal = exact, so strict equality, no tolerance).+-- ================================================================++-- concrete account titles only (no wildcard); use canonical Show names so the+-- CSV round-trip does not exercise the ambiguous-alias path.+genAccountTitle :: Gen AccountTitles+genAccountTitle = elements EC.concreteAccountTitles++genSideCsv :: Gen Side+genSideCsv = elements [Debit, Credit]++-- non-negative MoneyDecimal with up to 2 decimal places, written exactly as a+-- decimal literal (terminating) so scientificAmount parses it back exactly.+genAmountMD :: Gen (MoneyDecimal, T.Text)+genAmountMD = do+ whole <- choose (0, 99999) :: Gen Integer+ cents <- choose (0, 99) :: Gen Integer+ let txt = T.pack (show whole) <> T.pack "." <>+ T.pack (let s = show cents in if length s == 1 then '0':s else s)+ val = fromRational (toRational whole + toRational cents / 100) :: MoneyDecimal+ pure (val, txt)++genPostingCsv :: Gen (Side, AccountTitles, MoneyDecimal, T.Text)+genPostingCsv = do+ s <- genSideCsv+ a <- genAccountTitle+ (v, vtx) <- genAmountMD+ pure (s, a, v, vtx)++renderCsv :: [(Side, AccountTitles, MoneyDecimal, T.Text)] -> T.Text+renderCsv rows =+ T.unlines (header : L.map line rows)+ where+ header = T.pack "side,account,amount"+ line (s, a, _, vtx) =+ T.intercalate (T.pack ",")+ [ sideText s, T.pack (show a), vtx ]+ sideText Debit = T.pack "debit"+ sideText Credit = T.pack "credit"+ sideText Side = T.pack "debit" -- unused (generator never yields wildcard)++testConvertCsvRoundTrip :: IO ()+testConvertCsvRoundTrip = do+ quickProp "convert-csv: render -> parse is exact (MoneyDecimal)" $+ forAll (resize 30 (listOf genPostingCsv)) $ \rows ->+ let csv = renderCsv rows+ expected = EC.journalFromSides+ [ (s, a, v) | (s, a, v, _) <- rows ]+ :: EA.Alg MoneyDecimal (HatBase AccountTitles)+ parsed = ECsv.parseJournalCsv csv+ :: Either EC.ConvError+ (EA.Alg MoneyDecimal (HatBase AccountTitles))+ in parsed == Right expected++ -- structural guards: bad header, unknown account, negative amount, bad arity.+ let badHeader = T.pack "s,a,amt\ndebit,Cash,1\n"+ badAcct = T.pack "side,account,amount\ndebit,Goodwill_X,1\n"+ badAmt = T.pack "side,account,amount\ndebit,Cash,-1\n"+ badArity = T.pack "side,account,amount\ndebit,Cash\n"+ run t = ECsv.parseJournalCsv t+ :: Either EC.ConvError+ (EA.Alg MoneyDecimal (HatBase AccountTitles))+ expectLeft label pat t = case run t of+ Left e | pat e -> putStrLn ("[PASS] " ++ label)+ | otherwise -> do putStrLn ("[FAIL] " ++ label ++ ": wrong error " ++ show e); exitFailure+ Right _ -> do putStrLn ("[FAIL] " ++ label ++ ": accepted bad input"); exitFailure+ expectLeft "convert-csv: rejects bad header"+ (\e -> case e of EC.MalformedCsv _ -> True; _ -> False) badHeader+ expectLeft "convert-csv: rejects unknown account"+ (\e -> case e of EC.UnknownAccount _ -> True; _ -> False) badAcct+ expectLeft "convert-csv: rejects negative amount"+ (\e -> case e of EC.BadAmount _ -> True; _ -> False) badAmt+ expectLeft "convert-csv: rejects wrong field count"+ (\e -> case e of EC.MalformedCsv _ -> True; _ -> False) badArity++-- ================================================================+-- ExchangeAlgebra.Assist: account descriptions and LLM feedback helpers.+-- ================================================================++testAssistDescriptionsDrift :: IO ()+testAssistDescriptionsDrift =+ assertEqual "Assist descriptions are the registry projection"+ registryProjection AssistDesc.accountDescriptions+ where+ registryProjection =+ [ (title, Registry.asNameEn spec, Registry.asNameJa spec, Registry.asDescription spec)+ | title <- Registry.concreteAccountTitles+ , Just spec <- [Registry.accountSpec title]+ ]++testAssistDescribeAccount :: IO ()+testAssistDescribeAccount = do+ let missing =+ [ title+ | title <- EC.concreteAccountTitles+ , Assist.describeAccount title == Nothing+ ]+ assertEqual "Assist.describeAccount covers every concrete account"+ ([] :: [AccountTitles]) missing+ assertEqual "Assist.describeAccount rejects wildcard AccountTitle"+ Nothing (Assist.describeAccount AccountTitle)++testAssistAllAccountInfos :: IO ()+testAssistAllAccountInfos = do+ assertEqual "Assist.allAccountInfos length" 235 (length Assist.allAccountInfos)+ assertEqual "Assist.allAccountInfos follows concreteAccountTitles order"+ EC.concreteAccountTitles (L.map Assist.aiTitle Assist.allAccountInfos)+ forM_ Assist.allAccountInfos $ \info -> do+ let title = Assist.aiTitle info+ case Registry.accountSemantics title of+ Nothing -> do+ putStrLn ("[FAIL] missing account semantics: " ++ show title)+ exitFailure+ Just semantics -> do+ assertEqual ("Assist.aiRoles " ++ show title)+ (Registry.asemRoles semantics) (Assist.aiRoles info)+ assertEqual ("Assist.aiPostingCapability " ++ show title)+ (Registry.asemPostingCapability semantics)+ (Assist.aiPostingCapability info)+ assertEqual ("Assist.aiDivisionSemantics " ++ show title)+ (Registry.asemDivisionSemantics semantics)+ (Assist.aiDivisionSemantics info)+ assertEqual ("Assist.aiHomeSideSemantics " ++ show title)+ (Registry.asemHomeSideSemantics semantics)+ (Assist.aiHomeSideSemantics info)+ assertEqual ("Assist.aiReportingEligibility " ++ show title)+ (Registry.asemReportingEligibility semantics)+ (Assist.aiReportingEligibility info)++testAccountMetadataLand1 :: IO ()+testAccountMetadataLand1 = do+ let semantics =+ [ (title, value)+ | title <- Registry.concreteAccountTitles+ , Just value <- [Registry.accountSemantics title]+ ]+ exceptional =+ [ NetIncome, NetLoss, GrossProfit, OrdinaryProfit, IncomeSummary+ , SuspensePayments, SuspenseReceipts, CashOverShort, SuspenseAccount+ , BranchCurrentAccount, HeadOfficeCurrentAccount+ , NetIncomeAttributableToNCI, NetLossAttributableToNCI+ ]+ lookupSem title = Registry.accountSemantics title+ lookupInfo title = Assist.describeAccount title+ nonStatementTitles =+ [ title+ | (title, value) <- semantics+ , case Registry.asemDivisionSemantics value of+ StatementDivision _ -> False+ _ -> True+ ]+ assertEqual "Land 1 metadata covers all 235 concrete titles"+ 235 (L.length semantics)+ assertEqual "Land 1 metadata rejects wildcard AccountTitle"+ Nothing (Registry.accountSemantics AccountTitle)+ assertEqual "Land 1 non-statement metadata is exactly the reviewed exception set"+ (L.sort exceptional) (L.sort nonStatementTitles)+ forM_ semantics $ \(title, value) -> do+ assertEqual ("Land 1 roles are non-empty: " ++ show title)+ True (not (L.null (Registry.asemRoles value)))+ -- Land 4a: rolesFor no longer consults asIsContra (explicit+ -- enumeration), so pin the contra role to the registry flag.+ assertEqual ("Land 4a contra role matches registry isContra: " ++ show title)+ (Registry.classifyAccountContra title)+ (ContraAccount `elem` Registry.asemRoles value)+ case Registry.asemDivisionSemantics value of+ StatementDivision division -> do+ assertEqual ("Land 1 statement division preserves legacy value: " ++ show title)+ (classifyAccountDivision title) division+ _ -> assertEqual ("Land 1 exceptional title is closed-listed: " ++ show title)+ True (title `L.elem` exceptional)+ case Registry.asemHomeSideSemantics value of+ FixedHomeSide side ->+ assertEqual ("Land 1 fixed home side preserves legacy value: " ++ show title)+ (whichSide (Not :< title)) side+ _ -> pure ()+ assertEqual "Land 1 Cash semantics"+ (Just ( [OrdinaryAccount], OrdinaryPosting+ , StatementDivision Assets, FixedHomeSide Debit, StatementEligible ))+ (fmap semanticsTuple (lookupSem Cash))+ assertEqual "Land 1 IncomeSummary semantics"+ (Just ( [ClosingDevice], ClosingOnly+ , DirectionEncoding Assets, ContextDependentHomeSide, NotPresented ))+ (fmap semanticsTuple (lookupSem IncomeSummary))+ assertEqual "Land 1 NetIncome semantics"+ (Just ( [PeriodResult], EngineGeneratedOnly+ , DirectionEncoding Cost, FixedHomeSide Debit, DerivedPresentation ))+ (fmap semanticsTuple (lookupSem NetIncome))+ assertEqual "Land 1 GrossProfit is an engine-generated coordinate"+ (Just ( [ReportingSubtotal], EngineGeneratedOnly+ , DirectionEncoding Revenue, FixedHomeSide Credit+ , DerivedPresentation ))+ (fmap semanticsTuple (lookupSem GrossProfit))+ assertEqual "Land 1 NCI profit is distinct from bare net income"+ (Just ( [AttributionAccount, PeriodResult], ConsolidationOnly+ , DirectionEncoding Cost, FixedHomeSide Debit+ , ContextualPresentation ))+ (fmap semanticsTuple (lookupSem NetIncomeAttributableToNCI))+ assertEqual "Land 1a NCI equity is consolidation-only"+ (Just ( [AttributionAccount], ConsolidationOnly+ , StatementDivision Equity, FixedHomeSide Credit+ , ContextualPresentation ))+ (fmap semanticsTuple (lookupSem NonControllingInterests))+ assertEqual "Land 1 branch account semantics"+ (Just ( [ReciprocalAccount], OrdinaryPosting+ , BookkeepingControlClass Assets, FixedHomeSide Debit+ , ContextualPresentation ))+ (fmap semanticsTuple (lookupSem BranchCurrentAccount))+ assertEqual "Land 1 IncomeSummary LLM description does not classify it as an asset"+ True (case lookupInfo IncomeSummary of+ Just info -> not (T.isPrefixOf (T.pack "Asset") (Assist.aiDesc info))+ && T.isInfixOf (T.pack "not a balance-sheet classification")+ (Assist.aiDesc info)+ Nothing -> False)+ assertEqual "Land 1 NetIncome LLM name drops legacy Expense wording"+ (Just (T.pack "当期純利益")) (fmap Assist.aiNameJa (lookupInfo NetIncome))+ where+ semanticsTuple value =+ ( Registry.asemRoles value+ , Registry.asemPostingCapability value+ , Registry.asemDivisionSemantics value+ , Registry.asemHomeSideSemantics value+ , Registry.asemReportingEligibility value+ )++accountMetadataLand1Header :: T.Text -> T.Text+accountMetadataLand1Header what =+ T.pack "# account-semantics-050 Land 1 " <> what+ <> T.pack "; schema 1; base 09c8a60c0bfb1a7fedb01689ceee789b8b4e6084\n"++accountMetadataLand1Row :: AccountTitles -> T.Text+accountMetadataLand1Row title = case Registry.accountSemantics title of+ Nothing -> error ("missing AccountSemantics for " ++ show title)+ Just semantics -> T.intercalate (T.pack "\t")+ [ goldenShow title+ , goldenShow (Registry.asemRoles semantics)+ , goldenShow (Registry.asemPostingCapability semantics)+ , goldenShow (Registry.asemDivisionSemantics semantics)+ , goldenShow (Registry.asemHomeSideSemantics semantics)+ , goldenShow (Registry.asemReportingEligibility semantics)+ ]++accountMetadataLand1InfoRow :: Assist.AccountInfo -> T.Text+accountMetadataLand1InfoRow info = T.intercalate (T.pack "\t")+ [ goldenShow (Assist.aiTitle info)+ , goldenShow (Assist.aiRoles info)+ , goldenShow (Assist.aiPostingCapability info)+ , goldenShow (Assist.aiDivisionSemantics info)+ , goldenShow (Assist.aiHomeSideSemantics info)+ , goldenShow (Assist.aiReportingEligibility info)+ , goldenEsc (Assist.aiNameEn info)+ , goldenEsc (Assist.aiNameJa info)+ , goldenEsc (Assist.aiDesc info)+ ]++accountMetadataLand1Suggestions :: T.Text+accountMetadataLand1Suggestions =+ accountMetadataLand1Header+ (T.pack "LLM suggestAccounts (query, total matches, top-10 titles)")+ <> T.unlines (L.map row corpus)+ where+ infos = Assist.allAccountInfos+ fields = L.concat+ [ [goldenShow (Assist.aiTitle info), Assist.aiNameEn info, Assist.aiNameJa info]+ | info <- infos+ ]+ descTokens = L.concatMap (T.words . Assist.aiDesc) infos+ corpus = goldenDedupSort+ (L.concatMap (\value -> [value, T.toLower value]) fields <> descTokens)+ row query =+ let matches = L.map Assist.aiTitle (Assist.suggestAccounts query)+ in goldenEsc query <> T.pack "\t" <> goldenShow (L.length matches)+ <> T.pack "\t"+ <> T.intercalate (T.pack ",") (L.map goldenShow (L.take 10 matches))++testAccountMetadataLand1Golden :: IO ()+testAccountMetadataLand1Golden = do+ metadata <- TIO.readFile "test/fixtures/account-semantics-050/metadata.tsv"+ info <- TIO.readFile "test/fixtures/account-semantics-050/account-info.tsv"+ suggest <- TIO.readFile "test/fixtures/account-semantics-050/suggest.tsv"+ let expectedMetadata =+ accountMetadataLand1Header+ (T.pack "registry (title, roles, posting, divisionSemantics, homeSideSemantics, reportingEligibility)")+ <> T.unlines (L.map accountMetadataLand1Row Registry.concreteAccountTitles)+ expectedInfo =+ accountMetadataLand1Header+ (T.pack "LLM AccountInfo (title, roles, posting, divisionSemantics, homeSideSemantics, reportingEligibility, nameEn, nameJa, description)")+ <> T.unlines (L.map accountMetadataLand1InfoRow Assist.allAccountInfos)+ assertEqual "Land 1 metadata fixture has 235 rows"+ 235 (L.length (L.drop 1 (T.lines metadata)))+ assertEqual "Land 1 metadata fixture" metadata expectedMetadata+ assertEqual "Land 1 LLM AccountInfo fixture" info expectedInfo+ assertEqual "Land 1 LLM suggestion fixture"+ suggest accountMetadataLand1Suggestions++testAccountInfoLand1Migration :: IO ()+testAccountInfoLand1Migration = do+ legacy <- TIO.readFile "test/fixtures/pre-account-semantics-050/account-info.tsv"+ let titleMap = M.fromList+ [ (goldenShow title, title) | title <- Registry.concreteAccountTitles ]+ rows = L.filter (not . T.null) (L.drop 1 (T.lines legacy))+ assertEqual "Land 1 AccountInfo migration covers 232 legacy rows"+ 232 (L.length rows)+ forM_ rows $ \line -> case T.splitOn (T.pack "\t") line of+ [titleText, oldDivision, oldSide, oldNameEn, oldNameJa, oldDesc] ->+ case M.lookup titleText titleMap of+ Nothing -> assertEqual "Land 1 migration unknown legacy title"+ (T.pack "") titleText+ Just title -> case (Registry.accountSemantics title, Assist.describeAccount title) of+ (Just semantics, Just info) -> do+ assertEqual ("Land 1 legacy division is recoverable: " ++ show title)+ oldDivision+ (legacyDivisionText (Registry.asemDivisionSemantics semantics))+ assertEqual ("Land 1 legacy home side is recoverable: " ++ show title)+ oldSide (goldenShow (whichSide (Not :< title)))+ case Registry.asemDivisionSemantics semantics of+ StatementDivision _ -> do+ assertEqual ("Land 1 ordinary nameEn unchanged: " ++ show title)+ oldNameEn (goldenEsc (Assist.aiNameEn info))+ -- Land 4a: the Assist projection (aiNameJa) now+ -- returns the annotation-free asLabelJa and is+ -- pinned by account-semantics-050/account-info.tsv;+ -- the pre-golden pins the registry fields.+ assertEqual ("Land 1 registry nameJa unchanged: " ++ show title)+ oldNameJa (maybe T.empty+ (goldenEsc . Registry.asNameJa)+ (Registry.accountSpec title))+ assertEqual ("Land 1 registry description unchanged: " ++ show title)+ oldDesc (maybe T.empty+ (goldenEsc . Registry.asDescription)+ (Registry.accountSpec title))+ _ -> pure ()+ _ -> do+ putStrLn ("[FAIL] missing Land 1 migration metadata: " ++ show title)+ exitFailure+ _ -> assertEqual "Land 1 migration malformed legacy row" (T.pack "") line+ where+ legacyDivisionText semantics = goldenShow $ case semantics of+ StatementDivision division -> division+ BookkeepingControlClass division -> division+ DirectionEncoding division -> division+ NoStatementDivision -> error "legacy division is unavailable"++testAssistSuggestAccounts :: IO ()+testAssistSuggestAccounts = do+ assertEqual "Assist.suggestAccounts cash contains Cash"+ True (Cash `elem` L.map Assist.aiTitle (Assist.suggestAccounts (T.pack "cash")))+ assertEqual "Assist.suggestAccounts 現金 contains Cash"+ True (Cash `elem` L.map Assist.aiTitle (Assist.suggestAccounts (T.pack "現金")))+ assertEqual "Assist.suggestAccounts empty query"+ [] (Assist.suggestAccounts T.empty)+ assertEqual "Assist.suggestAccounts no match"+ [] (Assist.suggestAccounts (T.pack "zzzznomatch"))++-- ================================================================+-- Land 1 registry: frozen pre-registry behaviour.+-- ================================================================++goldenCommit :: T.Text+goldenCommit = T.pack "2d9164642f2862725c653e496976770f6e2c7d6f"++goldenHeader :: T.Text -> T.Text+goldenHeader what =+ T.pack "# pre-land1 " <> what <> T.pack "; commit " <> goldenCommit <> T.pack "\n"++goldenShow :: Show a => a -> T.Text+goldenShow = T.pack . show++goldenEsc :: T.Text -> T.Text+goldenEsc = T.replace (T.pack "\t") (T.pack "\\t")+ . T.replace (T.pack "\n") (T.pack "\\n")++goldenDedupSort :: [T.Text] -> [T.Text]+goldenDedupSort = L.map L.head . L.group . L.sort++legacySuggestAccounts :: [Assist.AccountInfo] -> T.Text -> [Assist.AccountInfo]+legacySuggestAccounts infos query+ | L.null tokens = []+ | otherwise = L.map snd+ . L.sortOn (\(rank, info) -> (negate rank, fromEnum (Assist.aiTitle info)))+ . L.filter ((> 0) . fst)+ $ [ (matchRank info, info) | info <- infos ]+ where+ tokens = L.map T.toCaseFold (T.words query)+ matchRank info = L.length+ [ token+ | token <- tokens+ , L.any (T.isInfixOf token) (legacySearchFields info)+ ]+ legacySearchFields info = L.map T.toCaseFold $ case Registry.accountSpec (Assist.aiTitle info) of+ Just spec ->+ [ goldenShow (Assist.aiTitle info)+ , Registry.asNameEn spec+ , Registry.asNameJa spec+ , Registry.asDescription spec+ ]+ Nothing -> []++goldenInfoRow :: Assist.AccountInfo -> T.Text+-- Historical schema reconstruction. The live Land 1 AccountInfo projection is+-- pinned separately by accountMetadataLand1InfoRow.+goldenInfoRow info = case Registry.accountSpec (Assist.aiTitle info) of+ Nothing -> error "goldenInfoRow: wildcard AccountTitle"+ Just spec -> T.intercalate (T.pack "\t")+ [ goldenShow (Assist.aiTitle info)+ , goldenShow (Registry.asDivision spec)+ , goldenShow (whichSide (Not :< Assist.aiTitle info))+ , goldenEsc (Registry.asNameEn spec)+ , goldenEsc (Registry.asNameJa spec)+ , goldenEsc (Registry.asDescription spec)+ ]++goldenAliasResolution :: T.Text -> T.Text+goldenAliasResolution fixture =+ goldenHeader (T.pack "parseAccountTitle over corpus (query, show(Either ConvError AccountTitles))")+ <> T.unlines (L.map row queries)+ where+ queries = L.map (T.takeWhile (/= '\t')) (L.drop 1 (T.lines fixture))+ row query = goldenEsc query <> T.pack "\t"+ <> goldenEsc (goldenShow (EC.parseAccountTitle query))++goldenSuggestions :: T.Text+goldenSuggestions =+ goldenHeader (T.pack "suggestAccounts over corpus (query, total matches, top-10 titles)")+ <> T.unlines (L.map row corpus)+ where+ -- The post-Land2 fixture is a closed diff over the pre-vocabulary 116+ -- titles. V-Land 2 appends new titles, which are tested separately and+ -- must not retroactively change this historical fuzzy-suggestion oracle.+ infos = L.take 116 Assist.allAccountInfos+ historicalTitles = L.map Assist.aiTitle infos+ nameFields = L.concatMap legacyNameFields infos+ descTokens = L.concatMap legacyDescTokens infos+ corpus = goldenDedupSort+ (L.concatMap (\q -> [q, T.toLower q]) nameFields <> descTokens)+ row query =+ let matches = L.filter (`L.elem` historicalTitles)+ (L.map Assist.aiTitle (legacySuggestAccounts infos query))+ in goldenEsc query <> T.pack "\t"+ <> goldenShow (L.length matches) <> T.pack "\t"+ <> T.intercalate (T.pack ",") (L.map goldenShow (L.take 10 matches))+ legacyNameFields info = case Registry.accountSpec (Assist.aiTitle info) of+ Just spec ->+ [ goldenShow (Assist.aiTitle info)+ , Registry.asNameEn spec+ , Registry.asNameJa spec+ ]+ Nothing -> []+ legacyDescTokens info = case Registry.accountSpec (Assist.aiTitle info) of+ Just spec -> T.words (Registry.asDescription spec)+ Nothing -> []++postVocabHeader :: T.Text -> T.Text+postVocabHeader what = T.pack "# post-vocab " <> what <> T.pack "; schema 1\n"++postVocabInfoGolden :: T.Text+postVocabInfoGolden =+ postVocabHeader (T.pack "AccountInfo (title, division, homeSide, nameEn, nameJa, description)")+ <> T.unlines (L.map goldenInfoRow Assist.allAccountInfos)++postVocabSuggestionsGolden :: T.Text+postVocabSuggestionsGolden =+ postVocabHeader (T.pack "suggestAccounts (query, total matches, top-10 titles)")+ <> T.unlines (L.map row corpus)+ where+ infos = Assist.allAccountInfos+ nameFields = L.concatMap legacyNameFields infos+ descTokens = L.concatMap legacyDescTokens infos+ corpus = goldenDedupSort+ (L.concatMap (\q -> [q, T.toLower q]) nameFields <> descTokens)+ row query =+ let matches = L.map Assist.aiTitle (legacySuggestAccounts infos query)+ in goldenEsc query <> T.pack "\t"+ <> goldenShow (L.length matches) <> T.pack "\t"+ <> T.intercalate (T.pack ",") (L.map goldenShow (L.take 10 matches))+ legacyNameFields info = case Registry.accountSpec (Assist.aiTitle info) of+ Just spec ->+ [ goldenShow (Assist.aiTitle info)+ , Registry.asNameEn spec+ , Registry.asNameJa spec+ ]+ Nothing -> []+ legacyDescTokens info = case Registry.accountSpec (Assist.aiTitle info) of+ Just spec -> T.words (Registry.asDescription spec)+ Nothing -> []++postVocabOrdinalsGolden :: T.Text+postVocabOrdinalsGolden =+ postVocabHeader (T.pack "Enum ordinals (constructor, fromEnum)")+ <> T.unlines+ [ goldenShow title <> T.pack "\t" <> goldenShow (fromEnum title)+ | title <- [minBound .. maxBound] :: [AccountTitles]+ ]++postVocabSemanticsGolden :: T.Text+postVocabSemanticsGolden =+ postVocabHeader (T.pack "semantics (title, whatDiv, isContra, whichSide Not, whichSide Hat, whatPIMO, fixedCurrent, finalStockProbe)")+ <> T.unlines (L.map row Registry.concreteAccountTitles)+ where+ row title =+ let nb = Not :< title :: HatBase AccountTitles+ hb = Hat :< title :: HatBase AccountTitles+ in T.intercalate (T.pack "\t")+ [ goldenShow title+ , goldenShow (whatDiv nb)+ , goldenShow (Registry.classifyAccountContra title)+ , goldenShow (whichSide nb)+ , goldenShow (whichSide hb)+ , goldenShow (whatPIMO nb)+ , goldenShow (fixedCurrent nb)+ , T.pack (finalStockProbeRule title)+ ]++testPostVocabGolden :: IO ()+testPostVocabGolden = do+ ordinals <- TIO.readFile "test/fixtures/post-vocab/ordinals.tsv"+ semantics <- TIO.readFile "test/fixtures/post-vocab/semantics.tsv"+ info <- TIO.readFile "test/fixtures/post-vocab/account-info.tsv"+ suggestions <- TIO.readFile "test/fixtures/post-vocab/suggest.tsv"+ assertEqual "post-vocab ordinal fixture" ordinals postVocabOrdinalsGolden+ assertEqual "post-vocab semantics fixture" semantics postVocabSemanticsGolden+ assertEqual "post-vocab account-info fixture" info postVocabInfoGolden+ assertEqual "post-vocab suggest fixture" suggestions postVocabSuggestionsGolden++-- ================================================================+-- 0.5.0.0 account-semantics pipeline: pre-change compatibility baseline.+-- ================================================================++accountSemanticsBaselineCommit :: T.Text+accountSemanticsBaselineCommit = T.pack "0d8e2791429145f2a48c79adbe62563328ee5c0b"++accountSemanticsHeader :: T.Text -> T.Text+accountSemanticsHeader what =+ T.pack "# pre-account-semantics-050 " <> what+ <> T.pack "; schema 1; commit " <> accountSemanticsBaselineCommit <> T.pack "\n"++accountSemanticsBinaryHex :: AccountTitles -> T.Text+accountSemanticsBinaryHex = T.pack . concatMap hexByte . BL.unpack . Binary.encode+ where+ hexByte byte = case showHex byte "" of+ [digit] -> ['0', digit]+ digits -> digits++-- The pre-account-semantics fixture is immutable and predates the three+-- Land 4a constructors appended after ordinal 231.+accountSemanticsBaselineTitles :: [AccountTitles]+accountSemanticsBaselineTitles = L.take 232 Registry.concreteAccountTitles++accountSemanticsSemanticsGolden :: T.Text+accountSemanticsSemanticsGolden =+ accountSemanticsHeader (T.pack "semantics (title, enum, binaryHex, division, closing, isContra, whichSide Not, whichSide Hat, whatPIMO, fixedCurrent, finalStockProbe)")+ <> T.unlines (L.map row accountSemanticsBaselineTitles)+ where+ row title =+ let nb = Not :< title :: HatBase AccountTitles+ hb = Hat :< title :: HatBase AccountTitles+ spec = case Registry.accountSpec title of+ Just value -> value+ Nothing -> error ("missing AccountSpec for " ++ show title)+ in T.intercalate (T.pack "\t")+ [ goldenShow title+ , goldenShow (fromEnum title)+ , accountSemanticsBinaryHex title+ , goldenShow (Registry.asDivision spec)+ , goldenShow (Registry.asClosing spec)+ , goldenShow (Registry.asIsContra spec)+ , goldenShow (whichSide nb)+ , goldenShow (whichSide hb)+ , goldenShow (whatPIMO nb)+ , goldenShow (fixedCurrent nb)+ , T.pack (finalStockProbeRule title)+ ]++accountSemanticsInfoGolden :: T.Text+accountSemanticsInfoGolden =+ accountSemanticsHeader (T.pack "AccountInfo (title, division, homeSide, nameEn, nameJa, description)")+ <> T.unlines (L.map goldenInfoRow (L.take 232 Assist.allAccountInfos))++accountSemanticsProjectionGolden :: T.Text+accountSemanticsProjectionGolden =+ accountSemanticsHeader (T.pack "projection flags for Not then Hat (currentAssets, fixedAssets, deferredAssets, currentLiability, fixedLiability, capitalStock, contraAssets, contra)")+ <> T.unlines (L.map row accountSemanticsBaselineTitles)+ where+ kept :: (EA.Alg Double (HatBase AccountTitles)+ -> EA.Alg Double (HatBase AccountTitles))+ -> EA.Alg Double (HatBase AccountTitles)+ -> T.Text+ kept projection value = if norm (projection value) == (1 :: Double)+ then T.pack "1" else T.pack "0"+ row title = T.intercalate (T.pack "\t")+ (goldenShow title : L.concatMap (probe title) [Not, Hat])+ probe title hat =+ let value = 1 .@ hat :< title :: EA.Alg Double (HatBase AccountTitles)+ in [ kept EA.projCurrentAssets value+ , kept EA.projFixedAssets value+ , kept EA.projDeferredAssets value+ , kept EA.projCurrentLiability value+ , kept EA.projFixedLiability value+ , kept EA.projCapitalStock value+ , kept EA.projContraAssets value+ , kept EA.projContra value+ ]++accountSemanticsPresentationGolden :: T.Text+accountSemanticsPresentationGolden =+ accountSemanticsHeader (T.pack "legacy presentation probe (title, bsRows of 1@Not, plRows of 1@Not)")+ <> T.unlines (L.map row accountSemanticsBaselineTitles)+ where+ row title =+ let value = 1 .@ Not :< title :: EA.Alg Double (HatBase AccountTitles)+ in T.intercalate (T.pack "\t")+ [ goldenShow title+ , goldenEsc (goldenShow (EW.bsRows value))+ , goldenEsc (goldenShow (EW.plRows value))+ ]++testAccountSemanticsPrechangeGolden :: IO ()+testAccountSemanticsPrechangeGolden = do+ semantics <- TIO.readFile "test/fixtures/pre-account-semantics-050/semantics.tsv"+ info <- TIO.readFile "test/fixtures/pre-account-semantics-050/account-info.tsv"+ projections <- TIO.readFile "test/fixtures/pre-account-semantics-050/projection-membership.tsv"+ presentation <- TIO.readFile "test/fixtures/pre-account-semantics-050/presentation.tsv"+ assertEqual "pre-account-semantics semantics fixture has 232 rows"+ 232 (L.length (L.drop 1 (T.lines semantics)))+ assertEqual "pre-account-semantics semantics fixture"+ semantics accountSemanticsSemanticsGolden+ assertEqual "pre-account-semantics legacy AccountInfo fixture"+ info accountSemanticsInfoGolden+ assertEqual "pre-account-semantics projection fixture"+ projections accountSemanticsProjectionGolden+ let oldPresentation = L.drop 1 (T.lines presentation)+ newPresentation = L.drop 1 (T.lines accountSemanticsPresentationGolden)+ changedTitles =+ [ T.takeWhile (/= '\t') new+ | (old, new) <- L.zip oldPresentation newPresentation+ , old /= new+ ]+ assertEqual "pre-account-semantics presentation row count"+ (L.length oldPresentation) (L.length newPresentation)+ assertEqual "Land 3 presentation closed diff = contra rows plus two formerly phantom totals"+ [ T.pack "NetLoss"+ , T.pack "AllowanceForDoubtfulAccounts"+ , T.pack "AccumulatedDepreciation"+ , T.pack "SalesRebates"+ , T.pack "RefundOfIncomeTaxes"+ , T.pack "PurchaseRebates"+ , T.pack "NetLossAttributableToNCI"+ ]+ changedTitles++-- Land 2 (Definition 7 contra amendment) 以降: alias 解決だけが byte 一致+-- (parseAccountTitle は division 非依存)。semantics / info / suggest は+-- 意図的差分を持つため, closed-diff test (testLand2*ClosedDiff) が引き継ぐ。+testRegistryGolden :: IO ()+testRegistryGolden = do+ aliases <- TIO.readFile "test/fixtures/pre-land1/alias-resolution.tsv"+ jcci <- TIO.readFile "test/fixtures/jcci-2022/queries.tsv"+ let oldRows = L.filter (not . T.null) (L.drop 1 (T.lines aliases))+ currentRows = L.filter (not . T.null)+ (L.drop 1 (T.lines (goldenAliasResolution aliases)))+ changedQueries =+ [ T.takeWhile (/= '\t') old+ | (old, current) <- L.zip oldRows currentRows+ , old /= current+ ]+ expectedChangedQueries = L.sort (L.map T.pack+ [ "未払金", "借入金", "仮払金", "仮受金"+ , "有価証券", "投資有価証券"+ , " 未払金 ", " 借入金 ", " 仮払金 ", " 仮受金 "+ , " 有価証券 ", " 投資有価証券 "+ ])+ officialQueries =+ [ EC.normalizeTitle (fields L.!! 3)+ | line <- L.filter (not . T.null) (L.drop 1 (T.lines jcci))+ , let fields = T.splitOn (T.pack "\t") line+ , L.length fields == 7+ ]+ assertEqual "registry golden: historical row count"+ (L.length oldRows) (L.length currentRows)+ assertEqual "registry golden: every changed historical alias is JCCI-scoped"+ ([] :: [T.Text])+ [q | q <- changedQueries, EC.normalizeTitle q `L.notElem` officialQueries]+ -- The pre-land fixture freezes each old result; testJcciAccountNameCoverage+ -- freezes each new result. Freezing this exact query set closes the diff.+ assertEqual "registry golden: exact adjudicated historical alias diff"+ expectedChangedQueries (L.sort changedQueries)++-- | JCCI 2022 A欄/B欄の全 distinct query は, 一意のRightかfixtureで候補を+-- 閉じたAmbiguousのどちらかでなければならない. Unknown/first-matchは不可.+testJcciAccountNameCoverage :: IO ()+testJcciAccountNameCoverage = do+ source <- TIO.readFile "test/fixtures/jcci-2022/source.tsv"+ fixture <- TIO.readFile "test/fixtures/jcci-2022/queries.tsv"+ let sourceRows = L.filter (not . T.null) (L.drop 1 (T.lines source))+ rows = L.filter (not . T.null) (L.drop 1 (T.lines fixture))+ outcomes = [field L.!! 4 | row <- rows, let field = T.splitOn (T.pack "\t") row]+ sourceFields =+ [ fields+ | row <- sourceRows+ , let fields = T.splitOn (T.pack "\t") row+ , L.length fields == 6+ ]+ sourceEntries =+ [ (EC.normalizeTitle label, standardName)+ | fields <- sourceFields+ , let standardName = fields L.!! 2+ , label <- standardName : T.splitOn (T.pack "|") (fields L.!! 3)+ , not (T.null (T.strip label))+ ]+ sourceLabels = goldenDedupSort (L.map fst sourceEntries)+ fixtureLabels = goldenDedupSort+ [ EC.normalizeTitle (fields L.!! 3)+ | row <- rows+ , let fields = T.splitOn (T.pack "\t") row+ , L.length fields == 7+ ]+ assertEqual "JCCI source A-row count" 215 (L.length sourceRows)+ assertEqual "JCCI source rows have six columns"+ (L.length sourceRows) (L.length sourceFields)+ assertEqual "JCCI distinct normalized A/B query count" 316 (L.length rows)+ assertEqual "JCCI fixture covers exactly the source A/B labels"+ sourceLabels fixtureLabels+ assertEqual "JCCI unique resolutions" 295 (L.length (L.filter (== T.pack "right") outcomes))+ assertEqual "JCCI policy ambiguities" 21 (L.length (L.filter (== T.pack "ambiguous") outcomes))+ forM_ rows $ \row -> case T.splitOn (T.pack "\t") row of+ [_, _, _, query, _, _, standardNames] ->+ assertEqual ("JCCI source provenance: " ++ T.unpack query)+ (goldenDedupSort+ [ standardName+ | (label, standardName) <- sourceEntries+ , label == EC.normalizeTitle query+ ])+ (goldenDedupSort (T.splitOn (T.pack "|") standardNames))+ fields -> assertEqual "JCCI fixture provenance row has seven columns"+ (7 :: Int) (L.length fields)+ mapM_ check rows+ where+ check row = case T.splitOn (T.pack "\t") row of+ [_, _, _, query, outcome, candidateText, _] ->+ let names = T.splitOn (T.pack "|") candidateText+ in case traverse (`M.lookup` land2TitleMap) names of+ Nothing -> assertEqual "JCCI fixture names only real constructors"+ (T.pack "known constructors") candidateText+ Just candidates -> case candidates of+ [candidate] | outcome == T.pack "right" ->+ assertEqual ("JCCI Right: " ++ T.unpack query)+ (Right candidate) (EC.parseAccountTitle query)+ _ : _ : _ | outcome == T.pack "ambiguous" ->+ assertEqual ("JCCI Ambiguous: " ++ T.unpack query)+ (Left (EC.AmbiguousAccount query candidates))+ (EC.parseAccountTitle query)+ _ -> assertEqual "JCCI fixture outcome/candidate arity"+ (T.pack "right=1 or ambiguous>=2")+ (outcome <> T.pack ":" <> candidateText)+ _ -> assertEqual "JCCI fixture row has seven columns" (7 :: Int)+ (L.length (T.splitOn (T.pack "\t") row))++-- | Every cleaned Japanese label is a bare account name, and every level-2+-- JCCI A-column name that resolves uniquely is the profile display label.+testAccountLabelsLand4a :: IO ()+testAccountLabelsLand4a = do+ let labels =+ [ (title, Registry.asLabelJa spec)+ | title <- Registry.concreteAccountTitles+ , Just spec <- [Registry.accountSpec title]+ ]+ forbidden = L.map T.pack ["。", "—", "\\/", "'"]+ invalid =+ [ (title, label)+ | (title, label) <- labels+ , any (`T.isInfixOf` label) forbidden+ || T.any (\c -> isAscii c && isAlpha c) label+ ]+ assertEqual "Land 4a asLabelJa covers all 235 concrete titles"+ 235 (L.length labels)+ assertEqual "Land 4a asLabelJa contains only cleaned Japanese account names"+ ([] :: [(AccountTitles, T.Text)]) invalid++ source <- TIO.readFile "test/fixtures/jcci-2022/source.tsv"+ let rows =+ [ fields+ | row <- L.drop 1 (T.lines source)+ , not (T.null row)+ , let fields = T.splitOn (T.pack "\t") row+ , L.length fields == 6+ , T.pack "2" `T.isInfixOf` (fields L.!! 0)+ ]+ aNames = L.map (L.!! 2) rows+ resolved =+ [ (name, title, RP.presentationLabel RP.JcciSecondGradeReport title)+ | name <- aNames+ , Right title <- [EC.parseAccountTitle name]+ ]+ skipped =+ [ name+ | name <- aNames+ , Left (EC.AmbiguousAccount _ _) <- [EC.parseAccountTitle name]+ ]+ unexpected =+ [ (name, show err)+ | name <- aNames+ , Left err <- [EC.parseAccountTitle name]+ , case err of EC.AmbiguousAccount _ _ -> False; _ -> True+ ]+ mismatches =+ [ (name, title, label)+ | (name, title, label) <- resolved+ , label /= name+ ]+ assertEqual "Land 4a JCCI level-2 A-column rows" 117 (L.length aNames)+ assertEqual "Land 4a JCCI A-column MATCH count" 113 (L.length resolved)+ assertEqual "Land 4a JCCI A-column skip set"+ (L.sort (L.map T.pack+ ["営業収益", "営業費用", "為替差損益", "有価証券評価損益"]))+ (L.sort skipped)+ assertEqual "Land 4a JCCI A-column has no unexpected parse failures"+ ([] :: [(T.Text, String)]) unexpected+ assertEqual "Land 4a JCCI A-column labels MATCH 113/113"+ ([] :: [(T.Text, AccountTitles, T.Text)]) mismatches++testRegistryWildcards :: IO ()+testRegistryWildcards = do+ divisionResult <- try (evaluate (classifyAccountDivision AccountTitle))+ :: IO (Either SomeException AccountDivision)+ assertEqual "registry wildcard: classifyAccountDivision errors"+ True (case divisionResult of Left _ -> True; Right _ -> False)+ assertEqual "registry wildcard: fixedCurrent is Other"+ Other (fixedCurrent (Not :< AccountTitle))+ assertEqual "registry wildcard: describeAccount is Nothing"+ Nothing (Assist.describeAccount AccountTitle)++testRegistryContraLand2 :: IO ()+testRegistryContraLand2 =+ assertEqual "registry contra True set = valuation accounts plus V-Land 2 P/L contra accounts"+ allContra+ (L.filter Registry.classifyAccountContra Registry.concreteAccountTitles)++-- ================================================================+-- Land 2 (Definition 7 contra amendment): closed-diff vs pre-land1+-- golden, contract / relation properties, presentation invariance.+-- pre-land1 fixtures stay frozen as the pre-amendment reference.+-- ================================================================++land2Contra :: [AccountTitles]+land2Contra = [AllowanceForDoubtfulAccounts, AccumulatedDepreciation]++allContra :: [AccountTitles]+allContra = land2Contra <> [SalesRebates, RefundOfIncomeTaxes, PurchaseRebates]++land2TitleMap :: M.Map T.Text AccountTitles+land2TitleMap = M.fromList+ [ (T.pack (show t), t) | t <- Registry.concreteAccountTitles ]++-- registry から生成しない literal 期待値 (循環 oracle 回避)+land2ExpectedDesc :: AccountTitles -> T.Text+land2ExpectedDesc AllowanceForDoubtfulAccounts = T.pack+ "Asset (contra): Allowance for doubtful accounts (貸倒引当金), a credit-balance valuation account (評価勘定) deducted from receivables. Home side is Credit because it is a contra asset (isContra); values stay non-negative and the Hat\\/Not structure is intact. B\\/S deduction (net) presentation is the Write side's job."+land2ExpectedDesc AccumulatedDepreciation = T.pack+ "Asset (contra): Accumulated depreciation (減価償却累計額), a credit-balance valuation account (評価勘定) under the indirect method (間接法), deducted from the related depreciable assets. Home side is Credit because it is a contra asset (isContra). This is the canonical bookkeeping account for accumulated depreciation; the existing 'ReserveForDepreciation' is retained as the legacy SNA\\/macro-accounting name."+land2ExpectedDesc t = T.pack ("land2ExpectedDesc: not a contra account: " ++ show t)++-- T1: 全域機械比較 — whichSide/whatPIMO/fixedCurrent は全一致,+-- whatDiv は当該 2 件 (Liability→Assets) ちょうど。+testLand2SemanticsClosedDiff :: IO ()+testLand2SemanticsClosedDiff = do+ fixture <- TIO.readFile "test/fixtures/pre-land1/account-semantics.tsv"+ let rows = L.filter (not . T.null) (L.drop 1 (T.lines fixture))+ assertEqual "land2 semantics: fixture row count" 116 (L.length rows)+ mapM_ checkRow rows+ where+ checkRow line = case T.splitOn (T.pack "\t") line of+ [name, oldDiv, oldPimo, oldSideN, oldSideH, oldFc] ->+ case M.lookup name land2TitleMap of+ Nothing -> assertEqual "land2 semantics: unknown fixture title" (T.pack "") name+ Just t -> do+ let nb = Not :< t :: HatBase AccountTitles+ hb = Hat :< t :: HatBase AccountTitles+ assertEqual ("land2 whatPIMO invariant: " ++ show t)+ oldPimo (goldenShow (whatPIMO nb))+ assertEqual ("land2 whichSide Not invariant: " ++ show t)+ oldSideN (goldenShow (whichSide nb))+ assertEqual ("land2 whichSide Hat invariant: " ++ show t)+ oldSideH (goldenShow (whichSide hb))+ assertEqual ("land2 fixedCurrent invariant: " ++ show t)+ oldFc (goldenShow (fixedCurrent nb))+ if t `L.elem` land2Contra+ then do+ assertEqual ("land2 whatDiv old was Liability: " ++ show t)+ (T.pack "Liability") oldDiv+ assertEqual ("land2 whatDiv new is Assets: " ++ show t)+ Assets (whatDiv nb)+ else assertEqual ("land2 whatDiv invariant: " ++ show t)+ oldDiv (goldenShow (whatDiv nb))+ _ -> assertEqual "land2 semantics: malformed fixture row" (T.pack "") line++-- T8 込み: allAccountInfos の閉じた差分 — 当該 2 行だけ aiDivision と+-- aiDesc が変わり (desc は "Asset (contra):" で始まる), 他は byte 一致。+testLand2InfoClosedDiff :: IO ()+testLand2InfoClosedDiff = do+ fixture <- TIO.readFile "test/fixtures/pre-land1/account-info.tsv"+ let oldRows = L.filter (not . T.null) (L.drop 1 (T.lines fixture))+ -- V-Land 2 appends new constructors after every pre-vland2 concrete+ -- title. This closed-diff compares only the pinned historical prefix.+ newRows = L.take (L.length oldRows)+ [ (Assist.aiTitle i, goldenInfoRow i) | i <- Assist.allAccountInfos ]+ assertEqual "land2 info: row count" (L.length oldRows) (L.length newRows)+ mapM_ check (L.zip oldRows newRows)+ where+ check (oldLine, (t, newLine))+ | t `L.elem` land2Contra = do+ let oldF = T.splitOn (T.pack "\t") oldLine+ newF = T.splitOn (T.pack "\t") newLine+ assertEqual ("land2 info title invariant: " ++ show t)+ (oldF L.!! 0) (newF L.!! 0)+ assertEqual ("land2 info old division was Liability: " ++ show t)+ (T.pack "Liability") (oldF L.!! 1)+ assertEqual ("land2 info new division is Assets: " ++ show t)+ (T.pack "Assets") (newF L.!! 1)+ assertEqual ("land2 info home side invariant: " ++ show t)+ (oldF L.!! 2) (newF L.!! 2)+ assertEqual ("land2 info nameEn invariant: " ++ show t)+ (oldF L.!! 3) (newF L.!! 3)+ assertEqual ("land2 info nameJa invariant: " ++ show t)+ (oldF L.!! 4) (newF L.!! 4)+ assertEqual ("land2 info desc updated to contra wording: " ++ show t)+ True (oldF L.!! 5 /= newF L.!! 5)+ assertEqual ("land2 info new desc literal: " ++ show t)+ (land2ExpectedDesc t) (newF L.!! 5)+ | otherwise =+ assertEqual ("land2 info invariant: " ++ show t) oldLine newLine++-- suggest の閉じた差分: 変化した (追加/削除/変更) query は全て, 当該 2 科目の+-- 旧/新 desc に対する token match rank の変化で説明できる。+testLand2SuggestClosedDiff :: IO ()+testLand2SuggestClosedDiff = do+ fixture <- TIO.readFile "test/fixtures/pre-land1/suggest.tsv"+ infoFixture <- TIO.readFile "test/fixtures/pre-land1/account-info.tsv"+ let toMap txt = M.fromList+ [ (T.takeWhile (/= '\t') line, line)+ | line <- L.filter (not . T.null) (L.drop 1 (T.lines txt)) ]+ oldMap = toMap fixture+ newMap = toMap goldenSuggestions+ oldFieldsOf t = L.concat+ [ [ fs L.!! 0, fs L.!! 3, fs L.!! 4, fs L.!! 5 ]+ | line <- L.filter (not . T.null) (L.drop 1 (T.lines infoFixture))+ , let fs = T.splitOn (T.pack "\t") line+ , fs L.!! 0 == T.pack (show t)+ ]+ newFieldsOf t = case Assist.describeAccount t of+ Just i -> [ T.pack (show t), Assist.aiNameEn i, Assist.aiNameJa i, Assist.aiDesc i ]+ Nothing -> []+ rank fields q = L.length+ [ tok+ | tok <- L.map T.toCaseFold (T.words q)+ , L.any (T.isInfixOf tok) (L.map T.toCaseFold fields) ]+ -- corpus 帰属の変化 (query が旧/新 desc の token 集合の片方にだけある)+ -- も desc 変更の帰結として許容する (行の追加/削除がこれで起きる)。+ descTokensOf fields = case fields of+ [_, _, _, desc] -> T.words desc+ _ -> []+ tokenMembershipChange q = L.or+ [ (q `L.elem` descTokensOf (oldFieldsOf t))+ /= (q `L.elem` descTokensOf (newFieldsOf t))+ | t <- land2Contra ]+ affected q = tokenMembershipChange q || L.or+ [ rank (oldFieldsOf t) q /= rank (newFieldsOf t) q | t <- land2Contra ]+ diffQueries = L.nub+ ( [ q | (q, old) <- M.toList oldMap, maybe True (/= old) (M.lookup q newMap) ]+ ++ [ q | q <- M.keys newMap, not (M.member q oldMap) ] )+ postFixture <- TIO.readFile "test/fixtures/post-land2/suggest.tsv"+ assertEqual "land2 suggest: post fixture byte-identical (expected output itself)"+ postFixture goldenSuggestions+ assertEqual "land2 suggest: some diff exists (descs changed)"+ True (not (L.null diffQueries))+ mapM_ (\q -> assertEqual+ ("land2 suggest diff explained by contra desc change: " ++ T.unpack q)+ True (affected q))+ diffQueries++-- T2: 契約 isContra(b) ⇔ homeSide(b) ≠ defaultSide(whatDiv b)+testLand2Contract :: IO ()+testLand2Contract = mapM_ check Registry.concreteAccountTitles+ where+ check t =+ let nb = Not :< t :: HatBase AccountTitles+ in assertEqual ("land2 contract (isContra = reversed home side): " ++ show t)+ (isContra nb) (whichSide nb /= defaultSide (whatDiv nb))++-- T3: pimoFlip は自己逆で, 原典の交換関係を保つ+testLand2PimoFlip :: IO ()+testLand2PimoFlip = do+ let pimoAll = [PS, IN, MS, OUT]+ mapM_ (\x -> assertEqual ("land2 pimoFlip involution: " ++ show x)+ x (pimoFlip (pimoFlip x))) pimoAll+ mapM_ (\(x, y) -> assertEqual ("land2 pimoFlip preserves (<=>): " ++ show (x, y))+ (x <=> y) (pimoFlip x <=> pimoFlip y))+ [ (x, y) | x <- pimoAll, y <- pimoAll ]++-- T4: (<=>) — PIMO instance = 原典 Prop 5.3.8, Division instance =+-- pimoFromDivision 経由の外延, 旧 instance との差分 = ordered 4 case ちょうど。+testLand2ExchangeRelation :: IO ()+testLand2ExchangeRelation = do+ let pimoAll = [PS, IN, MS, OUT]+ divAll = [Assets, Equity, Liability, Cost, Revenue]+ assertEqual "land2 (<=>) PIMO instance = Prop 5.3.8 pairs"+ [ (PS,IN), (PS,MS), (IN,PS), (IN,OUT), (MS,PS), (MS,OUT), (OUT,IN), (OUT,MS) ]+ [ (x, y) | x <- pimoAll, y <- pimoAll, x <=> y ]+ mapM_ (\(a, b) -> assertEqual ("land2 (<=>) division = via pimoFromDivision: " ++ show (a, b))+ (pimoFromDivision a <=> pimoFromDivision b) (a <=> b))+ [ (a, b) | a <- divAll, b <- divAll ]+ let oldRel (Assets, Liability) = True+ oldRel (Liability, Assets) = True+ oldRel (Assets, Equity) = True+ oldRel (Equity, Assets) = True+ oldRel (Cost, Liability) = True+ oldRel (Liability, Cost) = True+ oldRel (Cost, Equity) = True+ oldRel (Equity, Cost) = True+ oldRel _ = False+ assertEqual "land2 (<=>) division migration = exactly 4 ordered cases"+ [ (Assets, Revenue), (Cost, Revenue), (Revenue, Assets), (Revenue, Cost) ]+ [ (a, b) | a <- divAll, b <- divAll, oldRel (a, b) /= (a <=> b) ]++-- T9: 8 組込み instance + custom instance (SimHatBase2) の全科目 sweepで+-- isContra のTrue集合が既存評価勘定2件 + V-Land 2 P/L控除3件になること。+testLand2IsContraInstances :: IO ()+testLand2IsContraInstances = do+ let day0 = fromGregorian 2026 1 1+ tod0 = TimeOfDay 0 0 0+ nm = T.pack "spec"+ sweep :: ExBaseClass b => String -> (AccountTitles -> b) -> IO ()+ sweep label mk = assertEqual ("land2 isContra sweep: " ++ label)+ allContra+ [ t | t <- Registry.concreteAccountTitles, isContra (mk t) ]+ sweep "HatBase AccountTitles" (\t -> Not :< t :: HatBase AccountTitles)+ sweep "HatBase (AccountTitles, Day)" (\t -> Not :< (t, day0))+ sweep "HatBase (AccountTitles, Name)" (\t -> Not :< (t, nm))+ sweep "HatBase (CountUnit, AccountTitles)" (\t -> Not :< (Yen, t))+ sweep "HatBase (AccountTitles, Name, CountUnit)" (\t -> Not :< (t, nm, Yen))+ sweep "HatBase (AccountTitles, Name, CountUnit, Subject)"+ (\t -> Not :< (t, nm, Yen, nm))+ sweep "HatBase (AccountTitles, Name, CountUnit, Subject, Day)"+ (\t -> Not :< (t, nm, Yen, nm, day0))+ sweep "HatBase (AccountTitles, Name, CountUnit, Subject, Day, TimeOfDay)"+ (\t -> Not :< (t, nm, Yen, nm, day0, tod0))+ sweep "SimHatBase2 (custom instance)" (\t -> Not :< (t, 1, 2, Yen) :: SimHatBase2)++-- T8: LLM-facing メタデータの literal 期待値 (registry から生成しない)+testLand2AiDivision :: IO ()+testLand2AiDivision = do+ assertEqual "land2 statement metadata literal: AllowanceForDoubtfulAccounts"+ (Just (StatementDivision Assets, FixedHomeSide Credit))+ (fmap (\i -> (Assist.aiDivisionSemantics i, Assist.aiHomeSideSemantics i))+ (Assist.describeAccount AllowanceForDoubtfulAccounts))+ assertEqual "land2 statement metadata literal: AccumulatedDepreciation"+ (Just (StatementDivision Assets, FixedHomeSide Credit))+ (fmap (\i -> (Assist.aiDivisionSemantics i, Assist.aiHomeSideSemantics i))+ (Assist.describeAccount AccumulatedDepreciation))++-- T5/T6: presentation battery。bsRows/plRows の literal は, 以下で明記する+-- vocabulary closing 差分を除き, Land 1 出力 (pre-land2 golden, commit+-- 1c1f3f2) と byte 一致 = 表示互換 shim の証明。+-- division projection は contra を含まず, contra は projContraAssets のみが選ぶ+-- (意図的差分: projCurrentLiability/projFixedLiability から当該 2 件が消えた)。+land2B1, land2B2, land2B3, land2B4, land2B5 :: BAlg+land2B1 = 100 .@ Not:<Cash .+ 60 .@ Not:<LoansPayable .+ 40 .@ Not:<CapitalStock+land2B2 = 500 .@ Not:<Sales .+ 300 .@ Not:<SalesCost+land2B3 = 1000 .@ Not:<AccountsReceivable .+ 900 .@ Not:<Cash .+ 800 .@ Not:<Building+ .+ 100 .@ Not:<AllowanceForDoubtfulAccounts .+ 200 .@ Not:<AccumulatedDepreciation+ .+ 2000 .@ Not:<CapitalStock .+ 400 .@ Not:<LoansPayable+land2B4 = 30 .@ Not:<Cash .+ 80 .@ Hat:<Cash+ .+ 100 .@ Not:<AllowanceForDoubtfulAccounts .+ 120 .@ Hat:<AllowanceForDoubtfulAccounts+ .+ 500 .@ Not:<Building .+ 200 .@ Not:<LoansPayable .+ 300 .@ Hat:<LoansPayable+ .+ 250 .@ Not:<AccumulatedDepreciation .+ 50 .@ Hat:<AccumulatedDepreciation+land2B5 = land2B3 .+ 300 .@ Not:<SalesCost .+ 500 .@ Not:<Sales .+ 200 .@ Not:<Cash++testLand3PresentationGroups :: IO ()+testLand3PresentationGroups = do+ let defaultDefs = RG.defaultPresentationGrouping+ tradeDef = maybe (error "missing TradeReceivablesGroup") id+ (RG.lookupGroupDef defaultDefs RG.TradeReceivablesGroup)+ amount below magnitude = RG.RelativeAmount below (magnitude :: Double)+ present defs entries = RG.presentGroups defs (M.fromList entries)+ contraTitles =+ [ title+ | title <- Registry.concreteAccountTitles+ , maybe False Registry.asIsContra (Registry.accountSpec title)+ ]+ deductionTitles = L.concatMap RG.pgDeductions defaultDefs+ allMembers def = RG.pgGross def ++ RG.pgDeductions def+ assertEqual "Land 3 default groups cover each registry contra exactly once"+ (L.sort contraTitles) (L.sort deductionTitles)+ assertEqual "Land 3 default group memberships are disjoint"+ (L.length (L.concatMap allMembers defaultDefs))+ (Set.size (Set.fromList (L.concatMap allMembers defaultDefs)))+ forM_ defaultDefs $ \def ->+ forM_ (allMembers def) $ \title ->+ assertEqual ("Land 3 default title lookup: " ++ show title)+ (Just (RG.pgKey def)) (RG.presentationGroupOf title)+ forM_ defaultDefs $ \def -> do+ forM_ (RG.pgGross def) $ \title ->+ assertEqual ("Land 3 gross member is non-contra: " ++ show title)+ (Just (RG.pgDivision def, False))+ ((\spec -> (Registry.asDivision spec, Registry.asIsContra spec))+ <$> Registry.accountSpec title)+ forM_ (RG.pgDeductions def) $ \title ->+ assertEqual ("Land 3 deduction member is same-division contra: " ++ show title)+ (Just (RG.pgDivision def, True))+ ((\spec -> (Registry.asDivision spec, Registry.asIsContra spec))+ <$> Registry.accountSpec title)++ let exceeded = present [tradeDef]+ [ (AccountsReceivable, (100, 0))+ , (AllowanceForDoubtfulAccounts, (0, 150))+ ]+ assertEqual "Land 3 edge: contra exceeding gross yields a negative net"+ [(tradeDef,+ [ RG.GroupRow (RG.GrossRow AccountsReceivable) (amount False 100)+ , RG.GroupRow (RG.DeductionRow AllowanceForDoubtfulAccounts) (amount True 150)+ , RG.GroupRow (RG.NetRow RG.TradeReceivablesGroup) (amount True 50)+ ])]+ (RG.gpBlocks exceeded)++ let parentAbsent = present [tradeDef]+ [(AllowanceForDoubtfulAccounts, (0, 30))]+ assertEqual "Land 3 edge: parent absent still renders deduction and net"+ [(tradeDef,+ [ RG.GroupRow (RG.DeductionRow AllowanceForDoubtfulAccounts) (amount True 30)+ , RG.GroupRow (RG.NetRow RG.TradeReceivablesGroup) (amount True 30)+ ])]+ (RG.gpBlocks parentAbsent)++ let multiContraDef = tradeDef+ { RG.pgGross = [AccountsReceivable]+ , RG.pgDeductions =+ [AllowanceForDoubtfulAccounts, AccumulatedDepreciation]+ }+ multipleContra = present [multiContraDef]+ [ (AccountsReceivable, (1000, 0))+ , (AllowanceForDoubtfulAccounts, (0, 100))+ , (AccumulatedDepreciation, (0, 200))+ ]+ assertEqual "Land 3 edge: multiple contra rows deduct exactly once"+ [(multiContraDef,+ [ RG.GroupRow (RG.GrossRow AccountsReceivable) (amount False 1000)+ , RG.GroupRow (RG.DeductionRow AllowanceForDoubtfulAccounts) (amount True 100)+ , RG.GroupRow (RG.DeductionRow AccumulatedDepreciation) (amount True 200)+ , RG.GroupRow (RG.NetRow RG.TradeReceivablesGroup) (amount False 700)+ ])]+ (RG.gpBlocks multipleContra)++ let child = tradeDef { RG.pgParent = Just RG.DepreciableAssetsGroup }+ parent = maybe (error "missing DepreciableAssetsGroup") id+ (RG.lookupGroupDef defaultDefs RG.DepreciableAssetsGroup)+ nested = present [parent, child]+ [ (AccountsReceivable, (1000, 0))+ , (AllowanceForDoubtfulAccounts, (0, 100))+ , (Building, (800, 0))+ , (AccumulatedDepreciation, (0, 200))+ ]+ assertEqual "Land 3 edge: nested child precedes and rolls into parent"+ [ (child,+ [ RG.GroupRow (RG.GrossRow AccountsReceivable) (amount False 1000)+ , RG.GroupRow (RG.DeductionRow AllowanceForDoubtfulAccounts) (amount True 100)+ , RG.GroupRow (RG.NetRow RG.TradeReceivablesGroup) (amount False 900)+ ])+ , (parent,+ [ RG.GroupRow (RG.GrossRow Building) (amount False 800)+ , RG.GroupRow (RG.SubgroupRow RG.TradeReceivablesGroup) (amount False 900)+ , RG.GroupRow (RG.DeductionRow AccumulatedDepreciation) (amount True 200)+ , RG.GroupRow (RG.NetRow RG.DepreciableAssetsGroup) (amount False 1500)+ ])+ ]+ (RG.gpBlocks nested)+ assertEqual "Land 3 edge: only the nested root contributes to totals"+ (M.singleton Assets (1800, 300)) (RG.gpRootTotals nested)+ assertEqual "Land 3 edge: all nested definition members are consumed"+ (Set.fromList (L.concatMap allMembers [parent, child]))+ (RG.gpConsumed nested)++ let inactiveChild = present [parent, child]+ [ (AccountsReceivable, (1000, 0))+ , (Building, (800, 0))+ , (AccumulatedDepreciation, (0, 200))+ ]+ assertEqual "Land 3 edge: inactive child gross is not rolled into its parent"+ [(parent,+ [ RG.GroupRow (RG.GrossRow Building) (amount False 800)+ , RG.GroupRow (RG.DeductionRow AccumulatedDepreciation) (amount True 200)+ , RG.GroupRow (RG.NetRow RG.DepreciableAssetsGroup) (amount False 600)+ ])]+ (RG.gpBlocks inactiveChild)+ assertEqual "Land 3 edge: inactive child gross remains unconsumed"+ (Set.fromList (allMembers parent)) (RG.gpConsumed inactiveChild)+ assertEqual "Land 3 edge: inactive child cannot inflate the parent total"+ (M.singleton Assets (800, 200)) (RG.gpRootTotals inactiveChild)++ let salesDef = maybe (error "missing NetSalesGroup") id+ (RG.lookupGroupDef defaultDefs RG.NetSalesGroup)+ offsetContra = present [salesDef]+ [ (Sales, (0, 500))+ , (SalesRebates, (50, 50))+ ]+ assertEqual "Land 3 edge: fully offset contra activity still activates its group"+ [(salesDef,+ [ RG.GroupRow (RG.GrossRow Sales) (amount False 500)+ , RG.GroupRow (RG.NetRow RG.NetSalesGroup) (amount False 500)+ ])]+ (RG.gpBlocks offsetContra)+ assertEqual "Land 3 edge: fully offset contra title cannot leak to ordinary rows"+ (Set.fromList [Sales, SalesRebates]) (RG.gpConsumed offsetContra)++ let rows f b = L.map (L.map T.unpack) (f b)+ excessiveChart = (100 .@ Not:<AccountsReceivable+ .+ 150 .@ Not:<AllowanceForDoubtfulAccounts+ :: EA.Alg Double (HatBase AccountTitles))+ purchasesAndTaxes = (500 .@ Not:<Purchases+ .+ 50 .@ Not:<PurchaseRebates+ .+ 300 .@ Not:<CorporateIncomeTaxes+ .+ 40 .@ Not:<RefundOfIncomeTaxes+ :: EA.Alg Double (HatBase AccountTitles))+ excessiveRows = rows bsRows excessiveChart+ purchasesAndTaxesRows = rows plRows purchasesAndTaxes+ assertEqual "Land 3 rendering: contra excess signs both net and asset total"+ True+ (["TradeReceivablesNet","-50.0","",""] `elem` excessiveRows+ && ["Total","-50.0","",""] `elem` excessiveRows)+ assertEqual "Land 3 rendering: purchase rebate and net label are pinned"+ True+ (["PurchaseRebates","-50.0","",""] `elem` purchasesAndTaxesRows+ && ["NetPurchases","450.0","",""] `elem` purchasesAndTaxesRows)+ assertEqual "Land 3 rendering: tax refund and net label are pinned"+ True+ (["RefundOfIncomeTaxes","-40.0","",""] `elem` purchasesAndTaxesRows+ && ["IncomeTaxesNet","260.0","",""] `elem` purchasesAndTaxesRows)++testLand2Presentation :: IO ()+testLand2Presentation = do+ let rows f b = L.map (L.map T.unpack) (f b)+ -- Non-contra statements remain byte-identical. Contra statements use+ -- Land 3 gross -> deduction -> net presentation.+ assertEqual "land2 bsRows b1 (= Land 1)"+ [ ["Asset","","Liability",""]+ , ["Cash","100.0","LoansPayable","60.0"]+ , ["Total","100.0","Equity",""]+ , ["","","CapitalStock","40.0"]+ , ["","","Total","100.0"] ]+ (rows bsRows land2B1)+ assertEqual "land3 bsRows b3 contra groups"+ [ ["Asset","","Liability",""]+ , ["Cash","900.0","LoansPayable","400.0"]+ , ["AccountsReceivable","1000.0","Equity",""]+ , ["AllowanceForDoubtfulAccounts","-100.0","CapitalStock","2000.0"]+ , ["TradeReceivablesNet","900.0","Total","2400.0"]+ , ["Building","800.0","",""]+ , ["AccumulatedDepreciation","-200.0","",""]+ , ["DepreciableAssetsNet","600.0","",""]+ , ["Total","2400.0","",""] ]+ (rows bsRows land2B3)+ assertEqual "land3 bsRows b4 abnormal balances"+ [ ["Asset","","Liability",""]+ , ["LoansPayable","100.0","Equity",""]+ , ["AllowanceForDoubtfulAccounts","20.0","Total","0.0"]+ , ["TradeReceivablesNet","20.0","",""]+ , ["Building","500.0","",""]+ , ["AccumulatedDepreciation","-200.0","",""]+ , ["DepreciableAssetsNet","300.0","",""]+ , ["Total","420.0","",""] ]+ (rows bsRows land2B4)+ -- Pre-vocab difference: SalesCost is now closed by the registry-derived+ -- Cost rule, so Sales 500 - SalesCost 300 becomes RetainedEarnings 200.+ assertEqual "land3 bsRows b5 closing and contra groups"+ [ ["Asset","","Liability",""]+ , ["Cash","1100.0","LoansPayable","400.0"]+ , ["AccountsReceivable","1000.0","Equity",""]+ , ["AllowanceForDoubtfulAccounts","-100.0","CapitalStock","2000.0"]+ , ["TradeReceivablesNet","900.0","RetainedEarnings","200.0"]+ , ["Building","800.0","Total","2600.0"]+ , ["AccumulatedDepreciation","-200.0","",""]+ , ["DepreciableAssetsNet","600.0","",""]+ , ["Total","2600.0","",""] ]+ (rows bsRows land2B5)+ assertEqual "land2 plRows b2 (= Land 1)"+ [ ["Cost","","Revenue",""]+ , ["SalesCost","300.0","Sales","500.0"]+ , ["Total","500.0","Total","300.0"] ]+ (rows plRows land2B2)+ -- projections: 資産系は Land 1 と一致, liability 系は contra が消える (意図的差分),+ -- contra は projContraAssets のみが Hat/Not 双方を保持して選ぶ。+ assertEqual "land2 projCurrentAssets b3 (= Land 1)"+ "900.00:@Not:<Cash .+ 1000.00:@Not:<AccountsReceivable"+ (show (EA.projCurrentAssets land2B3))+ assertEqual "land2 projCurrentLiability b3 (intentional: contra dropped)"+ "400.00:@Not:<LoansPayable"+ (show (EA.projCurrentLiability land2B3))+ assertEqual "land2 projFixedLiability b3 (intentional: contra dropped)"+ "0"+ (show (EA.projFixedLiability land2B3))+ assertEqual "land2 projContraAssets b3"+ "100.00:@Not:<AllowanceForDoubtfulAccounts .+ 200.00:@Not:<AccumulatedDepreciation"+ (show (EA.projContraAssets land2B3))+ assertEqual "land2 projCurrentAssets b4 (= Land 1; no contra Hat leakage)"+ "30.00:@Not:<Cash"+ (show (EA.projCurrentAssets land2B4))+ assertEqual "land2 projFixedAssets b4 (= Land 1; no contra Hat leakage)"+ "500.00:@Not:<Building"+ (show (EA.projFixedAssets land2B4))+ assertEqual "land2 projCurrentLiability b4 (intentional: contra dropped)"+ "200.00:@Not:<LoansPayable"+ (show (EA.projCurrentLiability land2B4))+ assertEqual "land2 projContraAssets b4 keeps Hat and Not, excludes Cash"+ "120.00:@Hat:<AllowanceForDoubtfulAccounts .+ 100.00:@Not:<AllowanceForDoubtfulAccounts .+ 50.00:@Hat:<AccumulatedDepreciation .+ 250.00:@Not:<AccumulatedDepreciation"+ (show (EA.projContraAssets land2B4))+ assertEqual "land2 projContraAssets b5"+ "100.00:@Not:<AllowanceForDoubtfulAccounts .+ 200.00:@Not:<AccumulatedDepreciation"+ (show (EA.projContraAssets land2B5))++-- Current presentation battery. Exact Land 3 rows are pinned above; this dump+-- is also compared structurally with the pre-Land 2 fixture below.+land2PresentationLines :: [T.Text]+land2PresentationLines = L.concatMap sect+ [ ("b1-basic", land2B1), ("b2-pl", land2B2), ("b3-contra", land2B3)+ , ("b4-abnormal", land2B4), ("b5-closing", land2B5) ]+ where+ sect (n, a) =+ (T.pack ("## " ++ n))+ : T.pack "-- bsRows"+ : L.map (T.pack . show . L.map T.unpack) (bsRows a)+ ++ T.pack "-- plRows"+ : L.map (T.pack . show . L.map T.unpack) (plRows a)+ ++ L.concat [ [T.pack ("-- " ++ pn), T.pack (show (pf a))] | (pn, pf) <- projList ]+ projList =+ [ ("projCurrentAssets", EA.projCurrentAssets)+ , ("projFixedAssets", EA.projFixedAssets)+ , ("projDeferredAssets", EA.projDeferredAssets)+ , ("projCurrentLiability", EA.projCurrentLiability)+ , ("projFixedLiability", EA.projFixedLiability)+ , ("projCapitalStock", EA.projCapitalStock)+ ]++testLand2PresentationClosedDiff :: IO ()+testLand2PresentationClosedDiff = do+ fixture <- TIO.readFile "test/fixtures/pre-land2/presentation.txt"+ let oldLines = L.filter (not . T.null) (L.drop 1 (T.lines fixture))+ newLines = land2PresentationLines+ names = L.map T.pack ["b1-basic", "b2-pl", "b3-contra", "b4-abnormal", "b5-closing"]+ markers = L.map T.pack+ [ "-- bsRows", "-- plRows", "-- projCurrentAssets"+ , "-- projFixedAssets", "-- projDeferredAssets"+ , "-- projCurrentLiability", "-- projFixedLiability"+ , "-- projCapitalStock"+ ]+ section name xs = takeWhile (not . T.isPrefixOf (T.pack "## "))+ (drop 1 (dropWhile (/= (T.pack "## " <> name)) xs))+ block marker xs = marker+ : takeWhile (not . T.isPrefixOf (T.pack "-- "))+ (drop 1 (dropWhile (/= marker) xs))+ changedBlocks =+ [ (name, marker)+ | name <- names+ , marker <- markers+ , block marker (section name oldLines) /= block marker (section name newLines)+ ]+ assertEqual "Land 3 closed diff: only adjudicated battery blocks changed"+ [ (T.pack "b2-pl", T.pack "-- bsRows")+ , (T.pack "b3-contra", T.pack "-- bsRows")+ , (T.pack "b3-contra", T.pack "-- projCurrentLiability")+ , (T.pack "b3-contra", T.pack "-- projFixedLiability")+ , (T.pack "b4-abnormal", T.pack "-- bsRows")+ , (T.pack "b4-abnormal", T.pack "-- projCurrentLiability")+ , (T.pack "b4-abnormal", T.pack "-- projFixedLiability")+ , (T.pack "b5-closing", T.pack "-- bsRows")+ , (T.pack "b5-closing", T.pack "-- projCurrentLiability")+ , (T.pack "b5-closing", T.pack "-- projFixedLiability")+ ]+ changedBlocks++-- HatNot は whichSide で明示 error (規約の regression 固定)+testLand2HatNotPolicy :: IO ()+testLand2HatNotPolicy = do+ r <- try (evaluate (whichSide (HatNot :< Cash :: HatBase AccountTitles)))+ :: IO (Either SomeException Side)+ assertEqual "land2 whichSide HatNot policy: explicit error"+ True (case r of Left _ -> True; Right _ -> False)++-- ================================================================+-- ExchangeAlgebra.Convert.Checked: checked construction for generated entries.+-- ================================================================++type CheckedAlgM = EA.Alg MoneyDecimal (HatBase AccountTitles)+type CheckedJournalM = EJ.Journal Int MoneyDecimal (HatBase AccountTitles)++checkedEntryM :: [(Side, AccountTitles, MoneyDecimal)]+ -> Either (NE.NonEmpty (ECC.EntryError MoneyDecimal)) CheckedAlgM+checkedEntryM = ECC.checkedEntry++checkedJournalM :: [(Int, [(Side, AccountTitles, MoneyDecimal)])]+ -> Either (NE.NonEmpty (ECC.JournalError Int MoneyDecimal)) CheckedJournalM+checkedJournalM = ECC.checkedJournal++genPositiveAmountMD :: Gen MoneyDecimal+genPositiveAmountMD = fromInteger <$> choose (1, 9999)++genCheckedAmountMD :: Gen MoneyDecimal+genCheckedAmountMD = fromInteger <$> choose (-5, 20)++genCheckedSide :: Gen Side+genCheckedSide = frequency+ [ (8, elements [Debit, Credit])+ , (1, pure Side)+ ]++genCheckedAccountTitle :: Gen AccountTitles+genCheckedAccountTitle = frequency+ [ (12, genAccountTitle)+ , (1, pure AccountTitle)+ ]++genCheckedPosting :: Gen (Side, AccountTitles, MoneyDecimal)+genCheckedPosting =+ (,,) <$> genCheckedSide <*> genCheckedAccountTitle <*> genCheckedAmountMD++genAcceptedEntryRows :: Gen [(Side, AccountTitles, MoneyDecimal)]+genAcceptedEntryRows = do+ amount <- genPositiveAmountMD+ debitAccount <- genOrdinaryPostingTitle+ creditAccount <- genOrdinaryPostingTitle+ pure [ (Debit, debitAccount, amount)+ , (Credit, creditAccount, amount)+ ]++genOrdinaryPostingTitle :: Gen AccountTitles+genOrdinaryPostingTitle = elements+ [ title+ | title <- Registry.concreteAccountTitles+ , Just semantics <- [Registry.accountSemantics title]+ , Registry.asemPostingCapability semantics == OrdinaryPosting+ ]++genCheckedEntryRows :: Gen [(Side, AccountTitles, MoneyDecimal)]+genCheckedEntryRows = frequency+ [ (5, resize 8 (listOf genCheckedPosting))+ , (3, genAcceptedEntryRows)+ , (1, pure [])+ ]++genKnownCertPosting :: Gen (Side, AccountTitles, MoneyDecimal)+genKnownCertPosting =+ (,,) <$> elements [Debit, Credit] <*> genAccountTitle <*> genCheckedAmountMD++genKnownCertJournal :: Gen [(Int, [(Side, AccountTitles, MoneyDecimal)])]+genKnownCertJournal = do+ rows <- resize 6 (listOf (resize 6 (listOf genKnownCertPosting)))+ pure (zip [1..] rows)++sideTextForCert :: Side -> T.Text+sideTextForCert Debit = T.pack "debit"+sideTextForCert Credit = T.pack "credit"+sideTextForCert Side = T.pack "Side"++textJournalForCert+ :: [(Int, [(Side, AccountTitles, MoneyDecimal)])]+ -> [(Int, [(T.Text, T.Text, MoneyDecimal)])]+textJournalForCert = L.map renderEntry+ where+ renderEntry (txid, rows) = (txid, L.map renderPosting rows)+ renderPosting (side, account, amount) =+ (sideTextForCert side, T.pack (show account), amount)++checkedJournalTextReference+ :: [(Int, [(T.Text, T.Text, MoneyDecimal)])]+ -> Either (NE.NonEmpty (ECC.JournalError Int MoneyDecimal)) CheckedJournalM+checkedJournalTextReference entries =+ case errors of+ [] -> Right (L.foldl' (.+) mempty journals)+ e : es -> Left (e NE.:| es)+ where+ checked =+ [ (txid, ECC.checkedEntryText rows)+ | (txid, rows) <- entries+ ]+ errors =+ [ ECC.EntryErrors txid errs+ | (txid, Left errs) <- checked+ ]+ journals =+ [ alg .| txid+ | (txid, Right alg) <- checked+ ]++prop_certifyKnownAccountsMatchesCheckedJournal :: Property+prop_certifyKnownAccountsMatchesCheckedJournal =+ forAll genKnownCertJournal $ \entries ->+ let textEntries = textJournalForCert entries+ in case ( ECC.certifyJournalText textEntries+ , checkedJournalTextReference textEntries+ , checkedJournalM entries+ ) of+ (ECC.FullyResolved actual, Right textExpected, Right expected) ->+ EJ.toMap actual == EJ.toMap textExpected+ && EJ.toMap actual == EJ.toMap expected+ (ECC.Rejected _, Left _, Left _) -> True+ _ -> False++prop_certifyUnknownAccountPreservesBalance :: Property+prop_certifyUnknownAccountPreservesBalance =+ forAll genAcceptedEntryRows $ \rows ->+ let replaceAccount (side, _, amount) =+ (sideTextForCert side, T.pack "NoSuchAccount_XYZ", amount)+ in case ECC.certifyJournalText [(1 :: Int, L.map replaceAccount rows)] of+ ECC.BalancedUnresolved {} -> True+ _ -> False++prop_certifyImbalancePrecedesUnknownAccount :: Property+prop_certifyImbalancePrecedesUnknownAccount =+ forAll genPositiveAmountMD $ \amount ->+ let input =+ [ (1 :: Int,+ [ (T.pack "debit", T.pack "NoSuchAccount_XYZ", amount)+ , (T.pack "credit", T.pack "Cash", amount + 1)+ ])+ ]+ in case ECC.certifyJournalText input of+ ECC.Rejected errs -> any journalHasImbalance (NE.toList errs)+ _ -> False+ where+ journalHasImbalance (ECC.EntryErrors _ errs) =+ any isImbalanced (NE.toList errs)+ journalHasImbalance _ = False++ isImbalanced ECC.Imbalanced {} = True+ isImbalanced _ = False++prop_certifyDuplicateTxIdAlwaysRejected :: Property+prop_certifyDuplicateTxIdAlwaysRejected =+ forAll genAcceptedEntryRows $ \rows ->+ let input = textJournalForCert [(1, rows), (1, rows)]+ in case ECC.certifyJournalText input of+ ECC.Rejected errs -> ECC.DuplicateTxId 1 `elem` NE.toList errs+ _ -> False++prop_certifyBalancedUnresolvedTotals :: Property+prop_certifyBalancedUnresolvedTotals =+ forAll genPositiveAmountMD $ \amount ->+ let input =+ [ (1 :: Int,+ [ (T.pack "debit", T.pack "NoSuchAccount_XYZ", amount)+ , (T.pack "credit", T.pack "Sales", amount)+ ])+ ]+ in case ECC.certifyJournalText input of+ ECC.BalancedUnresolved+ { ECC._certDebitTotal = debitTotal+ , ECC._certCreditTotal = creditTotal+ } -> debitTotal == creditTotal+ && debitTotal == amount+ && creditTotal == amount+ _ -> False++checkedEntryAcceptsSpec :: [(Side, AccountTitles, MoneyDecimal)] -> Bool+checkedEntryAcceptsSpec rows =+ not (null rows)+ && all validPosting rows+ && ECC.exactBalanced (EC.journalFromSides rows :: CheckedAlgM)+ where+ validPosting (side, account, amount) =+ side /= Side+ && account /= AccountTitle+ && maybe False+ (ECC.postingAllowedIn ECC.OrdinaryJournal+ . Registry.asemPostingCapability)+ (Registry.accountSemantics account)+ && amount > 0+ && not (EA.isErrorValue amount)++data ConsolidationFixtureRow+ = FixturePosting String String [String]+ AccountTitles AccountTitles MoneyDecimal+ | FixtureLink String String MoneyDecimal+ deriving (Show, Eq)++parseConsolidationFixtureRow :: T.Text -> Either String ConsolidationFixtureRow+parseConsolidationFixtureRow line = case T.splitOn (T.pack "\t") line of+ [kind, rowId, sourceIdsText, debitText, creditText, amountText]+ | kind == T.pack "source" || kind == T.pack "adjustment" -> do+ debitAccount <- firstShow (EC.parseAccountTitle debitText)+ creditAccount <- firstShow (EC.parseAccountTitle creditText)+ amount <- parseAmount amountText+ let sourceIds+ | sourceIdsText == T.pack "-" = []+ | otherwise = L.map T.unpack+ (T.splitOn (T.pack "|") sourceIdsText)+ Right (FixturePosting (T.unpack kind) (T.unpack rowId) sourceIds+ debitAccount creditAccount amount)+ | kind == T.pack "link" -> do+ amount <- parseAmount amountText+ Right (FixtureLink (T.unpack rowId) (T.unpack sourceIdsText) amount)+ fields -> Left ("invalid consolidation fixture row: " ++ show fields)+ where+ firstShow (Left err) = Left (show err)+ firstShow (Right value) = Right value+ parseAmount amountText = case reads (T.unpack amountText) of+ [(amount, "")] -> Right (fromInteger amount)+ _ -> Left ("invalid fixture amount: " ++ T.unpack amountText)++loadMinimalConsolidationFixture+ :: IO (CW.WorksheetInput String String MoneyDecimal)+loadMinimalConsolidationFixture = do+ contents <- TIO.readFile+ "test/fixtures/consolidation-worksheet-050/minimal.tsv"+ rows <- case traverse parseConsolidationFixtureRow+ (filter (not . T.null) (drop 1 (T.lines contents))) of+ Left err -> fail err+ Right parsed -> pure parsed+ let postings =+ [ (kind, rowId, sourceIds, debitAccount, creditAccount, amount)+ | FixturePosting kind rowId sourceIds debitAccount creditAccount amount+ <- rows+ ]+ sources =+ [ CW.TrialBalanceSource rowId+ (EC.journalFromSides+ [ (Debit, debitAccount, amount)+ , (Credit, creditAccount, amount)+ ] :: CheckedAlgM)+ | (kind, rowId, _, debitAccount, creditAccount, amount) <- postings+ , kind == "source"+ ]+ adjustments =+ [ CW.WorksheetAdjustment rowId (sourceId NE.:| sourceIdsTail)+ (EC.journalFromSides+ [ (Debit, debitAccount, amount)+ , (Credit, creditAccount, amount)+ ] :: CheckedAlgM)+ | (kind, rowId, sourceId : sourceIdsTail,+ debitAccount, creditAccount, amount) <- postings+ , kind == "adjustment"+ ]+ links = M.fromList+ [ (rowId, (direction, amount))+ | FixtureLink rowId direction amount <- rows+ ]+ sourceList <- case NE.nonEmpty sources of+ Nothing -> fail "minimal consolidation fixture has no sources"+ Just nonEmptySources -> pure nonEmptySources+ plResult <- fixtureResult links "pl-net-income"+ ownersPlResult <- fixtureResult links "pl-parent-net-income"+ ownersSsResult <- fixtureResult links "ss-parent-net-income"+ nciResult <- fixtureResult links "nci-period-share"+ openingRetained <- fixtureBalance links "opening-retained-earnings"+ retainedDividends <- fixtureAmount links "retained-earnings-dividends"+ ssClosingRetained <- fixtureBalance links "ss-closing-retained-earnings"+ bsRetained <- fixtureBalance links "bs-retained-earnings"+ openingNci <- fixtureBalance links "opening-nci"+ nciDividends <- fixtureAmount links "nci-dividends"+ closingNci <- fixtureBalance links "closing-nci"+ bsNci <- fixtureBalance links "bs-nci"+ pure (CW.WorksheetInput sourceList adjustments+ (CW.WorksheetLinkage+ { CW._profitOrLossNetIncome = plResult+ , CW._profitOrLossNetIncomeAttributableToOwners = ownersPlResult+ , CW._statementOfChangesNetIncomeAttributableToOwners =+ ownersSsResult+ , CW._openingRetainedEarnings = openingRetained+ , CW._retainedEarningsDividends = retainedDividends+ , CW._statementOfChangesClosingRetainedEarnings = ssClosingRetained+ , CW._balanceSheetRetainedEarnings = bsRetained+ , CW._openingNonControllingInterests = openingNci+ , CW._nonControllingInterestsPeriodShare = nciResult+ , CW._nonControllingInterestsDividends = nciDividends+ , CW._statementOfChangesClosingNonControllingInterests = closingNci+ , CW._balanceSheetNonControllingInterests = bsNci+ }))+ where+ fixtureAmount links key = case M.lookup key links of+ Just ("amount", amount) -> pure amount+ Just (direction, _) -> fail+ ("expected amount direction for " ++ key ++ ", got " ++ direction)+ Nothing -> fail ("missing fixture link: " ++ key)+ fixtureResult links key = case M.lookup key links of+ Just ("profit", amount) -> pure (CW.PeriodProfit amount)+ Just ("loss", amount) -> pure (CW.PeriodLoss amount)+ Just ("break-even", _) -> pure CW.PeriodBreakEven+ Just (direction, _) -> fail+ ("invalid period-result direction for " ++ key ++ ": " ++ direction)+ Nothing -> fail ("missing fixture link: " ++ key)+ fixtureBalance links key = case M.lookup key links of+ Just ("credit", amount) -> pure (CW.CreditBalance amount)+ Just ("debit", amount) -> pure (CW.DebitBalance amount)+ Just (direction, _) -> fail+ ("invalid balance direction for " ++ key ++ ": " ++ direction)+ Nothing -> fail ("missing fixture link: " ++ key)++leftErrors :: Either (NE.NonEmpty e) a -> Maybe (NE.NonEmpty e)+leftErrors (Left errors) = Just errors+leftErrors (Right _) = Nothing++testConsolidationWorksheet :: IO ()+testConsolidationWorksheet = do+ fixture <- loadMinimalConsolidationFixture+ validated <- case CW.validateConsolidationWorksheet fixture of+ Left errors -> fail ("valid consolidation fixture rejected: " ++ show errors)+ Right value -> pure value+ assertEqual "consolidation worksheet: source provenance retained"+ ["parent", "subsidiary"]+ [ CW._sourceId source+ | source <- NE.toList (CW.validatedSources validated)+ ]+ assertEqual "consolidation worksheet: adjustment provenance retained"+ [("nci-attribution", ["parent", "subsidiary"])]+ [ ( CW._adjustmentId adjustment+ , NE.toList (CW._adjustmentSourceIds adjustment)+ )+ | adjustment <- CW.validatedAdjustments validated+ ]+ let combined = CW.combinedWorksheet validated+ assertEqual "consolidation worksheet: combined fixture stays balanced"+ True (ECC.exactBalanced combined)+ assertEqual "consolidation worksheet: combined debit total"+ (200 :: MoneyDecimal) (EA.norm (EA.decL combined))+ assertEqual "consolidation worksheet: combined credit total"+ (200 :: MoneyDecimal) (EA.norm (EA.decR combined))+ assertEqual "consolidation worksheet: combination preserves all sequences"+ 6 (L.length (EA.vals combined))++ let rawAdjustment =+ (10 EA..@ (Not :< Cash)) EA..++ (5 EA..@ (Not :< Cash)) EA..++ (15 EA..@ (Not :< Sales)) :: CheckedAlgM+ rawInput = CW.WorksheetInput (CW._worksheetSources fixture)+ [CW.WorksheetAdjustment "raw-three-posting"+ ("parent" NE.:| []) rawAdjustment]+ (CW._worksheetLinkage fixture)+ assertEqual "consolidation worksheet: accepts non-journal-shaped raw Alg"+ True+ (case CW.validateConsolidationWorksheet rawInput of+ Right _ -> True+ Left _ -> False)++ let sources = CW._worksheetSources fixture+ links = CW._worksheetLinkage fixture+ debitOnly = EC.journalFromSides+ [(Debit, Cash, 10 :: MoneyDecimal)] :: CheckedAlgM+ creditOnly = EC.journalFromSides+ [(Credit, Sales, 10 :: MoneyDecimal)] :: CheckedAlgM+ cancellingSet = debitOnly EA..+ creditOnly+ cancellingInput = CW.WorksheetInput sources+ [ CW.WorksheetAdjustment "bad-debit" ("parent" NE.:| []) debitOnly+ , CW.WorksheetAdjustment "bad-credit" ("parent" NE.:| []) creditOnly+ ] links+ assertEqual "consolidation worksheet: malformed set can balance in aggregate"+ True (ECC.exactBalanced cancellingSet)+ assertEqual "consolidation worksheet: atomic gate rejects both malformed adjustments"+ (Just+ ( CW.UnbalancedAdjustment "bad-debit" 10 0 NE.:|+ [CW.UnbalancedAdjustment "bad-credit" 0 10]+ ))+ (leftErrors (CW.validateConsolidationWorksheet cancellingInput))++ let forbidden = EC.journalFromSides+ [ (Debit, NetIncome, 10 :: MoneyDecimal)+ , (Credit, RetainedEarnings, 10)+ ] :: CheckedAlgM+ forbiddenInput = CW.WorksheetInput sources+ [CW.WorksheetAdjustment "forbidden" ("parent" NE.:| []) forbidden]+ links+ assertEqual "consolidation worksheet: context capability applies to raw Alg"+ (Just (CW.AdjustmentPostingNotAllowed "forbidden" NetIncome+ EngineGeneratedOnly NE.:| []))+ (leftErrors (CW.validateConsolidationWorksheet forbiddenInput))++ let provenanceInput = CW.WorksheetInput sources+ [CW.WorksheetAdjustment "unknown"+ ("ghost" NE.:| ["ghost"]) (mempty :: CheckedAlgM)] links+ assertEqual "consolidation worksheet: provenance rejects duplicate and unknown source"+ (Just+ ( CW.DuplicateAdjustmentSource "unknown" "ghost" NE.:|+ [ CW.UnknownAdjustmentSource "unknown" "ghost"+ , CW.EmptyAdjustment "unknown"+ ]+ ))+ (leftErrors (CW.validateConsolidationWorksheet provenanceInput))++ let mismatchedLinks = links+ { CW._statementOfChangesNetIncomeAttributableToOwners =+ CW.PeriodProfit 40 }+ mismatchedInput = fixture { CW._worksheetLinkage = mismatchedLinks }+ assertEqual "consolidation worksheet: P/L to S/S mismatch is explicit"+ True+ (case CW.validateConsolidationWorksheet mismatchedInput of+ Left errors -> CW.OwnersPeriodResultLinkMismatch+ (CW.PeriodProfit 50) (CW.PeriodProfit 40) `elem` NE.toList errors+ Right _ -> False)++ let attributionLinks = links+ { CW._profitOrLossNetIncome = CW.PeriodProfit 60 }+ attributionInput = fixture { CW._worksheetLinkage = attributionLinks }+ assertEqual "consolidation worksheet: total attribution mismatch is explicit"+ True+ (case CW.validateConsolidationWorksheet attributionInput of+ Left errors -> CW.NetIncomeAttributionMismatch 60 70+ `elem` NE.toList errors+ Right _ -> False)++ let retainedLinks = links+ { CW._retainedEarningsDividends = 11 }+ retainedInput = fixture { CW._worksheetLinkage = retainedLinks }+ assertEqual "consolidation worksheet: retained-earnings mismatch is explicit"+ True+ (case CW.validateConsolidationWorksheet retainedInput of+ Left errors -> CW.RetainedEarningsRollForwardMismatch 150 151+ `elem` NE.toList errors+ Right _ -> False)++ let balanceSheetLinks = links+ { CW._balanceSheetRetainedEarnings = CW.CreditBalance 139 }+ balanceSheetInput = fixture { CW._worksheetLinkage = balanceSheetLinks }+ assertEqual "consolidation worksheet: S/S to B/S mismatch is explicit"+ True+ (case CW.validateConsolidationWorksheet balanceSheetInput of+ Left errors -> CW.BalanceSheetRetainedEarningsMismatch+ (CW.CreditBalance 140) (CW.CreditBalance 139)+ `elem` NE.toList errors+ Right _ -> False)++ let nciLinks = links { CW._nonControllingInterestsDividends = 6 }+ nciInput = fixture { CW._worksheetLinkage = nciLinks }+ assertEqual "consolidation worksheet: NCI roll-forward mismatch is explicit"+ True+ (case CW.validateConsolidationWorksheet nciInput of+ Left errors -> CW.NonControllingInterestsRollForwardMismatch 50 51+ `elem` NE.toList errors+ Right _ -> False)++ let nciBalanceSheetLinks = links+ { CW._balanceSheetNonControllingInterests = CW.CreditBalance 44 }+ nciBalanceSheetInput = fixture+ { CW._worksheetLinkage = nciBalanceSheetLinks }+ assertEqual "consolidation worksheet: NCI S/S to B/S mismatch is explicit"+ True+ (case CW.validateConsolidationWorksheet nciBalanceSheetInput of+ Left errors -> CW.BalanceSheetNonControllingInterestsMismatch+ (CW.CreditBalance 45) (CW.CreditBalance 44)+ `elem` NE.toList errors+ Right _ -> False)++ let lossLinks = links+ { CW._profitOrLossNetIncome = CW.PeriodLoss 25+ , CW._profitOrLossNetIncomeAttributableToOwners = CW.PeriodLoss 20+ , CW._statementOfChangesNetIncomeAttributableToOwners =+ CW.PeriodLoss 20+ , CW._openingRetainedEarnings = CW.CreditBalance 100+ , CW._retainedEarningsDividends = 10+ , CW._statementOfChangesClosingRetainedEarnings =+ CW.CreditBalance 70+ , CW._balanceSheetRetainedEarnings = CW.CreditBalance 70+ , CW._openingNonControllingInterests = CW.CreditBalance 30+ , CW._nonControllingInterestsPeriodShare = CW.PeriodLoss 5+ , CW._nonControllingInterestsDividends = 5+ , CW._statementOfChangesClosingNonControllingInterests =+ CW.CreditBalance 20+ , CW._balanceSheetNonControllingInterests = CW.CreditBalance 20+ }+ lossInput = fixture { CW._worksheetLinkage = lossLinks }+ assertEqual "consolidation worksheet: loss roll-forwards preserve direction"+ True+ (case CW.validateConsolidationWorksheet lossInput of+ Right _ -> True+ Left _ -> False)++ let deficitLinks = links+ { CW._profitOrLossNetIncome = CW.PeriodLoss 50+ , CW._profitOrLossNetIncomeAttributableToOwners = CW.PeriodLoss 50+ , CW._statementOfChangesNetIncomeAttributableToOwners =+ CW.PeriodLoss 50+ , CW._openingRetainedEarnings = CW.CreditBalance 10+ , CW._retainedEarningsDividends = 0+ , CW._statementOfChangesClosingRetainedEarnings =+ CW.DebitBalance 40+ , CW._balanceSheetRetainedEarnings = CW.DebitBalance 40+ , CW._openingNonControllingInterests = CW.CreditBalance 0+ , CW._nonControllingInterestsPeriodShare = CW.PeriodBreakEven+ , CW._nonControllingInterestsDividends = 0+ , CW._statementOfChangesClosingNonControllingInterests =+ CW.CreditBalance 0+ , CW._balanceSheetNonControllingInterests = CW.CreditBalance 0+ }+ deficitInput = fixture { CW._worksheetLinkage = deficitLinks }+ assertEqual "consolidation worksheet: accumulated deficit is structural"+ True+ (case CW.validateConsolidationWorksheet deficitInput of+ Right _ -> True+ Left _ -> False)++ let invalidLinks = links+ { CW._openingRetainedEarnings = CW.CreditBalance (-1) }+ invalidInput = fixture { CW._worksheetLinkage = invalidLinks }+ assertEqual "consolidation worksheet: negative linkage amount rejected"+ (Just (CW.InvalidLinkAmount CW.OpeningRetainedEarnings (-1) NE.:| []))+ (leftErrors (CW.validateConsolidationWorksheet invalidInput))++ let wildcardAccount =+ (10 EA..@ (Not :< AccountTitle)) EA..++ (10 EA..@ (Hat :< Cash)) :: CheckedAlgM+ wildcardInput = CW.WorksheetInput sources+ [CW.WorksheetAdjustment "wildcard" ("parent" NE.:| [])+ wildcardAccount] links+ assertEqual "consolidation worksheet: wildcard error list is total"+ (Just (CW.WildcardAdjustmentAccount "wildcard" NE.:| []))+ (leftErrors (CW.validateConsolidationWorksheet wildcardInput))++ let duplicateSources = case sources of+ source NE.:| rest -> source NE.:| (source : rest)+ duplicateAdjustments =+ [ CW.WorksheetAdjustment "same" ("parent" NE.:| [])+ rawAdjustment+ , CW.WorksheetAdjustment "same" ("parent" NE.:| [])+ rawAdjustment+ ]+ duplicateInput = CW.WorksheetInput duplicateSources+ duplicateAdjustments links+ assertEqual "consolidation worksheet: duplicate stable IDs rejected"+ True+ (case CW.validateConsolidationWorksheet duplicateInput of+ Left errors -> CW.DuplicateSourceId "parent" `elem` NE.toList errors+ && CW.DuplicateAdjustmentId "same" `elem` NE.toList errors+ Right _ -> False)++ let wildcardSource = CW.TrialBalanceSource "wild-source"+ (10 :@ (HatNot :< Cash) :: CheckedAlgM)+ wildcardSourceInput+ :: CW.WorksheetInput String String MoneyDecimal+ wildcardSourceInput = CW.WorksheetInput+ (wildcardSource NE.:| []) [] links+ assertEqual "consolidation worksheet: wildcard source side is total"+ (Just (CW.WildcardSourceSide "wild-source" NE.:| []))+ (leftErrors (CW.validateConsolidationWorksheet wildcardSourceInput))++ let wildcardSide =+ 10 :@ (HatNot :< Cash) :: CheckedAlgM+ wildcardSideInput = CW.WorksheetInput sources+ [CW.WorksheetAdjustment "wild-side" ("parent" NE.:| [])+ wildcardSide] links+ assertEqual "consolidation worksheet: wildcard adjustment side is total"+ (Just (CW.WildcardAdjustmentSide "wild-side" NE.:| []))+ (leftErrors (CW.validateConsolidationWorksheet wildcardSideInput))++ let unbalancedSource = CW.TrialBalanceSource "unbalanced-source"+ (EC.journalFromSides+ [(Debit, Cash, 10 :: MoneyDecimal)] :: CheckedAlgM)+ unbalancedSourceInput+ :: CW.WorksheetInput String String MoneyDecimal+ unbalancedSourceInput = CW.WorksheetInput+ (unbalancedSource NE.:| []) [] links+ assertEqual "consolidation worksheet: unbalanced source rejected"+ (Just (CW.UnbalancedSourceTrialBalance+ "unbalanced-source" 10 0 NE.:| []))+ (leftErrors (CW.validateConsolidationWorksheet unbalancedSourceInput))++trialBalanceInput+ :: CheckedAlgM+ -> TB.TrialBalanceStage+ -> TB.TrialBalanceInput MoneyDecimal+trialBalanceInput alg stage = TB.TrialBalanceInput+ { TB._trialBalanceElement = alg+ , TB._trialBalanceStage = stage+ , TB._temporaryBalanceExplanations = M.empty+ , TB._reclassificationRules = []+ , TB._maturityEvidenceTitles = Set.empty+ }++testSharedAccountBalancePrimitives :: IO ()+testSharedAccountBalancePrimitives = do+ let balances =+ [ TBB.NoBalance+ , TBB.DebitBalance 7+ , TBB.CreditBalance 11+ ] :: [TBB.AccountBalance Int]+ assertEqual "account balance: pair netting round trip"+ balances (fmap (TBB.netPair . TBB.balancePair) balances)+ assertEqual "account balance: structural sides"+ [Side, Debit, Credit] (fmap TBB.balanceSide balances)++testTrialBalanceValidation :: IO ()+testTrialBalanceValidation = do+ let reciprocalMismatch = EC.journalFromSides+ [ (Debit, BranchCurrentAccount, 40 :: MoneyDecimal)+ , (Credit, HeadOfficeCurrentAccount, 30)+ , (Credit, CapitalStock, 10)+ ] :: CheckedAlgM+ reciprocalInput = trialBalanceInput reciprocalMismatch TB.BeforeClosing+ expectedReciprocal = TB.ReciprocalMismatch+ (TB.DebitBalance 40) (TB.CreditBalance 30)+ assertEqual "trial balance: reciprocal mismatch independent of global balance"+ (Just (expectedReciprocal NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.strictTrialBalancePolicy reciprocalInput))+ assertEqual "trial balance: two-sided mismatch is never a standalone waiver"+ (Just (expectedReciprocal NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy reciprocalInput))+ let standaloneReciprocal = EC.journalFromSides+ [ (Debit, BranchCurrentAccount, 40 :: MoneyDecimal)+ , (Credit, CapitalStock, 40)+ ] :: CheckedAlgM+ standaloneInput = trialBalanceInput standaloneReciprocal TB.BeforeClosing+ expectedStandalone = TB.StandaloneReciprocalBalance+ BranchCurrentAccount (TB.DebitBalance 40)+ standalone <- case TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy standaloneInput of+ Left errors -> fail ("standalone reciprocal balance rejected: " ++ show errors)+ Right value -> pure value+ assertEqual "trial balance: standalone policy retains permitted finding"+ [expectedStandalone] (TB.validatedFindings standalone)++ let explainedSuspense = EC.journalFromSides+ [ (Debit, SuspensePayments, 10 :: MoneyDecimal)+ , (Credit, CapitalStock, 10)+ ] :: CheckedAlgM+ explanation = T.pack "invoice received after reporting date"+ explainedInput = (trialBalanceInput explainedSuspense TB.BeforeClosing)+ { TB._temporaryBalanceExplanations =+ M.singleton SuspensePayments explanation }+ expectedExplained = TB.ExplainedTemporaryBalance SuspensePayments+ (TB.DebitBalance 10) explanation+ explained <- case TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy explainedInput of+ Left errors -> fail ("explained suspense balance rejected: " ++ show errors)+ Right value -> pure value+ assertEqual "trial balance: explained temporary balance retained"+ [expectedExplained] (TB.validatedFindings explained)+ assertEqual "trial balance: policy can block explained temporary balance"+ (Just (expectedExplained NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.strictTrialBalancePolicy explainedInput))+ let unresolvedInput = trialBalanceInput explainedSuspense TB.BeforeClosing+ assertEqual "trial balance: unexplained temporary balance blocks"+ (Just (TB.UnresolvedTemporaryBalance SuspensePayments+ (TB.DebitBalance 10) NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy unresolvedInput))+ let blankInput = explainedInput+ { TB._temporaryBalanceExplanations =+ M.singleton SuspensePayments (T.pack " ") }+ assertEqual "trial balance: blank explanation never opens the gate"+ (Just (TB.BlankTemporaryExplanation SuspensePayments+ (TB.DebitBalance 10) NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy blankInput))++ let closingResidual = EC.journalFromSides+ [ (Debit, CashOverShort, 10 :: MoneyDecimal)+ , (Credit, IncomeSummary, 10)+ ] :: CheckedAlgM+ closingInput = trialBalanceInput closingResidual TB.AfterClosing+ assertEqual "trial balance: closing devices must be zero after closing"+ (Just+ ( TB.ClosingDeviceResidual CashOverShort (TB.DebitBalance 10)+ NE.:|+ [TB.ClosingDeviceResidual IncomeSummary (TB.CreditBalance 10)]+ ))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy closingInput))+ let unclosedNominal = EC.journalFromSides+ [ (Debit, Cash, 10 :: MoneyDecimal)+ , (Credit, Sales, 10)+ ] :: CheckedAlgM+ assertEqual "trial balance: nominal balances must close after closing"+ (Just (TB.UnclosedNominalBalance Sales+ (TB.CreditBalance 10) NE.:| []))+ (leftErrors (TB.validateTrialBalance TB.standaloneTrialBalancePolicy+ (trialBalanceInput unclosedNominal TB.AfterClosing)))++ let derivedResidual = EC.journalFromSides+ [ (Debit, NetIncome, 10 :: MoneyDecimal)+ , (Credit, CapitalStock, 10)+ ] :: CheckedAlgM+ assertEqual "trial balance: derived coordinates are residuals after closing"+ (Just (TB.DerivedCoordinateResidual NetIncome+ (TB.DebitBalance 10) NE.:| []))+ (leftErrors (TB.validateTrialBalance TB.standaloneTrialBalancePolicy+ (trialBalanceInput derivedResidual TB.AfterClosing)))++ let abnormalDeposit = EC.journalFromSides+ [ (Debit, Cash, 10 :: MoneyDecimal)+ , (Credit, CurrentDeposits, 10)+ ] :: CheckedAlgM+ abnormalInput = trialBalanceInput abnormalDeposit TB.BeforeClosing+ oneRule = TB.SideReclassificationRule CurrentDeposits Credit+ (ShortTermLoansPayable NE.:| [])+ twoRules = TB.SideReclassificationRule CurrentDeposits Credit+ (BankOverdraft NE.:| [ShortTermLoansPayable])+ assertEqual "trial balance: unexplained abnormal side is explicit"+ (Just (TB.UnexplainedAbnormalBalance CurrentDeposits Debit+ (TB.CreditBalance 10) NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy abnormalInput))+ assertEqual "trial balance: unique reclassification is an instruction"+ (Just (TB.AbnormalBalanceWithReclassificationRule CurrentDeposits+ (TB.CreditBalance 10) ShortTermLoansPayable NE.:| []))+ (leftErrors (TB.validateTrialBalance TB.standaloneTrialBalancePolicy+ abnormalInput { TB._reclassificationRules = [oneRule] }))+ assertEqual "trial balance: ambiguous reclassification is never automatic"+ (Just (TB.AmbiguousReclassification CurrentDeposits+ (TB.CreditBalance 10)+ (BankOverdraft NE.:| [ShortTermLoansPayable]) NE.:| []))+ (leftErrors (TB.validateTrialBalance TB.standaloneTrialBalancePolicy+ abnormalInput { TB._reclassificationRules = [twoRules] }))++ let recordedTransfer = EC.journalFromSides+ [ (Debit, CurrentDeposits, 10 :: MoneyDecimal)+ , (Credit, ShortTermLoansPayable, 10)+ ] :: CheckedAlgM+ transferred = abnormalDeposit .+ recordedTransfer+ transferredInput = (trialBalanceInput transferred TB.BeforeClosing)+ { TB._reclassificationRules = [oneRule] }+ assertEqual "trial balance: recorded transfer clears abnormal finding"+ True+ (case TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy transferredInput of+ Right _ -> True+ Left _ -> False)+ assertEqual "trial balance: validation never rewrites the admitted element"+ transferred+ (case TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy transferredInput of+ Right value -> TB.validatedTrialBalance value+ Left _ -> mempty)+ assertEqual "trial balance: validated stage is retained"+ TB.BeforeClosing+ (case TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy transferredInput of+ Right value -> TB.validatedStage value+ Left _ -> TB.AfterClosing)++ let invalidRule = TB.SideReclassificationRule CashOverShort Credit+ (MiscellaneousIncome NE.:| [])+ invalidRuleInput = (trialBalanceInput mempty TB.BeforeClosing)+ { TB._reclassificationRules = [invalidRule] }+ assertEqual "trial balance: inapplicable rules are explicit"+ (Just (TB.InapplicableReclassificationRule invalidRule NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy invalidRuleInput))+ let deadRule = TB.SideReclassificationRule CurrentDeposits Debit+ (Cash NE.:| [])+ deadRuleInput = (trialBalanceInput mempty TB.BeforeClosing)+ { TB._reclassificationRules = [deadRule] }+ assertEqual "trial balance: normal-side trigger is a dead rule"+ (Just (TB.InapplicableReclassificationRule deadRule NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy deadRuleInput))++ let maturityBalance = EC.journalFromSides+ [ (Debit, LoansReceivable, 25 :: MoneyDecimal)+ , (Credit, CapitalStock, 25)+ ] :: CheckedAlgM+ maturityRule = TB.MaturityEvidenceRequired LoansReceivable+ missingMaturity = (trialBalanceInput maturityBalance TB.BeforeClosing)+ { TB._reclassificationRules = [maturityRule] }+ suppliedMaturity = missingMaturity+ { TB._maturityEvidenceTitles = Set.singleton LoansReceivable }+ assertEqual "trial balance: maturity-sensitive title requires evidence"+ (Just (TB.MissingMaturityEvidence LoansReceivable NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy missingMaturity))+ assertEqual "trial balance: supplied maturity evidence clears finding"+ True+ (case TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy suppliedMaturity of+ Right _ -> True+ Left _ -> False)++ let unbalanced = EC.journalFromSides+ [(Debit, Cash, 10 :: MoneyDecimal)] :: CheckedAlgM+ unbalancedInput = trialBalanceInput unbalanced TB.BeforeClosing+ assertEqual "trial balance: exact global imbalance blocks"+ (Just (TB.UnbalancedTrialBalance 10 0 NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy unbalancedInput))++ let wildcard = 10 :@ (HatNot :< Cash) :: CheckedAlgM+ wildcardInput = trialBalanceInput wildcard TB.BeforeClosing+ assertEqual "trial balance: wildcard side reports without whichSide crash"+ (Just (TB.WildcardTrialBalanceSide NE.:| []))+ (leftErrors (TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy wildcardInput))++ let titlesFor role =+ [ title+ | title <- Registry.concreteAccountTitles+ , Just semantics <- [Registry.accountSemantics title]+ , role `elem` Registry.asemRoles semantics+ ]+ assertEqual "trial balance: reciprocal registry role is pinned"+ [BranchCurrentAccount, HeadOfficeCurrentAccount]+ (titlesFor ReciprocalAccount)+ assertEqual "trial balance: suspense registry role is pinned"+ [SuspensePayments, CashOverShort, SuspenseReceipts, SuspenseAccount]+ (titlesFor SuspenseOrClearingAccount)+ assertEqual "trial balance: closing-device registry role is pinned"+ [IncomeSummary] (titlesFor ClosingDevice)++testReportingPresentation :: IO ()+testReportingPresentation = do+ let reportingBalance = EC.journalFromSides+ [ (Debit, Cash, 100 :: MoneyDecimal)+ , (Debit, LoansReceivable, 30)+ , (Debit, Purchases, 40)+ , (Debit, BranchCurrentAccount, 10)+ , (Debit, IncomeTaxesRefundReceivable, 5)+ , (Credit, CapitalStock, 90)+ , (Credit, Sales, 70)+ , (Credit, AdvancesReceived, 15)+ , (Credit, HeadOfficeCurrentAccount, 10)+ ] :: CheckedAlgM+ maturityRule = TB.MaturityEvidenceRequired LoansReceivable+ reportingInput = (trialBalanceInput reportingBalance TB.BeforeClosing)+ { TB._reclassificationRules = [maturityRule]+ , TB._maturityEvidenceTitles = Set.singleton LoansReceivable+ }+ validated = case TB.validateTrialBalance+ TB.strictTrialBalancePolicy reportingInput of+ Right value -> value+ Left errors -> error ("reporting fixture did not validate: " ++ show errors)+ rationale = T.pack "material under the documented tax review"+ context scope = (RP.jcciSecondGradeContext scope)+ { RP._presentationAllocations =+ [RP.PresentationAllocation LoansReceivable 10 20+ (T.pack "contract maturity schedule")]+ , RP._presentationRelabels =+ [RP.PresentationRelabel Purchases SalesCost+ (T.pack "JCCI report cost-of-sales label")]+ , RP._materialityDecisions =+ [RP.MaterialityDecision IncomeTaxesRefundReceivable+ RP.PresentSeparately rationale]+ , RP._subtotalDefinitions =+ [ RP.SubtotalDefinition RM.GrossProfitMetric [Sales] [SalesCost]+ RP.RequireAllTitlesPresent+ , RP.SubtotalDefinition RM.OrdinaryProfitMetric [Sales] [SalesCost]+ RP.RequireAllTitlesPresent+ ]+ }+ standalone = rightStatements (RP.present (context RP.Standalone) validated)+ combined = rightStatements (RP.present (context RP.Combined) validated)+ standaloneTitles = L.map RP._lineAccount (RP._statementLines standalone)+ combinedTitles = L.map RP._lineAccount (RP._statementLines combined)+ assertEqual "reporting: same validated TB changes with scope"+ True (RP._statementLines standalone /= RP._statementLines combined)+ assertEqual "reporting: standalone retains reciprocal lines"+ True (BranchCurrentAccount `elem` standaloneTitles+ && HeadOfficeCurrentAccount `elem` standaloneTitles)+ assertEqual "reporting: combined eliminates reciprocal lines"+ True (BranchCurrentAccount `notElem` combinedTitles+ && HeadOfficeCurrentAccount `notElem` combinedTitles+ && any isElimination (RP._presentationAudit combined))+ assertEqual "reporting: maturity evidence splits one title"+ [ (RP.CurrentAssetsSection, 10)+ , (RP.NoncurrentAssetsSection, 20)+ ]+ [ (RP._lineSection line, RP._lineAmount line)+ | line <- RP._statementLines standalone+ , RP._lineAccount line == LoansReceivable+ ]+ assertEqual "reporting: JCCI profile uses contract-liability label"+ [T.pack "契約負債"]+ [ RP._lineLabel line+ | line <- RP._statementLines standalone+ , RP._lineAccount line == AdvancesReceived+ ]+ assertEqual "reporting: Purchases is relabeled to SalesCost"+ True (Purchases `notElem` standaloneTitles && SalesCost `elem` standaloneTitles)+ assertEqual "reporting: GrossProfit is a subtotal, not a basis line"+ ( [ RP.StatementSubtotal RM.GrossProfitMetric+ (T.pack "売上総利益") (TB.CreditBalance 30)+ , RP.StatementSubtotal RM.OrdinaryProfitMetric+ (T.pack "経常利益") (TB.CreditBalance 30)+ ]+ , False+ )+ ( RP._statementSubtotals standalone+ , GrossProfit `elem` standaloneTitles+ || OrdinaryProfit `elem` standaloneTitles+ )+ assertEqual "reporting: materiality rationale survives in audit"+ True (RP.MaterialityApplied IncomeTaxesRefundReceivable+ RP.PresentSeparately (TB.DebitBalance 5) rationale+ `elem` RP._presentationAudit standalone)+ assertEqual "reporting: relabel never mutates validated bookkeeping coordinates"+ True (Purchases `elem` basesAccountTitles (TB.validatedTrialBalance validated)+ && SalesCost `notElem`+ basesAccountTitles (TB.validatedTrialBalance validated))++ let missingAllocation = (context RP.Standalone)+ { RP._presentationAllocations = [] }+ assertEqual "reporting: missing maturity evidence blocks presentation"+ True (case RP.present missingAllocation validated of+ Left issues -> RP.MissingPresentationAllocation LoansReceivable+ `elem` NE.toList issues+ Right _ -> False)++ let explainedSuspense = EC.journalFromSides+ [ (Debit, SuspensePayments, 10 :: MoneyDecimal)+ , (Credit, CapitalStock, 10)+ ] :: CheckedAlgM+ explainedInput = (trialBalanceInput explainedSuspense TB.BeforeClosing)+ { TB._temporaryBalanceExplanations = M.singleton SuspensePayments+ (T.pack "pending invoice") }+ explainedValidated = case TB.validateTrialBalance+ TB.standaloneTrialBalancePolicy explainedInput of+ Right value -> value+ Left errors -> error ("explained fixture rejected: " ++ show errors)+ assertEqual "reporting: stricter combined context re-gates retained finding"+ True (case RP.present (RP.jcciSecondGradeContext RP.Combined)+ explainedValidated of+ Left issues -> any isValidationBlock (NE.toList issues)+ Right _ -> False)++ let contraBalance = EC.journalFromSides+ [ (Debit, AccountsReceivable, 100 :: MoneyDecimal)+ , (Credit, AllowanceForDoubtfulAccounts, 10)+ , (Credit, CapitalStock, 90)+ ] :: CheckedAlgM+ contraValidated = validateFixture contraBalance+ separateContext = (RP.jcciSecondGradeContext RP.Standalone)+ { RP._contraPresentationRules =+ [RP.PresentContraSeparately AllowanceForDoubtfulAccounts+ (T.pack "show allowance as deduction")] }+ netContext = (RP.jcciSecondGradeContext RP.Standalone)+ { RP._contraPresentationRules =+ [RP.NetContraAgainst AllowanceForDoubtfulAccounts+ AccountsReceivable (T.pack "net receivables policy")] }+ separateLines = RP._statementLines+ (rightStatements (RP.present separateContext contraValidated))+ netLines = RP._statementLines+ (rightStatements (RP.present netContext contraValidated))+ assertEqual "reporting: contra policy supports separate presentation"+ True (any (\line -> RP._lineAccount line == AllowanceForDoubtfulAccounts+ && RP._lineIsDeduction line) separateLines)+ assertEqual "reporting: contra policy supports net presentation"+ [(Debit, 90)]+ [ (RP._lineSide line, RP._lineAmount line)+ | line <- netLines, RP._lineAccount line == AccountsReceivable+ ]++ let taxBalance = EC.journalFromSides+ [ (Debit, Cash, 85 :: MoneyDecimal)+ , (Debit, CorporateIncomeTaxes, 20)+ , (Credit, CapitalStock, 100)+ , (Credit, RefundOfIncomeTaxes, 5)+ ] :: CheckedAlgM+ taxValidated = validateFixture taxBalance+ netTaxContext = (RP.jcciSecondGradeContext RP.Standalone)+ { RP._materialityDecisions =+ [RP.MaterialityDecision RefundOfIncomeTaxes+ (RP.NetAgainst CorporateIncomeTaxes)+ (T.pack "immaterial refund netted under tax policy")] }+ taxLines = RP._statementLines+ (rightStatements (RP.present netTaxContext taxValidated))+ assertEqual "reporting: materiality policy supports tax netting"+ (False, [(Debit, 15)])+ ( RefundOfIncomeTaxes `elem` L.map RP._lineAccount taxLines+ , [ (RP._lineSide line, RP._lineAmount line)+ | line <- taxLines, RP._lineAccount line == CorporateIncomeTaxes+ ]+ )+ let badAllocation current noncurrent evidence = (context RP.Standalone)+ { RP._presentationAllocations =+ [RP.PresentationAllocation LoansReceivable current noncurrent evidence] }+ assertEqual "reporting: blank allocation evidence blocks"+ True (hasPresentationIssue isBlankEvidence (RP.present+ (badAllocation 10 20 (T.pack " ")) validated))+ assertEqual "reporting: negative allocation blocks"+ True (hasPresentationIssue isInvalidAllocation (RP.present+ (badAllocation (-1) 31 (T.pack "schedule")) validated))+ assertEqual "reporting: non-summing allocation blocks"+ True (hasPresentationIssue isInvalidAllocation (RP.present+ (badAllocation 10 19 (T.pack "schedule")) validated))+ let duplicateAllocation = (context RP.Standalone)+ { RP._presentationAllocations =+ [ RP.PresentationAllocation LoansReceivable 10 20+ (T.pack "schedule A")+ , RP.PresentationAllocation LoansReceivable 10 20+ (T.pack "schedule B")+ ] }+ assertEqual "reporting: duplicate allocation blocks"+ True (hasPresentationIssue isDuplicateAllocation+ (RP.present duplicateAllocation validated))+ let unexpectedAllocation = (context RP.Standalone)+ { RP._presentationAllocations =+ [ RP.PresentationAllocation LoansReceivable 10 20+ (T.pack "contract maturity schedule")+ , RP.PresentationAllocation Cash 100 0 (T.pack "none")+ ] }+ assertEqual "reporting: unrequired allocation blocks"+ True (hasPresentationIssue isUnexpectedAllocation+ (RP.present unexpectedAllocation validated))++ let contraRequiredInput = (trialBalanceInput contraBalance TB.BeforeClosing)+ { TB._reclassificationRules =+ [TB.MaturityEvidenceRequired AccountsReceivable]+ , TB._maturityEvidenceTitles = Set.singleton AccountsReceivable+ }+ contraRequired = case TB.validateTrialBalance TB.strictTrialBalancePolicy+ contraRequiredInput of+ Right value -> value+ Left errors -> error ("required contra fixture rejected: " ++ show errors)+ netAllocatedContext = netContext+ { RP._presentationAllocations =+ [RP.PresentationAllocation AccountsReceivable 60 30+ (T.pack "receivable maturity schedule")] }+ assertEqual "reporting: required net target can be allocated after netting"+ [(RP.CurrentAssetsSection, 60), (RP.NoncurrentAssetsSection, 30)]+ [ (RP._lineSection line, RP._lineAmount line)+ | line <- RP._statementLines+ (rightStatements (RP.present netAllocatedContext contraRequired))+ , RP._lineAccount line == AccountsReceivable+ ]+ let consumeRequired = (context RP.Standalone)+ { RP._materialityDecisions =+ [RP.MaterialityDecision LoansReceivable (RP.NetAgainst Cash)+ (T.pack "must not erase maturity obligation")] }+ assertEqual "reporting: explicit maturity obligation cannot be consumed"+ True (hasPresentationIssue isConflictingInstruction+ (RP.present consumeRequired validated))++ let emptyValidated = validateFixture mempty+ emptyStatements = rightStatements (RP.present+ (RP.jcciSecondGradeContext RP.Combined) emptyValidated)+ assertEqual "reporting: empty combined TB has no fabricated elimination"+ ([], [])+ (RP._statementLines emptyStatements, RP._presentationAudit emptyStatements)+ where+ rightStatements (Right statements) = statements+ rightStatements (Left issues) = error ("presentation failed: " ++ show issues)+ validateFixture alg = case TB.validateTrialBalance TB.strictTrialBalancePolicy+ (trialBalanceInput alg TB.BeforeClosing) of+ Right value -> value+ Left errors -> error ("fixture did not validate: " ++ show errors)+ isElimination (RP.ReciprocalAccountsEliminated _ _) = True+ isElimination _ = False+ isValidationBlock (RP.ValidationFindingBlocks _) = True+ isValidationBlock _ = False+ hasPresentationIssue predicate (Left issues) = any predicate (NE.toList issues)+ hasPresentationIssue _ (Right _) = False+ isBlankEvidence (RP.BlankPresentationEvidence LoansReceivable) = True+ isBlankEvidence _ = False+ isInvalidAllocation (RP.InvalidPresentationAllocation LoansReceivable _ _ _) = True+ isInvalidAllocation _ = False+ isDuplicateAllocation (RP.DuplicatePresentationAllocation LoansReceivable) = True+ isDuplicateAllocation _ = False+ isUnexpectedAllocation (RP.UnexpectedPresentationAllocation Cash) = True+ isUnexpectedAllocation _ = False+ isConflictingInstruction+ (RP.ConflictingPresentationInstruction LoansReceivable) = True+ isConflictingInstruction _ = False+ basesAccountTitles alg =+ [ title | _ :< title <- EA.bases alg ]++testDerivedMetricsLand5 :: IO ()+testDerivedMetricsLand5 = do+ assertEqual "Land 5: legacy derived-coordinate ordinals and Binary bytes"+ [ (NetIncome, 49, T.pack "0031")+ , (GrossProfit, 54, T.pack "0036")+ , (OrdinaryProfit, 55, T.pack "0037")+ , (NetLoss, 64, T.pack "0040")+ , (IncomeSummary, 216, T.pack "00d8")+ ]+ [ (title, fromEnum title, accountSemanticsBinaryHex title)+ | title <- [NetIncome, GrossProfit, OrdinaryProfit, NetLoss, IncomeSummary]+ ]+ assertEqual "Land 5: exactly four legacy coordinates map to typed metrics"+ [ (NetIncome, RM.PeriodResultMetric)+ , (GrossProfit, RM.GrossProfitMetric)+ , (OrdinaryProfit, RM.OrdinaryProfitMetric)+ , (NetLoss, RM.PeriodResultMetric)+ ]+ [ (title, metric)+ | title <- Registry.concreteAccountTitles+ , Just metric <- [RM.metricForLegacyTitle title]+ ]++ let salesOnly = 100 .@ Not :< Sales :: CheckedAlgM+ afterLegacyBalancer = EAT.incomeSummaryAccount salesOnly+ hatSales = 25 .@ Hat :< Sales :: CheckedAlgM+ assertEqual "Land 5: period metric ignores an inserted legacy balancer"+ (Right (RM.PeriodProfit 100), Right (RM.PeriodProfit 100))+ ( RM.periodResultOfAlg salesOnly+ , RM.periodResultOfAlg afterLegacyBalancer+ )+ assertEqual "Land 5: Hat is interpreted through account side, not as a scalar sign"+ (Right (RM.PeriodLoss 25)) (RM.periodResultOfAlg hatSales)+ assertEqual "Land 5: empty nominal basis is break-even"+ (Right RM.PeriodBreakEven :: Either RM.MetricError (RM.PeriodResult MoneyDecimal))+ (RM.periodResultOfAlg (mempty :: CheckedAlgM))+ assertEqual "Land 5: raw metric boundary rejects wildcard sides"+ (Left (RM.WildcardMetricSide Sales))+ (RM.periodResultOfAlg (10 .@ HatNot :< Sales :: CheckedAlgM))++ let ordinaryLedger = EC.journalFromSides+ [ (Debit, Cash, 100 :: MoneyDecimal)+ , (Credit, Sales, 100)+ ] :: CheckedAlgM+ ordinaryValidated = validateBefore ordinaryLedger+ assertEqual "Land 5: validated before-closing TB derives one period-result identity"+ (Right (RM.PeriodProfit 100))+ (RM.periodResultOf ordinaryValidated)++ let legacyLedger = EC.journalFromSides+ [ (Debit, NetIncome, 10 :: MoneyDecimal)+ , (Credit, CapitalStock, 10)+ ] :: CheckedAlgM+ legacyValidated = validateBefore legacyLedger+ assertEqual "Land 5: typed metric rejects a residual legacy coordinate"+ (Left (RM.ResidualDerivedCoordinate NetIncome))+ (RM.periodResultOf legacyValidated)+ assertEqual "Land 5: legacy intermediate and presentation paths are explicit alternatives"+ True (case RP.present+ (RP.jcciSecondGradeContext RP.Standalone) legacyValidated of+ Left issues -> RP.UnpresentableBalance NetIncome+ (TB.DebitBalance 10) `elem` NE.toList issues+ Right _ -> False)++ let emptyValidated = validateBefore (mempty :: CheckedAlgM)+ subtotal = RP.SubtotalDefinition RM.GrossProfitMetric+ [Sales] [SalesCost] RP.TreatAbsentAsZero+ duplicateContext = (RP.jcciSecondGradeContext RP.Standalone)+ { RP._subtotalDefinitions = [subtotal, subtotal] }+ assertEqual "Land 5: duplicate metric identity blocks presentation"+ True (case RP.present duplicateContext emptyValidated of+ Left issues -> RP.DuplicateMetricIdentity RM.GrossProfitMetric+ `elem` NE.toList issues+ Right _ -> False)+ let absentAsZeroContext = (RP.jcciSecondGradeContext RP.Standalone)+ { RP._subtotalDefinitions = [subtotal] }+ assertEqual "Land 5: canonical subtotal may treat unposted titles as zero"+ [RP.StatementSubtotal RM.GrossProfitMetric+ (T.pack "売上総損益") TB.NoBalance]+ (RP._statementSubtotals (case RP.present absentAsZeroContext emptyValidated of+ Right statements -> statements+ Left issues -> error ("absent-as-zero subtotal rejected: " ++ show issues)))++ let relabelledLedger = EC.journalFromSides+ [ (Debit, Purchases, 30 :: MoneyDecimal)+ , (Credit, Sales, 30)+ ] :: CheckedAlgM+ relabelledValidated = validateBefore relabelledLedger+ removedTitleContext = (RP.jcciSecondGradeContext RP.Standalone)+ { RP._presentationRelabels =+ [RP.PresentationRelabel Purchases SalesCost (T.pack "policy")]+ , RP._subtotalDefinitions =+ [RP.SubtotalDefinition RM.GrossProfitMetric+ [Sales] [Purchases] RP.TreatAbsentAsZero]+ }+ assertEqual "Land 5: absent-as-zero does not hide a relabelled non-zero title"+ True (case RP.present removedTitleContext relabelledValidated of+ Left issues -> RP.InvalidSubtotalDefinition RM.GrossProfitMetric+ `elem` NE.toList issues+ Right _ -> False)++ let Just customId = RM.mkMetricId (T.pack "ebitda-adjusted")+ customDefinition = RP.SubtotalDefinition (RM.CustomMetric customId)+ [Sales] [] RP.TreatAbsentAsZero+ unlabelledContext = (RP.jcciSecondGradeContext RP.Standalone)+ { RP._subtotalDefinitions = [customDefinition] }+ assertEqual "Land 5: custom metric identity requires separate labels"+ True (case RP.present unlabelledContext emptyValidated of+ Left issues -> RP.UnlabelledCustomMetric customId+ `elem` NE.toList issues+ Right _ -> False)+ let labelledContext = unlabelledContext+ { RP._presentationProfile = RP.CanonicalEnglish+ , RP._customMetricLabels =+ [RP.CustomMetricLabel customId+ (T.pack "調整後EBITDA") (T.pack "Adjusted EBITDA")]+ }+ assertEqual "Land 5: custom metric identity and profile label are separate"+ [RP.StatementSubtotal (RM.CustomMetric customId)+ (T.pack "Adjusted EBITDA") TB.NoBalance]+ (RP._statementSubtotals (case RP.present labelledContext emptyValidated of+ Right statements -> statements+ Left issues -> error ("labelled custom metric rejected: " ++ show issues)))+ let duplicateLabelContext = labelledContext+ { RP._customMetricLabels = RP._customMetricLabels labelledContext+ ++ RP._customMetricLabels labelledContext+ }+ assertEqual "Land 5: standalone label lookup rejects duplicate identities"+ Nothing+ (RP.metricLabel duplicateLabelContext (RM.CustomMetric customId)+ TB.NoBalance)+ where+ validateBefore alg = case TB.validateTrialBalance+ TB.strictTrialBalancePolicy (trialBalanceInput alg TB.BeforeClosing) of+ Right value -> value+ Left errors -> error ("Land 5 fixture did not validate: " ++ show errors)++-- Pins the complete 4 context x 5 capability truth table of the accounting+-- posting policy, row by row, so that a change to any single cell is visible+-- as a diff here rather than only through the checked-conversion wrappers.+testPostingPolicyTruthTable :: IO ()+testPostingPolicyTruthTable = do+ let contexts =+ [ PP.OrdinaryJournal+ , PP.ClosingProcess+ , PP.ConsolidationWorksheet+ , PP.EngineComputation+ ]+ capabilities =+ [ OrdinaryPosting+ , ClosingOnly+ , ConsolidationOnly+ , EngineGeneratedOnly+ , NotPostable+ ]+ truthTable =+ [ (PP.OrdinaryJournal, OrdinaryPosting, True)+ , (PP.OrdinaryJournal, ClosingOnly, False)+ , (PP.OrdinaryJournal, ConsolidationOnly, False)+ , (PP.OrdinaryJournal, EngineGeneratedOnly, False)+ , (PP.OrdinaryJournal, NotPostable, False)+ , (PP.ClosingProcess, OrdinaryPosting, True)+ , (PP.ClosingProcess, ClosingOnly, True)+ , (PP.ClosingProcess, ConsolidationOnly, False)+ , (PP.ClosingProcess, EngineGeneratedOnly, False)+ , (PP.ClosingProcess, NotPostable, False)+ , (PP.ConsolidationWorksheet, OrdinaryPosting, True)+ , (PP.ConsolidationWorksheet, ClosingOnly, False)+ , (PP.ConsolidationWorksheet, ConsolidationOnly, True)+ , (PP.ConsolidationWorksheet, EngineGeneratedOnly, False)+ , (PP.ConsolidationWorksheet, NotPostable, False)+ , (PP.EngineComputation, OrdinaryPosting, True)+ , (PP.EngineComputation, ClosingOnly, False)+ , (PP.EngineComputation, ConsolidationOnly, False)+ , (PP.EngineComputation, EngineGeneratedOnly, True)+ , (PP.EngineComputation, NotPostable, False)+ ]+ assertEqual "posting policy: truth table enumerates every context/capability pair"+ [ (context, capability)+ | context <- contexts+ , capability <- capabilities+ ]+ [ (context, capability) | (context, capability, _) <- truthTable ]+ assertEqual "posting policy: 4 x 5 truth table of postingAllowedIn"+ [ (context, capability, expected)+ | (context, capability, expected) <- truthTable+ ]+ [ (context, capability, PP.postingAllowedIn context capability)+ | (context, capability, _) <- truthTable+ ]+ assertEqual "posting policy: Convert.Checked re-exports the same gate"+ [ PP.postingAllowedIn context capability+ | (context, capability, _) <- truthTable+ ]+ [ ECC.postingAllowedIn context capability+ | (context, capability, _) <- truthTable+ ]+ assertEqual "posting policy: wildcard title is NotPostable"+ NotPostable (PP.postingCapabilityFor AccountTitle)+ assertEqual "posting policy: concrete titles report registry capability"+ [ Registry.asemPostingCapability <$> Registry.accountSemantics title+ | title <- Registry.concreteAccountTitles+ ]+ [ Just (PP.postingCapabilityFor title)+ | title <- Registry.concreteAccountTitles+ ]++testPostingCapabilityGate :: IO ()+testPostingCapabilityGate = do+ let contexts =+ [ ECC.OrdinaryJournal+ , ECC.ClosingProcess+ , ECC.ConsolidationWorksheet+ , ECC.EngineComputation+ ]+ capabilities =+ [ OrdinaryPosting+ , ClosingOnly+ , ConsolidationOnly+ , EngineGeneratedOnly+ , NotPostable+ ]+ allowed context capability = (context, capability) `elem`+ [ (ECC.OrdinaryJournal, OrdinaryPosting)+ , (ECC.ClosingProcess, OrdinaryPosting)+ , (ECC.ClosingProcess, ClosingOnly)+ , (ECC.ConsolidationWorksheet, OrdinaryPosting)+ , (ECC.ConsolidationWorksheet, ConsolidationOnly)+ , (ECC.EngineComputation, OrdinaryPosting)+ , (ECC.EngineComputation, EngineGeneratedOnly)+ ]+ assertEqual "posting gate: closed context/capability matrix"+ [ (context, capability, allowed context capability)+ | context <- contexts+ , capability <- capabilities+ ]+ [ (context, capability, ECC.postingAllowedIn context capability)+ | context <- contexts+ , capability <- capabilities+ ]+ assertEqual "posting gate: all 235 titles follow the closed matrix"+ [ (context, title, ECC.postingAllowedIn context capability)+ | context <- contexts+ , title <- Registry.concreteAccountTitles+ , Just semantics <- [Registry.accountSemantics title]+ , let capability = Registry.asemPostingCapability semantics+ ]+ [ (context, title, accepted context title)+ | context <- contexts+ , title <- Registry.concreteAccountTitles+ ]+ assertEqual "posting gate: derived profit coordinates stay engine-only"+ (replicate 4 (Just EngineGeneratedOnly))+ [ Registry.asemPostingCapability <$> Registry.accountSemantics title+ | title <- [GrossProfit, OrdinaryProfit, NetIncome, NetLoss]+ ]+ assertEqual "posting gate: consolidation-only set is closed"+ [ EquityInEarningsOfInvestee+ , CumulativeTranslationAdjustment+ , NonControllingInterests+ , NetIncomeAttributableToNCI+ , NetLossAttributableToNCI+ ]+ [ title+ | title <- Registry.concreteAccountTitles+ , Just semantics <- [Registry.accountSemantics title]+ , Registry.asemPostingCapability semantics == ConsolidationOnly+ ]++ assertEqual "posting gate: ordinary wrapper rejects engine-generated result"+ (Left (ECC.PostingNotAllowed 0 NetIncome EngineGeneratedOnly+ ECC.OrdinaryJournal NE.:| []))+ (checkedEntryM+ [ (Debit, NetIncome, 10)+ , (Credit, RetainedEarnings, 10)+ ])++ assertEqual "posting gate: closing admits IncomeSummary"+ True+ (case ECC.checkedEntryIn ECC.ClosingProcess+ [ (Debit, Sales, 10 :: MoneyDecimal)+ , (Credit, IncomeSummary, 10)+ ] of+ Right _ -> True+ Left _ -> False)+ assertEqual "posting gate: closing rejects engine-generated result"+ True+ (case ECC.checkedEntryIn ECC.ClosingProcess+ [ (Debit, NetIncome, 10 :: MoneyDecimal)+ , (Credit, RetainedEarnings, 10)+ ] of+ Left (ECC.PostingNotAllowed 0 NetIncome EngineGeneratedOnly+ ECC.ClosingProcess NE.:| []) -> True+ _ -> False)++ assertEqual "posting gate: consolidation admits NCI attribution"+ True+ (case ECC.checkedEntryIn ECC.ConsolidationWorksheet+ [ (Debit, NetIncomeAttributableToNCI, 10 :: MoneyDecimal)+ , (Credit, NonControllingInterests, 10)+ ] of+ Right _ -> True+ Left _ -> False)+ assertEqual "posting gate: ordinary journal rejects NCI equity"+ True+ (case ECC.checkedEntry+ [ (Debit, Cash, 10 :: MoneyDecimal)+ , (Credit, NonControllingInterests, 10)+ ] of+ Left (ECC.PostingNotAllowed 1 NonControllingInterests+ ConsolidationOnly ECC.OrdinaryJournal NE.:| []) -> True+ _ -> False)+ assertEqual "posting gate: engine admits period result"+ True+ (case ECC.checkedEntryIn ECC.EngineComputation+ [ (Debit, NetIncome, 10 :: MoneyDecimal)+ , (Credit, RetainedEarnings, 10)+ ] of+ Right _ -> True+ Left _ -> False)++ assertEqual "posting gate: text path uses ordinary context"+ True+ (case ECC.checkedEntryText+ [ (T.pack "debit", T.pack "NetIncome", 10 :: MoneyDecimal)+ , (T.pack "credit", T.pack "RetainedEarnings", 10)+ ] of+ Left (ECC.PostingNotAllowed 0 NetIncome EngineGeneratedOnly+ ECC.OrdinaryJournal NE.:| []) -> True+ _ -> False)+ assertEqual "posting gate: unknown account does not create false imbalance"+ True+ (case ECC.checkedEntryText+ [ (T.pack "debit", T.pack "UnknownAccount_X", 10 :: MoneyDecimal)+ , (T.pack "credit", T.pack "Cash", 10)+ ] of+ Left (ECC.EntryParse 0 _ NE.:| []) -> True+ _ -> False)+ assertEqual "posting gate: consolidation text path admits NCI loss"+ True+ (case ECC.checkedEntryTextIn ECC.ConsolidationWorksheet+ [ (T.pack "debit", T.pack "NonControllingInterests", 10 :: MoneyDecimal)+ , (T.pack "credit", T.pack "NetLossAttributableToNCI", 10)+ ] of+ Right _ -> True+ Left _ -> False)+ assertEqual "posting gate: journal error retains txid"+ True+ (case checkedJournalM+ [ (7,+ [ (Debit, IncomeSummary, 10)+ , (Credit, RetainedEarnings, 10)+ ])+ ] of+ Left (ECC.EntryErrors 7+ (ECC.PostingNotAllowed 0 IncomeSummary ClosingOnly+ ECC.OrdinaryJournal NE.:| []) NE.:| []) -> True+ _ -> False)+ assertEqual "posting gate: certification rejects known disallowed title"+ True+ (case ECC.certifyJournalText+ [ (9 :: Int,+ [ (T.pack "debit", T.pack "NetIncome", 10 :: MoneyDecimal)+ , (T.pack "credit", T.pack "RetainedEarnings", 10)+ ])+ ] of+ ECC.Rejected+ (ECC.EntryErrors 9+ (ECC.PostingNotAllowed 0 NetIncome EngineGeneratedOnly+ ECC.OrdinaryJournal NE.:| []) NE.:| []) -> True+ _ -> False)+ assertEqual "posting gate: disallowed known title outranks unresolved title"+ True+ (case ECC.certifyJournalText+ [ (11 :: Int,+ [ (T.pack "debit", T.pack "NetIncome", 10 :: MoneyDecimal)+ , (T.pack "credit", T.pack "UnknownAccount_X", 10)+ ])+ ] of+ ECC.Rejected+ (ECC.EntryErrors 11+ (ECC.PostingNotAllowed 0 NetIncome EngineGeneratedOnly+ ECC.OrdinaryJournal NE.:| []) NE.:| []) -> True+ _ -> False)+ assertEqual "posting gate: certification honours closing context"+ True+ (case ECC.certifyJournalTextIn ECC.ClosingProcess+ [ (10 :: Int,+ [ (T.pack "debit", T.pack "Sales", 10 :: MoneyDecimal)+ , (T.pack "credit", T.pack "IncomeSummary", 10)+ ])+ ] of+ ECC.FullyResolved _ -> True+ _ -> False)+ assertEqual "posting gate: certification honours engine context"+ True+ (case ECC.certifyJournalTextIn ECC.EngineComputation+ [ (12 :: Int,+ [ (T.pack "debit", T.pack "NetLoss", 10 :: MoneyDecimal)+ , (T.pack "credit", T.pack "RetainedEarnings", 10)+ ])+ ] of+ ECC.FullyResolved _ -> True+ _ -> False)+ where+ accepted context title =+ case ECC.checkedEntryIn context+ [ (Debit, title, 1 :: MoneyDecimal)+ , (Credit, Cash, 1)+ ] of+ Right _ -> True+ Left _ -> False++checkedConvertProperties :: IO ()+checkedConvertProperties = do+ testPostingPolicyTruthTable+ testPostingCapabilityGate+ testConsolidationWorksheet+ testSharedAccountBalancePrimitives+ testTrialBalanceValidation+ testReportingPresentation+ testDerivedMetricsLand5++ quickProp "convert-checked: certify known accounts matches checkedJournal" $+ prop_certifyKnownAccountsMatchesCheckedJournal++ quickProp "convert-checked: unresolved vocabulary preserves balance" $+ prop_certifyUnknownAccountPreservesBalance++ quickProp "convert-checked: imbalance precedes unresolved vocabulary" $+ prop_certifyImbalancePrecedesUnknownAccount++ quickProp "convert-checked: certify duplicate txid always rejected" $+ prop_certifyDuplicateTxIdAlwaysRejected++ quickProp "convert-checked: balanced unresolved totals match input" $+ prop_certifyBalancedUnresolvedTotals++ quickProp "convert-checked: checkedEntry accepts iff checked predicate" $+ forAll genCheckedEntryRows $ \rows ->+ let expected = checkedEntryAcceptsSpec rows+ actual = case checkedEntryM rows of+ Right _ -> True+ Left _ -> False+ in actual == expected++ quickProp "convert-checked: checkedEntry equals journalFromSides on accept" $+ forAll genCheckedEntryRows $ \rows ->+ case checkedEntryM rows of+ Right alg -> alg == (EC.journalFromSides rows :: CheckedAlgM)+ Left _ -> True++ quickProp "convert-checked: accepted entries form exact-balanced submonoid" $+ forAll genAcceptedEntryRows $ \rows1 ->+ forAll genAcceptedEntryRows $ \rows2 ->+ case (checkedEntryM rows1, checkedEntryM rows2) of+ (Right alg1, Right alg2) -> ECC.exactBalanced (alg1 .+ alg2)+ _ -> False++ quickProp "convert-checked: checkedJournal duplicate txid only DuplicateTxId" $+ forAll genAcceptedEntryRows $ \rows1 ->+ forAll genAcceptedEntryRows $ \rows2 ->+ case checkedJournalM [(1, rows1), (1, rows2)] of+ Left errs -> NE.toList errs == [ECC.DuplicateTxId 1]+ Right _ -> False++ quickProp "convert-checked: reconcileSources coverage and amount checks" $+ forAll genPositiveAmountMD $ \amount ->+ let entry amt = [(Debit, Cash, amt), (Credit, Sales, amt)]+ shifted = amount + 1+ journalResult = checkedJournalM [(1, entry amount)]+ unknownResult = checkedJournalM [(1, entry amount), (2, entry 5)]+ in case (journalResult, unknownResult) of+ (Right journal, Right journalWithUnknown) ->+ ECC.reconcileSources [(1, amount)] journal == []+ && ECC.reconcileSources [(1, amount), (2, 5)] journal+ == [ECC.MissingSource 2]+ && ECC.reconcileSources [(1, amount)] journalWithUnknown+ == [ECC.UnknownSource 2]+ && ECC.reconcileSources [(1, shifted)] journal+ == [ECC.AmountMismatch 1 shifted amount]+ _ -> False++axiomProperties :: IO ()+axiomProperties = do+ -- Definition 6 axioms (Double; semantic equality via exact per-base nets)+ quickProp "axiom: Hat involution (x^^ = x)" $+ forAll genAlgD $ \x -> netByBase ((.^) ((.^) x)) == netByBase x+ quickProp "axiom: scalar on singleton (a*(v:@b) = (a*v):@b)" $+ forAll genNNDouble $ \a -> forAll genNNDouble $ \v -> forAll genBase $ \b ->+ netByBase (a .* (v .@ b)) == netByBase (((a * v) .@ b) :: TestAlg)+ quickProp "axiom: scalar distributes over (.+)" $+ forAll genNNDouble $ \a -> forAll genAlgD $ \x -> forAll genAlgD $ \y ->+ netByBase (a .* (x .+ y)) == netByBase ((a .* x) .+ (a .* y))+ quickProp "axiom: norm additivity (norm(x+y) = norm x + norm y)" $+ forAll genAlgD $ \x -> forAll genAlgD $ \y ->+ epsEq (norm (x .+ y)) (norm x + norm y)+ quickProp "axiom: norm homogeneity (norm(a*x) = a*norm x, a>=0)" $+ forAll genNNDouble $ \a -> forAll genAlgD $ \x ->+ epsEq (norm (a .* x)) (a * norm x)+ -- derived lemmas+ quickProp "lemma: bar idempotent (bar(bar x) = bar x)" $+ forAll genAlgD $ \x -> netByBase (bar (bar x)) == netByBase (bar x)+ quickProp "lemma: zero identity (x .+ Zero = x)" $+ forAll genAlgD $ \x -> netByBase (x .+ EA.Zero) == netByBase x+ quickProp "lemma: (.+) associative" $+ forAll genAlgD $ \x -> forAll genAlgD $ \y -> forAll genAlgD $ \z ->+ netByBase ((x .+ y) .+ z) == netByBase (x .+ (y .+ z))+ -- regression: union must not relabel a value onto a zero posting's base+ -- (the 0.4.1.1 bug; raw (:@) so zero-valued singletons are exercised)+ quickProp "regression: union preserves per-base net (zero-base bug)" $+ forAll genNNDouble $ \v1 -> forAll genBase $ \b1 ->+ forAll genNNDouble $ \v2 -> forAll genBase $ \b2 ->+ let s1 = v1 :@ b1 :: TestAlg+ s2 = v2 :@ b2 :: TestAlg+ in netByBase (s1 .+ s2)+ == M.unionWith (+) (netByBase s1) (netByBase s2)+ -- construction-order independence for the exact value type (MoneyDecimal)+ quickProp "MoneyDecimal: fromList per-base net is construction-order independent" $+ forAll (listOf ((,) <$> (realToFrac <$> genNNDouble) <*> genBase)) $ \ps ->+ let singles = [ v :@ b | (v, b) <- ps ] :: [NNAlg]+ viaList = EA.fromList singles+ viaFoldr = foldr (.+) EA.Zero singles+ viaFoldl = L.foldl' (.+) EA.Zero singles+ in netByBase viaList == netByBase viaFoldr+ && netByBase viaFoldr == netByBase viaFoldl+ -- mapBasePart (Phase 3): identity + norm preservation (no value lost on collision)+ quickProp "mapBasePart id preserves per-base net (MoneyDecimal)" $+ forAll genAlgN $ \x -> netByBase (EA.mapBasePart id x :: NNAlg) == netByBase x+ quickProp "mapBasePart preserves norm under base collapse (MoneyDecimal)" $+ forAll genAlgN $ \x -> norm (EA.mapBasePart (const Amount) x :: NNAlg) == norm x+ -- S-4: functoriality of the base-relabel map pi_kappa (Prop 2.8(4)):+ -- mapBasePart (kappa' . kappa) x ~=_pi mapBasePart kappa' (mapBasePart kappa x)+ -- The two kappa are non-identity, non-injective relabelers on CountUnit so the+ -- composite collapses bases (Yen -> Dollar -> Amount), exercising the value+ -- merge on both sides. There is no dedicated ~=_pi comparator in this suite;+ -- we use 'netByBase' (per-base signed net), which is the same bar/order-robust+ -- observational equality the other mapBasePart / axiom properties use -- i.e.+ -- "equal after bar, compared per base". The 'kappa' relabels the BasePart+ -- (= CountUnit here), with mapBasePart re-merging colliding sides, so this is+ -- exactly the bar-then-map equivalence the audit note specifies.+ quickProp "S-4: mapBasePart is functorial (pi_{k'.k} ~=_pi pi_k' . pi_k, MoneyDecimal)" $+ forAll genAlgN $ \x ->+ let kappa, kappa' :: CountUnit -> CountUnit+ kappa u = if u == Yen then Dollar else u -- Yen -> Dollar+ kappa' u = if u == Dollar then Amount else u -- Dollar -> Amount+ lhs = EA.mapBasePart (kappa' . kappa) x :: NNAlg+ inner = EA.mapBasePart kappa x :: NNAlg+ rhs = EA.mapBasePart kappa' inner :: NNAlg+ in netByBase lhs == netByBase rhs+ -- netPairMapBy (ν_κ pair read-out): three properties from the+ -- easp-2026-06-11-netpairmapby handoff.+ -- (a) signed-diff consistency: balanceMapBy == n - h of the pair.+ -- n - h can be negative, so this is checked on the SIGNED value type+ -- (Double); a non-negative-only type would break the n-h component.+ quickProp "netPairMapBy: balanceMapBy x == n - h of netPairMapBy x (Double, signed)" $+ forAll genAlgD $ \x ->+ let bm = EA.balanceMapBy Just x :: M.Map CountUnit Double+ np = EA.netPairMapBy Just x :: M.Map CountUnit (Double, Double)+ diff = fmap (\(n, h) -> n - h) np+ -- balanceMapBy keeps zero-net keys; netPairMapBy drops them.+ -- Compare on the union: a key absent from one side reads as 0.+ in all (\k -> epsEq (M.findWithDefault 0 k bm)+ (M.findWithDefault 0 k diff))+ (M.keys bm ++ M.keys diff)+ -- (b) both pair components are non-negative (value-domain regularity).+ -- Exact value type so the >= 0 check has no tolerance ambiguity.+ quickProp "netPairMapBy: both components non-negative (MoneyDecimal)" $+ forAll genAlgN $ \x ->+ all (\(n, h) -> n >= 0 && h >= 0)+ (M.elems (EA.netPairMapBy Just x :: M.Map CountUnit (MoneyDecimal, MoneyDecimal)))+ -- (c) ~=_pi invariance: like the S-4 / netByBase observational equality,+ -- the pair read-out is construction-order independent (bar-then-net is+ -- robust to seq order and reassociation). Exact MoneyDecimal.+ quickProp "netPairMapBy: ~=_pi invariant (construction-order independent, MoneyDecimal)" $+ forAll (listOf ((,) <$> (realToFrac <$> genNNDouble) <*> genBase)) $ \ps ->+ let singles = [ v :@ b | (v, b) <- ps ] :: [NNAlg]+ viaList = EA.netPairMapBy Just (EA.fromList singles)+ viaFoldr = EA.netPairMapBy Just (foldr (.+) EA.Zero singles)+ viaFoldl = EA.netPairMapBy Just (L.foldl' (.+) EA.Zero singles)+ in viaList == (viaFoldr :: M.Map CountUnit (MoneyDecimal, MoneyDecimal))+ && viaFoldr == viaFoldl++-- ================================================================+-- Category-theory phase 1 laws and layer boundaries (P2c)+-- ================================================================++categoricalPhase1Properties :: IO ()+categoricalPhase1Properties = do+ quickProp "P2c mapBasePart: identity holds through per-base multisets" $+ forAll genAlgN $ \x -> observe (EA.mapBasePart id x) == observe x+ quickProp "P2c mapBasePart: composition holds through per-base multisets" $+ forAll genAlgN $ \x ->+ let f, g :: CountUnit -> CountUnit+ f u = if u == Yen || u == Dollar then Yen else u+ g u = if u == Yen || u == Amount then Amount else u+ in observe (EA.mapBasePart (g . f) x :: NNAlg)+ == observe (EA.mapBasePart g (EA.mapBasePart f x :: NNAlg) :: NNAlg)+ quickProp "P2c mapBasePart: (.+) homomorphism holds through per-base multisets" $+ forAll genAlgN $ \x -> forAll genAlgN $ \y ->+ let f u = if u == Yen || u == Dollar then Amount else u+ in observe (EA.mapBasePart f (x .+ y) :: NNAlg)+ == observe ((EA.mapBasePart f x .+ EA.mapBasePart f y) :: NNAlg)+ quickProp "P2c mapBasePart: norm is preserved" $+ forAll genAlgN $ \x ->+ norm (EA.mapBasePart (const Amount) x :: NNAlg) == norm x+ quickProp "P2c mapBasePart: Hat commutes under raw Eq" $+ forAll genAlgN $ \x ->+ EA.mapBasePart (const Amount) ((.^) x)+ == ((.^) (EA.mapBasePart (const Amount) x) :: NNAlg)+ -- bar keeps the Liner constructor even when cancellation leaves one key+ -- with one value; mapBasePart rebuilds that map as a singleton. Eq treats+ -- the two constructors as distinct, although ℘ observes the same entry.+ let identitySource = (1 .@ (Not :< Yen))+ .+ (1 .@ (Hat :< Yen))+ .+ (2 .@ (Not :< Dollar)) :: NNAlg+ oneKeyLiner = bar identitySource+ identityMapped = EA.mapBasePart id oneKeyLiner :: NNAlg+ assertEqual "P2c mapBasePart: identity counterexample has equal multisets"+ (observe oneKeyLiner) (observe identityMapped)+ assertEqual "P2c mapBasePart: identity fails under raw Eq for one-key Liner"+ False (identityMapped == oneKeyLiner)++ let sandwichLeft = bar+ (EA.mapBasePart (const Amount) oneKeyLiner :: NNAlg)+ sandwichRight = bar+ (EA.mapBasePart (const Amount) identitySource :: NNAlg)+ assertEqual "P2c mapBasePart: bar sandwich counterexample has equal multisets"+ (observe sandwichLeft) (observe sandwichRight)+ assertEqual "P2c mapBasePart: bar sandwich can fail under raw Eq"+ False (sandwichLeft == sandwichRight)++ -- Three distinct source keys are read back in whatever order the source+ -- HashMap traverses them (a representation detail, so it is observed at+ -- run time rather than pinned). The first and last keys collide under f+ -- while the middle key survives as a separate intermediate key; g then+ -- merges everything. The two-pass route keeps the collision block+ -- contiguous, whereas the direct pass interleaves the middle key, so the+ -- raw Seq orders differ while the per-base multisets agree.+ let rawX = (10 .@ (Hat :< Yen))+ .+ (20 .@ (Hat :< Dollar))+ .+ (30 .@ (Hat :< Euro)) :: NNAlg+ sourceTraversal = EA.foldEntries+ (\acc _ (_ :< u) -> acc ++ [u]) [] rawX+ (firstU, lastU) = case sourceTraversal of+ [a, _, c] -> (a, c)+ other -> error ("P2c: unexpected source traversal " ++ show other)+ f u = if u == lastU then firstU else u+ g _ = Amount+ direct = EA.mapBasePart (g . f) rawX :: NNAlg+ staged = EA.mapBasePart g (EA.mapBasePart f rawX :: NNAlg) :: NNAlg+ assertEqual "P2c mapBasePart: composition counterexample traverses three source keys"+ 3 (length sourceTraversal)+ assertEqual "P2c mapBasePart: composition raw counterexample has equal multisets"+ (observe direct) (observe staged)+ assertEqual "P2c mapBasePart: composition fails under raw Eq after collision"+ False (direct == staged)++ let barX = (100 .@ (Not :< Yen))+ .+ (100 .@ (Hat :< Dollar)) :: NNAlg+ mappedAfterBar = EA.mapBasePart (const Amount) (bar barX) :: NNAlg+ barAfterMapped = bar (EA.mapBasePart (const Amount) barX :: NNAlg)+ assertEqual "P2c mapBasePart: does not commute with bar under collision"+ False (mappedAfterBar == barAfterMapped)+ assertEqual "P2c mapBasePart: map after bar retains both source residuals"+ 200 (norm mappedAfterBar)+ assertEqual "P2c mapBasePart: bar after map cancels collided residuals"+ 0 (norm barAfterMapped)++ quickProp "P2c foldEntries: commutative sum is construction-order independent" $+ forAll (listOf ((,) <$> (realToFrac <$> genNNDouble) <*> genBase)) $ \ps ->+ let singles = [ v .@ b | (v, b) <- ps ] :: [NNAlg]+ pairwise = EA.fromList singles+ bulk = EA.sigma ps (\(v, b) -> v .@ b :: NNAlg)+ sumEntries = EA.foldEntries (\acc v _ -> acc + v) 0+ in sumEntries pairwise == sumEntries bulk+ let entries = [1 .@ (Hat :< Yen), 2 .@ (Hat :< Yen), 3 .@ (Hat :< Yen)]+ :: [NNAlg]+ pairwise = EA.fromList entries+ bulk = EA.sigma [1, 2, 3 :: Int]+ (\i -> fromIntegral i .@ (Hat :< Yen) :: NNAlg)+ collect = EA.foldEntries (\acc v _ -> acc ++ [v]) []+ assertEqual "P2c foldEntries: non-commutative list append observes Seq order"+ False (collect pairwise == collect bulk)++ quickProp "P2c postFromNetBy: definition equation" $+ forAll genAlgN $ \x ->+ let keyOf (_ :< u) = Just u+ post u v = v .@ (Not :< u) :: NNAlg+ collectEntry v b = (\k -> (k, v)) <$> keyOf b+ rhs = EA.sigmaFromMap+ (EA.foldEntriesToMap collectEntry (bar x)) post+ in EA.postFromNetBy keyOf post x == rhs+ quickProp "P2c postFromNetBy: factors through bar" $+ forAll genAlgN $ \x ->+ let keyOf (_ :< u) = Just u+ post u v = v .@ (Not :< u) :: NNAlg+ in EA.postFromNetBy keyOf post x+ == EA.postFromNetBy keyOf post (bar x)+ quickProp "P2c bar: idempotence holds under raw Eq" $+ forAll genAlgN $ \x -> bar (bar x) == bar x++-- ================================================================+-- Journal-algebra axiom properties (Phase 1.5)+-- ================================================================++type NNJournal = EJ.Journal String MoneyDecimal (HatBase CountUnit)++genNote :: Gen String+genNote = elements ["a", "b", "c"]++genPosNN :: Gen MoneyDecimal -- strictly positive (avoids zero-note drop)+genPosNN = (\d -> realToFrac (1 + d)) <$> genNNDouble++genJournalN :: Gen NNJournal+genJournalN = sized $ \n -> do+ k <- choose (0, min 30 n)+ ps <- vectorOf k ((,,) <$> genPosNN <*> genBase <*> genNote)+ pure (EJ.fromList [ (v :@ b) .| nt | (v, b, nt) <- ps ])++-- per-(note, base) signed net; exact (Rational)+netJournal :: NNJournal -> M.Map (String, CountUnit) Rational+netJournal j = M.fromList+ [ ((nt, u), r)+ | (nt, alg) <- HM.toList (EJ.toMap j)+ , (u, r) <- M.toList (netByBase alg) ]++journalProperties :: IO ()+journalProperties = do+ quickProp "journal: norm additivity (norm(j1.+j2) = norm j1 + norm j2, MoneyDecimal)" $+ forAll genJournalN $ \j1 -> forAll genJournalN $ \j2 ->+ norm (j1 .+ j2) == norm j1 + norm j2+ quickProp "journal: Hat preserves the note set" $+ forAll genJournalN $ \j ->+ L.sort (HM.keys (EJ.toMap ((.^) j))) == L.sort (HM.keys (EJ.toMap j))+ quickProp "journal: fromList per-(note,base) net is construction-order independent (MoneyDecimal)" $+ forAll (listOf ((,,) <$> genPosNN <*> genBase <*> genNote)) $ \ps ->+ let js = [ (v :@ b) .| nt | (v, b, nt) <- ps ] :: [NNJournal]+ in netJournal (EJ.fromList js) == netJournal (foldr (.+) mempty js)++-- ================================================================+-- Quotient decomposition properties (Phase 1, feat/quotient-decomposition)+--+-- Encodes the dec_κ / π_κ axioms of the scaling formalization+-- (agent-notes/drafts/scaling-formalization.md §2, §7) as QuickCheck+-- properties, plus fixed sentinels for the side-sensitive non-commutation+-- cases that MUST NOT silently start commuting (they encode a semantic+-- choice, not a bug).+-- ================================================================++-- proper classifier: factors through the base part (never sees Hat/Not)+properKf :: HatBase CountUnit -> Maybe CountUnit+properKf (_ :< u) = Just u++-- partial proper classifier: Yen entries fall into the residual+partialKf :: HatBase CountUnit -> Maybe CountUnit+partialKf (_ :< Yen) = Nothing+partialKf (_ :< u) = Just u++-- side-sensitive classifier: sees the Hat/Not state (like decP/decM)+sideKf :: HatBase CountUnit -> Maybe Bool+sideKf b = Just (isHat b)++-- residual of a partial classifier (reference implementation via filter)+residualOf :: (HatBase CountUnit -> Maybe CountUnit) -> NNAlg -> NNAlg+residualOf kf = EA.filter (\s -> s /= EA.Zero && kf (EA._hatBase s) == Nothing)++-- per-base nets with exact-zero entries dropped (bar drops zero-net bases,+-- so commutation properties are compared modulo zero nets)+nonZeroNet :: NNAlg -> M.Map CountUnit Rational+nonZeroNet = M.filter (/= 0) . netByBase++quotientProperties :: IO ()+quotientProperties = do+ -- reconstruction: Σ_k x_k (+ residual) = x (formalization Prop 2.3)+ quickProp "decBy: reconstruction, total classifier (MoneyDecimal)" $+ forAll genAlgN $ \x ->+ netByBase (mconcat (M.elems (EA.decBy properKf x))) == netByBase x+ quickProp "decBy: reconstruction with residual, partial classifier" $+ forAll genAlgN $ \x ->+ netByBase (mconcat (M.elems (EA.decBy partialKf x)) .+ residualOf partialKf x)+ == netByBase x+ -- norm additivity over classes (formalization Prop 2.4(1))+ quickProp "decBy: norm additivity over classes + residual (MoneyDecimal)" $+ forAll genAlgN $ \x ->+ norm x == L.foldl' (+) 0 (L.map norm (M.elems (EA.decBy partialKf x)))+ + norm (residualOf partialKf x)+ -- proper classifier commutes with bar componentwise (Prop 2.4(4))+ quickProp "decBy: bar commutes componentwise (proper classifier)" $+ forAll genAlgN $ \x ->+ M.filter (not . M.null) (M.map nonZeroNet (EA.decBy properKf (bar x)))+ == M.filter (not . M.null) (M.map (nonZeroNet . bar) (EA.decBy properKf x))+ -- decBy equals the naive per-class filter loop (semantics check)+ quickProp "decBy: equals naive per-class filter (MoneyDecimal)" $+ forAll genAlgN $ \x ->+ let d = EA.decBy properKf x+ naive k = EA.filter+ (\s -> s /= EA.Zero && properKf (EA._hatBase s) == Just k) x+ in all (\(k, alg) -> netByBase alg == netByBase (naive k)) (M.toList d)+ -- postFromNetBy equals an independent per-key projNetNorm pipeline+ quickProp "postFromNetBy: equals per-key projNetNorm reference (MoneyDecimal)" $+ forAll genAlgN $ \x ->+ let kf b = if isHat b then Just (unitOf b) else Nothing+ unitOf (_ :< u) = u+ post u v = v .@ (Not :< u) :: NNAlg+ viaApi = EA.postFromNetBy kf post x+ viaRef = mconcat+ [ post u s+ | u <- [Yen, Dollar, Amount]+ , let s = EA.projNetNorm [Hat :< u] (bar x)+ , s /= 0 ]+ in netByBase viaApi == netByBase viaRef+ -- decTo: flatten reconstructs and norm is preserved (total classifier)+ quickProp "decTo: toAlg . decTo reconstructs (total classifier, MoneyDecimal)" $+ forAll genAlgN $ \x ->+ let j = EJ.decTo (\(_ :< u) -> Just (show u)) x+ :: EJ.Journal String MoneyDecimal (HatBase CountUnit)+ in netByBase (EJ.toAlg j) == netByBase x && norm j == norm x+ -- sentinel: side-sensitive classifier does NOT commute with bar+ -- (decP/decM-style split; x = v:@Not:<Yen .+ v:@Hat:<Yen nets to zero+ -- globally but each side survives within its own class)+ let xCancel = (5 .@ (Not :< Yen)) .+ (5 .@ (Hat :< Yen)) :: NNAlg+ lhs = M.filter (not . EA.isZero) (M.map bar (EA.decBy sideKf xCancel))+ rhs = EA.decBy sideKf (bar xCancel)+ assertEqual "sentinel: side-sensitive decBy does not commute with bar"+ True (M.keys lhs /= M.keys rhs)+ -- sentinel: whichSide-style classifier is also side-sensitive+ -- (Cash homeSide = Debit, so Hat flips it to Credit: the two sides of one+ -- base land in different classes — Deguchi Def 2.13)+ let xCash = (100 .@ (Not :< Cash)) .+ (100 .@ (Hat :< Cash))+ :: EA.Alg MoneyDecimal (HatBase AccountTitles)+ bySide = EA.decBy (\b -> Just (whichSide b)) xCash+ assertEqual "sentinel: whichSide splits one base across classes (side-sensitive)"+ [Credit, Debit] (L.sort (M.keys bySide))+ assertEqual "sentinel: whichSide decBy does not commute with bar"+ True (M.filter (not . EA.isZero) (M.map bar bySide)+ /= EA.decBy (\b -> Just (whichSide b)) (bar xCash))+ -- sentinel: π_κ (mapBasePart, non-injective) does not commute with bar+ -- (formalization §2.8: coarsen-then-net /= net-then-coarsen)+ let xPi = (100 .@ (Not :< Yen)) .+ (100 .@ (Hat :< Dollar)) :: NNAlg+ assertEqual "sentinel: bar (mapBasePart const) nets across the class"+ 0 (norm (bar (EA.mapBasePart (const Amount) xPi :: NNAlg)))+ assertEqual "sentinel: mapBasePart (bar x) keeps both sides (no cross-base netting)"+ 200 (norm (EA.mapBasePart (const Amount) (bar xPi) :: NNAlg))++-- ================================================================+-- Bookkeeping closing-adjustment builders (Phase B)+-- ================================================================++type BAlg = EA.Alg Double (HatBase AccountTitles)+type BAlgM = EA.Alg MoneyDecimal (HatBase AccountTitles)++mkA :: EB.MkBase (HatBase AccountTitles)+mkA = (:<)++-- balanced-ness: debit-side norm equals credit-side norm (貸借一致)+isBalancedD :: BAlg -> Bool+isBalancedD x = epsEq (norm (EA.decL x)) (norm (EA.decR x))++bookkeepingProperties :: IO ()+bookkeepingProperties = do+ -- (1) balanced property: every builder produces a debit=credit entry+ quickProp "bookkeeping: cogsAdjustmentEntries balanced" $+ forAll genNNDouble $ \beg -> forAll genNNDouble $ \end ->+ isBalancedD (EB.cogsAdjustmentEntries mkA beg end)+ quickProp "bookkeeping: depreciationIndirectEntry balanced" $+ forAll genNNDouble $ \amt -> isBalancedD (EB.depreciationIndirectEntry mkA amt)+ quickProp "bookkeeping: depreciationDirectEntry balanced" $+ forAll genNNDouble $ \amt -> isBalancedD (EB.depreciationDirectEntry mkA amt Fixtures)+ quickProp "bookkeeping: allowanceReplenishmentEntry balanced" $+ forAll genNNDouble $ \est -> forAll genNNDouble $ \cur ->+ isBalancedD (EB.allowanceReplenishmentEntry mkA est cur)+ quickProp "bookkeeping: allowanceResetEntries balanced" $+ forAll genNNDouble $ \est -> forAll genNNDouble $ \cur ->+ isBalancedD (EB.allowanceResetEntries mkA est cur)+ quickProp "bookkeeping: prepaidExpenseEntry balanced" $+ forAll genNNDouble $ \amt -> isBalancedD (EB.prepaidExpenseEntry mkA amt RentExpense)+ quickProp "bookkeeping: unearnedRevenueEntry balanced" $+ forAll genNNDouble $ \amt -> isBalancedD (EB.unearnedRevenueEntry mkA amt RentalIncome)+ quickProp "bookkeeping: accruedRevenueEntry balanced" $+ forAll genNNDouble $ \amt -> isBalancedD (EB.accruedRevenueEntry mkA amt InterestEarned)+ quickProp "bookkeeping: accruedExpenseEntry balanced" $+ forAll genNNDouble $ \amt -> isBalancedD (EB.accruedExpenseEntry mkA amt InterestExpense)+ quickProp "bookkeeping: corporateTaxInterimEntry balanced" $+ forAll genNNDouble $ \amt -> isBalancedD (EB.corporateTaxInterimEntry mkA amt)+ -- consumption / corporate tax settlement: amounts where received >= paid,+ -- total >= interim (the in-scope branch)+ quickProp "bookkeeping: consumptionTaxSettlementEntry balanced (received>=paid)" $+ forAll genNNDouble $ \paid -> forAll genNNDouble $ \extra ->+ isBalancedD (EB.consumptionTaxSettlementEntry mkA paid (paid + extra))+ quickProp "bookkeeping: corporateTaxSettlementEntries balanced (total>=interim)" $+ forAll genNNDouble $ \interim -> forAll genNNDouble $ \extra ->+ isBalancedD (EB.corporateTaxSettlementEntries mkA (interim + extra) interim)+ quickProp "bookkeeping: priorPeriodErrorCorrection balanced" $+ forAll genNNDouble $ \curr -> forAll genNNDouble $ \prior ->+ isBalancedD (EB.priorPeriodErrorCorrection mkA curr prior Depreciation Land)++ -- (2) unit tests: expected bases/amounts on representative lecture figures+ -- COGS (ch.24): beg 100,000 / end 50,000. In isolation this entry's+ -- Purchases net = beg - end = +50,000 (so the Purchases balance becomes COGS),+ -- and MerchandiseInventory net = end - beg = -50,000 (it replaces the opening+ -- balance with the closing one, i.e. 100,000 - 50,000 leaves on the ledger).+ let cogs = EB.cogsAdjustmentEntries mkA 100000 50000 :: BAlg+ assertNear "cogs: Purchases net = beg - end (= cost of goods sold)"+ 50000 (signedNet Purchases cogs)+ assertNear "cogs: MerchandiseInventory net = end - beg"+ (-50000) (signedNet MerchandiseInventory cogs)+ -- 差額補充法, estimate>current (ch.16): 1,400 - 1,000 -> provide 400+ let repl1 = EB.allowanceReplenishmentEntry mkA 1400 1000 :: BAlg+ assertNear "allowance(差額補充, shortfall): ProvisionForDoubtfulAccounts = 400"+ 400 (norm (EA.projByAccountTitle ProvisionForDoubtfulAccounts repl1))+ assertNear "allowance(差額補充, shortfall): AllowanceForDoubtfulAccounts = 400"+ 400 (norm (EA.projByAccountTitle AllowanceForDoubtfulAccounts repl1))+ -- 差額補充法, estimate<current (ch.16): 1,800 - 2,000 -> release 200+ let repl2 = EB.allowanceReplenishmentEntry mkA 1800 2000 :: BAlg+ assertNear "allowance(差額補充, excess): ReversalOfAllowanceForDoubtfulAccounts = 200"+ 200 (norm (EA.projByAccountTitle ReversalOfAllowanceForDoubtfulAccounts repl2))+ -- estimate==current -> no entry+ assertEqual "allowance(差額補充, equal): Zero"+ True (EA.isZero (EB.allowanceReplenishmentEntry mkA 1500 1500 :: BAlg))+ -- consumption tax (ch.23): paid 1,000 / received 20,000 -> unpaid 19,000+ let ctax = EB.consumptionTaxSettlementEntry mkA 1000 20000 :: BAlg+ assertNear "consumptionTax: AccruedConsumptionTax = received - paid = 19000"+ 19000 (norm (EA.projByAccountTitle AccruedConsumptionTax ctax))+ -- corporate tax (ch.23): total 800,000 / interim 500,000 -> unpaid 300,000+ let crp = EB.corporateTaxSettlementEntries mkA 800000 500000 :: BAlg+ assertNear "corporateTax: AccruedCorporateIncomeTaxes = total - interim = 300000"+ 300000 (norm (EA.projByAccountTitle AccruedCorporateIncomeTaxes crp))+ -- prior-period error correction (#15 anchor): patent 55,000/10yr discovered 2028+ -- current 5,500 / prior 2yr 11,000 -> debit=credit=16,500 (Patent credit)+ let ppec = EB.priorPeriodErrorCorrection mkA 5500 11000 AmortizationExpense Patent :: BAlg+ assertNear "priorPeriodErrorCorrection (#15): Patent credit = 16500"+ 16500 (norm (EA.projByAccountTitle Patent ppec))+ assertNear "priorPeriodErrorCorrection (#15): balanced (decL == decR)"+ (norm (EA.decL ppec)) (norm (EA.decR ppec))+ -- consumption-tax refund (received<paid) is rejected (out of 3-級 scope)+ rRefund <- try (evaluate (norm (EB.consumptionTaxSettlementEntry mkA 5000 1000 :: BAlg)))+ :: IO (Either SomeException Double)+ case rRefund of+ Left _ -> putStrLn "[PASS] consumptionTaxSettlementEntry rejects received<paid"+ Right _ -> do putStrLn "[FAIL] consumptionTaxSettlementEntry accepted refund"; exitFailure++ -- (3) reversingEntry: involution + exact cancellation (MoneyDecimal exact)+ quickProp "bookkeeping: reversingEntry is involution (MoneyDecimal)" $+ forAll genBAlgM $ \x -> EB.reversingEntry (EB.reversingEntry x) == x+ quickProp "bookkeeping: bar (x .+ reversingEntry x) == Zero (MoneyDecimal)" $+ forAll genBAlgM $ \x -> bar (x .+ EB.reversingEntry x) == EA.Zero+ where+ -- exact per-account signed net (Not +, Hat -) for Double-based unit checks+ signedNet :: AccountTitles -> BAlg -> Double+ signedNet t = EA.foldEntries step 0+ where step acc v b+ | getAccountTitle b == t = if isHat b then acc - v else acc + v+ | otherwise = acc++-- small exact MoneyDecimal algebra over AccountTitles bases (closing-adjustment+-- shaped: a few postings on bookkeeping titles), for the reversal properties.+genBAlgM :: Gen BAlgM+genBAlgM = sized $ \n -> do+ k <- choose (0, min 8 n)+ ps <- vectorOf k ((,) <$> genSmallMoney <*> genBookBase)+ pure (EA.fromList [ v .@ b | (v, b) <- ps ])+ where+ genSmallMoney :: Gen MoneyDecimal+ genSmallMoney = fromInteger <$> choose (1, 9999)+ genBookBase :: Gen (HatBase AccountTitles)+ genBookBase = (:<) <$> elements [Hat, Not]+ <*> elements [ Purchases, MerchandiseInventory, Depreciation+ , AccumulatedDepreciation, PrepaidExpenses+ , AccruedExpenses, AccruedConsumptionTax+ , Cash, InterestExpense ]++-- ================================================================+-- Closing-document Write functions (Phase D): worksheet,+-- post-closing trial balance, account ledger+-- ================================================================++closingDocsTests :: IO ()+closingDocsTests = do+ -- A small balanced pre-adjustment ledger (ebex1-shaped):+ -- opening capital 2,000,000; a cash sale of 500,000;+ -- wages (cost) 140,000 paid in cash.+ -- Pre-adjustment trial balance balances by construction.+ let pre = (2000000 .@ (Not :< Cash)) -- 現金 (asset, debit)+ .+ (2000000 .@ (Not :< CapitalStock)) -- 資本金 (equity, credit)+ .+ (500000 .@ (Not :< Cash)) -- cash from sale (debit)+ .+ (500000 .@ (Not :< Sales)) -- 売上 (revenue, credit)+ .+ (140000 .@ (Hat :< Cash)) -- cash paid out (credit)+ .+ (140000 .@ (Not :< WageExpenditure)) -- 給料 (cost, debit)+ :: BAlg+ -- one adjustment: accrue 10,000 of unpaid wages (費用の見越し)+ let adj = (10000 .@ (Not :< WageExpenditure)) -- cost debit+ .+ (10000 .@ (Not :< AccruedExpenses)) -- liability credit+ :: BAlg+ let combined = pre .+ adj++ -- (1) Worksheet self-check: the P/L column imbalance must equal the+ -- B/S column imbalance, and both equal the net income.+ -- Each account's *net* balance (diffRL) is routed by division:+ -- P/L: Sales net 500,000 (credit) vs WageExpenditure net 150,000+ -- (debit) => net income 350,000.+ -- B/S: Cash net 2,360,000 (debit) vs CapitalStock 2,000,000 ++ -- AccruedExpenses 10,000 (credit) = 2,010,000 => 350,000.+ -- We compute the column sums the same way worksheetRows does: per+ -- account title, place the *net* balance into the debit or credit+ -- column according to its balance side.+ let titles = L.nub (EA.foldEntries (\acc _ b -> getAccountTitle b : acc) [] combined) :: [AccountTitles]+ netSide t = EA.diffRL (EA.projByAccountTitle t combined) :: (Side, Double)+ colSums divs =+ L.foldl' (\(d,c) t ->+ if classifyAccountDivision t `elem` divs+ then case netSide t of+ (Debit, m) -> (d + m, c)+ (Credit, m) -> (d, c + m)+ _ -> (d, c)+ else (d, c)) (0,0) titles+ (plD, plC) = colSums [Cost, Revenue]+ (bsD, bsC) = colSums [Assets, Liability, Equity]+ plDiff = abs (plD - plC)+ bsDiff = abs (bsD - bsC)+ assertNear "worksheet self-check: P/L diff = 350000" 350000 plDiff+ assertNear "worksheet self-check: B/S diff = 350000" 350000 bsDiff+ assertNear "worksheet self-check: P/L diff == B/S diff (= net income)" plDiff bsDiff++ -- the rendered worksheet's net-income row must carry the same figure on+ -- the P/L debit and B/S credit columns (positions 6 and 9, 1-based).+ let wrows = EW.worksheetRows pre adj+ netRow' = last (init wrows) -- penultimate-from-end: the Net row+ assertEqual "worksheet: net row label is Net Income"+ (T.pack "Net Income") (head netRow')+ assertEqual "worksheet: net income on P/L debit column = 350000.0"+ (T.pack "350000.0") (netRow' !! 5)+ assertEqual "worksheet: net income on B/S credit column = 350000.0"+ (T.pack "350000.0") (netRow' !! 8)++ -- (2) Post-closing trial balance must contain only real accounts+ -- (Assets/Liability/Equity) — no Cost/Revenue titles.+ let pcrows = EW.postClosingTrialBalanceRows combined+ titleCells = [ row !! 1 | row <- drop 1 pcrows ] -- middle column = title+ forbidden = L.map (T.pack . show) [Sales, WageExpenditure]+ assertEqual "post-closing TB excludes Cost/Revenue titles"+ True (not (any (`elem` forbidden) titleCells))+ assertEqual "post-closing TB includes Cash"+ True (T.pack (show Cash) `elem` titleCells)+ assertEqual "post-closing TB includes AccruedExpenses (liability)"+ True (T.pack (show AccruedExpenses) `elem` titleCells)++ -- (3) Account ledger preserves the seq: the number of posting lines for a+ -- title equals the number of postings on that title (no aggregation).+ -- Cash has 3 postings (2 debit, 1 credit) in `pre`.+ let lrows = EW.accountLedgerRows [Cash] pre (const dummyDay)+ -- drop the 2 header rows (title + sub-header); the rest are postings.+ bodyLines = drop 2 lrows+ cashPostings = EA.foldEntries (\acc _ b -> if getAccountTitle b == Cash then acc + 1 else acc) (0 :: Int) pre+ -- each body row holds at most one debit + one credit cell; count+ -- non-empty value cells (debit col=1, credit col=3).+ nonEmptyVals = Prelude.length+ [ () | row <- bodyLines, c <- [1,3], not (T.null (row !! c)) ]+ assertEqual "account ledger: Cash posting count preserved (= 3, no aggregation)"+ cashPostings nonEmptyVals+ where+ dummyDay :: Day+ dummyDay = fromGregorian 2024 4 1++-- ================================================================+-- Simulate.Lite tests (Phase 2, feat/simulate-lite)+-- ================================================================++-- A concrete Note type for the Lite models: (event tag, term index).+type LNote = (String, Int)+type LBaseD = HatBase AccountTitles+type LedgerD = Journal LNote MoneyDouble LBaseD -- IEEE-754 path (DET-1, BSP, equiv)+type LBaseM = HatBase AccountTitles+type LedgerM = Journal LNote MoneyDecimal LBaseM -- exact path (DET-2)++------------------------------------------------------------------+-- Lite test 1: boilerplate acceptance example (3 fields, 2 stages).+-- The body of this function (the World record, the two stages, the spec and+-- the run) is the "~20 line" boilerplate the design targets.+------------------------------------------------------------------++-- A product-only HKD world: a ledger, a scalar price, a scalar tax rate.+data MiniW f = MiniW+ { mwLedger :: HK f LedgerD+ , mwPrice :: HK f MoneyDouble+ , mwTax :: HK f Double+ } deriving Generic++-- stage A: each agent buys 1 unit at the snapshot price (a pure message).+buyStage :: Stage MiniW Int LNote MoneyDouble LBaseD+buyStage = stageFor "buy" [1 .. 5 :: Int] $ \w t _g i ->+ let amt = mwPrice w * fromIntegral i+ in ((amt .@ Not :< Purchases) .+ (amt .@ Hat :< Cash)) .| ("buy", t)++-- stage B: a single bookkeeping step paying tax on the snapshot price.+taxStage :: Stage MiniW Int LNote MoneyDouble LBaseD+taxStage = stage "tax" $ \w t ->+ let amt = mwPrice w * realToFrac (mwTax w)+ in ((amt .@ Not :< Sales) .+ (amt .@ Hat :< Cash)) .| ("tax", t)++miniSpec :: SimSpec MiniW Int LNote MoneyDouble LBaseD+miniSpec = mkSimSpec (1, 3) 42 mwLedger [buyStage, taxStage]++testLiteBoilerplate :: IO ()+testLiteBoilerplate = do+ let w0 = MiniW { mwLedger = carry mempty+ , mwPrice = carry 10+ , mwTax = carry 0.1 }+ n = runLite miniSpec w0 (realToFrac . norm . mwLedger)+ -- 3 terms * (sum_{i=1..5} 10*i*2 + 10*0.1*2) = 3 * (300 + 2) = 906+ assertNear "Lite: boilerplate mini-model runs (norm)" 906.0 n++------------------------------------------------------------------+-- Lite test 2 (DET-2): MoneyDecimal Sequential vs ParChunk exact match.+------------------------------------------------------------------++data DecW f = DecW+ { dwLedger :: HK f LedgerM+ } deriving Generic++decStage :: Stage DecW Int LNote MoneyDecimal LBaseM+decStage = stageFor "post" [1 .. 50 :: Int] $ \_w t g i ->+ let (k, _) = randomR (1, 9 :: Int) g+ amt = fromIntegral (i + k) :: MoneyDecimal+ in ((amt .@ Not :< Purchases) .+ (amt .@ Hat :< Cash)) .| ("post", t)++decSpec :: Par -> SimSpec DecW Int LNote MoneyDecimal LBaseM+decSpec par = (mkSimSpec (1, 4) 7 dwLedger [decStage]) { Lite.specParallel = par }++testLiteDet2 :: IO ()+testLiteDet2 = do+ let w0 = DecW { dwLedger = carry mempty }+ runP par = runLite (decSpec par) w0 (toMap . dwLedger)+ seqMap = runP Sequential+ parMap = runP (ParChunk 8)+ assertEqual "Lite DET-2: Sequential and ParChunk produce identical ledgers (exact)"+ seqMap parMap++------------------------------------------------------------------+-- Lite test 3 (DET-1): MoneyDouble reproducibility across two runs.+------------------------------------------------------------------++testLiteDet1 :: IO ()+testLiteDet1 = do+ let w0 = MiniW { mwLedger = carry mempty+ , mwPrice = carry 10+ , mwTax = carry 0.1 }+ n1 = runLite miniSpec w0 (realToFrac . norm . mwLedger)+ n2 = runLite miniSpec w0 (realToFrac . norm . mwLedger)+ assertNear "Lite DET-1: same spec run twice gives same norm" n1 n2++------------------------------------------------------------------+-- Lite test 4 (BSP intra-stage invisibility sentinel).+-- Every agent in a stage reads the SAME snapshot. We encode the snapshot+-- ledger's norm into each agent's message; if a later agent could see an+-- earlier agent's write within the same stage, the encoded norms would differ+-- from the all-zero baseline (the ledger starts empty for term 1 stage 0).+------------------------------------------------------------------++data BspW f = BspW+ { bwLedger :: HK f LedgerD+ } deriving Generic++-- each agent posts (1 + norm-of-snapshot-ledger). On term 1, stage 0, the+-- snapshot ledger is empty for every agent, so each posts exactly 1.0.+bspStage :: Stage BspW Int LNote MoneyDouble LBaseD+bspStage = stageFor "bsp" [1 .. 10 :: Int] $ \w t _g _i ->+ let seenNorm = norm (bwLedger w) -- must be 0 for ALL agents (BSP)+ amt = 1 + realToFrac seenNorm :: MoneyDouble+ in ((amt .@ Not :< Purchases) .+ (amt .@ Hat :< Cash)) .| ("bsp", t)++bspSpec :: SimSpec BspW Int LNote MoneyDouble LBaseD+bspSpec = mkSimSpec (1, 1) 0 bwLedger [bspStage]++testLiteBspInvisibility :: IO ()+testLiteBspInvisibility = do+ let w0 = BspW { bwLedger = carry mempty }+ n = runLite bspSpec w0 (realToFrac . norm . bwLedger)+ -- 10 agents each post Not:<Purchases 1.0 + Hat:<Cash 1.0 = norm 20.+ -- If intra-stage writes were visible, later agents would post > 1.0 and the+ -- norm would exceed 20.+ assertNear "Lite BSP: intra-stage invisibility (all agents see empty ledger)"+ 20.0 n++------------------------------------------------------------------+-- Lite test 5: gate toy-model equivalence (3 terms, agents [1..10], norm 3300).+-- Rebuilds the gate-report.md prototype with the Lite API; same norm.+------------------------------------------------------------------++data GateW f = GateW+ { gwLedger :: HK f LedgerD+ , gwPrice :: HK f MoneyDouble+ } deriving Generic++gateStage :: Stage GateW Int LNote MoneyDouble LBaseD+gateStage = stageFor "buy" [1 .. 10 :: Int] $ \w t _g i ->+ let amt = gwPrice w * fromIntegral i+ in ((amt .@ Not :< Purchases) .+ (amt .@ Hat :< Cash)) .| ("buy", t)++gateSpec :: SimSpec GateW Int LNote MoneyDouble LBaseD+gateSpec = mkSimSpec (1, 3) 1 gwLedger [gateStage]++testLiteGateEquivalence :: IO ()+testLiteGateEquivalence = do+ let w0 = GateW { gwLedger = carry mempty, gwPrice = carry 10 }+ n = runLite gateSpec w0 (realToFrac . norm . gwLedger)+ -- norm = 3 terms * sum_{i=1..10} (10*i*2) = 3 * 2 * 10 * 55 = 3300+ assertNear "Lite: gate toy-model equivalence (norm 3300)" 3300.0 n++------------------------------------------------------------------+-- Lite test 6: term-boundary Field rules (Carry / ResetEach / UpdateEach).+-- One agent posts (current price) each term; the three runs differ only in the+-- price field's boundary rule, exercising each Field constructor.+------------------------------------------------------------------++data RuleW f = RuleW+ { rwLedger :: HK f LedgerD+ , rwPrice :: HK f MoneyDouble+ } deriving Generic++ruleStage :: Stage RuleW Int LNote MoneyDouble LBaseD+ruleStage = stage "post" $ \w t ->+ let amt = rwPrice w+ in ((amt .@ Not :< Purchases) .+ (amt .@ Hat :< Cash)) .| ("post", t)++ruleSpec :: SimSpec RuleW Int LNote MoneyDouble LBaseD+ruleSpec = mkSimSpec (1, 3) 0 rwLedger [ruleStage]++runRule :: Field MoneyDouble -> Double+runRule priceField =+ let w0 = RuleW { rwLedger = carry mempty, rwPrice = priceField }+ in realToFrac (runLite ruleSpec w0 (norm . rwLedger))+ -- norm counts both Not:<Purchases and Hat:<Cash, hence 2 * price each term++testLiteFieldRules :: IO ()+testLiteFieldRules = do+ -- Carry 10: price stays 10 every term -> 3 * 2 * 10 = 60+ assertNear "Lite Field: Carry keeps the value" 60.0 (runRule (carry 10))+ -- ResetEach 5: price reset to 5 at each boundary, but stage reads BEFORE+ -- the term-1 boundary commit at the same value -> 3 * 2 * 5 = 30+ assertNear "Lite Field: ResetEach restores each term" 30.0 (runRule (resetEach 5))+ -- UpdateEach 10 (*2): term1 price 10, boundary doubles -> term2 20, term3 40.+ -- norm = 2 * (10 + 20 + 40) = 140+ assertNear "Lite Field: UpdateEach applies the step each boundary"+ 140.0 (runRule (updateEach 10 (* 2)))++-- regression: the boundary rule must fire once per TERM, not per stage.+-- (Parser pitfall: a trailing backtick operator after an inner lambda's+-- do-block is swallowed into the lambda body, turning the term-boundary+-- commit into a per-stage commit. Single-stage tests cannot see this.)+-- 2 identical stages x UpdateEach 10 (*2): both stages must read the SAME+-- price within a term -> norm = 2 entries * 2 stages * (10+20+40) = 280.+-- The per-stage-commit bug yields 2 * (10+20 + 40+80 + 160+320) = 1260.+testLiteBoundaryOncePerTerm :: IO ()+testLiteBoundaryOncePerTerm =+ assertNear "Lite: term boundary fires once per term (2 stages)" 280.0+ (let spec2 = mkSimSpec (1, 3) 0 rwLedger [ruleStage, ruleStage]+ w0 = RuleW { rwLedger = carry mempty, rwPrice = updateEach 10 (* 2) }+ in realToFrac (runLite spec2 w0 (norm . rwLedger)))++-- ================================================================+-- Simulate.Policy tests (Phase 4, feat/ledger-policy)+--+-- LedgerPolicy = declarative retention / spill / compaction, applied at the+-- term boundary by runLiteWithPolicy. The exact MoneyDecimal value type lets us+-- assert lossless round-trips and norm/compaction invariants by strict equality.+-- ================================================================++-- A one-field world whose stage posts a few distinct bases per term, so that a+-- closed term has redundant per-base sequences (exercising CompressClosedTerms)+-- and a multi-term history (exercising RetainRecent + spill).+data PolW f = PolW+ { pwLedger :: HK f LedgerM+ } deriving Generic++-- A single-field world for the classic-bridge test: a constructor @a@ of kind+-- @Type -> Type@ whose @a RealWorld@ is an @STRef RealWorld LedgerM@ (so it fits+-- the @SpillOptions t a payload@ shape, where @a@ is applied to the state token).+newtype LedgerRef s = LedgerRef (STRef s LedgerM)++-- Each agent posts twice to the SAME base within the term, so the term's per-base+-- posting sequence has length 2 before compress and length 1 after.+polStage :: Stage PolW Int LNote MoneyDecimal LBaseM+polStage = stageFor "post" [1 .. 4 :: Int] $ \_w t _g i ->+ let amt = fromIntegral i :: MoneyDecimal+ one = 1 :: MoneyDecimal+ m1 = ((amt .@ Not :< Purchases) .+ (amt .@ Hat :< Cash)) .| ("post", t)+ m2 = ((one .@ Not :< Purchases) .+ (one .@ Hat :< Cash)) .| ("post", t)+ in m1 .+ m2 :: Journal LNote MoneyDecimal LBaseM++polSpec :: SimSpec PolW Int LNote MoneyDecimal LBaseM+polSpec = mkSimSpec (1, 5) 0 pwLedger [polStage]++polW0 :: PolW InitT+polW0 = PolW { pwLedger = carry mempty }++-- run a temp spill file, returning (result, path); caller removes the file.+withTempSpill :: String -> (FilePath -> IO a) -> IO a+withTempSpill tag act = do+ let path = "/tmp/exchangealgebra_policy_" ++ tag ++ ".bin"+ -- ensure no stale file from a previous run (append-mode would accumulate)+ _ <- try (removeFile path) :: IO (Either SomeException ())+ r <- act path+ _ <- try (removeFile path) :: IO (Either SomeException ())+ pure r++-- Test 1 (flagship): defaultLedgerPolicy is observationally equal to runLite.+testPolicyEquivalence :: IO ()+testPolicyEquivalence = do+ let pureLedger = runLite polSpec polW0 (toMap . pwLedger)+ polLedger <- runLiteWithPolicy Policy.defaultLedgerPolicy polSpec polW0 (toMap . pwLedger)+ assertEqual "Policy: runLiteWithPolicy defaultLedgerPolicy == runLite (exact)"+ pureLedger polLedger++-- Test 2 (flagship): RetainRecent w + spillTo gives an in-memory window AND a+-- lossless restore that equals the FullAudit ledger.+testPolicyWindowRoundTrip :: IO ()+testPolicyWindowRoundTrip = withTempSpill "window" $ \path -> do+ let full = runLite polSpec polW0 (toMap . pwLedger) -- FullAudit reference+ pol = Policy.defaultLedgerPolicy+ { Policy.retain = Policy.RetainRecent 2+ , Policy.spillTo = Just path }+ -- ONE policy run (append-mode spill: a second run would double the file),+ -- projecting the live journal; we derive both checks from it.+ residentJournal <- runLiteWithPolicy pol polSpec polW0 pwLedger+ -- (a) in-memory ledger after the run contains ONLY the most recent 2 terms.+ let residentMap = toMap residentJournal+ residentTerms = L.sort (L.nub [ t | (_, t) <- HM.keys residentMap ])+ assertEqual "Policy: RetainRecent 2 leaves only the most recent 2 terms resident"+ [4, 5] residentTerms+ -- (b) restoreLedger (spill file + resident remainder) == FullAudit ledger.+ restored <- Policy.restoreLedger path residentJournal :: IO LedgerM+ assertEqual "Policy: restoreLedger (spill + remainder) == FullAudit ledger (lossless, exact)"+ full (toMap restored)++-- Test 3: CompressClosedTerms — norm/balance invariant, closed-term seq length 1,+-- in-progress term keeps full redundancy.+testPolicyCompressClosed :: IO ()+testPolicyCompressClosed = do+ let full = runLite polSpec polW0 (toMap . pwLedger)+ compactedJ <- runLiteWithPolicy+ (Policy.defaultLedgerPolicy { Policy.compaction = Policy.CompressClosedTerms })+ polSpec polW0 pwLedger+ let fullJ = runLite polSpec polW0 pwLedger+ -- (a) norm is invariant under compaction.+ assertEqual "Policy: CompressClosedTerms preserves norm (exact)"+ (norm fullJ) (norm compactedJ)+ -- (b) balance result unchanged (still balanced overall).+ assertEqual "Policy: CompressClosedTerms preserves balance"+ (EA.balance fullJ) (EA.balance compactedJ)+ -- (c) each CLOSED term (1..4) has at most one posting per base/side: its Alg+ -- compresses to itself (idempotent), so compress . entry == entry.+ let compactedMap = toMap compactedJ+ closedOk = all+ (\((_, t), alg) -> t == (5 :: Int) || EA.compress alg == alg)+ (HM.toList compactedMap)+ assertEqual "Policy: closed terms are already compressed (compress is a no-op on them)"+ True closedOk+ -- (d) the in-progress term (5) keeps its redundancy: in the FULL ledger term+ -- 5's entry has a length-2 sequence, and the compacted ledger keeps the+ -- SAME term-5 entry (untouched), i.e. it differs from its own compress.+ let term5Full = HM.lookup ("post", 5) (toMap fullJ)+ term5Comp = HM.lookup ("post", 5) compactedMap+ assertEqual "Policy: in-progress term is untouched by CompressClosedTerms"+ term5Full term5Comp+ case term5Comp of+ Just alg -> assertEqual "Policy: in-progress term retains its redundant sequence"+ False (EA.compress alg == alg)+ Nothing -> assertEqual "Policy: in-progress term present" True False++-- Test 4: deletion-only (spillTo Nothing + RetainRecent) narrows the ledger to+-- the window and reduces its norm by exactly the discarded terms' norm.+testPolicyDeleteOnly :: IO ()+testPolicyDeleteOnly = do+ let pol = Policy.defaultLedgerPolicy { Policy.retain = Policy.RetainRecent 2 }+ residentJournal <- runLiteWithPolicy pol polSpec polW0 pwLedger+ let residentMap = toMap residentJournal+ residentTerms = L.sort (L.nub [ t | (_, t) <- HM.keys residentMap ])+ -- the FullAudit ledger restricted to the same window must match exactly+ -- (deletion is just a filter; the kept terms are untouched).+ full = runLite polSpec polW0 pwLedger+ windowOfFull = EJ.filterWithNote (\(_, t) _ -> t >= 4) full+ assertEqual "Policy: delete-only leaves only the window terms" [4, 5] residentTerms+ assertEqual "Policy: delete-only window equals FullAudit restricted to the window (exact)"+ (toMap windowOfFull) residentMap+ -- norm strictly drops (terms 1..3 were discarded with no spill).+ assertEqual "Policy: discarding older terms strictly reduces norm"+ True (norm residentJournal < norm full)++-- Test 5: DET — policy runs are reproducible and Sequential == ParChunk (exact).+testPolicyDeterminism :: IO ()+testPolicyDeterminism = withTempSpill "det" $ \_ -> do+ let pol = Policy.defaultLedgerPolicy { Policy.retain = Policy.RetainRecent 3 }+ specPar p = polSpec { Lite.specParallel = p }+ r1 <- runLiteWithPolicy pol (specPar Sequential) polW0 (toMap . pwLedger)+ r2 <- runLiteWithPolicy pol (specPar Sequential) polW0 (toMap . pwLedger)+ rP <- runLiteWithPolicy pol (specPar (ParChunk 2)) polW0 (toMap . pwLedger)+ assertEqual "Policy DET-1: same policy run twice is identical" r1 r2+ assertEqual "Policy DET-2: Sequential == ParChunk under policy (exact)" r1 rP++-- Test 6 (classic bridge): policySpillOptions drives the classic engine and the+-- result restores losslessly, mirroring the existing binary-spill restore test.+-- We exercise the derived chunk extraction + eviction directly (no full+-- StateSpace needed) by checking the option fields it builds.+testPolicyClassicBridge :: IO ()+testPolicyClassicBridge = withTempSpill "bridge" $ \path -> do+ -- Build a ledger spanning terms 1..3, spill terms 1..2 via the policy-derived+ -- chunk extractor, keep term 3 as the remainder, then restore == whole ledger.+ let pol = Policy.defaultLedgerPolicy+ { Policy.retain = Policy.RetainRecent 1, Policy.spillTo = Just path }+ whole :: LedgerM+ whole = EJ.fromList+ [ (1 .@ Not :< Purchases) .| ("post", 1)+ , (2 .@ Not :< Purchases) .| ("post", 2)+ , (3 .@ Not :< Purchases) .| ("post", 3) ]+ -- the option built by the bridge; we use its spillExtractChunk to carve+ -- terms 1..2 and write them, exactly as runSimulationWithSpill would.+ opts = Policy.policySpillOptions pol 2+ (\(LedgerRef r) -> readSTRef r)+ (\f (LedgerRef r) -> modifySTRef' r f)+ :: ES.SpillOptions Int LedgerRef LedgerM+ -- emulate a single spill of the [1,2] chunk + eviction of term <= 2.+ ref <- LedgerRef <$> stToIO (newSTRef whole)+ chunk <- case ES.spillExtractChunk opts of+ Just extract -> stToIO (extract (1, 2) ref)+ Nothing -> error "policySpillOptions must set spillExtractChunk"+ withFile path WriteMode $ \h -> ES.spillWriteChunk opts h (1, 2) chunk+ stToIO (ES.spillDeleteRange opts (1, 2) ref)+ let LedgerRef r0 = ref+ remainder <- stToIO (readSTRef r0)+ -- remainder is now only term 3; restore merges spill + remainder == whole.+ restored <- Policy.restoreLedger path remainder :: IO LedgerM+ assertEqual "Policy bridge: policySpillOptions chunk keeps spilled-range terms"+ (toMap (EJ.filterWithNote (\(_, t) _ -> t >= 1 && t <= 2) whole)) (toMap chunk)+ assertEqual "Policy bridge: after eviction the remainder is only the kept window"+ (toMap (EJ.filterWithNote (\(_, t) _ -> t > 2) whole)) (toMap remainder)+ assertEqual "Policy bridge: restore (spill + remainder) == whole ledger (lossless)"+ (toMap whole) (toMap restored)++-- Test 7 (HasTermAxis): termOf returns the LAST Note component for pair/triple.+testPolicyHasTermAxis :: IO ()+testPolicyHasTermAxis = do+ assertEqual "Policy HasTermAxis: pair termOf = snd" (7 :: Int) (Policy.termOf ("e", 7 :: Int))+ assertEqual "Policy HasTermAxis: triple termOf = 3rd" (9 :: Int)+ (Policy.termOf ("e1", "e2", 9 :: Int))++-- ================================================================+-- Simulate.Network tests (Phase 3, feat/trade-network)+--+-- Property + unit tests for the TradeNetwork / InputCoefficients separation,+-- the deterministic generators, the smart-constructor invariants, and the+-- edge-summation sigmaEdges. All read-outs are Ord-ascending (no hash order).+-- ================================================================++type NetJD = Journal (Int, Int) MoneyDecimal (HatBase CountUnit)++-- a tiny per-edge journal builder used by the sigmaEdges equivalence test+edgeJ :: Int -> Int -> NetJD+edgeJ i j = ((fromIntegral (i + 2 * j) :: MoneyDecimal) .@ Not :< Amount) .| (i, j)++-- Test 1: completeNetwork makes sigmaEdges coincide with the all-pairs sum.+-- "The notation is unchanged; only the set Σ runs over changes."+testNetCompleteEquiv :: IO ()+testNetCompleteEquiv = do+ let ks = [1 .. 6 :: Int]+ viaEdges = sigmaEdges (completeNetwork ks) edgeJ :: NetJD+ viaPairs = EJ.sigma2When ks ks (/=) edgeJ :: NetJD+ assertEqual "Network: sigmaEdges complete == all-pairs sigma2When (exact)"+ (toMap viaPairs) (toMap viaEdges)++-- Test 2: determinism (DET-1). Same StdGen -> identical edges for every+-- generator, checked by running each twice and comparing.+testNetDeterminism :: IO ()+testNetDeterminism = do+ let ks = [1 .. 30 :: Int]+ g = mkStdGen 42+ twice f = assertEqual ("Network DET-1: " ++ fst f) (edges (snd f g)) (edges (snd f g))+ twice ("kRegular", \s -> kRegular s ks 4)+ twice ("erdosRenyi", \s -> erdosRenyi s ks 0.3)+ twice ("scaleFree", \s -> scaleFree s ks 3)+ twice ("sectorBlock",\s -> sectorBlock s [(k, k `mod` 3) | k <- ks] (\(a,b) -> if a==b then 0.5 else 0.1))++-- Test 3: smart constructors reject the four illegal cases.+testNetSmartConstructor :: IO ()+testNetSmartConstructor = do+ assertEqual "Network: self-loop rejected"+ (Left SelfLoop) (tradeNetwork [1,2] [(1,1)] :: Either NetworkError (TradeNetwork Int))+ assertEqual "Network: duplicate edge rejected"+ (Left DuplicateEdge) (tradeNetwork [1,2] [(1,2),(1,2)] :: Either NetworkError (TradeNetwork Int))+ let Right g = tradeNetwork [1,2,3] [(1,3)] :: Either NetworkError (TradeNetwork Int)+ assertEqual "Network: coefficient outside network rejected"+ (Left CoefOutsideNetwork)+ (inputCoefficients g [(2,3,0.5)] :: Either NetworkError (InputCoefficients Int Double))+ assertEqual "Network: negative coefficient rejected"+ (Left NegativeCoefficient)+ (inputCoefficients g [(1,3,-0.5)] :: Either NetworkError (InputCoefficients Int Double))+ assertEqual "Network: duplicate coefficient rejected"+ (Left DuplicateCoefficient)+ (inputCoefficients g [(1,3,0.2),(1,3,0.3)] :: Either NetworkError (InputCoefficients Int Double))++-- Test 4: Hawkins-Simon — every buyer's column sum is strictly below 1.+testNetHawkinsSimon :: IO ()+testNetHawkinsSimon = do+ let g = completeNetwork [1 .. 12 :: Int]+ a = randomCoefficients (mkStdGen 11) defaultCoefOptions g :: InputCoefficients Int Double+ ok = all (\j -> sum (Prelude.map snd (inputsOf a j)) < 1.0) (nodes g)+ assertEqual "Network: randomCoefficients (hawkinsSimon) all column sums < 1" True ok++-- Test 5: generator structure.+testNetGeneratorStructure :: IO ()+testNetGeneratorStructure = do+ let ks = [1 .. 8 :: Int]+ kr = kRegular (mkStdGen 3) ks 3 :: TradeNetwork Int+ assertEqual "Network: kRegular in-degree = min k (N-1)"+ (replicate (length ks) 3)+ (Prelude.map (length . suppliersOf kr) (nodes kr))+ -- erdosRenyi p=1 == complete, p=0 == empty+ assertEqual "Network: erdosRenyi p=1 == completeNetwork edges"+ (edges (completeNetwork ks))+ (edges (erdosRenyi (mkStdGen 0) ks 1.0 :: TradeNetwork Int))+ assertEqual "Network: erdosRenyi p=0 has no edges"+ 0 (edgeCount (erdosRenyi (mkStdGen 0) ks 0.0 :: TradeNetwork Int))+ -- scaleFree edge count is deterministic: C(m+1,2) + (N-m-1)*m+ let n = length ks; m = 2+ expected = (m * (m + 1) `div` 2) + (n - m - 1) * m+ assertEqual "Network: scaleFree edge count matches preferential-attachment formula"+ expected (edgeCount (scaleFree (mkStdGen 9) ks m :: TradeNetwork Int))++-- Test 6: out/in adjacency consistency on an arbitrary generated network.+-- (i,j) in edges <=> i in suppliersOf j <=> j in buyersOf i+testNetAdjacencyConsistency :: IO ()+testNetAdjacencyConsistency = do+ let ks = [1 .. 25 :: Int]+ g = erdosRenyi (mkStdGen 77) ks 0.25 :: TradeNetwork Int+ es = edges g+ fwd = all (\(i,j) -> i `elem` suppliersOf g j && j `elem` buyersOf g i) es+ -- and the reverse: every (i,j) reconstructed from suppliersOf equals edges+ viaSuppliers = L.sort [ (i, j) | j <- nodes g, i <- suppliersOf g j ]+ viaBuyers = L.sort [ (i, j) | i <- nodes g, j <- buyersOf g i ]+ assertEqual "Network: edges <=> suppliersOf (forward)" True fwd+ assertEqual "Network: edges == reconstruction from suppliersOf" (L.sort es) viaSuppliers+ assertEqual "Network: edges == reconstruction from buyersOf" (L.sort es) viaBuyers++-- Test 7: CSV round-trip — parse . render == id (render is a test helper).+renderEdgeCsv :: [(T.Text, T.Text)] -> T.Text+renderEdgeCsv rows = T.unlines (T.pack "from,to" : [ a <> T.pack "," <> b | (a, b) <- rows ])++renderCoefCsv :: [(T.Text, T.Text, Double)] -> T.Text+renderCoefCsv rows =+ T.unlines (T.pack "from,to,coef" :+ [ a <> T.pack "," <> b <> T.pack "," <> T.pack (show c) | (a, b, c) <- rows ])++testNetCsvRoundTrip :: IO ()+testNetCsvRoundTrip = do+ let eRows = [(T.pack "a", T.pack "b"), (T.pack "b", T.pack "c"), (T.pack "a", T.pack "c")]+ assertEqual "Network: edge CSV parse . render == id"+ (Right eRows) (parseEdgeCsv (renderEdgeCsv eRows))+ let cRows = [(T.pack "a", T.pack "b", 0.25), (T.pack "b", T.pack "c", 0.5)]+ assertEqual "Network: coef CSV parse . render == id"+ (Right cRows) (parseCoefCsv (renderCoefCsv cRows))+ -- ingestion helpers agree with the network/coefficient invariants+ let Right (g, a) = coefficientsFromTable [(1,3,0.2),(2,3,0.5)]+ :: Either NetworkError (TradeNetwork Int, InputCoefficients Int Double)+ assertEqual "Network: coefficientsFromTable edges" [(1,3),(2,3)] (edges g)+ assertEqual "Network: coefficientsFromTable inputsOf" [(1,0.2),(2,0.5)] (inputsOf a 3)+ -- fromCoefficientMatrix drops zero cells from the support+ let m i j = if i < j then fromIntegral (i + j) else 0 :: Double+ (gm, am) = fromCoefficientMatrix [1,2,3 :: Int] m+ assertEqual "Network: fromCoefficientMatrix support drops zeros"+ [(1,2),(1,3),(2,3)] (edges gm)+ assertEqual "Network: fromCoefficientMatrix coefficient" (Just 4.0) (coefficient am 1 3)+ -- networkFromTable derives nodes from rows+ let Right gt = networkFromTable [(1,2),(2,3)] :: Either NetworkError (TradeNetwork Int)+ assertEqual "Network: networkFromTable derives node set" [1,2,3] (nodes gt)++-- Test 8: the requested sparse regimes have exactly m*N edges.+testIndustrialNetworkEdgeCount :: IO ()+testIndustrialNetworkEdgeCount = do+ let cases = [(200, 5, 20), (1000, 4, 10)]+ forM_ cases $ \(n, k, m) -> do+ let economy = industrialNetwork 2025 n k m+ assertEqual ("Industrial network: exact |E|=mN for " ++ show (n,k,m))+ (m * n) (edgeCount (ieNetwork economy))+ let capacityLimited = industrialNetwork 1 10 1 20+ assertEqual "Industrial network: capacity shortage uses every eligible pair"+ 45 (edgeCount (ieNetwork capacityLimited))+ let nearOne = industrialNetworkWith+ defaultIndustrialOptions { ioExponent = 1.001 } 1 1000 3 5+ assertEqual "Industrial network: gamma near 1 keeps every size finite"+ True (all (\w -> w > 0 && not (isNaN w) && not (isInfinite w))+ (M.elems (ieSize nearOne)))+ assertEqual "Industrial network: gamma near 1 retains exact |E|=mN"+ 5000 (edgeCount (ieNetwork nearOne))++-- Test 9: every edge obeys the ordered-sector DAG invariant and is unique.+testIndustrialNetworkStructure :: IO ()+testIndustrialNetworkStructure = do+ let economy = industrialNetwork 19 500 5 12+ es = industrialEdges economy+ sectors = ieSector economy+ valid (i, j) =+ let si = sectors M.! i+ sj = sectors M.! j+ in i /= j && (si < sj || (si == sj && i < j))+ assertEqual "Industrial network: no duplicate edges"+ (length es) (Set.size (Set.fromList es))+ assertEqual "Industrial network: sector order and intra-sector id DAG"+ True (all valid es)++-- Test 10: the integer seed fixes sectors, sizes, and edges.+testIndustrialNetworkDeterminism :: IO ()+testIndustrialNetworkDeterminism = do+ let a = industrialNetwork 4242 300 5 8+ b = industrialNetwork 4242 300 5 8+ assertEqual "Industrial network: same seed gives identical economy" a b++-- Test 11: one-sector economies use the id order as a DAG and still hit m*N.+testIndustrialNetworkKOne :: IO ()+testIndustrialNetworkKOne = do+ let n = 200+ m = 20+ economy = industrialNetwork 3 n 1 m+ es = industrialEdges economy+ assertEqual "Industrial network: K=1 exact |E|=mN" (m * n) (length es)+ assertEqual "Industrial network: K=1 edges are increasing ids"+ True (all (uncurry (<)) es)++-- Test 12: market-scale construction smoke. There is deliberately no timing+-- assertion; forcing the full 1.28M-edge economy catches accidental all-pairs+-- construction and latent exceptions while remaining machine-independent.+testIndustrialNetworkLarge :: IO ()+testIndustrialNetworkLarge = do+ economy <- evaluate (force (industrialNetwork 2025 64000 5 20))+ assertEqual "Industrial network: N=64000 smoke exact |E|=mN"+ (64000 * 20) (edgeCount (ieNetwork economy))++-- Test 13: exact one-pass flow identities, divisibility, and tax cancellation.+testIndustrialFlowsIdentities :: IO ()+testIndustrialFlowsIdentities = do+ let rate = TaxRate 1 10+ den = taxDenominator rate+ economy = industrialNetwork 2025 300 5 12+ flows = industrialFlows rate economy+ net = ieNetwork economy+ js = firms economy+ z i j = M.findWithDefault 0 (i,j) (flowTrade flows)+ x j = flowOutput flows M.! j+ input j = flowInput flows M.! j+ va j = flowValueAdded flows M.! j+ f j = flowFinalDemand flows M.! j+ incoming j = sum [ z i j | i <- suppliersOf net j ]+ outgoing j = sum [ z j m | m <- buyersOf net j ]+ allAmounts = M.elems (flowTrade flows)+ ++ M.elems (flowOutput flows)+ ++ M.elems (flowInput flows)+ ++ M.elems (flowValueAdded flows)+ ++ M.elems (flowFinalDemand flows)+ taxReceivedTrade = sum+ [ taxOf rate (z i j) | i <- js, j <- buyersOf net i ]+ taxPaidTrade = sum+ [ taxOf rate (z i j) | j <- js, i <- suppliersOf net j ]+ finalTax = sum [ taxOf rate (f j) | j <- js ]+ netTax = taxReceivedTrade + finalTax - taxPaidTrade+ expectedTax = taxNumerator rate * sum (Prelude.map f js) `div` den+ assertEqual "Industrial flows: all final demand positive" True (all ((> 0) . f) js)+ assertEqual "Industrial flows: all value added non-negative" True (all ((>= 0) . va) js)+ assertEqual "Industrial flows: output = orders + final demand"+ True (all (\j -> x j == outgoing j + f j) js)+ assertEqual "Industrial flows: output = input + value added"+ True (all (\j -> x j == incoming j + va j && input j == incoming j) js)+ assertEqual "Industrial flows: every amount divisible by tax denominator"+ True (all (\amount -> amount `mod` den == 0) allAmounts)+ assertEqual "Industrial flows: sum value added = sum final demand"+ (sum (Prelude.map f js)) (sum (Prelude.map va js))+ assertEqual "Industrial flows: trade output tax equals trade input tax"+ taxReceivedTrade taxPaidTrade+ assertEqual "Industrial flows: trade tax cancels and net tax equals final-demand tax"+ expectedTax netTax++-- Test 14: zero allocations are retained per edge, and a hand-built economy+-- outside the ordered DAG is rejected before backward substitution.+testIndustrialFlowEdgeCases :: IO ()+testIndustrialFlowEdgeCases = do+ let rate = TaxRate 1 10+ economy = industrialNetwork 11 50 3 5+ zeroFlows = industrialFlowsWith (FlowOptions 10 0.5) rate economy+ assertEqual "Industrial flows: sub-denominator inputs permit z_ij=0"+ True (not (M.null (flowTrade zeroFlows)) && any (== 0) (M.elems (flowTrade zeroFlows)))+ let Right badNetwork = tradeNetwork [1,2] [(2,1)]+ :: Either NetworkError (TradeNetwork Int)+ badEconomy = IndustrialEconomy+ { ieNetwork = badNetwork+ , ieSector = M.fromList [(1,0),(2,0)]+ , ieSize = M.fromList [(1,1),(2,1)] }+ rejected <- try (evaluate (force (industrialFlows rate badEconomy)))+ :: IO (Either SomeException (IndustrialFlows Int))+ assertEqual "Industrial flows: unordered hand-built economy rejected"+ True (case rejected of Left _ -> True; Right _ -> False)++-- ================================================================+-- MarketModel equivalence tests (Phase 5, feat/market-scale-experiments)+--+-- The examples/market/MarketModel.hs core cannot be imported here (it declares+-- an orphan `instance StateTime Int` that would clash with the SICE harness's+-- `instance StateTime SimTerm`), so the trade simple/tuned stages and a small+-- BSP world are re-stated minimally (per the Phase 5 plan §2 commit 3 note).+-- We check the two properties the plan puts in CI:+-- (a) tradeStageSimple ≡ tradeStageTuned, EXACTLY, under MoneyDecimal;+-- (b) Sequential ≡ ParChunk (DET-2) for the whole 3-stage model, exactly.+-- (Perf ratios are out of CI; they live in run-market-experiments.sh.)+-- ================================================================++-- 4-axis base (AccountTitles, owner, counterparty, CountUnit), mirroring+-- MarketModel.MBase. It is exactly the SICE harness's SimHatBase2, so we reuse+-- that type (and its ExBaseClass / Element Int / BaseClass Int instances)+-- instead of re-declaring them.+type MktFirm = SimCompany -- = Int++-- ADT event tag mirroring MarketModel.MTag (typo'd tags become compile errors,+-- not silently-empty projections). 'MktPlank' is the explicit blank tag.+data MktTag = MktPlank | MktTrade | MktProduction | MktReport | MktClosing | MktCarryover+ deriving (Show, Eq, Ord, Enum, Bounded, Generic)+instance Hashable MktTag+-- needed so the spill / runLiteWithPolicy window-transparency test can serialise+-- a @Journal MktNote v b@ (derived structurally from Generic).+instance Binary.Binary MktTag+instance Note MktTag where+ plank = MktPlank++type MktNote = (MktTag, Int)+type MktBase = SimHatBase2 -- = HatBase (AccountTitles, Int, Int, CountUnit)+type MktLedgM = Journal MktNote MoneyDecimal MktBase++data MktW v f = MktW+ { mkLedger :: HK f (Journal MktNote v MktBase)+ , mkNet :: HK f (TradeNetwork MktFirm)+ , mkCoef :: HK f (InputCoefficients MktFirm v)+ } deriving Generic++-- own-product classifier shared by the mirror.+mktOwnerOfProduct :: BasePart MktBase -> Maybe MktFirm+mktOwnerOfProduct bp = case bp of+ (Products, o, c, _) | o == c -> Just o+ _ -> Nothing++-- opening stock read from the (MktCarryover, t) note (indexed per-note),+-- mirroring MarketModel.openingMap (carryover-based O(term) inventory).+mktOpening :: (HatVal v, Real v)+ => Int -> Journal MktNote v MktBase -> M.Map MktFirm v+mktOpening t ledger =+ EA.balanceMapBy mktOwnerOfProduct+ (EJ.toAlg (EJ.projWithNote [(MktCarryover, t)] ledger))++-- single-firm opening read (indexed per-note + per-base), mirroring+-- MarketModel.openingOf: balanceBy over firm j's own-product base only.+mktOpeningOf :: (HatVal v, Real v)+ => Int -> MktFirm -> Journal MktNote v MktBase -> v+mktOpeningOf t j ledger =+ EA.balanceBy [Not :< (Products, j, j, Amount)]+ [Hat :< (Products, j, j, Amount)]+ (EJ.toAlg (EJ.projWithNote [(MktCarryover, t)] ledger))++-- single-firm inventory-connected demand, mirroring MarketModel.demandOf.+mktDemandOf :: (HatVal v, Real v)+ => Double -> Int -> MktFirm -> Journal MktNote v MktBase -> Double+mktDemandOf target t j ledger =+ max 0 (target - realToFrac (mktOpeningOf t j ledger))++mktPurchase :: (HatVal v) => v -> MktFirm -> MktFirm -> EA.Alg v MktBase+mktPurchase amt i j =+ amt .@ Not :< (Products, j, j, Amount)+ .+ amt .@ Hat :< (Cash, j, j, Yen)+ .+ amt .@ Not :< (Purchases, j, j, Yen)+ .+ amt .@ Not :< (Cash, i, i, Yen)+ .+ amt .@ Not :< (Sales, i, i, Yen)+ .+ amt .@ Hat :< (Products, i, i, Amount)++mktOrderAmt :: (HatVal v, Real v)+ => InputCoefficients MktFirm v -> Double -> MktFirm -> MktFirm -> Double+mktOrderAmt coef d i j =+ realToFrac (maybe 0 id (coefficient coef i j)) * d++-- per-firm trade stage (stageFor over the firm list), mirroring+-- MarketModel.tradeStageSimple: buyer j folds its in-edges (suppliersOf).+mktTradeSimple :: (HatVal v, Real v) => [MktFirm] -> Double -> Stage (MktW v) Int MktNote v MktBase+mktTradeSimple fs target = stageOf MktTrade fs $ \w t _g j ->+ -- single-note stage: emit the bare Alg; the runner attaches (MktTrade, t).+ let net = mkNet w; coef = mkCoef w+ d = mktDemandOf target t j (mkLedger w)+ sup = suppliersOf net j+ one i = let amt = realToFrac (mktOrderAmt coef d i j)+ in if amt <= 0 then mempty else mktPurchase amt i j+ in EA.sigma sup one++mktTradeTuned :: (HatVal v, Real v) => [MktFirm] -> Double -> Stage (MktW v) Int MktNote v MktBase+mktTradeTuned fs target = stageOf MktTrade fs $ \w t _g j ->+ -- single-note stage: see 'mktTradeSimple'.+ let net = mkNet w; coef = mkCoef w+ d = mktDemandOf target t j (mkLedger w)+ sup = suppliersOf net j+ accum = L.foldl' step M.empty sup+ step acc i =+ let amt = realToFrac (mktOrderAmt coef d i j)+ in if amt <= 0 then acc+ else L.foldl' (\m (b, v) -> M.insertWith (+) b v m) acc+ [ (Not :< (Products, j, j, Amount), amt)+ , (Hat :< (Cash, j, j, Yen), amt)+ , (Not :< (Purchases, j, j, Yen), amt)+ , (Not :< (Cash, i, i, Yen), amt)+ , (Not :< (Sales, i, i, Yen), amt)+ , (Hat :< (Products, i, i, Amount), amt) ]+ in EA.sigmaFromMap accum (\b v -> v .@ b)++mktProduction :: (HatVal v, Real v) => [MktFirm] -> Double -> Stage (MktW v) Int MktNote v MktBase+mktProduction fs target = stageOf MktProduction fs $ \w t _g j ->+ -- single-note stage: emit the bare Alg; the runner attaches (MktProduction, t).+ let amt = realToFrac (mktDemandOf target t j (mkLedger w))+ in if amt <= 0 then mempty+ else (amt .@ Hat :< (Products, j, j, Amount))+ .+ (amt .@ Not :< (SalesCost, j, j, Yen))++mktReport :: (HatVal v) => Stage (MktW v) Int MktNote v MktBase+mktReport = stageOf MktReport [()] $ \w t _g () ->+ -- single-note aggregate stage: emit the bare Alg; runner attaches (MktReport, t).+ let flow = EJ.toAlg (EJ.projWithNote [(MktTrade, t), (MktProduction, t)] (mkLedger w))+ shortageK b = case b of+ Hat :< (Products, o, c, _) | o == c -> Just o+ _ -> Nothing+ in EA.postFromNetBy shortageK (\j v -> v .@ Not :< (Products, j, j, Amount)) flow++-- carryover stage (mirror): net this term's own-product stock and roll the+-- positive surplus into (MktCarryover, t+1). Mirrors MarketModel.carryoverStage.+mktCarryover :: (HatVal v, Real v) => Stage (MktW v) Int MktNote v MktBase+mktCarryover = stage "closing" $ \w t ->+ let termAlg = EJ.toAlg (EJ.filterByAxis 1 (NoteAxisKey (t :: Int)) (mkLedger w))+ netMap = EA.balanceMapBy mktOwnerOfProduct termAlg+ perFirm (j, v) =+ if v <= 0 then mempty+ else ((v .@ Hat :< (Products, j, j, Amount)) .| (MktClosing, t))+ <> ((v .@ Not :< (Products, j, j, Amount)) .| (MktCarryover, t + 1))+ in mconcat [ perFirm kv | kv <- M.toList netMap ]++-- a fixed small (G, A) used by both equivalence tests.+mktBuild :: (HatVal v) => Int -> (TradeNetwork MktFirm, InputCoefficients MktFirm v)+mktBuild n =+ let (gG, gA) = split (mkStdGen 2025)+ fs = [1 .. n]+ net = erdosRenyi gG fs 0.3+ a = randomCoefficients gA defaultCoefOptions net+ in (net, a)++mktSpec :: (HatVal v, Real v)+ => Bool -> Int -> Int -> Par -> SimSpec (MktW v) Int MktNote v MktBase+mktSpec tuned n lastT par =+ let fs = [1 .. n] in+ (mkSimSpec (1, lastT) 2025 mkLedger+ [ (if tuned then mktTradeTuned else mktTradeSimple) fs 10+ , mktProduction fs 10+ , mktReport+ , mktCarryover ])+ { Lite.specParallel = par }++mktW0 :: (HatVal v) => Int -> MktW v InitT+mktW0 n = let (net, a) = mktBuild n+ in MktW { mkLedger = carry mempty, mkNet = carry net, mkCoef = carry a }++-- (a) simple ≡ tuned, exactly, under MoneyDecimal (N=30, T=5).+-- the redundant-algebra-correct "same result": net each note's Alg per base+-- ('bar' drops the cancelled part and any zero-padding), keeping the Hat/Not+-- side. simple and tuned differ ONLY in seq redundancy (simple keeps the+-- per-edge posting sequence; tuned pre-sums per base), so they are equal exactly+-- after netting. (norm additivity already holds; this is the stronger per-base+-- exact check.)+nettedMktMap :: Journal MktNote MoneyDecimal MktBase+ -> HM.HashMap MktNote (EA.Alg MoneyDecimal MktBase)+nettedMktMap = toMap . EJ.map EA.bar++testMarketSimpleTunedEqual :: IO ()+testMarketSimpleTunedEqual = do+ let simpleL = runLite (mktSpec False 30 5 Sequential) (mktW0 30) mkLedger+ :: MktLedgM+ tunedL = runLite (mktSpec True 30 5 Sequential) (mktW0 30) mkLedger+ assertEqual "Market: tradeStageSimple == tradeStageTuned (MoneyDecimal, exact per-base net)"+ (nettedMktMap simpleL) (nettedMktMap tunedL)+ -- gross volume must also agree: bar-equality alone cannot detect an+ -- accidental early Hat/Not netting in the tuned path (bar is idempotent,+ -- but the pre-bar norm would shrink). norm pins the gross posting volume.+ assertEqual "Market: simple/tuned gross volume (norm) agrees (no early netting)"+ (norm simpleL) (norm tunedL)++-- (b) DET-2: Sequential ≡ ParChunk, exactly, under MoneyDecimal (simple path).+testMarketSeqParEqual :: IO ()+testMarketSeqParEqual = do+ let seqM = runLite (mktSpec False 30 5 Sequential) (mktW0 30) (toMap . mkLedger)+ :: HM.HashMap MktNote (EA.Alg MoneyDecimal MktBase)+ parM = runLite (mktSpec False 30 5 (ParChunk 8)) (mktW0 30) (toMap . mkLedger)+ assertEqual "Market DET-2: Sequential == ParChunk (MoneyDecimal, exact)"+ seqM parM++-- (c) sanity: the report's net shortage is strictly positive (Hawkins-Simon),+-- and the complete-network setting also runs (a participating-set sanity).+testMarketShortagePositive :: IO ()+testMarketShortagePositive = do+ let finalSh = runLite (mktSpec False 24 4 Sequential) (mktW0 24)+ (\final -> norm (EJ.projWithNote [(MktReport, 4)] (mkLedger final)))+ :: MoneyDecimal+ assertEqual "Market: final-term net shortage is strictly positive (Hawkins-Simon)"+ True (finalSh > 0)+ -- complete network on a tiny N just exercises the dense edge set end-to-end.+ let (gG, gA) = split (mkStdGen 2025)+ cnet = completeNetwork [1 .. 8 :: MktFirm]+ ccoef = randomCoefficients gA defaultCoefOptions cnet :: InputCoefficients MktFirm MoneyDecimal+ cw0 = MktW { mkLedger = carry mempty, mkNet = carry cnet, mkCoef = carry ccoef }+ cfs = [1 .. 8 :: MktFirm]+ cspec = (mkSimSpec (1, 3) 2025 mkLedger+ [ mktTradeSimple cfs 10, mktProduction cfs 10, mktReport, mktCarryover ])+ cNorm = runLite cspec cw0 (norm . mkLedger) :: MoneyDecimal+ _ = gG+ assertEqual "Market: complete-network run produces a positive ledger norm"+ True (cNorm > 0)++-- (d) WINDOW-TRANSPARENCY SENTINEL (Phase 5 fix, modification 3):+-- the carryover bookkeeping makes the model self-contained per term, so a+-- RetainRecent window must NOT change the observable result. Assert that+-- RetainRecent 2 (+ spill) and RetainAll produce the EXACT SAME final-term+-- report norm AND final carryover map (MoneyDecimal, so equality is exact).+-- This permanently guards against the bug this round fixed (a full-ledger+-- inventory sweep silently re-reading a window-truncated net: 9974.74 vs+-- 9993.56). N=40, T=8 so the window (2) is strictly smaller than the history.+testMarketWindowTransparent :: IO ()+testMarketWindowTransparent = withTempSpill "market_window" $ \path -> do+ let n = 40; lastT = 8+ spec = mktSpec False n lastT Sequential+ w0 = mktW0 n+ -- project the two observables we pin: the final-term report norm and the+ -- final carryover map (the next-term opening, keyed by firm).+ project final =+ let lj = mkLedger final :: MktLedgM+ reportN = norm (EJ.projWithNote [(MktReport, lastT)] lj) :: MoneyDecimal+ carryM = EA.balanceMapBy mktOwnerOfProduct+ (EJ.toAlg (EJ.projWithNote [(MktCarryover, lastT + 1)] lj))+ :: M.Map MktFirm MoneyDecimal+ in (reportN, carryM)+ polAll = Policy.defaultLedgerPolicy { Policy.retain = Policy.RetainAll }+ polWin = Policy.defaultLedgerPolicy+ { Policy.retain = Policy.RetainRecent 2, Policy.spillTo = Just path }+ (allN, allM) <- runLiteWithPolicy polAll spec w0 project+ (winN, winM) <- runLiteWithPolicy polWin spec w0 project+ assertEqual "Market window-transparency: final report norm equal under RetainAll vs RetainRecent 2 + spill"+ allN winN+ assertEqual "Market window-transparency: final carryover map equal under RetainAll vs RetainRecent 2 + spill"+ allM winM++-- (e) stageOf AUTO-NOTE SENTINEL: a 'stageOf' stage and the equivalent manual+-- 'stageFor' that writes @.| (tag, t)@ itself must produce the EXACT SAME ledger+-- (MoneyDecimal, so equality is exact). This pins the semantics of the runner's+-- single auto-attachment of @(stTag, t)@: moving the note from the stage body+-- into 'runStage' changes nothing observable (incl. the zero-drop at the sigma+-- commit). The manual stages below are byte-for-byte the bodies of the migrated+-- mirror stages, but tagged explicitly with the OLD @if isZero then mempty@ form.+mktTradeSimpleManual :: (HatVal v, Real v)+ => [MktFirm] -> Double -> Stage (MktW v) Int MktNote v MktBase+mktTradeSimpleManual fs target = stageFor "trade" fs $ \w t _g j ->+ let net = mkNet w; coef = mkCoef w+ d = mktDemandOf target t j (mkLedger w)+ sup = suppliersOf net j+ one i = let amt = realToFrac (mktOrderAmt coef d i j)+ in if amt <= 0 then mempty else mktPurchase amt i j+ alg = EA.sigma sup one+ in if EA.isZero alg then mempty else alg .| (MktTrade, t)++mktProductionManual :: (HatVal v, Real v)+ => [MktFirm] -> Double -> Stage (MktW v) Int MktNote v MktBase+mktProductionManual fs target = stageFor "production" fs $ \w t _g j ->+ let amt = realToFrac (mktDemandOf target t j (mkLedger w))+ in if amt <= 0 then mempty+ else ((amt .@ Hat :< (Products, j, j, Amount))+ .+ (amt .@ Not :< (SalesCost, j, j, Yen)))+ .| (MktProduction, t)++mktReportManual :: (HatVal v) => Stage (MktW v) Int MktNote v MktBase+mktReportManual = stage "report" $ \w t ->+ let flow = EJ.toAlg (EJ.projWithNote [(MktTrade, t), (MktProduction, t)] (mkLedger w))+ shortageK b = case b of+ Hat :< (Products, o, c, _) | o == c -> Just o+ _ -> Nothing+ sh = EA.postFromNetBy shortageK (\j v -> v .@ Not :< (Products, j, j, Amount)) flow+ in if EA.isZero sh then mempty else sh .| (MktReport, t)++-- the same 4-stage spec as 'mktSpec' but with the three single-note stages+-- expressed via manual stageFor + explicit @.| (tag, t)@ (carryover unchanged).+mktSpecManual :: (HatVal v, Real v)+ => Int -> Int -> Par -> SimSpec (MktW v) Int MktNote v MktBase+mktSpecManual n lastT par =+ let fs = [1 .. n] in+ (mkSimSpec (1, lastT) 2025 mkLedger+ [ mktTradeSimpleManual fs 10+ , mktProductionManual fs 10+ , mktReportManual+ , mktCarryover ])+ { Lite.specParallel = par }++testMarketStageOfAutoNote :: IO ()+testMarketStageOfAutoNote = do+ let stageOfL = runLite (mktSpec False 30 5 Sequential) (mktW0 30) (toMap . mkLedger)+ :: HM.HashMap MktNote (EA.Alg MoneyDecimal MktBase)+ manualL = runLite (mktSpecManual 30 5 Sequential) (mktW0 30) (toMap . mkLedger)+ assertEqual "Market stageOf auto-note: stageOf ledger == manual stageFor + .| (tag, t) (MoneyDecimal, exact)"+ stageOfL manualL++-- ================================================================+-- ExchangeAlgebra.Optimize: pluggable solvers (Annealing / GA)+-- ================================================================++-- | Shared annealing configuration: minimize over 'Double' with a+-- uniform-step neighbor, geometric cooling and Metropolis acceptance.+optAnnealCfg :: OA.AnnealingConfig Double+optAnnealCfg = OA.AnnealingConfig+ { OA.acDirection = O.Minimize+ , OA.acSteps = 2000+ , OA.acSchedule = OA.geometricCooling 1.0 0.995+ , OA.acNeighbor = \g x -> let (d, g') = randomR (-0.5, 0.5) g in (x + d, g')+ , OA.acAccept = OA.metropolis+ , OA.acSeed = 20260717+ }++optSphere :: UV.Vector Double -> Double+optSphere v = UV.sum (UV.map (\x -> (x - 1) ** 2) v)++-- | Annealing locates the minimum of a 1-D quadratic (seed-deterministic),+-- and the reported score is the returned candidate's objective value+-- (score contract: no re-evaluation, user orientation).+testOptimizeAnnealingQuadratic :: IO ()+testOptimizeAnnealingQuadratic = do+ (best, score) <- O.optimize OA.Annealing optAnnealCfg+ (\x -> return ((x - 3) ** 2)) 0+ assertEqual "Optimize.Annealing: quadratic minimum located (|x-3| < 0.2)"+ True (abs (best - 3) < 0.2)+ assertNear "Optimize.Annealing: score equals returned candidate's value"+ ((best - 3) ** 2) score++-- | GA approaches the sphere minimum, keeps the dimension, and respects+-- per-gene bounds.+testOptimizeGASphere :: IO ()+testOptimizeGASphere = do+ let cfg = OG.defaultGAConfig { OG.gaSeed = 20260717, OG.gaGenerations = 80 }+ (best, score) <- O.optimize OG.GA cfg (return . optSphere) (UV.replicate 3 0)+ assertEqual "Optimize.GA: chromosome dimension preserved" 3 (UV.length best)+ assertEqual "Optimize.GA: sphere minimum approached (score < 0.05)"+ True (score < 0.05)+ let bs = UV.replicate 3 (-0.5, 0.5)+ bCfg = OG.defaultGAConfig { OG.gaSeed = 3+ , OG.gaGenerations = 40+ , OG.gaBounds = Just bs }+ (bBest, _) <- O.optimize OG.GA bCfg (return . optSphere) (UV.replicate 3 0)+ assertEqual "Optimize.GA: bounds respected by every gene"+ True (UV.all (\x -> x >= -0.5 && x <= 0.5) bBest)++-- | Maximize reports the score in the user's orientation.+testOptimizeGAMaximize :: IO ()+testOptimizeGAMaximize = do+ let cfg = OG.defaultGAConfig { OG.gaDirection = O.Maximize+ , OG.gaSeed = 7+ , OG.gaGenerations = 80 }+ (_, score) <- O.optimize OG.GA cfg (return . negate . optSphere)+ (UV.replicate 3 0)+ assertEqual "Optimize.GA: Maximize reports user-oriented score (> -0.05)"+ True (score > -0.05)++-- | Acceptance-criteria demo: the objective runs in @ST s@ (mutable+-- evaluation counter), and the solver evaluates exactly once per step+-- plus once for the initial candidate.+testOptimizeSTObjective :: IO ()+testOptimizeSTObjective = do+ let cfg = optAnnealCfg { OA.acSteps = 100 }+ (best, score, evals) = runST $ do+ ref <- newSTRef (0 :: Int)+ (b, s) <- O.optimize OA.Annealing cfg+ (\x -> modifySTRef' ref (+ 1) >> return ((x - 3) ** 2)) 0+ n <- readSTRef ref+ return (b, s, n)+ assertEqual "Optimize: ST objective evaluated once per step + initial"+ (OA.acSteps cfg + 1) evals+ assertEqual "Optimize: ST run returns finite (best, score)"+ True (not (isNaN best) && not (isNaN score) && not (isInfinite score))++-- | Same seed, same (deterministic) objective => identical result.+testOptimizeDeterminism :: IO ()+testOptimizeDeterminism = do+ r1 <- O.optimize OA.Annealing optAnnealCfg (\x -> return ((x - 3) ** 2)) 0+ r2 <- O.optimize OA.Annealing optAnnealCfg (\x -> return ((x - 3) ** 2)) 0+ assertEqual "Optimize.Annealing: same seed => identical result" r1 r2+ let cfg = OG.defaultGAConfig { OG.gaSeed = 11, OG.gaGenerations = 20 }+ s1 <- O.optimize OG.GA cfg (return . optSphere) (UV.replicate 3 0)+ s2 <- O.optimize OG.GA cfg (return . optSphere) (UV.replicate 3 0)+ assertEqual "Optimize.GA: same seed => identical result" s1 s2++-- | Fail-fast contract: non-finite objective scores and invalid+-- configurations are rejected with 'error', never silently absorbed.+testOptimizeFailFast :: IO ()+testOptimizeFailFast = do+ r <- try (O.optimize OA.Annealing optAnnealCfg (\_ -> return (0 / 0)) 0)+ case (r :: Either SomeException (Double, Double)) of+ Left _ -> putStrLn "[PASS] Optimize.Annealing: NaN objective rejected (fail-fast)"+ Right v -> do+ putStrLn ("[FAIL] Optimize.Annealing: NaN objective accepted: " ++ show v)+ exitFailure+ let badCfg = OG.defaultGAConfig { OG.gaEliteCount = 999 }+ r2 <- try (O.optimize OG.GA badCfg (return . optSphere) (UV.replicate 3 0))+ case (r2 :: Either SomeException (UV.Vector Double, Double)) of+ Left _ -> putStrLn "[PASS] Optimize.GA: invalid config rejected (fail-fast)"+ Right v -> do+ putStrLn ("[FAIL] Optimize.GA: invalid config accepted: " ++ show v)+ exitFailure++main :: IO ()+main = do+ testAccountTitlesBinary+ testAccountTitleClassification+ testReplaceNotesMatchesInsert+ testMapPosting+ testMapMaybePosting+ testProjMultiPatternOnePass+ testProjNormFastPath+ testProjDuplicateExact+ testProjExactWildcardOverlap+ testProjNormBarIdentity+ testProjWithBaseNorm+ testProjWithNoteNorm+ testProjWithBaseNormBothSided+ testBasesNotSideRegression+ testNumericToleranceScaleAware+ testMoneyDecimalExactOrderIndependent+ testSigmaMergePath+ testSameBaseSeqOrderPathDependence+ testSigma2When+ testSigmaFromMap+ testJournalFromListStrict+ testUnionZeroSingletonBase+ testScalarRejectsNegative+ testProjConcreteNoIndexForce+ testLinerReservedFieldsPoisoned+ testJournalSigmaMergePath+ testJournalSigma2When+ testJournalSigmaOn+ testJournalSigmaOnFromMap+ testFilterByAxisEquivalent+ testFilterByAxisWithDeltaUpdates+ testFinalStockTransferAlgEquivalence+ testFinalStockTransferJournalEquivalence+ testFinalStockTransferAggregatedAlias+ testFinalStockRegistryClosedDiff+ testFinalStockRuleReference+ testVocabOrdinalPin+ testPreVland2SemanticsClosedDiff+ testIncomeSummaryBalancedNoCrash+ testSpillDecisionSingleSource+ testRestoreJournalFromBinarySpill+ testSpillCheckedReaderWellFormed+ testSpillCheckedReaderTruncated+ testSpillCheckedReaderStaleAppend+ testSpillCheckedReaderOverlap+ testSpillCheckedReaderGap+ testSpillCheckedReaderEmptyRange+ testSpillCheckedReaderEmptyFile+ testSimulateEx1Default+ testCsvTranspose+ testCsvWriteCSV+ testCsvWriteCSVWithQuotes+ testCsvWriteCSVEmpty+ testWriteBSPinned+ testWritePLPinned+ testWriteJournalPinned+ testWriteCompoundTrialBalancePinned+ testWriteAccountOfJournalPinned+ testLiteBoilerplate+ testLiteDet2+ testLiteDet1+ testLiteBspInvisibility+ testLiteGateEquivalence+ testLiteFieldRules+ testLiteBoundaryOncePerTerm+ testPolicyEquivalence+ testPolicyWindowRoundTrip+ testPolicyCompressClosed+ testPolicyDeleteOnly+ testPolicyDeterminism+ testPolicyClassicBridge+ testPolicyHasTermAxis+ testNetCompleteEquiv+ testNetDeterminism+ testNetSmartConstructor+ testNetHawkinsSimon+ testNetGeneratorStructure+ testNetAdjacencyConsistency+ testNetCsvRoundTrip+ testIndustrialNetworkEdgeCount+ testIndustrialNetworkStructure+ testIndustrialNetworkDeterminism+ testIndustrialNetworkKOne+ testIndustrialNetworkLarge+ testIndustrialFlowsIdentities+ testIndustrialFlowEdgeCases+ testMarketSimpleTunedEqual+ testMarketSeqParEqual+ testMarketShortagePositive+ testMarketWindowTransparent+ testMarketStageOfAutoNote+ testConvertCsvRoundTrip+ testAssistDescriptionsDrift+ testAssistDescribeAccount+ testAssistAllAccountInfos+ testAccountMetadataLand1+ testAccountMetadataLand1Golden+ testAccountInfoLand1Migration+ testAssistSuggestAccounts+ testPostVocabGolden+ testAccountSemanticsPrechangeGolden+ testRegistryGolden+ testJcciAccountNameCoverage+ testAccountLabelsLand4a+ testRegistryWildcards+ testRegistryContraLand2+ testLand2SemanticsClosedDiff+ testLand2InfoClosedDiff+ testLand2SuggestClosedDiff+ testLand2Contract+ testLand2PimoFlip+ testLand2ExchangeRelation+ testLand2IsContraInstances+ testLand2AiDivision+ testLand3PresentationGroups+ testLand2Presentation+ testLand2PresentationClosedDiff+ testLand2HatNotPolicy+ checkedConvertProperties+ axiomProperties+ categoricalPhase1Properties+ journalProperties+ quotientProperties+ bookkeepingProperties+ closingDocsTests+ testOptimizeAnnealingQuadratic+ testOptimizeGASphere+ testOptimizeGAMaximize+ testOptimizeSTObjective+ testOptimizeDeterminism+ testOptimizeFailFast
test/doctests.hs view
@@ -4,4 +4,22 @@ main :: IO () main = doctest [ "-isrc"- , "src/ExchangeAlgebra.hs"]+ , "src/ExchangeAlgebra.hs"+ , "src/ExchangeAlgebra/Algebra/Internal.hs"+ -- not reachable from the umbrella above; listed explicitly so+ -- its Haddock examples are checked too.+ , "src/ExchangeAlgebra/Simulate/Network.hs"+ , "src/ExchangeAlgebra/Simulate/Policy.hs"+ -- closing-adjustment builders: not re-exported from the+ -- umbrella, so listed explicitly to check its examples too.+ , "src/ExchangeAlgebra/Bookkeeping.hs"+ -- dependency-free input-conversion core: not re-exported from+ -- the umbrella, so listed explicitly to check its examples too.+ , "src/ExchangeAlgebra/Convert.hs"+ , "src/ExchangeAlgebra/Convert/Csv.hs"+ , "src/ExchangeAlgebra/Convert/Checked.hs"+ , "src/ExchangeAlgebra/Assist.hs"+ , "src/ExchangeAlgebra/Reporting/Group.hs"+ -- optimization layer: not re-exported from the umbrella,+ -- so listed explicitly to check its examples too.+ , "src/ExchangeAlgebra/Optimize.hs"]