recon_gen.common.spine.account_simulation

AccountSimulation — the AP.2 stateful-fold pattern, productionized.

A leaf internal account stepped forward day by day as a pure fold: State -> (flows, State’). Each day’s emitted stored balance IS the running State' (Σ recorded legs so far). Concrete ViolationGenerator impls compose AccountSimulation when they need multi-day choices; the simple DriftGenerator (AS.2, single-day) stays unchanged.

What AP.2 proved + this module locks in:

  • The fold is PURE (_fold() returns per-day emissions; emit(conn) writes them). Separating the two lets the SAME fold drive an all-at-once write OR a day-by-day write with detect-between (the violation_trajectory carries the violation set as state).

  • Non-violating = the same simulation with perturbations=(). Conformance is flow/state AGREEMENT, not the absence of activity (AP.2 finding Q2).

  • Propagation is governed by which side you break, predictable from the detector SQL: state-snapshot blip is LOCAL; unrecorded-flow PROPAGATES forward. Pinned by AP.2 + reusable here.

  • Generators STACK: pass perturbations=(p1, p2, ...); each adds its own violation to the carried set; existing ones persist.

AS.4 generalizes the State from a scalar balance to a vector dict[account_id, balance] (cross-account legs net to zero across accounts). AS.3 is the scalar foundation.

Classes

AccountSimulation(plans[, perturbations, ...])

A leaf internal account stepped forward day by day.

DayEmission(day, legs, stored)

The materialized result of one folded step: the legs to write and the stored balance that IS the running State'.

DayPlan(day, legs)

One day's intended activity — signed leg amounts.

Perturbation([kind, day_index, amount, ...])

How a single day's step deviates from the clean fold.

class recon_gen.common.spine.account_simulation.AccountSimulation(plans, perturbations=<factory>, account_id='acct-sim', account_role='CustomerSubledger', parent_role='CustomerLedger', opening_balance=0.0, prefix='spec_example', rng=<factory>, emit_legs=True)[source]

Bases: object

A leaf internal account stepped forward day by day.

Authoring shape mirrors the AP.2 spike: plans declare per-day flows, perturbations declare the AP.2 perturbation knobs. The fold is PURE (_fold() is side-effect free); run(conn) writes the rows; violation_trajectory(inv, conn) carries the violation set as state day by day.

Per the AS.1 RNG convention: every concrete generator that composes AccountSimulation passes an rng (seeded via scenario_rng); AS.3 itself doesn’t randomize anything (the legs are author-declared), but the field carries forward for compose- time choices in AT’s anomaly/money_trail.

Parameters:
  • plans (list[DayPlan])

  • perturbations (list[Perturbation])

  • account_id (str)

  • account_role (str)

  • parent_role (str)

  • opening_balance (float)

  • prefix (str)

  • rng (Random)

  • emit_legs (bool)

account_id: str = 'acct-sim'
account_role: str = 'CustomerSubledger'
emit(conn, *, scenario_id=None)[source]

Write the full fold to the connection in one pass.

AV.5: scenario_id kwarg tags each emitted row’s metadata column for ScenarioContext cleanup attribution. None (the default) preserves the pre-AV.5 untagged emit shape.

Return type:

None

Parameters:
  • conn (SyncConnection)

  • scenario_id (str | None)

emit_legs: bool = True

AS.4 — when False, the fold still computes per-day stored via Σ legs, but does NOT insert leg rows into _transactions. Right for parent-style ledger accounts that have no direct postings of their own — pure aggregators of child balances. Default True keeps every existing leaf-style use site unchanged. (Parents with mixed direct postings + child rollups also work; AO.L fixed _computed_ledger_balance to sum direct postings cumulatively across days, so the matview no longer relies on direct_totals=0 for clean drift.)

opening_balance: float = 0.0
parent_role: str = 'CustomerLedger'
perturbations: list[Perturbation]
plans: list[DayPlan]
prefix: str = 'spec_example'
rng: Random
violation_trajectory(invariant, conn)[source]

Run the fold day by day, refresh + detect after each day, return the per-day violation set. The carried-state shape from AP.2: each snapshot is the active violations as the institution reaches that day. The delta between consecutive snapshots IS each step’s effect (opened / closed / inert).

Return type:

list[set[Violation]]

Parameters:
  • invariant (Invariant)

  • conn (SyncConnection)

class recon_gen.common.spine.account_simulation.DayEmission(day, legs, stored)[source]

Bases: object

The materialized result of one folded step: the legs to write and the stored balance that IS the running State'. run iterates these; violation_trajectory writes one at a time + refreshes + detects between.

Parameters:
  • day (date)

  • legs (tuple[tuple[str, float], ...])

  • stored (float)

day: date
legs: tuple[tuple[str, float], ...]
stored: float
class recon_gen.common.spine.account_simulation.DayPlan(day, legs)[source]

Bases: object

One day’s intended activity — signed leg amounts. A clean fold sets State'.balance = State.balance + Σ legs and stores it.

Parameters:
  • day (date)

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

day: date
legs: tuple[float, ...]
class recon_gen.common.spine.account_simulation.Perturbation(kind='none', day_index=0, amount=0.0, correct_day_index=None)[source]

Bases: object

How a single day’s step deviates from the clean fold. The knob AP.2 surfaced — one shape, three kinds + a correction.

  • "none": clean — never emits anything on this day (no-op).

  • "state_blip": corrupt ONLY the stored snapshot on day_index by amount (the running balance stays clean → drift is LOCAL to that day).

  • "unrecorded_leg": emit an extra leg on day_index that is NOT folded into stored (flow/state DISAGREEMENT → drift PROPAGATES forward; computed is cumulative, stored stayed on the clean fold). Optional correct_day_index books the leg into stored on a later day (the AN.1 supersession shape — closes the forward propagation; the historical breach remains).

  • "recorded_leg": emit an EXTRA real leg that DOES fold into state — a different-but-consistent history (conforming).

Parameters:
  • kind (Literal['none', 'state_blip', 'unrecorded_leg', 'recorded_leg'])

  • day_index (int)

  • amount (float)

  • correct_day_index (int | None)

amount: float = 0.0
correct_day_index: int | None = None
day_index: int = 0
kind: Literal['none', 'state_blip', 'unrecorded_leg', 'recorded_leg'] = 'none'