# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : incremental.py # Description : Incremental compilation — diff two AIR documents, emit reversal + replacement entries. # ============================================================================= """Incremental compilation (Phase 2). When a source document changes (a corrected invoice, a removed duplicate, a new event), AIR never mutates posted history. Instead, like Git, we diff the old and new documents by event fingerprint and emit: - a REVERSAL entry (exact contra) for every changed or removed event; - a replacement entry (with a revision-suffixed id) for every changed event; - a normal entry for every added event; - nothing for unchanged events. Determinism holds: same (old, new, policies) triple => identical delta. Provenance scoping: reversal lines keep the provenance ids of the prior compilation (they describe the amounts being reversed); the `reverses` field links each contra entry to the original. Replacement/added entries carry the new compilation's provenance graph. """ from __future__ import annotations import hashlib from dataclasses import dataclass, field, replace from aic.compiler import compile_document from aic.diagnostics import CompilationError, Diagnostic, Severity from alsl.model import PolicySet from core.events import AirDocument, EconomicEvent from core.invariant import verify_entries, verify_equation from core.journal import CompiledJournal, JournalEntry, JournalLine, Side def event_fingerprint(event: EconomicEvent) -> str: """Content hash of an event (canonical JSON, field order fixed by the schema).""" return hashlib.sha256(event.model_dump_json().encode("utf-8")).hexdigest() @dataclass(frozen=True, slots=True) class DocumentDiff: added: tuple[str, ...] removed: tuple[str, ...] changed: tuple[str, ...] unchanged: tuple[str, ...] def is_empty(self) -> bool: return not (self.added or self.removed or self.changed) def diff_documents(old: AirDocument, new: AirDocument) -> DocumentDiff: old_fp = {e.id: event_fingerprint(e) for e in old.events} new_fp = {e.id: event_fingerprint(e) for e in new.events} added = tuple(i for i in new_fp if i not in old_fp) removed = tuple(i for i in old_fp if i not in new_fp) changed = tuple(i for i in new_fp if i in old_fp and new_fp[i] != old_fp[i]) unchanged = tuple(i for i in new_fp if i in old_fp and new_fp[i] == old_fp[i]) return DocumentDiff(added=added, removed=removed, changed=changed, unchanged=unchanged) def make_reversal(entry: JournalEntry) -> JournalEntry: """Exact contra of a posted entry (sides swapped, amounts identical).""" return JournalEntry( id=f"rev_{entry.id}", date=entry.date, description=f"REVERSAL: {entry.description}", lines=tuple( JournalLine( account=line.account, side=Side.CREDIT if line.side is Side.DEBIT else Side.DEBIT, amount=line.amount, memo=f"reversal of {entry.id}: {line.memo}", provenance_id=line.provenance_id, ) for line in entry.lines ), source_event_id=entry.source_event_id, policy_set=entry.policy_set, policy_version=entry.policy_version, reverses=entry.id, ) @dataclass class IncrementalResult: """The delta between two compilations: what must be posted on top.""" diff: DocumentDiff reversals: list[JournalEntry] = field(default_factory=list) new_entries: list[JournalEntry] = field(default_factory=list) journal: CompiledJournal = field(default_factory=CompiledJournal) # reversals + new diagnostics: list[Diagnostic] = field(default_factory=list) def recompile( old: AirDocument, new: AirDocument, policies: PolicySet, ) -> IncrementalResult: """Compile only the delta between two AIR documents. Both documents are compiled (compilation is cheap and deterministic; the OLD compile reconstructs exactly what was posted, so no external state is needed), but the returned journal contains ONLY the delta entries. """ old_journal, _ = compile_document(old, policies) new_journal, new_diags = compile_document(new, policies) diff = diff_documents(old, new) old_by_event = {e.source_event_id: e for e in old_journal.entries} new_by_event = {e.source_event_id: e for e in new_journal.entries} new_fp = {e.id: event_fingerprint(e) for e in new.events} result = IncrementalResult(diff=diff, diagnostics=list(new_diags)) for event_id in diff.removed + diff.changed: original = old_by_event.get(event_id) if original is not None: result.reversals.append(make_reversal(original)) for event_id in diff.added: entry = new_by_event.get(event_id) if entry is not None: result.new_entries.append(entry) for event_id in diff.changed: entry = new_by_event.get(event_id) if entry is not None: # revision-suffixed id: deterministic, unique per content revision revision = new_fp[event_id][:8] result.new_entries.append(replace(entry, id=f"{entry.id}_r{revision}")) delta_entries = result.reversals + result.new_entries result.journal = CompiledJournal( entries=delta_entries, provenance=new_journal.provenance, policy_set=policies.name, policy_version=policies.version, ) # the permanent invariant applies to the delta as well invariant = ( verify_entries(delta_entries, "incremental") + verify_equation(delta_entries, "incremental") ) result.diagnostics.extend(invariant) if any(d.severity is Severity.ERROR for d in result.diagnostics): raise CompilationError(result.diagnostics) return result