# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : provenance.py # Description : Immutable provenance graph — accounting SSA: every amount has one traceable origin. # ============================================================================= """Provenance graph: the accounting analogue of SSA form. In LLVM's SSA form every value is defined exactly once and every use points back to its unique definition. AIR transposes this to money: every amount that appears anywhere in a compilation (an invoice line, a tax amount, a converted FX amount, a posted journal line) is a *node* defined exactly once, and every derived amount points to the node(s) it was computed from, together with the operation that produced it (e.g. "tax:GST@0.05", "fx:USD->CAD@1.3500"). The graph is append-only and nodes are immutable: corrections never rewrite history, they add new nodes (mirroring reversal entries in the ledger). """ from __future__ import annotations import itertools from dataclasses import dataclass, field from typing import Iterator from core.money import Money @dataclass(frozen=True, slots=True) class ProvenanceNode: """A single, immutable definition of an amount.""" id: str kind: str # e.g. "invoice_line", "tax", "fx", "journal_line" operation: str # human/machine readable derivation, e.g. "tax:GST@0.05" amount: Money inputs: tuple[str, ...] = () # ids of the nodes this amount was derived from source_ref: str | None = None # external anchor: event id, document URI, ... class ProvenanceError(KeyError): """Raised when a node id is unknown or redefined (SSA violation).""" @dataclass class ProvenanceGraph: """Append-only DAG of amount definitions.""" _nodes: dict[str, ProvenanceNode] = field(default_factory=dict) _counter: itertools.count = field(default_factory=itertools.count) def define( self, kind: str, operation: str, amount: Money, inputs: tuple[str, ...] = (), source_ref: str | None = None, ) -> ProvenanceNode: """Define a new amount (exactly once — ids are generated, never reused).""" for parent in inputs: if parent not in self._nodes: raise ProvenanceError(f"Unknown provenance input: {parent}") node = ProvenanceNode( id=f"prov_{next(self._counter):06d}", kind=kind, operation=operation, amount=amount, inputs=inputs, source_ref=source_ref, ) self._nodes[node.id] = node return node def get(self, node_id: str) -> ProvenanceNode: try: return self._nodes[node_id] except KeyError: raise ProvenanceError(f"Unknown provenance node: {node_id}") from None def trace(self, node_id: str) -> list[ProvenanceNode]: """Full ancestry of a node (the node first, origins last), depth-first.""" seen: set[str] = set() out: list[ProvenanceNode] = [] def walk(nid: str) -> None: if nid in seen: return seen.add(nid) node = self.get(nid) out.append(node) for parent in node.inputs: walk(parent) walk(node_id) return out def __len__(self) -> int: return len(self._nodes) def __iter__(self) -> Iterator[ProvenanceNode]: return iter(self._nodes.values()) def to_dicts(self) -> list[dict[str, object]]: """Serialize for audit export (amounts as strings, never floats).""" return [ { "id": n.id, "kind": n.kind, "operation": n.operation, "amount": str(n.amount.amount), "currency": n.amount.currency, "inputs": list(n.inputs), "source_ref": n.source_ref, } for n in self._nodes.values() ]