Source code for recon_gen.common.spine.chain_parent_disagreement

"""Chain-parent-disagreement family — `Invariant` + `ViolationGenerator`.

AX.1 promotion of the AB.2.6 / AB.2.3 plant. The matview
`<prefix>_chain_parent_disagreement` GROUPs `<prefix>_current_transactions`
by `(transfer_id, template_name)` and surfaces rows where
`COUNT(DISTINCT transfer_parent_id) > 1` — a single child Transfer's
legs claim multiple parent Transfers, which is an ETL bug (parent
reference drift, cross-cycle contamination, or a chain emitter that
forgot the first-firing-wins rule from SPEC gap doc §3).

Per the AB.0 lock: chain integrity is an L2-SHAPE invariant (the L2
yaml declares the chain; ETL must honor the declared parent
linkage). It lives in `ALL_L2_SHAPE_INVARIANTS` alongside the other
3 chain/XOR/fan-in invariants AX promotes.

Matview semantics:
  - Filter: `transfer_parent_id IS NOT NULL` AND `template_name IS
    NOT NULL` AND `status <> 'Failed'`.
  - GROUP BY `(transfer_id, template_name)`.
  - HAVING `COUNT(DISTINCT transfer_parent_id) > 1`.
  - Account columns are NOT filtered or grouped → the generator's
    synthetic account is fine.

The plant is account-shape-agnostic — the matview only cares that
≥2 distinct `transfer_parent_id` values exist under one
`(transfer_id, template_name)`. The generator emits 2 Posted leg
rows sharing one synthetic `transfer_id` + `template_name`, each
carrying a distinct synthetic `transfer_parent_id`.

Single-edge property (matches AT.3 / anomaly / money_trail):
transfers-only — no daily_balances rows → no drift trip from this
plant.
"""

from __future__ import annotations

from recon_gen.common.db import SyncConnection
from dataclasses import dataclass, field
from datetime import date
from typing import ClassVar

from recon_gen.common.l2.primitives import L2Instance
from recon_gen.common.spine._db import fetch_all
from recon_gen.common.spine._emit_helpers import (
    insert_tx,
    load_spec_example,
    ts,
)
from recon_gen.common.spine.violation import RuleViolation, Violation


[docs] @dataclass(frozen=True) class ChainParentDisagreementInvariant: """Detector for the AB.2.3 matview. Identity tuple: `(transfer_id, child_template_name)`. The matview's other columns (`distinct_parent_count`, `parent_transfer_id_min`, `parent_transfer_id_max`, `business_day`) are diagnostic — they help an analyst eyeball which parents disagreed but they're not part of the violation's identity. """ name: ClassVar[str] = "chain_parent_disagreement" prefix: str = "spec_example"
[docs] def detect(self, conn: SyncConnection) -> set[Violation]: rows = fetch_all( conn, f"SELECT transfer_id, child_template_name " f"FROM {self.prefix}_chain_parent_disagreement", ) return { RuleViolation.of( "chain_parent_disagreement", transfer_id=str(tid), child_template_name=str(tname), ) for tid, tname in rows }
[docs] def scenario_for( self, *, anchor_day: date = date(2030, 1, 1), instance: L2Instance | None = None, ) -> "ChainParentDisagreementGenerator": """Pick a chain whose singleton child is a TransferTemplate from the L2; return a generator that plants ONE child Transfer with 2 legs carrying disagreeing `transfer_parent_id` values. Raises `ValueError` if the L2 has no chain whose singleton child resolves to a TransferTemplate (the AB.2.6 picker's input requirement). """ inst = instance if instance is not None else load_spec_example() # The AB.2.6 picker (`_pick_two_template_chain_inputs`) returns # any chain-child template, INCLUDING fan_in templates — those # are silently excluded by the matview's # `_render_chain_parent_disagreement_fan_in_filter` (fan_in # children are legitimately multi-parent by design). We need # a NON-fan_in template here so the plant actually surfaces. child_name = _pick_non_fan_in_chain_child(inst) if child_name is None: raise ValueError( "shape has no chain whose singleton child resolves to " "a non-fan_in TransferTemplate; cannot manufacture a " "chain_parent_disagreement scenario (fan_in children " "are excluded by the matview's fan-in filter and would " "yield 0 detected violations even when planted)" ) return ChainParentDisagreementGenerator( child_template_name=str(child_name), anchor_day=anchor_day, )
def _pick_non_fan_in_chain_child(instance: L2Instance) -> str | None: """Pick any chain-child TransferTemplate that is NOT marked `fan_in=True`. The chain_parent_disagreement matview excludes fan_in children via its NOT IN filter (`_render_chain_parent_disagreement_fan_in_filter`), so a fan_in pick would yield a silently-empty matview. Walks chains in deterministic order (sorted by parent name + children names — same key the AB.2.6 picker uses) so the same L2 always yields the same pick. Skips chains with no template-shaped children at all. """ template_names = {str(t.name) for t in instance.transfer_templates} for chain in sorted( instance.chains, key=lambda ch: ( str(ch.parent), ",".join(sorted(str(d.name) for d in ch.children)), ), ): for child in chain.children: if child.fan_in: continue name = str(child.name) if name in template_names: return name return None
[docs] @dataclass class ChainParentDisagreementGenerator: """Emit 2 Posted transaction legs sharing one `transfer_id` + `template_name` but assigning different `transfer_parent_id` values — surfaces in the matview's `COUNT(DISTINCT transfer_parent_id) > 1` branch. Account fields are synthetic + deterministic (the matview's GROUP BY ignores them). The transfer_id + template_name combination IS the violation's identity; both are derived from `child_template_name` so two generators built for the same template would collide at compose time — caught by AV.5's `claimed_accounts` pairwise-disjoint check. Single-edge: transfers-only emit → no balance rows → no drift trip (matches the AT.3 anomaly / money_trail shape). AY.4.c.2 — account_id_override allows the plant adapter (AY.4.c.3) to thread OLD plant account_ids through, preventing PK collisions when N plants of the same shape compose. """ child_template_name: str anchor_day: date parent_a_transfer_id: str = field(default="tr-cpd-parent-a") parent_b_transfer_id: str = field(default="tr-cpd-parent-b") rail_name: str = "_spine_plant" prefix: str = "spec_example" account_id_override: str | None = None @property def transfer_id(self) -> str: """Deterministic child transfer_id — used as the matview's natural-key tuple value + the row's id discriminator.""" return f"tr-cpd-{self.child_template_name}" @property def account_id(self) -> str: """Single synthetic account_id the generator's 2 legs land on. Matview filters don't depend on account columns; the account is here just to satisfy NOT NULL constraints + AV.5's claimed_accounts contract. ``account_id_override`` wins when set (AY.4.c.2 — plant-adapter PK-collision avoidance).""" if self.account_id_override is not None: return self.account_id_override return f"acct-cpd-{self.child_template_name}" @property def intended(self) -> RuleViolation: """The natural-key tuple the matview surfaces post-plant.""" return RuleViolation.of( "chain_parent_disagreement", transfer_id=self.transfer_id, child_template_name=self.child_template_name, ) @property def claimed_accounts(self) -> frozenset[str]: """Single synthetic account — AV.5 contract. Two generators targeting the same `child_template_name` collide here (same account_id derivation → same string in the claimed set).""" return frozenset({self.account_id})
[docs] def emit( self, conn: SyncConnection, *, scenario_id: str | None = None, ) -> None: from recon_gen.common.spine.scenario_context import scenario_metadata metadata = ( scenario_metadata( scenario_id, generator="ChainParentDisagreementGenerator", ) if scenario_id is not None else None ) posting = ts(self.anchor_day) # 2 legs, same transfer_id + template_name, different parent. # The matview reads `COUNT(DISTINCT transfer_parent_id)` and # surfaces the row at 2 distinct parents. for i, parent_tid in enumerate(( self.parent_a_transfer_id, self.parent_b_transfer_id, )): insert_tx( conn, prefix=self.prefix, id=f"tx-cpd-{self.child_template_name}-{i}", account_id=self.account_id, account_name=f"CPD plant ({self.child_template_name})", account_role="CustomerSubledger", account_scope="internal", account_parent_role="CustomerLedger", amount_money=100.0, # arbitrary; matview only counts parents amount_direction="Credit", status="Posted", posting=posting, transfer_id=self.transfer_id, transfer_parent_id=parent_tid, rail_name=self.rail_name, template_name=self.child_template_name, origin="ExternalForcePosted", metadata=metadata, )