SPB Git

spb/air

Public MIT

AIR — The Language of Accounting.

AIR v0.1.0 — Accounting Intermediate Representation

The language of accounting: LLMs produce economic events (AIR), a
deterministic compiler (AIC) produces balanced journal entries under
versioned, source-cited ALSL policies.

All six phases complete:
- Phase 0: 9 cited research reports (GST/QST from official sources,
  LLVM architecture, ledger engines, ERP APIs, camt.053/MT940, ...)
- Phase 1: AIR v0.1 schema, provenance graph (accounting SSA),
  double-entry invariant verified after every pass, 21 golden cases
- Phase 2: incremental compilation (content-fingerprint diff,
  reversal + replacement entries)
- Phase 3: QuickBooks backend (100% offline-tested mock transport),
  managed AIR home (hash-chained ledger, document archive)
- Phase 4: LLM ingestion with confidence routing + human approval inbox
- Phase 5: agent syscalls + hash-chained audit log, closed periods
- Phase 6: fusion/netting/duplicate passes, bank reconciliation
  (camt.053, MT940 with integrity check, CSV)

99 offline tests + 2 opt-in live tests. No floats, no rules in code,
append-only everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed (Aug 5, 2026) Browse files

98 changed files +11,076 −0

98 changed files
added .github/workflows/ci.yml +49 −0
@@ -0,0 +1,49 @@
1 +# Projet : AIR — Accounting Intermediate Representation
2 +# Auteur : Simon-Pierre Boucher
3 +# Contact : contact@spboucher.ai
4 +# CI: offline test suite (no API keys) + author-header gate on every push/PR.
5 +
6 +name: CI
7 +
8 +on:
9 + push:
10 + branches: [main]
11 + pull_request:
12 +
13 +jobs:
14 + test:
15 + runs-on: ubuntu-latest
16 + strategy:
17 + fail-fast: false
18 + matrix:
19 + python-version: ["3.11", "3.12", "3.13"]
20 + steps:
21 + - uses: actions/checkout@v4
22 + - uses: actions/setup-python@v5
23 + with:
24 + python-version: ${{ matrix.python-version }}
25 + - name: Install
26 + run: pip install -e ".[dev]"
27 + - name: Author-header gate
28 + run: python scripts/check_headers.py
29 + - name: Offline test suite (99 tests — no network, no API keys)
30 + run: pytest tests/ -q --ignore=tests/test_llm_live.py
31 +
32 + golden-determinism:
33 + # compile the demo document twice on a fresh interpreter each time:
34 + # byte-identical CSV output or the build fails
35 + runs-on: ubuntu-latest
36 + steps:
37 + - uses: actions/checkout@v4
38 + - uses: actions/setup-python@v5
39 + with:
40 + python-version: "3.12"
41 + - name: Install
42 + run: pip install -e .
43 + - name: Determinism check
44 + run: |
45 + python -m sdk.cli compile tests/fixtures/demo_document.yaml \
46 + --policies alsl/policies/ca-qc-2026.yaml --backend csv --out out_a
47 + python -m sdk.cli compile tests/fixtures/demo_document.yaml \
48 + --policies alsl/policies/ca-qc-2026.yaml --backend csv --out out_b
49 + diff out_a/*.csv out_b/*.csv && echo "byte-identical: OK"
added .gitignore +17 −0
@@ -0,0 +1,17 @@
1 +__pycache__/
2 +*.pyc
3 +.venv/
4 +.pytest_cache/
5 +.hypothesis/
6 +.mypy_cache/
7 +out/
8 +books/
9 +dist/
10 +build/
11 +*.egg-info/
12 +.DS_Store
13 +
14 +.env
15 +books/
16 +out_a/
17 +out_b/
added CLAUDE.md +320 −0
@@ -0,0 +1,320 @@
1 +# CLAUDE.md — Projet AIR (Accounting Intermediate Representation)
2 +
3 +> **Auteur du projet : Simon-Pierre Boucher — contact@spboucher.ai**
4 +> Ce fichier est la source de vérité pour Claude Code. Lis-le intégralement avant toute action.
5 +
6 +---
7 +
8 +## 0. RÈGLE ABSOLUE — En-tête obligatoire de chaque fichier
9 +
10 +**CHAQUE fichier créé ou modifié dans ce projet DOIT commencer par un en-tête d'auteur.**
11 +Aucune exception : code source, tests, docs, scripts, configs, schémas, notebooks.
12 +
13 +### Formats selon le type de fichier
14 +
15 +**Python (.py)**
16 +```python
17 +# =============================================================================
18 +# Projet : AIR — Accounting Intermediate Representation
19 +# Auteur : Simon-Pierre Boucher
20 +# Contact : contact@spboucher.ai
21 +# Fichier : <nom_du_fichier>
22 +# Description : <une ligne décrivant le rôle du fichier>
23 +# =============================================================================
24 +```
25 +
26 +**TypeScript / JavaScript / Rust / Go / C / Zig (.ts, .js, .rs, .go, .c, .zig)**
27 +```ts
28 +// =============================================================================
29 +// Projet : AIR — Accounting Intermediate Representation
30 +// Auteur : Simon-Pierre Boucher
31 +// Contact : contact@spboucher.ai
32 +// Fichier : <nom_du_fichier>
33 +// Description : <une ligne>
34 +// =============================================================================
35 +```
36 +
37 +**Markdown (.md)**
38 +```markdown
39 +<!--
40 +Projet : AIR — Accounting Intermediate Representation
41 +Auteur : Simon-Pierre Boucher
42 +Contact : contact@spboucher.ai
43 +Fichier : <nom_du_fichier>
44 +-->
45 +```
46 +
47 +**YAML / TOML / config (.yaml, .yml, .toml)**
48 +```yaml
49 +# Projet : AIR — Accounting Intermediate Representation
50 +# Auteur : Simon-Pierre Boucher
51 +# Contact : contact@spboucher.ai
52 +```
53 +
54 +**JSON** : le format JSON n'accepte pas les commentaires. Ajouter une clé au niveau racine :
55 +```json
56 +{ "_author": "Simon-Pierre Boucher <contact@spboucher.ai>", ... }
57 +```
58 +
59 +**SQL (.sql)**
60 +```sql
61 +-- =============================================================================
62 +-- Projet : AIR | Auteur : Simon-Pierre Boucher | contact@spboucher.ai
63 +-- =============================================================================
64 +```
65 +
66 +✅ Avant de terminer toute tâche, vérifie que tous les fichiers touchés portent l'en-tête.
67 +✅ Ajoute un hook / script `scripts/check_headers.py` qui échoue en CI si un fichier n'a pas l'en-tête.
68 +
69 +---
70 +
71 +## 1. RÈGLE ABSOLUE — Recherche web intensive AVANT de coder
72 +
73 +Ce projet touche des domaines où tes connaissances peuvent être incomplètes ou périmées : normes comptables, fiscalité, API des ERP, design de compilateurs. **Tu ne dois JAMAIS deviner.**
74 +
75 +### Protocole de recherche obligatoire
76 +
77 +Avant chaque nouveau module ou décision de design importante :
78 +
79 +1. **Fais au minimum 3 à 5 recherches web ciblées** sur le sujet (spécifications, docs officielles, articles récents, RFC, standards existants).
80 +2. **Consulte les sources primaires** : documentation officielle des ERP (SAP, QuickBooks, Xero, Odoo, Oracle NetSuite, Sage, Dynamics), sites gouvernementaux (ARC/Revenu Québec pour TPS/TVQ, IRS pour sales tax US), sites des normalisateurs (IFRS Foundation, FASB pour US GAAP, CPA Canada pour ASPE).
81 +3. **Documente tes trouvailles** dans `docs/research/<sujet>.md` (avec en-tête auteur) : sources, dates de consultation, résumé, décisions prises.
82 +4. **Vérifie l'existant** : avant d'inventer un format, recherche les standards déjà en place (voir §1.1) pour t'en inspirer ou t'y aligner.
83 +5. Si une info fiscale ou normative est incertaine ou pourrait avoir changé : **recherche web obligatoire**, jamais de mémoire seule.
84 +
85 +### 1.1 Sujets à rechercher intensivement (checklist de démarrage)
86 +
87 +- [ ] **Standards de données comptables existants** : XBRL / XBRL-GL, ISO 20022, UBL (Universal Business Language), Peppol, OFX, camt.053, EDIFACT, hledger/beancount/ledger-cli (plain text accounting), REA ontology (Resources-Events-Agents), ValueFlows, OpenCorporates schemas.
88 +- [ ] **Architecture LLVM** : structure de l'IR, forme SSA, pass manager, backends, tablegen — pour transposer correctement les concepts.
89 +- [ ] **API ERP** : SAP OData/BAPI/IDoc, QuickBooks Online API (JournalEntry, Invoice, Payment), Xero Accounting API, Odoo XML-RPC/ORM, NetSuite SuiteTalk, Sage Intacct.
90 +- [ ] **Normes comptables** : IFRS (IFRS 15 revenus, IFRS 16 locations, IAS 21 devises), US GAAP (ASC 606, ASC 842), ASPE canadien (chapitres pertinents).
91 +- [ ] **Fiscalité** : TPS/TVQ Canada (taux actuels, règles de lieu de fourniture), sales tax US (nexus, taux par État), TVA UE si pertinent.
92 +- [ ] **Event sourcing & double-entry engines** : Martin Fowler (Accounting Patterns), TigerBeetle, Formance Ledger, Modern Treasury, Increase, Stripe Ledger — architectures de grands ledgers.
93 +- [ ] **Rapprochement bancaire & formats bancaires** : ISO 20022 camt, MT940, Plaid/Flinks API.
94 +- [ ] **Structured outputs LLM** : meilleures pratiques actuelles pour extraction structurée (JSON Schema, tool use, validation).
95 +
96 +⚠️ Les taux de taxes, seuils de capitalisation, et versions d'API **changent**. Toujours vérifier la date des sources et privilégier les documents officiels récents.
97 +
98 +---
99 +
100 +## 2. Vision du projet
101 +
102 +AIR est à la comptabilité ce que LLVM est à la compilation.
103 +
104 +**Problème** : chaque ERP (SAP, Oracle, QuickBooks, Xero, Sage, Odoo, Dynamics) réinvente le même modèle comptable. Les LLM sont excellents pour comprendre ("cette facture = 3 ordinateurs payés par Visa") mais peu fiables pour appliquer des centaines de règles comptables/fiscales sans erreur.
105 +
106 +**Solution** : séparer la *compréhension* (LLM) de l'*application des règles* (compilateur déterministe).
107 +
108 +```
109 +Facture / Email / Banque / POS / API
110 + ↓
111 + LLM (extraction)
112 + ↓
113 + AIR (événement économique, PAS une écriture)
114 + ↓
115 + Passes (validation, taxes, FX, fraude, approbation)
116 + ↓
117 + Compilation déterministe (AIC)
118 + ↓
119 + Backends : SAP | QuickBooks | Xero | Odoo | IFRS | US GAAP | ASPE
120 +```
121 +
122 +**Principe fondamental** : le LLM ne produit JAMAIS une écriture comptable finale. Il produit uniquement de l'AIR. Le compilateur applique les politiques, normes et taxes de façon déterministe, traçable et testable.
123 +
124 +---
125 +
126 +## 3. Composants de l'écosystème
127 +
128 +| Composant | Analogie LLVM | Rôle |
129 +|---|---|---|
130 +| **AIR** | LLVM IR | Format universel décrivant les événements économiques |
131 +| **AIC** | clang/llc | Compilateur AIR → écritures comptables |
132 +| **ALSL** | TableGen | Langage déclaratif de règles (politiques, taxes, normes) |
133 +| **Backends** | x86/ARM backends | Générateurs SAP, QBO, Xero, Odoo, etc. |
134 +| **Passes** | Optimization passes | Validation, fusion, netting, doublons, conformité |
135 +| **SDK Agents** | libclang | API standard pour agents IA (syscalls comptables) |
136 +| **AIR Kernel** | microkernel | Services : Ledger, Tax, FX, Policy, Period, Audit, Approval, Reporting |
137 +
138 +### 3.1 AIR — le format
139 +
140 +Un événement économique (`EconomicEvent`), pas un journal. Exemple cible :
141 +
142 +```yaml
143 +EconomicEvent:
144 + id: evt_01H... # ULID
145 + type: Sale
146 + seller: company:acme
147 + buyer: customer:cust_123
148 + items:
149 + - sku: chair-std
150 + qty: 3
151 + unit_price: {amount: 333.33, currency: CAD}
152 + payment:
153 + method: card.visa
154 + gross: {amount: 1150.00, currency: CAD}
155 + delivery: {status: pending, expected: 2026-09-01}
156 + tax:
157 + jurisdiction: CA-QC
158 + codes: [GST, QST]
159 + meta:
160 + source: {kind: invoice_pdf, uri: "s3://...", ocr_score: 0.97}
161 + llm: {model: "...", confidence: 0.93, reasoning_hash: "sha256:..."}
162 + policy_version: "2026.08"
163 + timestamps: {ingested: ..., approved: null}
164 + approver: null
165 +```
166 +
167 +Exigences du format :
168 +- **Schéma formel** (JSON Schema + types Pydantic/TypeScript générés).
169 +- **Forme SSA comptable** : chaque montant a une origine unique et traçable (Invoice → Tax → Payment → FX → Settlement → Write-off). Implémenter un graphe de provenance immuable.
170 +- **Versionné** : `air_version` dans chaque document ; migrations explicites.
171 +- **Immutabilité + diff** : compilation incrémentale — si une facture change, on recompile uniquement le delta (comme Git), avec écritures de contrepassation générées automatiquement.
172 +
173 +### 3.2 AIC — le compilateur
174 +
175 +Pipeline de passes ordonnées :
176 +
177 +```
178 +OCR pass → Classification pass → Tax pass → FX pass →
179 +Fraud pass → Approval pass → Optimization passes → Posting pass
180 +```
181 +
182 +Passes d'optimisation :
183 +- **Fusion** : 50 paiements identiques → 1 batch.
184 +- **Netting** : 100 remboursements → compensation.
185 +- **Reclassement** : Expense → Asset selon politiques (ex. capitalisation > 5000 $).
186 +- **Détection doublons** : hachage + similarité.
187 +- **Invariant permanent** : `Assets = Liabilities + Equity` vérifié après CHAQUE passe. Toute violation = échec de compilation avec diagnostic précis (comme les erreurs clang : localisation, cause, suggestion).
188 +
189 +### 3.3 ALSL — le langage de règles
190 +
191 +Déclaratif, versionné, testable :
192 +
193 +```
194 +policy capitalization_ca:
195 + when event.type == Purchase and event.amount > 5000 CAD
196 + then classify as Asset(class: equipment)
197 +
198 +policy tax_quebec:
199 + when event.jurisdiction == CA-QC
200 + then apply GST(5%), QST(9.975%)
201 +```
202 +
203 +⚠️ Les taux ci-dessus sont des exemples — **vérifie les taux actuels par recherche web** avant de les coder, et ne les code JAMAIS en dur dans le moteur : ils vivent dans les policies ALSL versionnées.
204 +
205 +### 3.4 Backends
206 +
207 +Chaque backend implémente une interface commune `Backend`:
208 +- `capabilities()` — ce que la cible supporte
209 +- `compile(journal: CompiledJournal) -> TargetPayload`
210 +- `post(payload) -> PostingReceipt`
211 +- `reverse(receipt) -> ReversalReceipt`
212 +
213 +Ordre de développement : **1) Backend générique CSV/journal**, 2) QuickBooks Online (API la plus accessible), 3) Xero, 4) Odoo, 5) SAP (le plus complexe — recherche approfondie requise sur OData/BAPI).
214 +
215 +### 3.5 SDK Agents — les "syscalls"
216 +
217 +Les agents IA n'accèdent JAMAIS au grand livre directement. API exclusive :
218 +
219 +```
220 +CreateEconomicEvent() | Validate() | Compile() | Post() | Reverse()
221 +Merge() | ClosePeriod() | Reconcile() | GenerateReport()
222 +```
223 +
224 +Chaque appel est journalisé (audit log append-only, hash-chaîné).
225 +
226 +---
227 +
228 +## 4. Architecture technique
229 +
230 +### Stack recommandée (à valider par recherche)
231 +- **Cœur (AIR + AIC + passes)** : Rust (fiabilité, typage fort) OU Python typé strict (vitesse de dev). Décision à documenter dans `docs/adr/0001-language.md` après recherche comparative.
232 +- **Schémas** : JSON Schema comme source de vérité → génération de types Rust/Python/TS.
233 +- **Montants** : JAMAIS de float. Décimal fixe (rust_decimal / Python Decimal), arrondi banker's rounding documenté par juridiction (à vérifier par recherche : règles d'arrondi TPS/TVQ).
234 +- **Stockage** : event store append-only (PostgreSQL) + projections.
235 +- **ALSL** : parser dédié (pest/lark) ; commencer par un sous-ensemble YAML avant le DSL complet.
236 +
237 +### Structure du dépôt
238 +
239 +```
240 +air/
241 +├── CLAUDE.md
242 +├── README.md
243 +├── docs/
244 +│ ├── adr/ # Architecture Decision Records
245 +│ ├── research/ # Résultats de recherches web (OBLIGATOIRE)
246 +│ └── spec/ # Spécification formelle AIR / ALSL
247 +├── schemas/ # JSON Schemas versionnés
248 +├── core/ # AIR types + graphe de provenance
249 +├── aic/ # Compilateur + pass manager
250 +│ └── passes/
251 +├── alsl/ # Parser + évaluateur de règles
252 +├── backends/
253 +│ ├── generic_csv/
254 +│ ├── quickbooks/
255 +│ ├── xero/
256 +│ └── odoo/
257 +├── kernel/ # Services (ledger, tax, fx, policy, audit...)
258 +├── sdk/ # SDK agents (syscalls)
259 +├── ingestion/ # OCR + extraction LLM → AIR
260 +├── tests/
261 +│ ├── golden/ # Cas dorés : AIR → écritures attendues
262 +│ ├── property/ # Property-based (invariant bilan)
263 +│ └── fixtures/ # Factures réelles anonymisées
264 +└── scripts/
265 + └── check_headers.py # Vérifie les en-têtes auteur
266 +```
267 +
268 +---
269 +
270 +## 5. Exigences de qualité non négociables
271 +
272 +1. **Déterminisme** : même AIR + mêmes policies = mêmes écritures, toujours. Aucun appel LLM dans le compilateur.
273 +2. **Invariant comptable** : partie double vérifiée à chaque étape ; property-based testing (hypothesis/proptest) sur `Assets = Liabilities + Equity`.
274 +3. **Traçabilité totale** : de chaque ligne d'écriture, on remonte à l'événement source, au document, au score OCR/LLM, à la version de policy, à l'approbateur.
275 +4. **Golden tests** : chaque fonctionnalité comptable = cas dorés validés contre des exemples de la littérature comptable (trouvés par recherche web, sources citées).
276 +5. **Diagnostics de qualité compilateur** : erreurs précises, localisées, avec suggestions.
277 +6. **Aucun taux/seuil en dur** : tout paramètre fiscal ou de politique vit dans ALSL.
278 +7. **En-têtes auteur** partout (voir §0), vérifiés en CI.
279 +
280 +---
281 +
282 +## 6. Plan de développement par phases
283 +
284 +**Phase 0 — Recherche (docs/research/)** : compléter la checklist §1.1, produire un rapport par sujet, rédiger les ADR fondateurs (langage, alignement ou non avec XBRL-GL/UBL/REA, modèle de montants).
285 +
286 +**Phase 1 — Cœur** : schéma AIR v0.1, types, graphe de provenance, invariant bilan, backend CSV générique, 20 golden tests (vente simple, achat, taxes QC, FX, remboursement).
287 +
288 +**Phase 2 — Compilateur** : pass manager, passes Tax (CA-QC d'abord) / FX / Validation, ALSL v0.1 (YAML), compilation incrémentale (diff + reversal).
289 +
290 +**Phase 3 — Backend réel** : QuickBooks Online (sandbox), OAuth, idempotence, Post/Reverse.
291 +
292 +**Phase 4 — Ingestion** : PDF → extraction structurée LLM → AIR, avec scores de confiance et file d'approbation humaine sous seuil.
293 +
294 +**Phase 5 — Kernel + SDK** : services, syscalls, audit log hash-chaîné, agent de démonstration.
295 +
296 +**Phase 6 — Optimisations** : fusion, netting, doublons, rapprochement bancaire (camt.053/MT940).
297 +
298 +---
299 +
300 +## 7. Workflow de travail attendu de Claude
301 +
302 +À chaque session :
303 +1. Relire ce CLAUDE.md.
304 +2. Identifier la tâche → **recherche web d'abord** si le sujet touche normes, taxes, API externes, ou standards.
305 +3. Consigner la recherche dans `docs/research/`.
306 +4. Écrire les tests avant/avec le code (golden + property).
307 +5. Coder avec en-têtes auteur.
308 +6. Lancer `scripts/check_headers.py` + suite de tests.
309 +7. Mettre à jour la doc/ADR si une décision de design a été prise.
310 +
311 +### Interdits
312 +- ❌ Coder une règle fiscale de mémoire sans source web vérifiée et citée.
313 +- ❌ Utiliser des floats pour des montants.
314 +- ❌ Laisser le LLM produire des écritures finales.
315 +- ❌ Créer un fichier sans l'en-tête Simon-Pierre Boucher / contact@spboucher.ai.
316 +- ❌ Casser l'invariant de la partie double, même temporairement.
317 +
318 +---
319 +
320 +*Fin du CLAUDE.md — Projet AIR — Simon-Pierre Boucher — contact@spboucher.ai*
added CONTRIBUTING.md +57 −0
@@ -0,0 +1,57 @@
1 +<!--
2 +Projet : AIR — Accounting Intermediate Representation
3 +Auteur : Simon-Pierre Boucher
4 +Contact : contact@spboucher.ai
5 +Fichier : CONTRIBUTING.md
6 +-->
7 +
8 +# Contributing to AIR
9 +
10 +Thank you for considering a contribution! AIR has a few **non-negotiable
11 +rules** that CI enforces — read them before opening a PR.
12 +
13 +## The rules (enforced)
14 +
15 +1. **Author header on every file.** Every source/doc/config file starts with
16 + the project header (see any file for the format). `python scripts/check_headers.py`
17 + must pass — CI fails otherwise.
18 +2. **No floats for money — ever.** Amounts, quantities, rates are `Decimal`
19 + in code and strings in JSON/YAML. Floats are rejected at every boundary.
20 +3. **No tax rate, threshold, or rounding mode in code.** All fiscal and policy
21 + parameters live in versioned ALSL policy sets with a mandatory source
22 + citation. The loader rejects uncited rates.
23 +4. **Research before rules.** Anything touching tax law, accounting standards,
24 + external APIs, or data formats needs a cited report in `docs/research/`
25 + (official sources, consultation dates). Never from memory.
26 +5. **The compiler stays deterministic.** No LLM, network, or clock inside AIC.
27 + Same document + same policies = byte-identical journal.
28 +6. **The invariant is sacred.** `Assets = Liabilities + Equity` is verified
29 + after every compiler pass. New passes must keep entries balanced.
30 +7. **Append-only history.** Corrections are reversal entries; nothing posted
31 + is ever edited or deleted.
32 +
33 +## Workflow
34 +
35 +```bash
36 +python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
37 +.venv/bin/python -m pytest tests/ -q --ignore=tests/test_llm_live.py # offline suite
38 +python3 scripts/check_headers.py
39 +```
40 +
41 +- Write tests with the code: golden cases for accounting behavior
42 + (`tests/golden/cases/*.yaml`), property tests for invariants, unit tests
43 + for mechanics.
44 +- New event types need: posting rules, golden cases, and a spec update.
45 +- New backends implement `backends/base.py` (`capabilities/compile/post/reverse`)
46 + and must be fully testable offline (mock transport pattern —
47 + see `backends/quickbooks/`).
48 +- Architecture decisions get an ADR in `docs/adr/`.
49 +
50 +## What makes a good first contribution
51 +
52 +- A new ALSL policy set for another jurisdiction (with cited research)
53 +- The beancount/hledger export backend (near-free per the research)
54 +- Reference-first matching (`EndToEndId`) in reconciliation
55 +- A Xero or Odoo backend with an offline mock transport
56 +
57 +*Author: Simon-Pierre Boucher — contact@spboucher.ai*
added LICENSE +21 −0
@@ -0,0 +1,21 @@
1 +MIT License
2 +
3 +Copyright (c) 2026 Simon-Pierre Boucher <contact@spboucher.ai>
4 +
5 +Permission is hereby granted, free of charge, to any person obtaining a copy
6 +of this software and associated documentation files (the "Software"), to deal
7 +in the Software without restriction, including without limitation the rights
8 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +copies of the Software, and to permit persons to whom the Software is
10 +furnished to do so, subject to the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be included in all
13 +copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +SOFTWARE.
added README.md +233 −0
@@ -0,0 +1,233 @@
1 +<!--
2 +Projet : AIR — Accounting Intermediate Representation
3 +Auteur : Simon-Pierre Boucher
4 +Contact : contact@spboucher.ai
5 +Fichier : README.md
6 +-->
7 +
8 +<div align="center">
9 +
10 +# ⚙️ AIR
11 +
12 +### The Language of Accounting
13 +
14 +**LLVM for the ledger** — LLMs understand your documents, a deterministic compiler keeps your books.
15 +
16 +[![CI](https://github.com/spboucher-ai/air/actions/workflows/ci.yml/badge.svg)](https://github.com/spboucher-ai/air/actions/workflows/ci.yml)
17 +[![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](pyproject.toml)
18 +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
19 +[![Tests](https://img.shields.io/badge/tests-99%20offline%20%2B%202%20live-brightgreen)](tests/)
20 +[![Golden cases](https://img.shields.io/badge/golden%20cases-21-gold)](tests/golden/cases/)
21 +[![Amounts](https://img.shields.io/badge/floats-forbidden-red)](docs/adr/0003-monetary-amounts.md)
22 +[![Double entry](https://img.shields.io/badge/A%20%3D%20L%20%2B%20E-verified%20after%20every%20pass-blueviolet)](core/invariant.py)
23 +[![Ledger](https://img.shields.io/badge/ledger-append--only%20%7C%20hash--chained-orange)](kernel/ledger.py)
24 +[![Offline first](https://img.shields.io/badge/works%20offline-no%20ERP%2C%20no%20API%20key%20required-informational)](docs/cli-reference.md)
25 +
26 +*by [Simon-Pierre Boucher](mailto:contact@spboucher.ai)*
27 +
28 +</div>
29 +
30 +---
31 +
32 +## The idea
33 +
34 +Every ERP reinvents the same accounting model. LLMs are brilliant at *understanding*
35 +("this invoice = 3 chairs paid by Visa") and unreliable at *applying* hundreds of
36 +tax and accounting rules. **AIR separates the two** — exactly like LLVM separated
37 +language frontends from machine backends:
38 +
39 +```mermaid
40 +flowchart LR
41 + A[📄 Invoice / Email<br/>Bank / POS / API] --> B["🧠 LLM<br/><i>understanding</i>"]
42 + B -->|"AIR events<br/>(never journal entries)"| C["⚙️ AIC Compiler<br/><i>deterministic rules</i>"]
43 + P["📜 ALSL Policies<br/>taxes · thresholds · rounding<br/><i>versioned & cited</i>"] --> C
44 + C -->|"balanced entries<br/>+ full provenance"| D["📗 Native Ledger<br/>hash-chained, standalone"]
45 + C --> E["📤 CSV · QuickBooks<br/>Xero · Odoo · SAP*"]
46 + D --> F["📊 Trial balance · Balance sheet<br/>Income statement · GL"]
47 + style B fill:#f9e79f,stroke:#b7950b
48 + style C fill:#aed6f1,stroke:#2471a3
49 + style D fill:#a9dfbf,stroke:#1e8449
50 + style P fill:#f5b7b1,stroke:#c0392b
51 +```
52 +
53 +> **The one rule that never bends:** an LLM (or any frontend) only ever produces
54 +> **AIR** — a description of *what happened economically*. It never writes a journal
55 +> entry, an account code, or a debit. The compiler does that, deterministically,
56 +> with the double-entry invariant `Assets = Liabilities + Equity` verified after
57 +> **every** pass and clang-style diagnostics when anything is wrong.
58 +
59 +## What you get
60 +
61 +| | |
62 +|---|---|
63 +| 🗣️ **A universal language** | `EconomicEvent` (REA-based): sales, purchases, refunds, payments, FX — perspective-neutral, schema-validated, no debits anywhere |
64 +| ⚙️ **A real compiler** | Pass pipeline (validation → classification → tax → FX → posting), pass manager with verify-after-every-pass, diagnostics with location + cause + `help:` fix |
65 +| 📜 **Rules as data (ALSL)** | GST 5%, QST 9.975%, HST, capitalization thresholds, rounding modes — all in versioned YAML policy sets with **mandatory source citations** (the loader rejects uncited rates) |
66 +| 🏠 **Standalone books** | No ERP required: managed AIR home with an append-only, tamper-evident ledger, content-addressed document archive, and all financial statements in text/markdown/CSV/JSON |
67 +| 🔁 **Git-style corrections** | Changed invoice? `air recompile` diffs by content fingerprint and posts reversal + replacement entries — history is never edited |
68 +| 🤖 **An agent SDK** | AI agents get syscalls (`CreateEconomicEvent`, `Post`, `Reverse`, `ClosePeriod`, `Merge`, `Reconcile`…) — never the ledger. Every call, including refusals, lands in a hash-chained audit log |
69 +| 🧾 **LLM ingestion** | Invoice text → Claude (structured outputs) → schema-validated AIR → confidence routing → human approval inbox. Fully offline mock for tests |
70 +| 🚀 **Optimizations** | 50 identical payments → 1 batch entry (fusion), refund↔sale netting, duplicate detection — provenance preserved through every transformation |
71 +| 🏦 **Bank reconciliation** | camt.053 + MT940 (with statement integrity check) + CSV → matched against the books, differences reported on both sides |
72 +| 🔍 **Total traceability** | Every posted cent walks back through the provenance graph (accounting SSA) to its source event, document, OCR/LLM confidence, policy version, and rounding mode |
73 +
74 +## Quickstart
75 +
76 +```bash
77 +git clone https://github.com/spboucher-ai/air && cd air
78 +python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
79 +source .venv/bin/activate
80 +
81 +# 1) Your books live in a managed AIR home — one command
82 +air init --home books --policies alsl/policies/ca-qc-2026.yaml
83 +
84 +# 2) Compile economic events into the books (document archived, ledger chained)
85 +air compile tests/fixtures/demo_document.yaml --home books \
86 + --report trial-balance --report balance-sheet
87 +
88 +# 3) A source invoice was corrected? Post only the delta (reversal + replacement)
89 +air recompile old.yaml corrected.yaml --home books
90 +
91 +# 4) Statements any time, any format
92 +air report income-statement --home books --format markdown
93 +
94 +# 5) Ingest a real document (offline extractor; add --llm for Claude)
95 +air ingest tests/fixtures/invoice_high_confidence.txt --home books
96 +air inbox --home books
97 +
98 +# 6) Month end
99 +air reconcile statement.mt940 --home books
100 +air audit --home books
101 +```
102 +
103 +Full command reference: **[docs/cli-reference.md](docs/cli-reference.md)**
104 +
105 +## Sixty seconds of AIR
106 +
107 +An economic event — *what happened*, nothing else:
108 +
109 +```yaml
110 +events:
111 + - id: evt_01H8XGJWBW
112 + type: Sale
113 + date: 2026-07-20
114 + parties: {seller: "company:acme", buyer: "customer:cust_123"}
115 + items:
116 + - {sku: chair-std, qty: "3", unit_price: {amount: "333.33", currency: CAD}}
117 + payment: {method: card.visa, immediate: true}
118 + tax: {jurisdiction: CA-QC} # ← where. Rates live in policies, never here
119 +```
120 +
121 +The compiler applies the cited CA-QC policy set (GST 5%, QST 9.975% on the
122 +pre-GST price, half-up per Excise Tax Act s.165.2(2)) and emits a balanced entry:
123 +
124 +```
125 +DR 1000 Cash 1149.74
126 + CR 4000 Sales revenue 999.99
127 + CR 2310 GST payable 50.00 tax:GST@0.05~half_up
128 + CR 2320 QST payable 99.75 tax:QST@0.09975~half_up
129 +```
130 +
131 +Same input + same policies = **byte-identical output, always** (property-tested,
132 +and CI compiles the demo twice and `diff`s the results). When something is wrong,
133 +you get a compiler error, not a wrong number:
134 +
135 +```
136 +error[AIR-E400]: no tax policy in set 'ca-qc' matches jurisdiction 'CA-BC'
137 + --> event evt_x, field tax.jurisdiction
138 + pass: tax
139 + help: add an ALSL tax policy for this jurisdiction or mark the event tax.exempt: true
140 +```
141 +
142 +## Agents keep books through syscalls — and only syscalls
143 +
144 +```python
145 +from sdk.syscalls import AirKernel
146 +
147 +k = AirKernel("books", actor="agent:alice")
148 +k.create_economic_event({...}) # schema-validated draft
149 +k.post() # deterministic compile → books (idempotent)
150 +k.reverse("je_evt_x") # corrections are contra entries, never edits
151 +k.close_period("2026-01") # posting into it now fails with AIR-E700
152 +k.merge() # post with netting + payment fusion
153 +k.reconcile("statement.mt940") # bank matching
154 +```
155 +
156 +```
157 +$ air audit --home books
158 +#0000 OK agent:alice CreateEconomicEvent {"event_id": "evt_x", "type": "Sale"}
159 +#0001 OK agent:alice Post {"drafts": 1}
160 +#0008 ERR agent:alice Post {"drafts": 1} <- error[AIR-E700]: period 2026-01 is closed
161 +10 syscalls, hash chain VALID
162 +```
163 +
164 +Refused actions are audited like successful ones. Editing any record breaks the chain.
165 +
166 +## The seven guarantees
167 +
168 +1. **Determinism** — no LLM, network, or clock inside the compiler.
169 +2. **Double entry** — the invariant is verified after *every* pass; violations abort compilation.
170 +3. **Traceability** — provenance graph from every posted line to its source (accounting SSA).
171 +4. **No floats** — exact decimals end to end; floats rejected at every boundary.
172 +5. **No rules in code** — every rate/threshold lives in cited, versioned ALSL policies.
173 +6. **Append-only** — reversals, never edits; ledger and audit log are hash-chained.
174 +7. **Human in the loop** — low-confidence or schema-invalid extractions always route to a person.
175 +
176 +## Repository map
177 +
178 +| Path | Role | LLVM analogy |
179 +|---|---|---|
180 +| [`schemas/`](schemas/) | AIR JSON Schema | the IR definition |
181 +| [`core/`](core/) | events, Money, provenance, invariants | IR + verifier |
182 +| [`aic/`](aic/) | pass manager, passes, diagnostics, incremental | opt/llc |
183 +| [`alsl/`](alsl/) | rule language + cited policy sets | TableGen |
184 +| [`backends/`](backends/) | native, CSV, QuickBooks (offline-tested) | targets |
185 +| [`kernel/`](kernel/) | ledger, reporting, workspace, audit, reconciliation | runtime |
186 +| [`sdk/`](sdk/) | CLI, agent syscalls, demo agent | libclang |
187 +| [`ingestion/`](ingestion/) | extractors, confidence routing, approval queue | frontend |
188 +| [`docs/spec/`](docs/spec/) | **the AIR/ALSL specification** | LangRef |
189 +| [`docs/adr/`](docs/adr/) | architecture decision records | — |
190 +| [`docs/research/`](docs/research/) | 9 cited research reports (tax law, formats, APIs) | — |
191 +| [`tests/`](tests/) | golden cases, property tests, unit + live tests | lit |
192 +
193 +## Documentation
194 +
195 +- 📖 **[The AIR Specification](docs/spec/air-spec-v0.1.md)** — the language, the compiler, diagnostics, syscalls, reconciliation
196 +- 🧭 **[CLI Reference](docs/cli-reference.md)** — every command with examples
197 +- 🏛️ **[ADRs](docs/adr/)** — why Python, why REA events, why rounding is policy
198 +- 🔬 **[Research](docs/research/)** — GST/QST from official sources, camt.053/MT940 specs, ERP API surveys, LLVM architecture lessons
199 +- 🤝 **[Contributing](CONTRIBUTING.md)** — the non-negotiable rules CI enforces
200 +
201 +## Development
202 +
203 +```bash
204 +.venv/bin/python -m pytest tests/ -q --ignore=tests/test_llm_live.py # 99 tests, fully offline
205 +python3 scripts/check_headers.py # author-header gate
206 +
207 +# opt-in live LLM tests (cost a few cents)
208 +ANTHROPIC_API_KEY=sk-ant-... .venv/bin/python -m pytest tests/test_llm_live.py -v
209 +```
210 +
211 +Everything — including the QuickBooks backend and the LLM ingestion — develops
212 +and tests **fully offline**: mock transports simulate the real APIs' documented
213 +behaviors (idempotency replay, duplicate errors), so no account or key is ever
214 +required to work on AIR.
215 +
216 +## Roadmap
217 +
218 +- [x] Phases 0–6: research → core → compiler → backends → ingestion → agent SDK → optimizations & reconciliation *(complete)*
219 +- [ ] beancount/hledger export backend
220 +- [ ] Xero & Odoo backends (offline mock pattern)
221 +- [ ] Reference-first bank matching (`EndToEndId`)
222 +- [ ] More jurisdictions as cited ALSL policy sets (US sales tax, EU VAT)
223 +- [ ] AIR v0.2: REA commitments (pending deliveries, IFRS 15 performance obligations)
224 +
225 +---
226 +
227 +<div align="center">
228 +
229 +**AIR** — *because your books deserve a compiler.*
230 +
231 +MIT © 2026 [Simon-Pierre Boucher](mailto:contact@spboucher.ai)
232 +
233 +</div>
added aic/__init__.py +7 −0
@@ -0,0 +1,7 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : __init__.py
6 +# Description : Package marker.
7 +# =============================================================================
added aic/compiler.py +76 −0
@@ -0,0 +1,76 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : compiler.py
6 +# Description : Top-level AIC entry point — AIR document + ALSL policies -> CompiledJournal.
7 +# =============================================================================
8 +"""compile_document(): the deterministic core of AIR.
9 +
10 +Same AIR document + same policy set => byte-identical journal, always.
11 +No LLM, no network, no clock inside this function.
12 +"""
13 +from __future__ import annotations
14 +
15 +from aic.diagnostics import Diagnostic
16 +from aic.pass_manager import PassManager
17 +from aic.passes.classification import ClassificationPass
18 +from aic.passes.fx import FxPass
19 +from aic.passes.posting import PostingPass
20 +from aic.passes.tax import TaxPass
21 +from aic.passes.validation import ValidationPass
22 +from aic.unit import CompilationUnit
23 +from alsl.model import PolicySet
24 +from core.events import AirDocument
25 +from core.journal import CompiledJournal
26 +
27 +DEFAULT_PIPELINE = [
28 + ValidationPass(),
29 + ClassificationPass(),
30 + TaxPass(),
31 + FxPass(),
32 + PostingPass(),
33 +]
34 +
35 +
36 +def _optimized_pipeline() -> list:
37 + from aic.passes.optimize import (
38 + DuplicateDetectionPass,
39 + FusionPass,
40 + NettingPass,
41 + )
42 + return [
43 + ValidationPass(),
44 + DuplicateDetectionPass(),
45 + ClassificationPass(),
46 + TaxPass(),
47 + FxPass(),
48 + PostingPass(),
49 + NettingPass(),
50 + FusionPass(),
51 + ]
52 +
53 +
54 +def compile_document(
55 + document: AirDocument,
56 + policies: PolicySet,
57 + pipeline: list | None = None,
58 + optimize: bool = False,
59 +) -> tuple[CompiledJournal, list[Diagnostic]]:
60 + """Compile an AIR document into a journal. Raises CompilationError on errors.
61 +
62 + optimize=True adds the Phase 6 passes (duplicate detection, netting,
63 + fusion); the invariant is still verified after every pass.
64 + """
65 + if pipeline is None:
66 + pipeline = _optimized_pipeline() if optimize else DEFAULT_PIPELINE
67 + unit = CompilationUnit(document=document, policies=policies)
68 + manager = PassManager(pipeline)
69 + manager.run(unit)
70 + journal = CompiledJournal(
71 + entries=list(unit.entries),
72 + provenance=unit.provenance,
73 + policy_set=policies.name,
74 + policy_version=policies.version,
75 + )
76 + return journal, unit.diagnostics
added aic/diagnostics.py +53 −0
@@ -0,0 +1,53 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : diagnostics.py
6 +# Description : Clang-style diagnostics — precise location, cause, suggestion.
7 +# =============================================================================
8 +"""Compiler-quality diagnostics for AIC.
9 +
10 +Modeled on clang: every diagnostic names WHERE (event id / field path),
11 +WHAT (computed cause, e.g. the exact imbalance), and HOW TO FIX (suggestion).
12 +Errors abort compilation; warnings and notes ride along in the result.
13 +"""
14 +from __future__ import annotations
15 +
16 +import enum
17 +from dataclasses import dataclass
18 +
19 +
20 +class Severity(enum.Enum):
21 + ERROR = "error"
22 + WARNING = "warning"
23 + NOTE = "note"
24 +
25 +
26 +@dataclass(frozen=True, slots=True)
27 +class Diagnostic:
28 + code: str # e.g. "AIR-E101"
29 + severity: Severity
30 + message: str
31 + location: str # e.g. "event evt_01H..., field items[0].unit_price"
32 + suggestion: str | None = None
33 + origin_pass: str | None = None
34 +
35 + def render(self) -> str:
36 + head = f"{self.severity.value}[{self.code}]: {self.message}"
37 + lines = [head, f" --> {self.location}"]
38 + if self.origin_pass:
39 + lines.append(f" pass: {self.origin_pass}")
40 + if self.suggestion:
41 + lines.append(f" help: {self.suggestion}")
42 + return "\n".join(lines)
43 +
44 +
45 +class CompilationError(Exception):
46 + """Raised when any ERROR diagnostic is produced; carries all diagnostics."""
47 +
48 + def __init__(self, diagnostics: list[Diagnostic]):
49 + self.diagnostics = diagnostics
50 + errors = [d for d in diagnostics if d.severity is Severity.ERROR]
51 + super().__init__(
52 + f"{len(errors)} error(s):\n" + "\n".join(d.render() for d in errors)
53 + )
added aic/incremental.py +154 −0
@@ -0,0 +1,154 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : incremental.py
6 +# Description : Incremental compilation — diff two AIR documents, emit reversal + replacement entries.
7 +# =============================================================================
8 +"""Incremental compilation (Phase 2).
9 +
10 +When a source document changes (a corrected invoice, a removed duplicate, a
11 +new event), AIR never mutates posted history. Instead, like Git, we diff the
12 +old and new documents by event fingerprint and emit:
13 +
14 +- 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.
18 +
19 +Determinism holds: same (old, new, policies) triple => identical delta.
20 +
21 +Provenance scoping: reversal lines keep the provenance ids of the prior
22 +compilation (they describe the amounts being reversed); the `reverses` field
23 +links each contra entry to the original. Replacement/added entries carry the
24 +new compilation's provenance graph.
25 +"""
26 +from __future__ import annotations
27 +
28 +import hashlib
29 +from dataclasses import dataclass, field, replace
30 +
31 +from aic.compiler import compile_document
32 +from aic.diagnostics import CompilationError, Diagnostic, Severity
33 +from alsl.model import PolicySet
34 +from core.events import AirDocument, EconomicEvent
35 +from core.invariant import verify_entries, verify_equation
36 +from core.journal import CompiledJournal, JournalEntry, JournalLine, Side
37 +
38 +
39 +def 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()
42 +
43 +
44 +@dataclass(frozen=True, slots=True)
45 +class DocumentDiff:
46 + added: tuple[str, ...]
47 + removed: tuple[str, ...]
48 + changed: tuple[str, ...]
49 + unchanged: tuple[str, ...]
50 +
51 + def is_empty(self) -> bool:
52 + return not (self.added or self.removed or self.changed)
53 +
54 +
55 +def 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)
64 +
65 +
66 +def 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.lines
81 + ),
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 + )
87 +
88 +
89 +@dataclass
90 +class IncrementalResult:
91 + """The delta between two compilations: what must be posted on top."""
92 +
93 + diff: DocumentDiff
94 + reversals: list[JournalEntry] = field(default_factory=list)
95 + new_entries: list[JournalEntry] = field(default_factory=list)
96 + journal: CompiledJournal = field(default_factory=CompiledJournal) # reversals + new
97 + diagnostics: list[Diagnostic] = field(default_factory=list)
98 +
99 +
100 +def recompile(
101 + old: AirDocument,
102 + new: AirDocument,
103 + policies: PolicySet,
104 +) -> IncrementalResult:
105 + """Compile only the delta between two AIR documents.
106 +
107 + Both documents are compiled (compilation is cheap and deterministic; the
108 + OLD compile reconstructs exactly what was posted, so no external state is
109 + 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)
114 +
115 + 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}
118 +
119 + result = IncrementalResult(diff=diff, diagnostics=list(new_diags))
120 +
121 + 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))
125 +
126 + 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)
130 +
131 + 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 revision
135 + revision = new_fp[event_id][:8]
136 + result.new_entries.append(replace(entry, id=f"{entry.id}_r{revision}"))
137 +
138 + delta_entries = result.reversals + result.new_entries
139 + result.journal = CompiledJournal(
140 + entries=delta_entries,
141 + provenance=new_journal.provenance,
142 + policy_set=policies.name,
143 + policy_version=policies.version,
144 + )
145 +
146 + # the permanent invariant applies to the delta as well
147 + 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 result
added aic/pass_manager.py +35 −0
@@ -0,0 +1,35 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : pass_manager.py
6 +# Description : Pass manager — ordered passes, invariant verified after every pass.
7 +# =============================================================================
8 +"""AIC pass manager.
9 +
10 +Runs the pass pipeline in order. After EVERY pass, the double-entry invariant
11 +(per-entry balance + accounting equation) is verified — the accounting
12 +analogue of `opt -verify-each`. Any ERROR diagnostic aborts compilation with
13 +a CompilationError carrying clang-style diagnostics.
14 +"""
15 +from __future__ import annotations
16 +
17 +from aic.diagnostics import CompilationError, Severity
18 +from aic.passes.base import Pass
19 +from aic.unit import CompilationUnit
20 +from core.invariant import verify_entries, verify_equation
21 +
22 +
23 +class PassManager:
24 + def __init__(self, passes: list[Pass]):
25 + self.passes = passes
26 +
27 + def run(self, unit: CompilationUnit) -> CompilationUnit:
28 + for p in self.passes:
29 + unit.diagnostics.extend(p.run(unit))
30 + # the permanent invariant: checked after every pass, no exceptions
31 + unit.diagnostics.extend(verify_entries(unit.entries, p.name))
32 + unit.diagnostics.extend(verify_equation(unit.entries, p.name))
33 + if any(d.severity is Severity.ERROR for d in unit.diagnostics):
34 + raise CompilationError(unit.diagnostics)
35 + return unit
added aic/passes/__init__.py +7 −0
@@ -0,0 +1,7 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : __init__.py
6 +# Description : Package marker.
7 +# =============================================================================
added aic/passes/base.py +29 −0
@@ -0,0 +1,29 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : base.py
6 +# Description : Pass interface — every AIC transformation implements this.
7 +# =============================================================================
8 +"""Base class for AIC passes (LLVM-pass analogue).
9 +
10 +A pass is a deterministic transformation over the CompilationUnit. It returns
11 +diagnostics; it never calls an LLM, the network, or a clock. The pass manager
12 +re-verifies the double-entry invariant after every pass.
13 +"""
14 +from __future__ import annotations
15 +
16 +import abc
17 +
18 +from aic.diagnostics import Diagnostic
19 +from aic.unit import CompilationUnit
20 +
21 +
22 +class Pass(abc.ABC):
23 + """One deterministic compilation step."""
24 +
25 + name: str = "pass"
26 +
27 + @abc.abstractmethod
28 + def run(self, unit: CompilationUnit) -> list[Diagnostic]:
29 + """Transform the unit in place; return any diagnostics produced."""
added aic/passes/classification.py +55 −0
@@ -0,0 +1,55 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : classification.py
6 +# Description : Classification pass — applies ALSL classification policies (e.g. capitalization).
7 +# =============================================================================
8 +"""Classification pass.
9 +
10 +Decides which account role receives the debit side of a Purchase
11 +(expense vs capitalized asset) by evaluating ALSL classification policies —
12 +e.g. "capitalize equipment purchases at or above the policy threshold".
13 +Thresholds live in ALSL, never here.
14 +"""
15 +from __future__ import annotations
16 +
17 +from aic.diagnostics import Diagnostic, Severity
18 +from aic.passes.base import Pass
19 +from aic.unit import CompilationUnit
20 +from alsl.evaluator import applicable_classifications
21 +from core.events import EventType
22 +
23 +
24 +class ClassificationPass(Pass):
25 + name = "classification"
26 +
27 + def run(self, unit: CompilationUnit) -> list[Diagnostic]:
28 + diags: list[Diagnostic] = []
29 + for event in unit.document.events:
30 + if event.type is not EventType.PURCHASE:
31 + continue
32 + state = unit.event_state(event.id)
33 + if state.subtotal is None:
34 + continue # validation already failed this event
35 +
36 + hits = applicable_classifications(unit.policies, event, state.subtotal)
37 + if not hits:
38 + continue
39 + if len(hits) > 1:
40 + diags.append(Diagnostic(
41 + code="AIR-W300", severity=Severity.WARNING,
42 + message=(
43 + "multiple classification policies match: "
44 + + ", ".join(p.name for p in hits)
45 + + f"; applying '{hits[0].name}'"
46 + ),
47 + location=f"event {event.id}",
48 + suggestion="tighten the policies' when-clauses so at most one matches",
49 + origin_pass=self.name,
50 + ))
51 + policy = hits[0]
52 + state.classify_as = policy.classify_as
53 + state.classification_role = policy.account_role
54 + state.classification_policy = policy.name
55 + return diags
added aic/passes/fx.py +148 −0
@@ -0,0 +1,148 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : fx.py
6 +# Description : FX pass — converts to the functional currency; computes settlement gains/losses.
7 +# =============================================================================
8 +"""FX pass (IAS 21 semantics — see docs/research/accounting-standards.md and
9 +docs/research/fx-handling.md).
10 +
11 +- Transactions in the functional currency pass through (quantized for posting).
12 +- Foreign-currency transactions are converted at the event's observed rate
13 + (a fact provided by ingestion, e.g. a Bank of Canada Valet daily rate —
14 + never a constant in code).
15 +- Foreign-currency settlements (payments) compare the settlement rate to the
16 + booking rate of the related event and realize an FX gain or loss.
17 +Every conversion is a provenance node: "fx:USD->CAD@1.3500".
18 +"""
19 +from __future__ import annotations
20 +
21 +from aic.diagnostics import Diagnostic, Severity
22 +from aic.passes.base import Pass
23 +from aic.unit import CompilationUnit, TaxLineState
24 +from core.events import EconomicEvent, EventType
25 +from core.money import Money
26 +
27 +SETTLEMENT_TYPES = (EventType.PAYMENT_RECEIVED, EventType.PAYMENT_SENT)
28 +
29 +
30 +class FxPass(Pass):
31 + name = "fx"
32 +
33 + def run(self, unit: CompilationUnit) -> list[Diagnostic]:
34 + diags: list[Diagnostic] = []
35 + functional = unit.policies.functional_currency
36 + rounding = unit.policies.rounding.mode
37 +
38 + for event in unit.document.events:
39 + state = unit.event_state(event.id)
40 + if state.subtotal is None or state.subtotal_node is None:
41 + continue
42 + ccy = state.subtotal.currency
43 +
44 + if ccy == functional:
45 + state.post_subtotal = state.subtotal.quantized(rounding)
46 + state.post_subtotal_node = state.subtotal_node
47 + state.post_taxes = list(state.taxes)
48 + continue
49 +
50 + if event.fx is None:
51 + diags.append(Diagnostic(
52 + code="AIR-E500", severity=Severity.ERROR,
53 + message=(
54 + f"event is in {ccy} but functional currency is {functional} "
55 + "and no fx rate is provided"
56 + ),
57 + location=f"event {event.id}, field fx",
58 + suggestion="attach fx: {rate, source, rate_date} observed on the "
59 + "transaction date (e.g. Bank of Canada Valet)",
60 + origin_pass=self.name,
61 + ))
62 + continue
63 +
64 + rate = event.fx.rate
65 + op = f"fx:{ccy}->{functional}@{rate}"
66 +
67 + converted = Money(
68 + state.subtotal.amount * rate, functional
69 + ).quantized(rounding)
70 + node = unit.provenance.define(
71 + kind="fx", operation=op, amount=converted,
72 + inputs=(state.subtotal_node,), source_ref=event.fx.source,
73 + )
74 + state.post_subtotal = converted
75 + state.post_subtotal_node = node.id
76 +
77 + for tax in state.taxes:
78 + tax_converted = Money(
79 + tax.amount.amount * rate, functional
80 + ).quantized(rounding)
81 + tax_node = unit.provenance.define(
82 + kind="fx", operation=op, amount=tax_converted,
83 + inputs=(tax.node_id,), source_ref=event.fx.source,
84 + )
85 + state.post_taxes.append(TaxLineState(
86 + code=tax.code, amount=tax_converted, node_id=tax_node.id,
87 + payable_role=tax.payable_role,
88 + receivable_role=tax.receivable_role,
89 + recoverable=tax.recoverable, policy=tax.policy,
90 + ))
91 +
92 + if event.type in SETTLEMENT_TYPES:
93 + diags.extend(self._settlement_diff(unit, event, rate, functional))
94 + return diags
95 +
96 + def _settlement_diff(
97 + self, unit: CompilationUnit, event: EconomicEvent,
98 + settle_rate: object, functional: str,
99 + ) -> list[Diagnostic]:
100 + """Realized FX gain/loss: settlement rate vs the related event's booking rate."""
101 + state = unit.event_state(event.id)
102 + rounding = unit.policies.rounding.mode
103 +
104 + if event.related_event is None:
105 + return [Diagnostic(
106 + code="AIR-E501", severity=Severity.ERROR,
107 + message="foreign-currency settlement without related_event: "
108 + "the booking rate cannot be determined",
109 + location=f"event {event.id}, field related_event",
110 + suggestion="reference the Sale/Purchase this payment settles",
111 + origin_pass=self.name,
112 + )]
113 + origin = unit.find_event(event.related_event)
114 + if origin is None or origin.fx is None:
115 + return [Diagnostic(
116 + code="AIR-E502", severity=Severity.ERROR,
117 + message=(
118 + f"related event '{event.related_event}' not found in this "
119 + "document or carries no fx rate"
120 + ),
121 + location=f"event {event.id}, field related_event",
122 + suggestion="compile the settlement together with its origin event, "
123 + "or attach the origin's booking rate",
124 + origin_pass=self.name,
125 + )]
126 +
127 + assert state.subtotal is not None and state.subtotal_node is not None
128 + booked = Money(
129 + state.subtotal.amount * origin.fx.rate, functional
130 + ).quantized(rounding)
131 + settled = state.post_subtotal
132 + assert settled is not None and state.post_subtotal_node is not None
133 + diff = settled - booked
134 + state.booked_amount = booked
135 + state.fx_diff = diff
136 + if not diff.is_zero():
137 + node = unit.provenance.define(
138 + kind="fx_realized",
139 + operation=(
140 + f"fx_realized:{state.subtotal.currency}->{functional}"
141 + f"@{settle_rate}-vs-@{origin.fx.rate}"
142 + ),
143 + amount=diff,
144 + inputs=(state.post_subtotal_node,),
145 + source_ref=event.related_event,
146 + )
147 + state.fx_diff_node = node.id
148 + return []
added aic/passes/optimize.py +222 −0
@@ -0,0 +1,222 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : optimize.py
6 +# Description : Optimization passes — duplicate detection, netting, fusion (Phase 6).
7 +# =============================================================================
8 +"""Optimization passes (the LLVM optimization-pass analogue).
9 +
10 +Opt-in (compile with optimize=True); the default pipeline is untouched.
11 +All three preserve the double-entry invariant, which the pass manager
12 +re-verifies after each of them, and full provenance: every optimized line's
13 +provenance node points back to the lines it absorbed.
14 +
15 +- DuplicateDetectionPass — events with identical economic content but
16 + different ids get a warning (AIR-W800); a human decides, nothing is dropped.
17 +- NettingPass — a Refund referencing a Sale compiled in the same batch is
18 + netted against it: one net entry (or none, when fully offset) replaces both.
19 +- FusionPass — N same-day payments with the same posting shape become one
20 + batch entry (50 identical payouts -> 1 entry, amounts summed per line).
21 +"""
22 +from __future__ import annotations
23 +
24 +import hashlib
25 +import json
26 +from collections import defaultdict
27 +from decimal import Decimal
28 +
29 +from aic.diagnostics import Diagnostic, Severity
30 +from aic.passes.base import Pass
31 +from aic.unit import CompilationUnit
32 +from core.events import EconomicEvent, EventType
33 +from core.journal import JournalEntry, JournalLine, Side
34 +from core.money import Money
35 +
36 +
37 +def _event_fingerprint(event: EconomicEvent) -> str:
38 + """Economic-content hash: identical business facts, ignoring id and meta."""
39 + payload = json.loads(event.model_dump_json(exclude_none=True))
40 + payload.pop("id", None)
41 + payload.pop("meta", None)
42 + return hashlib.sha256(
43 + json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
44 +
45 +
46 +class DuplicateDetectionPass(Pass):
47 + name = "duplicate-detection"
48 +
49 + def run(self, unit: CompilationUnit) -> list[Diagnostic]:
50 + diags: list[Diagnostic] = []
51 + by_content: dict[str, list[str]] = defaultdict(list)
52 + for event in unit.document.events:
53 + by_content[_event_fingerprint(event)].append(event.id)
54 + for ids in by_content.values():
55 + if len(ids) > 1:
56 + diags.append(Diagnostic(
57 + code="AIR-W800", severity=Severity.WARNING,
58 + message=(
59 + f"{len(ids)} events carry identical economic content: "
60 + + ", ".join(ids)
61 + ),
62 + location=f"events {ids[0]} .. {ids[-1]}",
63 + suggestion="if these are duplicates, remove all but one and "
64 + "recompile; if legitimate repeats, add a "
65 + "distinguishing description",
66 + origin_pass=self.name,
67 + ))
68 + return diags
69 +
70 +
71 +class NettingPass(Pass):
72 + """Net Refund entries against the Sale they reverse (same compilation)."""
73 +
74 + name = "netting"
75 +
76 + def run(self, unit: CompilationUnit) -> list[Diagnostic]:
77 + diags: list[Diagnostic] = []
78 + by_event = {e.source_event_id: e for e in unit.entries}
79 +
80 + for event in unit.document.events:
81 + if event.type is not EventType.REFUND or not event.related_event:
82 + continue
83 + refund_entry = by_event.get(event.id)
84 + sale_entry = by_event.get(event.related_event)
85 + if refund_entry is None or sale_entry is None:
86 + continue
87 +
88 + # per-(account, sale side): refund lines mirror sale lines, so a
89 + # refund debit offsets the sale's credit on the same account
90 + sale_amts: dict[tuple[str, Side], Money] = {
91 + (l.account.code, l.side): l.amount for l in sale_entry.lines}
92 + refund_amts: dict[tuple[str, Side], Money] = {}
93 + refund_prov: dict[tuple[str, Side], str | None] = {}
94 + for l in refund_entry.lines:
95 + mirrored = Side.CREDIT if l.side is Side.DEBIT else Side.DEBIT
96 + refund_amts[(l.account.code, mirrored)] = l.amount
97 + refund_prov[(l.account.code, mirrored)] = l.provenance_id
98 + if set(refund_amts) - set(sale_amts):
99 + continue # shapes differ; not safely nettable
100 + if any((sale_amts[k] - refund_amts[k]).is_negative()
101 + for k in refund_amts):
102 + continue # refund exceeds sale; leave both entries alone
103 +
104 + net_lines: list[JournalLine] = []
105 + for sale_line in sale_entry.lines:
106 + key = (sale_line.account.code, sale_line.side)
107 + refunded = refund_amts.get(key)
108 + net = (sale_line.amount - refunded) if refunded else sale_line.amount
109 + if net.is_zero():
110 + continue
111 + inputs = tuple(p for p in (sale_line.provenance_id,
112 + refund_prov.get(key)) if p)
113 + node = unit.provenance.define(
114 + kind="netting",
115 + operation=f"net:{event.related_event}-{event.id}",
116 + amount=net,
117 + inputs=inputs,
118 + )
119 + net_lines.append(JournalLine(
120 + account=sale_line.account, side=sale_line.side, amount=net,
121 + memo=f"net of {sale_entry.id} minus {refund_entry.id}",
122 + provenance_id=node.id,
123 + ))
124 +
125 + unit.entries.remove(sale_entry)
126 + unit.entries.remove(refund_entry)
127 + if net_lines:
128 + unit.entries.append(JournalEntry(
129 + id=f"je_net_{event.related_event}",
130 + date=refund_entry.date,
131 + description=(f"Netting: {sale_entry.description} "
132 + f"minus {refund_entry.description}"),
133 + lines=tuple(net_lines),
134 + source_event_id=f"net({event.related_event},{event.id})",
135 + policy_set=sale_entry.policy_set,
136 + policy_version=sale_entry.policy_version,
137 + ))
138 + detail = "netted into one entry"
139 + else:
140 + detail = "fully offset: no entry posted"
141 + diags.append(Diagnostic(
142 + code="AIR-N801", severity=Severity.NOTE,
143 + message=(f"refund '{event.id}' netted against sale "
144 + f"'{event.related_event}' ({detail})"),
145 + location=f"events {event.related_event}, {event.id}",
146 + origin_pass=self.name,
147 + ))
148 + return diags
149 +
150 +
151 +class FusionPass(Pass):
152 + """Fuse same-day payments with identical posting shape into one batch."""
153 +
154 + name = "fusion"
155 + FUSABLE = (EventType.PAYMENT_RECEIVED, EventType.PAYMENT_SENT)
156 +
157 + def run(self, unit: CompilationUnit) -> list[Diagnostic]:
158 + diags: list[Diagnostic] = []
159 + events_by_id = {e.id: e for e in unit.document.events}
160 +
161 + groups: dict[tuple, list[JournalEntry]] = defaultdict(list)
162 + for entry in unit.entries:
163 + event = events_by_id.get(entry.source_event_id)
164 + if event is None or event.type not in self.FUSABLE:
165 + continue
166 + shape = tuple(sorted(
167 + (l.account.code, l.side.value, l.amount.currency)
168 + for l in entry.lines))
169 + groups[(event.type, entry.date, shape)].append(entry)
170 +
171 + for (etype, date, shape), entries in groups.items():
172 + if len(entries) < 2:
173 + continue
174 + summed: dict[tuple[str, str, str], Decimal] = defaultdict(Decimal)
175 + accounts = {}
176 + inputs: dict[tuple[str, str, str], list[str]] = defaultdict(list)
177 + for entry in entries:
178 + for line in entry.lines:
179 + key = (line.account.code, line.side.value,
180 + line.amount.currency)
181 + summed[key] += line.amount.amount
182 + accounts[line.account.code] = line.account
183 + if line.provenance_id:
184 + inputs[key].append(line.provenance_id)
185 +
186 + batch_hash = hashlib.sha256(
187 + ",".join(sorted(e.id for e in entries)).encode("utf-8")
188 + ).hexdigest()[:8]
189 + lines = []
190 + for (code, side, ccy), amount in sorted(summed.items()):
191 + money = Money(amount, ccy)
192 + node = unit.provenance.define(
193 + kind="fusion",
194 + operation=f"fuse:{len(entries)}x{etype.value}",
195 + amount=money,
196 + inputs=tuple(inputs[(code, side, ccy)]),
197 + )
198 + lines.append(JournalLine(
199 + account=accounts[code], side=Side(side), amount=money,
200 + memo=f"batch of {len(entries)} {etype.value} entries",
201 + provenance_id=node.id,
202 + ))
203 + for entry in entries:
204 + unit.entries.remove(entry)
205 + unit.entries.append(JournalEntry(
206 + id=f"je_batch_{batch_hash}",
207 + date=date,
208 + description=f"Batch: {len(entries)} x {etype.value}",
209 + lines=tuple(lines),
210 + source_event_id=(
211 + "batch(" + ",".join(e.source_event_id for e in entries) + ")"),
212 + policy_set=entries[0].policy_set,
213 + policy_version=entries[0].policy_version,
214 + ))
215 + diags.append(Diagnostic(
216 + code="AIR-N800", severity=Severity.NOTE,
217 + message=(f"fused {len(entries)} {etype.value} entries dated "
218 + f"{date} into one batch entry (je_batch_{batch_hash})"),
219 + location=f"entries {', '.join(e.id for e in entries)}",
220 + origin_pass=self.name,
221 + ))
222 + return diags
added aic/passes/posting.py +241 −0
@@ -0,0 +1,241 @@
1 +# =============================================================================
2 +# Projet : AIR — Accounting Intermediate Representation
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : posting.py
6 +# Description : Posting pass — lowers economic events into balanced journal entries.
7 +# =============================================================================
8 +"""Posting pass: the lowering stage (AIR -> journal entries).
9 +
10 +Maps each economic event to a balanced double-entry journal entry using the
11 +account roles defined in the ALSL policy set. Every line carries a provenance
12 +node so any posted figure traces back to its origin.
13 +
14 +Posting rules (v0.1):
15 +- Sale DR cash/AR (total) CR revenue, CR tax payables
16 +- Refund DR revenue, DR taxes CR cash/AR (mirror of Sale)
17 +- Purchase DR expense/asset (+non-recoverable tax), DR recoverable taxes
18 + CR cash/AP
19 +- PaymentReceived DR cash (settled) CR AR (booked) [± realized FX]
20 +- PaymentSent DR AP (booked) CR cash (settled) [± realized FX]
21 +- OwnerContribution DR cash CR owner capital
22 +- LoanReceived DR cash CR loan payable
23 +"""
24 +from __future__ import annotations
25 +
26 +from aic.diagnostics import Diagnostic, Severity
27 +from aic.passes.base import Pass
28 +from aic.unit import CompilationUnit, EventState
29 +from core.events import EconomicEvent, EventType
30 +from core.journal import Account, JournalEntry, JournalLine, Side
31 +from core.money import Money
32 +
33 +
34 +class PostingPass(Pass):
35 + name = "posting"
36 +
37 + def run(self, unit: CompilationUnit) -> list[Diagnostic]:
38 + diags: list[Diagnostic] = []
39 + for event in unit.document.events:
40 + state = unit.event_state(event.id)
41 + if state.post_subtotal is None:
42 + continue # a previous pass already rejected this event
43 + try:
44 + lines = self._lower(unit, event, state, diags)
45 + except KeyError as exc:
46 + diags.append(Diagnostic(
47 + code="AIR-E600", severity=Severity.ERROR,
48 + message=str(exc.args[0]),
49 + location=f"event {event.id}",
50 + suggestion="add the missing account role to the policy set's "
51 + "accounts section",
52 + origin_pass=self.name,
53 + ))
54 + continue
55 + if not lines:
56 + continue
57 + unit.entries.append(JournalEntry(
58 + id=f"je_{event.id}",
59 + date=event.date,
60 + description=event.description or event.type.value,
61 + lines=tuple(lines),
62 + source_event_id=event.id,
63 + policy_set=unit.policies.name,
64 + policy_version=unit.policies.version,
65 + reverses=(
66 + f"je_{event.related_event}"
67 + if event.type is EventType.REFUND and event.related_event
68 + else None
69 + ),
70 + ))
71 + return diags
72 +
73 + # -- helpers ---------------------------------------------------------------
74 + def _line(
75 + self, unit: CompilationUnit, account: Account, side: Side,
76 + amount: Money, memo: str, parent_node: str | None,
77 + ) -> JournalLine:
78 + node = unit.provenance.define(
79 + kind="journal_line",
80 + operation=f"post:{side.value}:{account.code}",
81 + amount=amount,
82 + inputs=(parent_node,) if parent_node else (),
83 + )
84 + return JournalLine(
85 + account=account, side=side, amount=amount,
86 + memo=memo, provenance_id=node.id,
87 + )
88 +
89 + def _settlement_account(self, unit: CompilationUnit, event: EconomicEvent,
90 + counterparty_role: str) -> Account:
91 + immediate = bool(event.payment and event.payment.immediate)
92 + return unit.policies.account("cash" if immediate else counterparty_role)
93 +
94 + def _check_gross(self, event: EconomicEvent, computed_total: Money,
95 + diags: list[Diagnostic], pass_name: str) -> None:
96 + """Warn when the document's stated gross disagrees with the computed total."""
97 + if event.payment is None or event.payment.gross is None:
98 + return
99 + gross = event.payment.gross.to_money()
100 + if gross.currency != computed_total.currency:
101 + return # gross stated in transaction currency, total in functional
102 + if gross.amount != computed_total.amount:
103 + diags.append(Diagnostic(
104 + code="AIR-W600", severity=Severity.WARNING,
105 + message=(
106 + f"stated gross {gross} differs from computed total "
107 + f"{computed_total}; the computed total is authoritative"
108 + ),
109 + location=f"event {event.id}, field payment.gross",
110 + suggestion="check the source document; a small delta usually means "
111 + "the issuer rounded differently",
112 + origin_pass=pass_name,
113 + ))
114 +
115 + # -- lowering per event type -------------------------------------------------
116 + def _lower(
117 + self, unit: CompilationUnit, event: EconomicEvent,
118 + state: EventState, diags: list[Diagnostic],
119 + ) -> list[JournalLine]:
120 + pol = unit.policies
121 + assert state.post_subtotal is not None
122 + subtotal = state.post_subtotal
123 + sub_node = state.post_subtotal_node
124 + taxes = state.post_taxes
125 + total = subtotal
126 + for t in taxes:
127 + total = total + t.amount
128 +
129 + if event.type is EventType.SALE:
130 + self._check_gross(event, total, diags, self.name)
131 + counter = self._settlement_account(unit, event, "accounts_receivable")
132 + lines = [self._line(unit, counter, Side.DEBIT, total,
133 + "sale: consideration incl. taxes", sub_node)]
134 + lines.append(self._line(unit, pol.account("revenue"), Side.CREDIT,
135 + subtotal, "sale: revenue", sub_node))
136 + for t in taxes:
137 + lines.append(self._line(
138 + unit, pol.account(t.payable_role), Side.CREDIT, t.amount,
139 + f"{t.code} collected ({t.policy})", t.node_id))
140 + return lines
141 +
142 + if event.type is EventType.REFUND:
143 + counter = self._settlement_account(unit, event, "accounts_receivable")
144 + lines = [self._line(unit, pol.account("revenue"), Side.DEBIT,
145 + subtotal, "refund: revenue reversal", sub_node)]
146 + for t in taxes:
147 + lines.append(self._line(
148 + unit, pol.account(t.payable_role), Side.DEBIT, t.amount,
149 + f"{t.code} refunded ({t.policy})", t.node_id))
150 + lines.append(self._line(unit, counter, Side.CREDIT, total,
151 + "refund: consideration incl. taxes", sub_node))
152 + return lines
153 +
154 + if event.type is EventType.PURCHASE:
155 + debit_role = state.classification_role or "expense_default"
156 + debit_base = subtotal
157 + recoverable = [t for t in taxes if t.recoverable]
158 + for t in taxes:
159 + if not t.recoverable:
160 + debit_base = debit_base + t.amount # non-recoverable tax is a cost
161 + memo = (
162 + f"purchase: capitalized ({state.classification_policy})"
163 + if state.classify_as == "asset" else "purchase: cost"
164 + )
165 + lines = [self._line(unit, pol.account(debit_role), Side.DEBIT,
166 + debit_base, memo, sub_node)]
167 + for t in recoverable:
168 + lines.append(self._line(
169 + unit, pol.account(t.receivable_role), Side.DEBIT, t.amount,
170 + f"{t.code} recoverable ({t.policy})", t.node_id))
171 + counter = self._settlement_account(unit, event, "accounts_payable")
172 + lines.append(self._line(unit, counter, Side.CREDIT, total,
173 + "purchase: consideration incl. taxes", sub_node))
174 + self._check_gross(event, total, diags, self.name)
175 + return lines
176 +
177 + if event.type is EventType.PAYMENT_RECEIVED:
178 + settled = subtotal
179 + booked = state.booked_amount or settled
180 + lines = [self._line(unit, pol.account("cash"), Side.DEBIT, settled,
181 + "payment received", sub_node)]
182 + lines.append(self._line(
183 + unit, pol.account("accounts_receivable"), Side.CREDIT, booked,
184 + f"settles {event.related_event or 'receivable'}", sub_node))
185 + diff = state.fx_diff
186 + if diff is not None and not diff.is_zero():
187 + if diff.is_negative():
188 + lines.append(self._line(unit, pol.account("fx_loss"),
189 + Side.DEBIT, -diff,
190 + "realized FX loss", state.fx_diff_node))
191 + else:
192 + lines.append(self._line(unit, pol.account("fx_gain"),
193 + Side.CREDIT, diff,
194 + "realized FX gain", state.fx_diff_node))
195 + return lines
196 +
197 + if event.type is EventType.PAYMENT_SENT:
198 + settled = subtotal
199 + booked = state.booked_amount or settled
200 + lines = [self._line(
201 + unit, pol.account("accounts_payable"), Side.DEBIT, booked,
202 + f"settles {event.related_event or 'payable'}", sub_node)]
203 + diff = state.fx_diff
204 + if diff is not None and not diff.is_zero():
205 + if diff.is_negative():
206 + # paying fewer functional units than booked -> gain
207 + lines.append(self._line(unit, pol.account("fx_gain"),
208 + Side.CREDIT, -diff,
209 + "realized FX gain", state.fx_diff_node))
210 + else:
211 + lines.append(self._line(unit, pol.account("fx_loss"),
212 + Side.DEBIT, diff,
213 + "realized FX loss", state.fx_diff_node))
214 + lines.append(self._line(unit, pol.account("cash"), Side.CREDIT, settled,
215 + "payment sent", sub_node))
216 + return lines
217 +
218 + if event.type is EventType.OWNER_CONTRIBUTION:
219 + return [
220 + self._line(unit, pol.account("cash"), Side.DEBIT, subtotal,
221 + "owner contribution", sub_node),
222 + self._line(unit, pol.account("owner_capital"), Side.CREDIT, subtotal,
223 + "owner contribution", sub_node),
224 + ]
225 +
226 + if event.type is EventType.LOAN_RECEIVED:
227 + return [
228 + self._line(unit, pol.account("cash"), Side.DEBIT, subtotal,
229 + "loan proceeds", sub_node),
230 + self._line(unit, pol.account("loan_payable"), Side.CREDIT, subtotal,
231 + "loan principal", sub_node),
232 + ]
233 +
234 + diags.append(Diagnostic(
235 + code="AIR-E601", severity=Severity.ERROR,
236 + message=f"no posting rule for event type '{event.type.value}'",
237 + location=f"event {event.id}",
238 + suggestion="extend the posting pass or use a supported event type",
239 + origin_pass=self.name,
240 + ))
241 + return []
added aic/passes/tax.py +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added aic/passes/validation.py +109 −0
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
added aic/unit.py +81 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added alsl/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added alsl/evaluator.py +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added alsl/loader.py +144 −0
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
added alsl/model.py +101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added alsl/policies/ca-qc-2026.yaml +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added backends/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added backends/base.py +75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added backends/generic_csv/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added backends/generic_csv/backend.py +121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added backends/native/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added backends/native/backend.py +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added backends/quickbooks/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added backends/quickbooks/backend.py +94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added backends/quickbooks/mapper.py +114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added backends/quickbooks/transport.py +147 −0
Large diff (147 lines) is not loaded by default to keep the page fast.Load diff
added core/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added core/document_io.py +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added core/events.py +178 −0
Large diff (178 lines) is not loaded by default to keep the page fast.Load diff
added core/invariant.py +116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added core/journal.py +90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added core/money.py +121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added core/provenance.py +117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added docs/adr/0001-implementation-language.md +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added docs/adr/0002-economic-event-model.md +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added docs/adr/0003-monetary-amounts.md +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added docs/cli-reference.md +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added docs/research/accounting-data-standards.md +164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added docs/research/accounting-standards.md +132 −0
Large diff (132 lines) is not loaded by default to keep the page fast.Load diff
added docs/research/bank-statement-formats.md +447 −0
Large diff (447 lines) is not loaded by default to keep the page fast.Load diff
added docs/research/canada-gst-qst.md +240 −0
Large diff (240 lines) is not loaded by default to keep the page fast.Load diff
added docs/research/erp-apis.md +194 −0
Large diff (194 lines) is not loaded by default to keep the page fast.Load diff
added docs/research/fx-handling.md +96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added docs/research/ledger-engines.md +216 −0
Large diff (216 lines) is not loaded by default to keep the page fast.Load diff
added docs/research/llm-structured-extraction.md +116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added docs/research/llvm-architecture.md +204 −0
Large diff (204 lines) is not loaded by default to keep the page fast.Load diff
added docs/spec/air-spec-v0.1.md +240 −0
Large diff (240 lines) is not loaded by default to keep the page fast.Load diff
added ingestion/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added ingestion/approval.py +134 −0
Large diff (134 lines) is not loaded by default to keep the page fast.Load diff
added ingestion/extractor.py +233 −0
Large diff (233 lines) is not loaded by default to keep the page fast.Load diff
added ingestion/pipeline.py +102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added kernel/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added kernel/audit.py +88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added kernel/bank_formats.py +222 −0
Large diff (222 lines) is not loaded by default to keep the page fast.Load diff
added kernel/ledger.py +203 −0
Large diff (203 lines) is not loaded by default to keep the page fast.Load diff
added kernel/reconcile.py +139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added kernel/reporting.py +187 −0
Large diff (187 lines) is not loaded by default to keep the page fast.Load diff
added kernel/workspace.py +126 −0
Large diff (126 lines) is not loaded by default to keep the page fast.Load diff
added pyproject.toml +66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added schemas/air-0.1.schema.json +565 −0
Large diff (566 lines) is not loaded by default to keep the page fast.Load diff
added scripts/check_headers.py +74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added sdk/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added sdk/cli.py +453 −0
Large diff (453 lines) is not loaded by default to keep the page fast.Load diff
added sdk/demo_agent.py +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added sdk/syscalls.py +230 −0
Large diff (230 lines) is not loaded by default to keep the page fast.Load diff
added tests/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/bank_statement.csv +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/bank_statement.mt940 +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/bank_statement.xml +64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/demo_document.yaml +72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/invoice_high_confidence.txt +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/invoice_low_confidence.txt +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/invoice_realistic.txt +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added tests/golden/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added tests/golden/cases/fx_payments_equity.yaml +181 −0
Large diff (181 lines) is not loaded by default to keep the page fast.Load diff
added tests/golden/cases/purchases.yaml +78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added tests/golden/cases/sales_and_refunds.yaml +148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
added tests/golden/test_golden.py +93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added tests/property/__init__.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added tests/property/test_balance_invariant.py +118 −0
Large diff (118 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_core.py +221 −0
Large diff (221 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_incremental.py +128 −0
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_ingestion.py +152 −0
Large diff (152 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_llm_live.py +74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_optimize.py +130 −0
Large diff (130 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_quickbooks.py +140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_reconcile.py +164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_syscalls.py +188 −0
Large diff (188 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_workspace.py +105 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme