spb/air Public MIT
AIR — The Language of Accounting.
Python 100%
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : incremental.py6# Description : Incremental compilation — diff two AIR documents, emit reversal + replacement entries.7# =============================================================================8"""Incremental compilation (Phase 2).910When a source document changes (a corrected invoice, a removed duplicate, a11new event), AIR never mutates posted history. Instead, like Git, we diff the12old and new documents by event fingerprint and emit:1314- a REVERSAL entry (exact contra) for every changed or removed event;15- a replacement entry (with a revision-suffixed id) for every changed event;16- a normal entry for every added event;17- nothing for unchanged events.1819Determinism holds: same (old, new, policies) triple => identical delta.2021Provenance scoping: reversal lines keep the provenance ids of the prior22compilation (they describe the amounts being reversed); the `reverses` field23links each contra entry to the original. Replacement/added entries carry the24new compilation's provenance graph.25"""26from __future__ import annotations2728import hashlib29from dataclasses import dataclass, field, replace3031from aic.compiler import compile_document32from aic.diagnostics import CompilationError, Diagnostic, Severity33from alsl.model import PolicySet34from core.events import AirDocument, EconomicEvent35from core.invariant import verify_entries, verify_equation36from core.journal import CompiledJournal, JournalEntry, JournalLine, Side373839def event_fingerprint(event: EconomicEvent) -> str:40 """Content hash of an event (canonical JSON, field order fixed by the schema)."""41 return hashlib.sha256(event.model_dump_json().encode("utf-8")).hexdigest()424344@dataclass(frozen=True, slots=True)45class DocumentDiff:46 added: tuple[str, ...]47 removed: tuple[str, ...]48 changed: tuple[str, ...]49 unchanged: tuple[str, ...]5051 def is_empty(self) -> bool:52 return not (self.added or self.removed or self.changed)535455def diff_documents(old: AirDocument, new: AirDocument) -> DocumentDiff:56 old_fp = {e.id: event_fingerprint(e) for e in old.events}57 new_fp = {e.id: event_fingerprint(e) for e in new.events}58 added = tuple(i for i in new_fp if i not in old_fp)59 removed = tuple(i for i in old_fp if i not in new_fp)60 changed = tuple(i for i in new_fp if i in old_fp and new_fp[i] != old_fp[i])61 unchanged = tuple(i for i in new_fp if i in old_fp and new_fp[i] == old_fp[i])62 return DocumentDiff(added=added, removed=removed, changed=changed,63 unchanged=unchanged)646566def make_reversal(entry: JournalEntry) -> JournalEntry:67 """Exact contra of a posted entry (sides swapped, amounts identical)."""68 return JournalEntry(69 id=f"rev_{entry.id}",70 date=entry.date,71 description=f"REVERSAL: {entry.description}",72 lines=tuple(73 JournalLine(74 account=line.account,75 side=Side.CREDIT if line.side is Side.DEBIT else Side.DEBIT,76 amount=line.amount,77 memo=f"reversal of {entry.id}: {line.memo}",78 provenance_id=line.provenance_id,79 )80 for line in entry.lines81 ),82 source_event_id=entry.source_event_id,83 policy_set=entry.policy_set,84 policy_version=entry.policy_version,85 reverses=entry.id,86 )878889@dataclass90class IncrementalResult:91 """The delta between two compilations: what must be posted on top."""9293 diff: DocumentDiff94 reversals: list[JournalEntry] = field(default_factory=list)95 new_entries: list[JournalEntry] = field(default_factory=list)96 journal: CompiledJournal = field(default_factory=CompiledJournal) # reversals + new97 diagnostics: list[Diagnostic] = field(default_factory=list)9899100def recompile(101 old: AirDocument,102 new: AirDocument,103 policies: PolicySet,104) -> IncrementalResult:105 """Compile only the delta between two AIR documents.106107 Both documents are compiled (compilation is cheap and deterministic; the108 OLD compile reconstructs exactly what was posted, so no external state is109 needed), but the returned journal contains ONLY the delta entries.110 """111 old_journal, _ = compile_document(old, policies)112 new_journal, new_diags = compile_document(new, policies)113 diff = diff_documents(old, new)114115 old_by_event = {e.source_event_id: e for e in old_journal.entries}116 new_by_event = {e.source_event_id: e for e in new_journal.entries}117 new_fp = {e.id: event_fingerprint(e) for e in new.events}118119 result = IncrementalResult(diff=diff, diagnostics=list(new_diags))120121 for event_id in diff.removed + diff.changed:122 original = old_by_event.get(event_id)123 if original is not None:124 result.reversals.append(make_reversal(original))125126 for event_id in diff.added:127 entry = new_by_event.get(event_id)128 if entry is not None:129 result.new_entries.append(entry)130131 for event_id in diff.changed:132 entry = new_by_event.get(event_id)133 if entry is not None:134 # revision-suffixed id: deterministic, unique per content revision135 revision = new_fp[event_id][:8]136 result.new_entries.append(replace(entry, id=f"{entry.id}_r{revision}"))137138 delta_entries = result.reversals + result.new_entries139 result.journal = CompiledJournal(140 entries=delta_entries,141 provenance=new_journal.provenance,142 policy_set=policies.name,143 policy_version=policies.version,144 )145146 # the permanent invariant applies to the delta as well147 invariant = (148 verify_entries(delta_entries, "incremental")149 + verify_equation(delta_entries, "incremental")150 )151 result.diagnostics.extend(invariant)152 if any(d.severity is Severity.ERROR for d in result.diagnostics):153 raise CompilationError(result.diagnostics)154 return result155