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

MoneyTrailGenerator(hop_account_role, ...[, ...])

Plant a parent-linked chain of chain_length Posted transfers.

MoneyTrailInvariant([prefix])

Money-trail edge detector.

MoneyTrailView([min_depth])

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

Plant 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.5 ScenarioContext.compose to 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: object

Money-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 AnomalyInvariantAnomalyView shape.

Parameters:

prefix (str)

detect(conn)[source]
Return type:

set[Violation]

Parameters:

conn (SyncConnection)

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:

MoneyTrailGenerator

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

Analyst-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
slice(violations)[source]

Return the subset of violations whose depth is ≥ min_depth. Violations missing the depth key are dropped silently (defensive against cross-invariant mix).

Return type:

set[Violation]

Parameters:

violations (set[Violation])