recon_gen.common.spine.ledger_simulation

LedgerSimulation — vector-state composition of AccountSimulation`s plus the AT.3 `Transfer primitive for cross-account flow.

AS.4 landed the vector-of-scalar-folds shape: each AccountSimulation steps its own balance forward independently, the cross-boundary invariants (ledger_drift’s Σ child.money roll-up) emerge from the matview SQL. AT.3 adds the Transfer primitive — multi-leg events that share a transfer_id (and optionally chain via parent_transfer_id).

What lives here:

  • AccountSimulation composition — the AS.4 surface; emit runs each account’s fold, violation_trajectory carries the cross-account violation set as state day by day.

  • TransferLeg + Transfer (AT.3) — the cross-account flow primitive. Each Transfer is one event (one transfer_id); legs are per-account amounts that sum to zero for a fully-balanced Posted transfer (the conservation law). Single-leg or unbalanced transfers are representable — useful for external arrivals and Pending entries.

  • LedgerSimulation.transfers (AT.3) — emitted alongside (or instead of) account folds. Anomaly’s pair-shaped plant is “transfers only, no folds” (no balance rows, single-edge to anomaly per AT.0’s finding). Money_trail’s recursive chain is “transfers with parent_transfer_id linkage”.

What this deliberately does NOT do:

  • Auto-route transfer legs into per-account DayPlan.legs. The two flow shapes — AccountSimulation.plans (account-as-source-of-truth) vs Transfer.legs (transfer-as-source-of-truth) — are kept side-by-side. A scenario that wants both (transfers + balance rows matching the transfer-induced sums) composes both. The matview SQL is the consistency contract, not the in-process emit logic.

State is the vector of per-account scalar balances PLUS the set of emitted transfers; conservation laws apply per-transfer (legs sum to zero across accounts) but are not enforced at construction — the matview is the truth-source, the generator is honest.

Classes

LedgerSimulation([accounts, transfers, prefix])

A vector of `AccountSimulation`s sharing one connection, plus an optional list of cross-account `Transfer`s.

Transfer(day, transfer_id, rail_name, legs)

A multi-leg money-movement event sharing one transfer_id.

TransferLeg(account_id, amount, ...[, ...])

One leg of a Transfer — money in or out of one account.

class recon_gen.common.spine.ledger_simulation.LedgerSimulation(accounts=<factory>, transfers=<factory>, prefix='spec_example')[source]

Bases: object

A vector of `AccountSimulation`s sharing one connection, plus an optional list of cross-account `Transfer`s.

Each per-account fold is independent (scalar); the LEDGER’s cross-account behavior emerges from the matview SQL — e.g., ledger_drift’s Σ child.money reads every account’s _current_daily_balances row. So vector state here is “many scalar folds emitted side by side”; the cross-boundary invariants pick up the structural property from the data.

transfers (AT.3) is the cross-account FLOW dimension — transfer-shaped emissions (multi-leg, shared transfer_id, optional parent_transfer_id for chains). Anomaly’s pair plant is “transfers only, no accounts” (no balance rows → single-edge to anomaly). Money_trail’s recursive chain is “transfers with parent linkage”. Drift-style scenarios continue to use the account-fold shape; a scenario that wants both composes both fields.

Per the AS.1 RNG convention: each composed AccountSimulation carries its own seeded rng. LedgerSimulation doesn’t override.

Parameters:
accounts: list[AccountSimulation]
emit(conn, *, scenario_id=None)[source]

Write every account’s full fold AND every transfer’s legs. Commits to the caller — so a scenario can compose multiple LedgerSimulations against one connection and refresh once at the end (the AP.3 pattern).

AV.5: scenario_id kwarg threads through to the per-row metadata tag ({"scenario_id": "..."}) when set; None preserves the pre-AV.5 untagged emit (byte-identical).

Return type:

None

Parameters:
  • conn (SyncConnection)

  • scenario_id (str | None)

prefix: str = 'spec_example'

Prefix for the <prefix>_transactions table when emitting transfers. For account-only ledgers this is unused (each AccountSimulation carries its own prefix). For transfer-only ledgers (anomaly’s shape) this is the source of truth.

transfers: list[Transfer]
violation_trajectory(invariant, conn)[source]

Per-day violation snapshots, like AccountSimulation’s but emitting all accounts’ rows for day i BEFORE refresh+detect.

Assumes every AccountSimulation in the ledger has the SAME number of plans (one DayPlan per ledger-day). Heterogeneous timelines would need a different fold; AT.x extensions can add a sparser shape when there’s a use case.

Return type:

list[set[Violation]]

Parameters:
  • invariant (Invariant)

  • conn (SyncConnection)

class recon_gen.common.spine.ledger_simulation.Transfer(day, transfer_id, rail_name, legs, status='Posted', parent_transfer_id=None, origin='etl', hour=12)[source]

Bases: object

A multi-leg money-movement event sharing one transfer_id.

The double-entry invariant: sum(leg.amount for leg in legs) == 0 for a fully-balanced Posted transfer. NOT enforced at construction — Pending transfers commonly carry only one leg until they post; the matview SQL filters on Posted + matched legs, so an unbalanced transfer is harmless (it just doesn’t surface). The is_balanced helper exposes the check for callers that want it.

parent_transfer_id chains transfers into trails — money_trail’s matview walks this recursively (each Posted multi-leg transfer becomes one edge per chain hop).

Parameters:
  • day (date)

  • transfer_id (str)

  • rail_name (str)

  • legs (tuple[TransferLeg, ...])

  • status (Literal['Posted', 'Pending'])

  • parent_transfer_id (str | None)

  • origin (str)

  • hour (int)

day: date
hour: int = 12

Hour of day used for the posting timestamp on each leg. Defaults to noon so each leg lands inside the day’s balance window (00:00 ≤ posting < 24:00). Mirrors ts()’s convention.

is_balanced()[source]

True iff sum(leg.amount) == 0 — the double-entry conservation law. Used by callers (incl. tests) that want to verify a transfer is fully-resolved; the constructor doesn’t enforce it because Pending single-leg transfers are valid intermediate state.

Return type:

bool

legs: tuple[TransferLeg, ...]
origin: str = 'etl'
parent_transfer_id: str | None = None
rail_name: str
status: Literal['Posted', 'Pending'] = 'Posted'
transfer_id: str
class recon_gen.common.spine.ledger_simulation.TransferLeg(account_id, amount, account_name, account_role, account_scope, account_parent_role=None)[source]

Bases: object

One leg of a Transfer — money in or out of one account.

amount > 0 = money IN (Credit), < 0 = money OUT (Debit) — the project sign convention. The denormalized account-side fields (name, role, scope, parent_role) MUST be on each leg because _transactions is the source-of-truth table for the matviews; the matview SQL doesn’t JOIN to a separate account dimension.

Parameters:
  • account_id (str)

  • amount (float)

  • account_name (str)

  • account_role (str)

  • account_scope (Literal['internal', 'external'])

  • account_parent_role (str | None)

account_id: str
account_name: str
account_parent_role: str | None = None
account_role: str
account_scope: Literal['internal', 'external']
amount: float