One parser for Monzo, Halifax, Revolut and HSBC: how heuristic matching beat four bespoke importers

The Halifax PDF has 34 transactions on page 3. The 4-bit quantised Llama 3.2 1B model running on-device via mlx-swift didn’t find 34. The devlog records this as hallucinating “at a destructive rate on real statements.” That’s the right framing. If your import path fabricates transactions, drops them, or merges two into one, the resulting dataset is worse than no data at all. You can’t reconcile what you can’t trust.

That finding, on a real Halifax PDF, ended the on-device LLM as the primary extraction route. PF-002 disabled it.

Why the LLM path ran at all

Phase H was a spike. The hypothesis: an on-device model could handle structured PDF extraction without per-bank templates. General enough for any statement format, private because no data leaves the device, fast enough for interactive import. A personal finance app has a specific constraint here: bank statement content should not route through a server unless the user has explicitly authorised it. On-device inference looked like the right answer to that constraint.

ios/project.yml added mlx-swift 0.21.2 and mlx-swift-examples as Swift Package dependencies. First inference ran on AnF. The path was live before it was validated against ground truth.

That sequencing is the honest version of what happened: wire it in, then check it. The model loaded. It ran. It produced output. The output looked plausible until you counted.

The structural problem with a 1B parameter model at 4-bit quantisation on PDF text is that PDF content is not prose. It is a stream of positional text fragments. A bank statement has an implicit table structure of date, description, and amount on each row. That structure exists in the visual rendering but not in raw text extraction. Correctly assigning fragments to rows requires spatial reasoning from sequential data. A model this small, this compressed, doesn’t do that reliably. It guesses. The guesses are confident. Some are wrong. The output stream carries no signal distinguishing the correct guesses from the wrong ones.

Thirty-four transactions. Wrong count. PF-002.

What replaced it

The band-based parser doesn’t guess. It reads Y-coordinates directly from the PDF rendering layer. Text fragments sharing a vertical position belong to the same row. Within a row, X-position determines the column. Date range, description range, amount range; each maps to a horizontal band derived from the actual document geometry.

Halifax PDF: 54 transactions, 54 correct. The parser carries no Halifax-specific logic. The band boundaries come from observing the document structure, not from a template authored upfront. A different bank’s PDF with a different layout produces different band measurements. The same extraction logic applies. Measure the document, extract by position, assume nothing the document doesn’t state.

The tradeoff is acknowledged in the devlog: “parked at v1 per Cj.” Band-based parsing is deterministic on a known layout. If a bank changes its statement structure between versions, the band measurements need updating. That’s a real maintenance surface, smaller than maintaining four bespoke importers, but not zero.

The CSV route

CSV import across four banks is a column-mapping problem. The exports share a conceptual schema of date, description, amount, and direction, but the column names vary. Consistently within each bank; enough variation across banks that a direct header match fails.

HeuristicMapper handles this in two layers. The preset signature match is the fast path: each bank’s CSV export has a fingerprint derived from its column headers. If the incoming file matches a known signature, the column map is already known and mapping is immediate. No scoring, no inference, no ambiguity.

The fallback fires for unrecognised formats: a bank not in the preset library, a header that changed in a bank’s latest export update. The heuristic normalises headers and scores candidates against known patterns. Drift, like a renamed column in next quarter’s export, is handled without a code change. Adding a fifth bank is, at most, adding one preset signature. If the headers are close enough to existing patterns, it may not need even that.

Monzo, Halifax, Revolut, HSBC. All validated in PR #13. PI-003 closed. Zero per-bank code paths on the CSV side.

Three dedup schemes into one

Before PR #13, the codebase had three coexisting hash schemes for deduplication. PDFs used positionHash(accountId, fileHash, page, row), a SHA hex identifying a transaction by its position within a specific source document. CSVs used a different scheme. Earlier import work had introduced a third. Each was reasonable in the context of the import type being built at the time. Collectively they were incoherent.

Three schemes means three surfaces for bugs, three places to update on any data model change, three possible answers to “have I imported this before?” depending on which code path fired.

PR #13 collapses all three to one canonical hash. CDImportSource lands alongside it as the source-document attribution layer: every imported transaction carries a reference to the file it came from, whether CSV or PDF. The dedup key is derived consistently across both types. One definition of duplicate.

The attribution landed end-to-end on AnF. Every transaction now traces back to its import file. A duplicated transaction has a known origin, useful for debugging now and for any future surface that exposes import provenance.

What this looks like in practice

One import flow. Any file from any of the four banks. The parser identifies the format via signature match (CSV) or layout observation (PDF). It maps to the canonical schema, runs dedup against the unified hash, and attributes each transaction to its source document. No conditional branch on the bank. No separate importer per format.

Phase Q’s smart category propagation already runs on this foundation. Three identical “Driver & Vehicle Licensing Agency” transactions at −£17.06: categorise one, the rule propagates to the others. That works cleanly only when the underlying import data is uniform. Per-bank quirks in the import layer surface as per-bank edge cases in the category layer. Uniform input, uniform output.

What’s explicitly parked

PDF scope: the band-based parser is validated on Halifax. Revolut and HSBC are next, closing PI-004. Beyond that, further PDF coverage is the next iteration.

On-device LLM: disabled at PF-002, not removed. The mlx-swift dependency stays in the project. The band-based parser is correct for the current validated set. Whether a future model tier handles positional PDF extraction reliably is a question for a later sprint, not this one.

Layout variance: the band-based parser derives band boundaries from a given document structure. If a bank revises its statement layout between versions, the observations need updating. No detection or adaptation mechanism exists yet. Noted; not this sprint.

All three are real problems. All three are known. None are in scope because the current implementation handles the validated cases correctly, and extending it further trades against the cost of the next features this import foundation is being built to support.

All writing