SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
5.9 KB · 155 lines python
Raw Blame History
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