recon_gen.common.spine.anomaly
Anomaly family — windowed-statistical L2 invariant + generator.
Promoted from tests/unit/test_at0_anomaly_full_spine.py (AT.0 spike).
The matview <prefix>_inv_pair_rolling_anomalies computes a rolling
2-day SUM per (sender, recipient) pair, then z-scores against the
population mean+stddev of all pair-windows. The AnomalyInvariant
detector projects EVERY (pair, window_end) row as a Violation; the
AnomalyView (AT.2, anomaly_view.py) slices on σ threshold.
Per AP.3 finding #2: statistical invariants CAN’T be generated from a single row — they need a POPULATION + a spike. AnomalyGenerator plants N baseline pairs (small uniform amounts) + 1 spike pair (large amount between the target sender + recipient). The spike’s z-score against the population distribution → high σ bucket → detector fires.
AT.0 finding (caught mid-spike, encoded as default + docstring): the spike’s z-score is REDUCED by its own contribution to the mean (outlier self-shift). With small baselines (e.g. 8 pairs) + 100k spike, z ≈ 2.67 (too low to fire 3σ). Default baseline_pair_count=100 dilutes the outlier effect to ~1% → z ≈ 9.95 (clearly ‘4+ sigma’).
AT.3 refactored emit() onto the Transfer / LedgerSimulation primitive — every leg pair goes through the same _emit_transfer path that MoneyTrailGenerator uses. Single-edge property preserved (transfers-only ledger → no balance rows → no drift trip). The detector + scenario_for are stable across the refactor.
Classes
|
Plant a baseline distribution + a spike between sender ↔ recipient. |
|
Pair-rolling-anomaly detector. |
- class recon_gen.common.spine.anomaly.AnomalyGenerator(sender_account_id, sender_account_role, sender_account_parent_role, recipient_account_id, recipient_account_role, recipient_account_parent_role, anchor_day, spike_magnitude, baseline_pair_count, baseline_amount, prefix='spec_example')[source]
Bases:
objectPlant a baseline distribution + a spike between sender ↔ recipient.
Emits baseline_pair_count extra pairs of background accounts with small uniform amounts on the anchor day (populates the matview’s pop_stddev) plus ONE spike pair (sender → recipient) with spike_magnitude (sits far above baseline → high z-score → fires).
Per AP.3 finding #2 (statistical invariants are multi-row by nature): the generator’s emit() writes ALL the rows in one call — the Protocol stays minimal; the per-row-iterator shape isn’t pushed onto the Generator contract.
AT.3 refactor: pairs are now emitted as Transfer`s through a transfers-only `LedgerSimulation. Single-edge property preserved (no AccountSimulation folds → no balance rows → no drift trip). Each baseline pair = one Posted 2-leg balanced Transfer; the spike is the same shape with spike_magnitude. Shape is identical to MoneyTrailGenerator’s — both consume the AT.3 primitive.
- Parameters:
sender_account_id (str)
sender_account_role (str)
sender_account_parent_role (str | None)
recipient_account_id (str)
recipient_account_role (str)
recipient_account_parent_role (str)
anchor_day (date)
spike_magnitude (float)
baseline_pair_count (int)
baseline_amount (float)
prefix (str)
- anchor_day: date
- baseline_amount: float
- baseline_pair_count: int
- property claimed_accounts: frozenset[str]
The 2 + 2*baseline_pair_count account_ids this plant touches: the spike pair + every baseline pair’s sender/recipient. Used by AV.5
ScenarioContext.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)
- property intended: RuleViolation
- prefix: str = 'spec_example'
- recipient_account_id: str
- recipient_account_parent_role: str
- recipient_account_role: str
- sender_account_id: str
- sender_account_parent_role: str | None
- sender_account_role: str
- spike_magnitude: float
- class recon_gen.common.spine.anomaly.AnomalyInvariant(prefix='spec_example')[source]
Bases:
objectPair-rolling-anomaly detector. Reads <prefix>_inv_pair_rolling_anomalies and projects EVERY row as a Violation — every (pair, window_end) the matview computed, across every z_bucket (including ‘0-1 sigma’ background).
Per AP.3 finding #3, the σ threshold belongs on the View, not the detector. AT.2 promoted AnomalyView (anomaly_view.py) that slices over the detected violation set on sigma_threshold. The detector here is now bucket-agnostic — AnomalyView(3.0).slice(…) reproduces AT.1’s behaviour exactly; other thresholds (2.0 for deep-dive triage, etc.) work over the same detect() result with no re-query.
- Parameters:
prefix (str)
- name: ClassVar[str] = 'inv_pair_rolling_anomalies'
- prefix: str = 'spec_example'
- scenario_for(sender_role, recipient_role, *, spike_magnitude=100000.0, baseline_pair_count=100, baseline_amount=100.0, anchor_day=datetime.date(2030, 1, 1), instance=None, sender_account_id=None, recipient_account_id=None)[source]
Resolve sender + recipient roles; return a generator that plants baseline_pair_count baseline pairs + 1 spike between sender + recipient.
See AT.0 spike’s docstring for the full statistical-coverage argument. The defaults (100 baseline / 100_000 spike) give a clear ~10σ separation; tweak for tests that explore the threshold boundary (set spike=baseline to defuse).
Raises ValueError if either role is missing from the shape’s internal accounts (sender) or leaf internal accounts (recipient — the matview’s recipient filter requires account_parent_role IS NOT NULL).
AY.4.c — sender_account_id / recipient_account_id override the default synthetic IDs. The plant adapter (AY.4.c.3) threads OLD AnomalyPlant account_ids through these kwargs so N anomaly plants on the same (sender_role, recipient_role) pair produce N distinct generators (the default f”acct-anomaly-{sender,recipient}-{role}” derivations would collide). Existing test callers can pass nothing → preserves the synthetic defaults byte-stable.
- Return type:
- Parameters:
sender_role (str)
recipient_role (str)
spike_magnitude (float)
baseline_pair_count (int)
baseline_amount (float)
anchor_day (date)
instance (L2Instance | None)
sender_account_id (str | None)
recipient_account_id (str | None)