reconcile-py
Deterministic, explainable fuzzy reconciliation of financial record sets.
reconcile-py matches one set of records against another — bank transactions against invoices or ledger entries, and any similar two-sided problem — under tolerance rules you configure. Every run is deterministic (same input + config → identical output), every match is explainable (it records why it matched), and genuinely uncertain matches are flagged for review instead of guessed. The engine is a generic pairwise matcher: it has no hardcoded bank/invoice/ledger types — you supply your own records.
Status:
0.1.0, pre-release. Not yet published to PyPI (install from source — see Installation).
Key Features
- Configurable tolerance — exact, absolute, or percentage amount tolerance; a ± day date window; fuzzy text matching on references and counterparties.
- Gates and grades — amount/date are hard gates (a pair outside tolerance can never match); reference/counterparty are graded (0..1 similarity that feeds a score).
- 1:1 and split (1:many) matches — batch settlements (one payment → many invoices) and installments (one invoice → many payments), searched in both directions.
- Deterministic & replayable — no wall-clock, no randomness; stable ordering by record id then input position.
- Explainable — every match carries the gates it passed and each field's similarity.
- Ambiguity surfacing — near-tie matches are flagged, not silently resolved.
- Pluggable scorer — zero-dependency
difflibdefault; drop in your own (e.g.rapidfuzz) via config. - Typed, pure, zero-core-dependencies — ships
py.typed; the engine imports nothing outside the standard library. pandas is an optional adapter.
Table of Contents
- How It Works
- Tech Stack
- Requirements
- Installation
- Quick Start
- Usage Guide
- API Reference
- Determinism & Explainability
- Project Structure
- Design Decisions (ADRs)
- Testing
- Packaging & Publishing
- Troubleshooting
- Scope & Roadmap
- Contributing
- License
How It Works
A match pass takes two record sets (a Left set and a Right set) plus a ReconcileConfig, and returns a ReconcileResult. The engine treats the two sides symmetrically — which real-world type sits on which side is your choice.
┌─────────────────────────────────────────────┐
left, right ──▶ │ 1. GATES amount + date within tolerance? │ fail ─▶ drop candidate
│ 2. GRADES weighted-mean similarity of │
│ reference / counterparty (0..1) │
│ 3. THRESHOLD score ≥ threshold? │ fail ─▶ drop candidate
│ 4. RESOLVE greedy best-first, consume-once, │
│ near-ties flagged ambiguous │
│ 5. SPLITS bounded subset-sum over residuals │
│ (1:many, both directions) │
└─────────────────────────────────────────────┘
│
▼
ReconcileResult(matches, left_residuals, right_residuals)
- Gates (
amount,date) are pass/fail. A pair outside any gate's tolerance is rejected before scoring — amount is a gate, never a weight, so a perfect reference string can't rescue a wrong amount. - Grades (
reference,counterparty) each produce a 0..1 similarity. The match score is their weighted mean (1.0 when no graded fields are configured). - A candidate is accepted only when it passes all gates and
score ≥ threshold. - Accepted candidates are resolved greedy best-first; each record is consumed by at most one match; score ties break by record id then input position. When the top two candidates for a record are within
ambiguity_epsilon, the match is flagged ambiguous rather than chosen silently. - Whatever the 1:1 pass leaves over is searched for splits: subsets of residuals whose amounts sum, within tolerance, to an opposite-side residual — bounded so pathological inputs stay residual instead of hanging.
The domain vocabulary (Match pass, Record, Gate/Graded field, Similarity, Residual, Ambiguous match, …) is defined in CONTEXT.md. The reasoning behind each design choice lives in docs/adr/ — see Design Decisions.
Tech Stack
- Language: Python ≥ 3.10
- Core dependencies: none (standard library only —
difflib,decimal,itertools,datetime) - Optional:
pandas≥ 2.0 (the DataFrame adapter only) - Testing:
pytest - Packaging:
setuptools(src/layout), typed (py.typed), PyPI-ready
Requirements
- Python 3.10 or higher
- pip (and, for the optional adapter,
pandas>=2.0) - No database, no services, no environment variables — it is a pure library.
Installation
The package is not yet on PyPI. Install from source:
# Clone
git clone https://github.com/duongdanghung20/reconcile-py.git
cd reconcile-py
# Core engine only (no third-party dependencies)
pip install -e .
# With the optional pandas adapter
pip install -e '.[pandas]'
Or install directly from the repository without cloning:
pip install "git+https://github.com/duongdanghung20/reconcile-py.git"
Once published, this becomes:
pip install reconcile-py # core
pip install 'reconcile-py[pandas]' # with the DataFrame adapter
Quick Start
from datetime import date
from decimal import Decimal
from reconcile import FieldRule, ReconcileConfig, Record, reconcile
# Bank side and invoice side — you choose which is "left" and which is "right".
bank = [
Record(Decimal("100.00"), date(2024, 1, 5), reference="INV-1024", id="B1"),
Record(Decimal("250.00"), date(2024, 1, 9), reference="ACME wire", id="B2"),
]
invoices = [
Record(Decimal("100.00"), date(2024, 1, 4), reference="inv 1024", id="I1"),
Record(Decimal("250.00"), date(2024, 1, 30), reference="ACME", id="I2"), # date too far
]
config = ReconcileConfig(
rules=(
FieldRule("amount", "gate", abs_tol=Decimal("0.00")), # amounts must match to the cent
FieldRule("date", "gate", day_tol=3), # within ± 3 days
FieldRule("reference", "grade", weight=1.0), # fuzzy-match the reference text
),
threshold=0.6,
)
result = reconcile(bank, invoices, config)
for m in result.matches:
print(f"match {m.left_ids} <-> {m.right_ids} score={m.score:.2f} ambiguous={m.ambiguous}")
print("left residuals :", [r.id for r in result.left_residuals])
print("right residuals:", [r.id for r in result.right_residuals])
Output:
match ('B1',) <-> ('I1',) score=1.00 ambiguous=False
left residuals : ['B2']
right residuals: ['I2']
B1/I1 match: same amount, one day apart, "INV-1024" vs "inv 1024" normalizes to an identical string. B2/I2 do not: the amounts and references agree, but the dates are 21 days apart, and date is a gate.
Usage Guide
Building a config
A ReconcileConfig is one immutable object describing the whole pass: the field rules plus global knobs. It is validated at construction — an invalid config raises ValueError immediately, rather than misbehaving mid-pass.
config = ReconcileConfig(
rules=(
FieldRule("amount", "gate", abs_tol=Decimal("0.01")),
FieldRule("date", "gate", day_tol=5),
FieldRule("reference", "grade", weight=2.0), # weigh reference twice as heavily
FieldRule("counterparty", "grade", weight=1.0),
),
threshold=0.8,
ambiguity_epsilon=0.05,
max_subset_size=4,
max_group_size=16,
)
An Amount gate rule is mandatory — a config without one raises ValueError.
Criteria fields
| Field | Allowed role(s) | Tolerance / weight | Notes |
|---|---|---|---|
amount |
gate |
abs_tol (Decimal) and/or pct_tol (Decimal, in percent) |
Mandatory gate. If neither tolerance is set, requires an exact match. pct_tol is measured against the larger of the two amounts. |
date |
gate |
day_tol (int, required for a date gate) |
Matches within ± day_tol days (inclusive). |
reference |
grade |
weight (float, default 1.0) |
Fuzzy text. Normalized (case-folded, punctuation/whitespace stripped) before scoring. |
counterparty |
grade |
weight (float, default 1.0) |
Fuzzy text, same normalization. |
amount/date can only be gates; reference/counterparty can only be graded. Violations raise ValueError at config construction.
Graded matching and the threshold
The match score is the weighted mean of the configured graded fields' similarities. A candidate is accepted only if it clears both the gates and threshold.
- Default
thresholdis1.0— with graded fields configured, that means only a perfect similarity match is accepted. Lower it to admit fuzzier matches (the Quick Start uses0.6). - A gate-only config (no graded fields) gives every gate-passing pair a score of
1.0.
Split (1:many) matches
Split search runs after the 1:1 pass, on the residuals. For a residual, the engine groups opposite-side residuals that clear its gates, then looks for a subset whose amounts sum within tolerance of it. It searches both directions — one left → many right, and one right → many left.
config = ReconcileConfig(
rules=(FieldRule("amount", "gate", abs_tol=Decimal("0.00")),),
max_subset_size=4, # a split has 2..4 members
max_group_size=16, # groups larger than this are skipped (stay residual)
)
payments = [Record(Decimal("300.00"), id="P1")] # one payment
invoices = [Record(Decimal("100.00"), id="I1"),
Record(Decimal("200.00"), id="I2")] # covers two invoices
result = reconcile(payments, invoices, config)
# -> one match: left_ids=('P1',), right_ids=('I1', 'I2')
The max_subset_size / max_group_size bounds keep the (otherwise exponential) subset-sum search finite: above the group cap, a record is left as a residual rather than triggering a blow-up.
Ambiguous matches
When a record's best two candidates score within ambiguity_epsilon of each other, the winning match is flagged ambiguous=True instead of being chosen silently. Ambiguous matches stay in result.matches; result.ambiguous is a convenience view over just those.
config = ReconcileConfig(rules=(...), threshold=0.7, ambiguity_epsilon=0.05)
result = reconcile(left, right, config)
for m in result.ambiguous:
print("needs review:", m.left_ids, m.right_ids, m.score)
ambiguity_epsilon defaults to 0.0 (flag only exact-tie competitors).
A custom scorer
A Scorer is any Callable[[str, str], float] returning a 0..1 similarity. The default normalizes strings and uses difflib.SequenceMatcher. Swap in your own — for example rapidfuzz for speed or token-set matching — via config.scorer:
from rapidfuzz.fuzz import token_set_ratio
config = ReconcileConfig(
rules=(FieldRule("amount", "gate", abs_tol=Decimal("0")),
FieldRule("counterparty", "grade")),
scorer=lambda a, b: token_set_ratio(a, b) / 100.0, # rapidfuzz returns 0..100
)
The pandas adapter
If your data lives in DataFrames, the optional adapter converts to and from Records so pandas never touches the engine.
from reconcile import ReconcileConfig, FieldRule, reconcile
from reconcile.pandas_adapter import from_dataframe, to_dataframe
from decimal import Decimal
left = from_dataframe(bank_df, amount="amt", date="posted", reference="memo", id="txn_id")
right = from_dataframe(invoice_df, amount="total", date="issued", reference="number", id="inv_no")
result = reconcile(left, right, ReconcileConfig(
rules=(FieldRule("amount", "gate", abs_tol=Decimal("0.00")),
FieldRule("date", "gate", day_tol=3)),
))
frames = to_dataframe(result) # NamedTuple(matches, left_residuals, right_residuals)
frames.matches.to_csv("matches.csv", index=False)
The adapter owns pandas' dtype quirks: NaN/NaT become absent optional fields, float amounts are converted through str so they land on their exact decimal value, and a numeric date column (ambiguous encoding) is rejected rather than silently misread — convert it with pd.to_datetime(...) first.
Importing
reconcile.pandas_adapterrequires pandas; importingreconcilenever does.
API Reference
Everything below is importable from the top-level reconcile package.
reconcile(left, right, config) -> ReconcileResult
The single entrypoint. left and right are sequences of Record; config is a ReconcileConfig. Pure and deterministic.
Record
Frozen dataclass — one entry on either side of a pass.
| Field | Type | Default | Notes |
|---|---|---|---|
amount |
Decimal |
— | Mandatory. Signed. |
date |
date | None |
None |
|
reference |
str | None |
None |
|
counterparty |
str | None |
None |
|
id |
str | None |
None |
Stable identity; falls back to input position when absent. |
FieldRule
Frozen dataclass — how one criteria field participates.
| Field | Type | Notes |
|---|---|---|
field |
"amount" | "date" | "reference" | "counterparty" |
|
role |
"gate" | "grade" |
|
abs_tol |
Decimal | None |
Amount gate: absolute tolerance. |
pct_tol |
Decimal | None |
Amount gate: percentage tolerance (e.g. Decimal("0.5") = 0.5%). |
day_tol |
int | None |
Date gate: ± day window (required for a date gate). |
weight |
float | None |
Graded field weight (default 1.0; must be positive). |
ReconcileConfig
Frozen dataclass — the full description of a pass.
| Field | Type | Default | Notes |
|---|---|---|---|
rules |
tuple[FieldRule, ...] |
— | Must include an Amount gate rule. |
threshold |
float |
1.0 |
Minimum score to accept a match. |
ambiguity_epsilon |
float |
0.0 |
Near-tie band for ambiguity flagging (≥ 0). |
max_subset_size |
int |
4 |
Max members in a split (≥ 2). |
max_group_size |
int |
16 |
Max split-search group before a record stays residual (≥ 1). |
scorer |
Scorer | None |
None |
Custom similarity function; defaults to normalized difflib. |
Match
Frozen dataclass. left_ids / right_ids are tuples (carrying both 1:1 and split matches, either direction); each id is the record's id, or its input position when absent.
| Field | Type |
|---|---|
left_ids |
tuple[str | int, ...] |
right_ids |
tuple[str | int, ...] |
score |
float |
reasons |
Reason |
ambiguous |
bool |
Reason
Frozen dataclass — why a match was made. gates_passed: tuple[str, ...], similarities: tuple[tuple[str, float], ...] (field, similarity).
ReconcileResult
Frozen dataclass. matches: tuple[Match, ...], left_residuals: tuple[Record, ...], right_residuals: tuple[Record, ...], and a computed ambiguous property (the matches flagged ambiguous).
Scorer
Type alias: Callable[[str, str], float] returning a 0..1 similarity.
Determinism & Explainability
Determinism is a hard guarantee, not a best effort (see ADR-0004):
- The engine uses no wall-clock time and no randomness.
- Output ordering is stable: by record
id, then input position. Shuffling the input order of either set produces a byte-identicalReconcileResult(this is locked by a property test). - Every
Matchcarries aReason(which gates passed, each graded similarity), so a result explains itself without re-running.
"Replayable" means re-running reproduces the result — the library does not persist a decision log; that is the consuming application's job.
Project Structure
reconcile-py/
├── src/reconcile/
│ ├── __init__.py # public API surface (re-exports)
│ ├── types.py # frozen domain types + all config validation
│ ├── engine.py # the pure pairwise match engine (reconcile)
│ ├── pandas_adapter.py # optional pandas edge (from_dataframe/to_dataframe)
│ └── py.typed # PEP 561 typing marker
├── tests/ # one file per capability, all through a public seam
│ ├── test_reconcile_seam.py
│ ├── test_graded_scoring.py
│ ├── test_greedy_resolution.py
│ ├── test_split_matches.py
│ ├── test_determinism_property.py
│ └── test_dataframe_adapter.py
├── docs/adr/ # architecture decision records (0001–0008)
├── CONTEXT.md # domain glossary
├── CLAUDE.md # guidance for AI coding agents
├── pyproject.toml
└── README.md
Two seams: the reconcile() call (the engine) and the adapter functions (from_dataframe/to_dataframe). Tests assert through these only, never engine internals.
Design Decisions (ADRs)
Each decision, and why it was made, is recorded under docs/adr/:
| ADR | Decision |
|---|---|
| 0001 | Generic pairwise engine — no hardcoded bank/invoice/ledger types. |
| 0002 | Typed records are the core contract; pandas is an edge adapter. |
| 0003 | Gate fields prune, graded fields score. |
| 0004 | Deterministic and explainable, but no persisted audit log. |
| 0005 | Weighted-mean score; pluggable scorer, difflib by default. |
| 0006 | Greedy resolution, near-ties flagged rather than guessed. |
| 0007 | Split matches after the 1:1 pass, bounded subset-sum. |
| 0008 | Percentage amount tolerance uses the larger of the two amounts. |
Testing
# Run the full suite (pythonpath and testpaths are set in pyproject.toml)
python -m pytest
# A single file
python -m pytest tests/test_split_matches.py
# Match by name
python -m pytest -k determinism
# Verbose
python -m pytest -v
The suite is 64 tests across six files, one per capability, all asserting external behavior through a public seam. Because the engine is a pure function, the tests are plain input/output assertions — no fixtures, no mocks. test_determinism_property.py locks the reproducibility guarantee by shuffling inputs and asserting identical results.
Packaging & Publishing
The project is configured for PyPI (src/ layout, py.typed, metadata in pyproject.toml).
# Build sdist + wheel
pip install build
python -m build # writes dist/reconcile_py-<version>.tar.gz and .whl
# Check and upload (first publish sets up the PyPI project)
pip install twine
twine check dist/*
twine upload dist/* # needs a PyPI API token
Bump version in pyproject.toml before each release. A PyPI API token is required for upload — store it in ~/.pypirc or a CI secret; never commit it.
Troubleshooting
ValueError: ReconcileConfig requires an Amount gate rule
Every config must include FieldRule("amount", "gate", ...). Amount is mandatory.
ValueError: 'reference' cannot be a gate field / 'amount' cannot be a graded field
Only amount/date are gates; only reference/counterparty are graded.
ValueError: date gate rule requires day_tol
A date gate needs a window: FieldRule("date", "gate", day_tol=3).
Nothing matches even though the data looks close
The default threshold is 1.0. With graded fields configured, only a perfect similarity clears it — lower threshold. Also confirm amounts are within tolerance: with no abs_tol/pct_tol, the amount gate requires an exact match.
Float amounts silently miss the amount gate
Build Record.amount from Decimal, and from a string, not a float: Decimal("100.10"), not Decimal(100.10) (which carries binary-float noise). The pandas adapter already converts through str for you.
ValueError: date column holds a numeric value ... (pandas adapter)
A numeric date column's encoding is ambiguous (Excel serial? epoch? YYYYMMDD?). Parse it explicitly with pd.to_datetime(...) before from_dataframe.
ModuleNotFoundError: No module named 'pandas'
The adapter needs pandas: pip install 'reconcile-py[pandas]' (or pip install pandas). The core engine does not.
Scope & Roadmap
Deliberately out of scope (see the ADRs and .scratch/reconcile-engine/spec.md):
- Full many-to-many matching (only 1:1 and 1:many are supported).
- A persisted decision/event log (the consuming app persists results itself).
- Global optimal assignment (Hungarian) — greedy + ambiguity flagging is the chosen trade-off.
- A non-stdlib scorer as the default (
rapidfuzzis available as a caller-supplied scorer).
Tracked shortcuts, to revisit only if real data demands it: swap difflib → rapidfuzz if similarity quality/speed falls short; a smarter split-search solver if a consumer hits the subset/group cap.
Contributing
- Install with the dev/adapter extra:
pip install -e '.[pandas]'. - Read
CONTEXT.md(vocabulary) and the relevant ADRs before changing behavior — use the glossary's terms. - Follow the existing test pattern: assert through a public seam, never engine internals; determinism must hold.
- Put new config invariants in
ReconcileConfig.__post_init__(fail loud at construction, not deep in the engine). python -m pytestmust be green before you open a PR.
License
MIT © 2026 duongdanghung20.
Release files for reconcile-py 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| reconcile_py-0.1.0.tar.gz | 36.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| reconcile_py-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 57.4 kB
Release files / reconcile_py-0.1.0.tar.gz
| Download URL | reconcile_py-0.1.0.tar.gz |
|---|---|
| Size | 36.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f86844d377f0457ff0e6d08b2bc9082e23dd27b290c1fabf2b8ad9cbaaf1cad9
|
|
BLAKE2b-256 checksum How to use checksums |
4222a9cad7f80f51896f156a4d24f9c5c9704e42bac2d87abf13521129f01f91
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.5
|
Release files / reconcile_py-0.1.0-py3-none-any.whl
| Download URL | reconcile_py-0.1.0-py3-none-any.whl |
|---|---|
| Size | 20.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
81805ef4ccb2acfc657a77ea8308c24f18027a2bbe9fdc3eb0b7c997d4ac7416
|
|
BLAKE2b-256 checksum How to use checksums |
ebf72f4bbe49f33f437f76f3638fd54c7147f76862ad14d0f6438b621bcbe9d6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.5
|