recon_gen.common.spine.money_trail
Money-trail family — recursive-graph L2 invariant + chain generator.
MoneyTrailInvariant reads <prefix>_inv_money_trail_edges (the WITH-RECURSIVE matview walking transfer_parent_id from each root down through descendants). Each row is one edge in a money trail; the detector projects every edge as a Violation with identity (root_transfer_id, transfer_id, depth).
What “violation” means here is context-dependent: a single-hop transfer is a 1-row trail (depth=0), perfectly normal. A 5-hop chain (depth=4) might be analytically interesting (concentrated value movement through multiple layers) but not categorically a violation. The actual threshold for “this trail is suspicious” lives on the View (mirrors AnomalyView’s σ-threshold pattern — AP.3 finding #3). MoneyTrailView owns the min_depth knob; the detector returns ALL edges.
AT.3 lands MoneyTrailGenerator — a parent-linked chain emission (root → child → grandchild, each a 2-leg Posted transfer per AP.3’s spike). Cross-account: each hop’s recipient = next hop’s sender, so money walks through the chain. Built on the AT.3 Transfer / LedgerSimulation primitive (same shape AnomalyGenerator refactored onto).
Classes
|
Plant a parent-linked chain of chain_length Posted transfers. |
|
Money-trail edge detector. |
|
Analyst-facing slice over the money-trail detector's full output. |
- class recon_gen.common.spine.money_trail.MoneyTrailGenerator(hop_account_role, hop_account_parent_role, chain_length, amount, anchor_day, prefix='spec_example', chain_id_prefix='money-trail')[source]
Bases:
objectPlant a parent-linked chain of chain_length Posted transfers.
Each transfer is a 2-leg balanced send: account[i] → account[i+1]. Transfer[i+1].parent_transfer_id = Transfer[i].transfer_id (the chain linkage the matview walks recursively). All transfers post on consecutive days from anchor_day (anchor_day, +1d, +2d, …) so the matview’s posted_at ordering matches chain depth.
Per AP.3 finding #2 generalized: graph invariants are multi-row by nature — N transfers + N+1 accounts per chain. Single-row plant would yield depth=0 only (not a “chain” in any analytically meaningful sense). The generator’s emit() writes ALL the rows in one call through a transfers-only LedgerSimulation.
Single-edge property: no AccountSimulation folds → no balance rows → no drift trip from the chain. Matches AnomalyGenerator’s AT.0-finding.
- Parameters:
hop_account_role (str)
hop_account_parent_role (str)
chain_length (int)
amount (float)
anchor_day (date)
prefix (str)
chain_id_prefix (str)
- amount: float
- anchor_day: date
- chain_id_prefix: str = 'money-trail'
AY.4.c — disambiguator threaded into _account_id / _transfer_id so N money-trail plants on the same hop_role produce N distinct chains. Defaults to the legacy “money-trail” value → existing test callers stay byte-stable.
- chain_length: int
- property claimed_accounts: frozenset[str]
The chain_length+1 hop account_ids the chain walks through.
account[0]is the chain’s root sender;account[N]is the leaf recipient. Used by AV.5ScenarioContext.composeto catch cross-generator collisions at the wiring site.
- emit(conn, *, scenario_id=None)[source]
- Return type:
None- Parameters:
conn (SyncConnection)
scenario_id (str | None)
- hop_account_parent_role: str
- hop_account_role: str
- property intended: RuleViolation
The deepest edge of the chain — the “story” of the trail. For chain_length=3, depth=2 (grandchild) is the most-removed- from-root edge, the analyst-meaningful endpoint.
Violation identity also includes transfer_id + root_transfer_id, both deterministic from this generator’s account/chain configuration.
- prefix: str = 'spec_example'
- class recon_gen.common.spine.money_trail.MoneyTrailInvariant(prefix='spec_example')[source]
Bases:
objectMoney-trail edge detector. Reads <prefix>_inv_money_trail_edges and projects every edge as a Violation with identity (root_transfer_id, transfer_id, depth).
The detector is bucket-agnostic — every edge is returned; the MoneyTrailView (this module) slices on min_depth for analyst- facing “suspicious chain” thresholds. Mirrors AT.2’s AnomalyInvariant ⋈ AnomalyView shape.
- Parameters:
prefix (str)
- name: ClassVar[str] = 'inv_money_trail_edges'
- prefix: str = 'spec_example'
- scenario_for(hop_role, *, chain_length=3, amount=100.0, anchor_day=datetime.date(2030, 1, 1), instance=None, chain_id_prefix=None)[source]
Resolve the chain’s account role + return a generator that plants a chain_length-deep parent-linked chain. Every account in the chain uses hop_role (the role of the leaf-eligible internal accounts the matview walks).
chain_length = number of transfers in the chain (depth runs 0..N-1). chain_length=1 = single transfer (no parent linkage, depth=0 only). chain_length=3 = root → child → grandchild (depths 0/1/2).
Each transfer’s recipient leg becomes the next transfer’s sender — money walks through a chain of chain_length + 1 distinct accounts. The matview surfaces each transfer as one edge (one source-leg × one target-leg per transfer).
AY.4.c — chain_id_prefix overrides the default synthetic ID prefix. Unlike the single-account spine factories, money_trail plants chain_length + 1 account_ids + chain_length transfer_ids; the natural disambiguator is a chain-wide prefix feeding both _account_id(i) and _transfer_id(i). The plant adapter (AY.4.c.3) threads OLD money-trail chain identifiers through this kwarg so N plants on the same hop_role produce N distinct generators (the default “acct-money-trail-hop” / “xfer-money-trail” derivations would collide). Existing test callers can pass nothing → preserves the synthetic defaults byte-stable.
- Return type:
- Parameters:
hop_role (str)
chain_length (int)
amount (float)
anchor_day (date)
instance (L2Instance | None)
chain_id_prefix (str | None)
- class recon_gen.common.spine.money_trail.MoneyTrailView(min_depth=0)[source]
Bases:
objectAnalyst-facing slice over the money-trail detector’s full output.
Holds the min_depth knob for “include edges whose chain depth is at-or-above this value”. min_depth=0 (default) includes every edge (matches the detector’s full return). min_depth=1 drops the root edge of every chain; min_depth=2 keeps only grandchild- and-deeper edges (the “this is a chain, not a one-off” view).
Pure (no IO; deterministic on its inputs); the detector still does the SQL read. Mirrors AnomalyView — the View pattern is the same for every L2 invariant.
- Parameters:
min_depth (int)
- min_depth: int = 0