In short
- Problem
- Can a finance app understand your money without ever being told what it is — no account linking, no server, no upload?
- Constraint
- Zero bytes out. A phone rather than a datacentre, and a model that weighs about a gigabyte.
- Decided
- Rules first, model second. Stub the engine in phase 0 before it existed. Four functions across the native boundary. Constrain generation with a grammar, then validate it anyway. Converge PDF onto the CSV pipeline.
- Rejected
- Building the LLM layer first because it is the interesting part — an ordering that would have made every other phase wait on it.
- Hardest
- Running a 4-bit Gemma-3 inside a phone's memory budget behind a hand-written FFI bridge, and giving the memory back afterwards.
- Outcome
- A working deterministic-first cascade at phase 4 of 5. Phase 5 is blocked on measurement from real use, deliberately. Unreleased, and no usage to report.
Question
Can an app understand your money without ever being told what it is?
India's financial life is UPI-heavy and SMS-native. A person's transaction history is scattered across bank SMS alerts — each bank with its own template and its own registered sender-ID conventions — UPI app notifications, PDF statements from banks and card issuers, and the occasional CSV export from a net-banking portal.
Every tool that stitches that together asks for the same thing in return: upload it all to our servers. And a transaction history is a startlingly complete portrait of a person — where they go, what they eat, who they pay, when they are struggling. It is a granularity most people would not hand to a stranger, and do, because the alternative is a spreadsheet.
So the question is not whether the categorisation can be done. It is whether it can be done without the data moving — whether the intelligence can come to the data instead of the data going to the intelligence.
Constraint
Zero bytes out. A phone, not a datacentre. And a model that weighs about a gigabyte.
The privacy rule is absolute rather than aspirational, and stating it that way removes every convenient option at once: no server-side parsing, no cloud model call, no analytics on transaction content, no "we only send anonymised data." Nothing leaves. That is the whole specification, and everything downstream is a consequence of it.
What remains has to run on a mid-range Android phone that is also running everything else its owner cares about. A resident model of roughly a gigabyte competing for that memory is a real risk rather than a theoretical one, and battery is a budget nobody approved.
The constraint is also the strategy. A privacy claim backed by architecture is verifiable in a way a privacy policy is not, and it is meaningfully harder to copy — matching it means solving on-device inference under mobile hardware limits, not calling a different API.
Decision
Eleven decisions. Several of them are about what to build before the interesting part.
Decision 01
Rules first. The model is the fallback, not the feature.
The parsing pipeline is a deterministic-first cascade. Layer one is regex and rule-based extraction handling the majority of cases — known SMS templates from major Indian banks, known statement table layouts — deterministically, fast, at zero inference cost and zero battery impact. Layer two is the on-device model, invoked only for the formats the rules cannot confidently parse, and for merchant-name normalisation where fuzzy semantic judgment genuinely helps.
This avoids a false choice. Handling every format perfectly with rules is unrealistic given how much SMS templates and statement layouts vary across dozens of Indian banks and card issuers. Routing everything through the model is unrealistic given what mobile inference costs in time and battery. The cascade takes the cheap path for the common case and reserves the expensive one for the tail.
Traded away: a single code path. Two parsing strategies means two sets of failure modes and a confidence threshold that has to be tuned rather than assumed.
Decision 02
Stub the engine in Phase 0, before it exists
The LlmEngine
abstract interface, with a fake stub implementation, was defined in the very first phase
— before any model work at all.
The effect is that the UI, the database layer, every parsing pipeline and the whole test suite could be built, run and tested without the real gigabyte model ever being downloaded or loaded. In a phased build where the inference subsystem lands late, defining that seam first is the difference between a project that progresses and one gated on its hardest problem.
Rejected: building the LLM layer first because it is the interesting part. That ordering would have made every other phase wait on the one with the most unknowns.
Decision 03
Keep the native surface to four functions
The model is Gemma-3 1B, quantised to 4 bits and packaged as GGUF, sourced from
Hugging Face and downloaded on first run rather than bundled so the
install stays a reasonable size. It runs on llama.cpp, compiled for mobile through NDK
and CMake and pinned to a specific release tag rather than tracking main, so
the native build stays reproducible and auditable.
Between that and Dart sits a hand-written C shim exposing a deliberately minimal
four-function API, bound in through
dart:ffi and
ffigen.
Keeping that surface small and stable isolates the Flutter side from llama.cpp's
internal API churn, and makes the FFI boundary — the place where a mistake becomes a
native crash rather than an exception — small enough to reason about and test
exhaustively.
Inference runs in a dedicated Dart isolate, so a slow or stalled generation never blocks the UI thread. The interface is deliberately runtime-agnostic: LiteRT-LM, Google's actively maintained successor to the deprecated MediaPipe inference API, is the intended future swap-in behind the same seam, and the architecture is built so that swap does not ripple outward.
The full record — decisions 04–11, the pipeline, the phase history The complete record · about 1,800 words
Decision 04
Constrain the generation, then validate it anyway
Model output is forced into valid JSON matching the app's schema using GBNF grammars at generation time — the model cannot emit malformed structure because the grammar will not let it.
And then a schema-validation pass runs afterwards regardless. That is belt and braces, and it is deliberate: a grammar constrains shape, not sense, and model output that is about to be written into someone's financial record is not data I am willing to trust on one guarantee.
Decision 05
Give the memory back after three minutes
A three-minute idle-unload policy releases the model from memory when it is not actively being used. On a RAM-constrained Android device, a gigabyte held resident because the user might ask another question is a gigabyte taken from everything else they are doing — and the fastest way to have a finance app uninstalled is to make the phone feel slow.
Decision 06
Converge PDF onto the CSV pipeline instead of building a second one
PDF import handles password-protected statements and detects scanned, image-only PDFs
that need different treatment from text-layer ones. Then it reconstructs page layout into
a grid — and feeds that grid into the same
header_mapper
and row_parser
components the CSV and Excel pipeline already used, unchanged.
Building a parallel PDF-specific parser would have been the obvious route and would have doubled the surface where the two paths could quietly disagree about the same statement. Instead a cross-format consistency test asserts that the PDF-derived and CSV-derived versions of the same transaction agree on date, amount, bank, reference number and direction.
That test deliberately asserts on the full dedupe-relevant field set rather than the easier, more obvious surface fields of date and amount. The fields that matter are the ones a duplicate would hide behind.
Decision 07
Exclude the date from the deduplication hash
Transactions arriving from different sources are deduplicated on
sha256(bank|amount|ref)
— and for transactions carrying a strong reference number, a UPI, IMPS or NEFT UTR, the
date is deliberately excluded.
This was discovered, not designed. An SMS alert and a statement row describing the same real-world payment can disagree on transaction date versus posting date. A date-inclusive hash would have silently created duplicate records for a meaningful fraction of transactions — silently being the operative word, because nothing would have errored and the totals would simply have been wrong.
It is the kind of rule that looks arbitrary in a diff and obvious in a bug report, which is exactly why it has tests around it.
Decision 08
Score the header row rather than assume it
Net-banking exports do not agree on where a table starts. So CSV and Excel import detects the header by scoring candidate rows instead of assuming a fixed position — and where the source data is ambiguous about whether a row is a credit or a debit, a direction-inferred flag surfaces in the import preview so the user can check the inference before it is committed to the database.
Showing an inference as an inference is the honest version of automation. The alternative silently books a refund as a purchase and lets the user find it in a monthly total.
Decision 09
Audit dependency health as a first-class criterion
Maintenance activity, license and single-maintainer risk are reviewed before a package is adopted, not after it fails. Two real problems were caught and replaced mid-build because of it.
SMS access moved off a package flagged as a maintenance risk and
onto a hand-written Kotlin
MethodChannel
— which also made room for DLT sender-ID normalisation, since Indian banks send through
registered sender IDs that have to be normalised before an SMS can be reliably mapped
back to its originating bank.
Database encryption moved off a fork flagged as poorly maintained and
single-maintainer, onto
drift over
SQLite paired with SQLite3MultipleCiphers. For a security-sensitive dependency in an app
whose entire promise is custody, a single-maintainer fork is a structural risk, not a
convenience trade.
PDF handling was likewise chosen after auditing alternatives, on MIT licensing and active maintenance. Encryption keys live in the Android Keystore and iOS Keychain rather than in application code or plaintext preferences, so key material gets hardware-backed protection where the device offers it.
Decision 10
Open every phase with a review block, not with code
Each phase begins with a design review and issue-fixing block before implementation starts, on the theory that design problems are cheapest at the moment they are noticed and most expensive once something is built on top of them.
It works. The two dependency problems above were caught this way. So were two open items in the current phase — including a diagnosability gap where the model load path has no async acknowledgment, so a silent failure from memory pressure or a corrupt file produces no clear signal. On its own that is a minor annoyance. Once user-facing features sit on top of the load path it becomes an opaque bug class, which is precisely why it was worth naming before that happened rather than after.
Decision 11
Let the phone decide the next phase
The next phase has to choose how the model gets invoked as a fallback: inline, blocking the interface while it runs; on-demand, triggered explicitly by the user; or background-batch, queued and processed opportunistically. Those are three genuinely different products.
That decision is blocked on measurement, deliberately. It will be made from prefill and decode tokens per second and peak resident memory, measured on representative hardware — not estimated. Which is why the current phase is not considered complete until those benchmarks exist.
Guessing here is cheap and wrong. If decode is fast enough, inline is the best experience and everything else is over-engineering. If it is not, inline is unusable and shipping it would be the kind of mistake that gets discovered by users.
The pipeline
Three sources in, one normalised record out.
| Source | Path | Notable handling |
|---|---|---|
| Transaction SMS | Kotlin MethodChannel, regex cascade |
DLT sender-ID normalisation; shared extractors reused across the cascade |
| CSV / Excel | excel_community → header mapper → row parser |
Header detection by scoring; direction-inferred flag shown in preview |
| PDF statements | layout → grid → the same header mapper and row parser | Password-protected and scanned-PDF detection; cross-format consistency test |
Every source converges on the same normalised record — date, amount, direction, bank, reference number, merchant — and then through the same deduplication. From there the app produces spending patterns, category breakdowns, recurring subscription detection, anomaly flags, cash-flow trends and budgeting suggestions, and answers natural-language questions over the user's own data. All of it on the device.
The asymmetry the product has to live with
Android SMS read access needs a runtime permission, and store policy around SMS-reading applications requires a narrowly scoped, user-facing justification — transaction alert parsing only, on-device, never transmitted. iOS has no equivalent capability at all.
That is not a gap to be closed; it is a structural difference. The iOS import experience is shaped toward PDF and CSV import and manual entry, and the product designs around that rather than papering over it with a feature that cannot exist.
On regulation: RBI guidance on financial data handling and the DPDP Act are the backdrop. On-device processing simplifies that posture considerably — there is no server-side storage or transmission to reason about. It does not eliminate the work. Permission scoping, on-device retention and user data-deletion controls all still have to be designed deliberately rather than assumed fine because they happen locally.
Phase history
Each phase opened with a review and closed with something testable.
| Phase | Built |
|---|---|
| 0 — Foundations | Repo scaffold, encrypted drift schema, domain models, the LlmEngine interface, and 5 SMS golden tests as the parsing correctness baseline |
| 1 — SMS parsing | Regex parser library, Kotlin MethodChannel, DLT sender-ID normalisation, shared extractors, the deterministic-first cascade |
| 2 — CSV / Excel | Statement import, header detection by scoring, cross-source deduplication including the reference-based date-excluded hash, direction-inferred flag in the preview UI |
| 3 — PDF | Password handling, scanned-PDF detection, layout-to-grid reconstruction feeding the existing mapper and parser unchanged, cross-format consistency test |
| 4 — Native build & FFI | llama.cpp pinned to a release tag, the C shim and its four-function API via ffigen, generation in a dedicated isolate with GBNF-constrained JSON, schema validation, three-minute idle unload |
| 5 — Invocation model | Design blocked on benchmarks by intent: inline, on-demand or background-batch, decided by measured prefill/decode throughput and peak RSS |
Five SMS golden tests in Phase 0 are the smallest artifact here and among the most load-bearing. A golden test written before the parser means every subsequent change to the regex cascade has to justify itself against a fixed expectation rather than against whatever the parser happened to produce last week.
Outcome
- 0Bytes of financial data transmitted
- 3Import sources, one pipeline
- 4Functions in the native API surface
- 1BGemma-3, 4-bit quantised, on device
- 108Tests passing, analyzer clean, at the native-inference checkpoint
Storage is a type-safe Dart persistence layer over SQLite paired with SQLite3MultipleCiphers, so the database is encrypted at rest as well as never transmitted, with keys held in platform hardware-backed storage rather than in the app.
The parsing pipelines, the database layer, the UI and the test suite were all built and exercised against a stubbed engine before the real model was ever loaded — which is the clearest evidence that Decision 02 was worth making early.
There is no usage to report. Phase 5 is blocked on measurement from real use, which is the same fact from the other direction — the numbers the next phase turns on are numbers the app has not collected yet, and inventing them would defeat the phase.
A privacy policy is a promise. An architecture is a constraint. Only one of them survives a change of management.
flutter · dart · dart:ffi · ffigen · kotlin · c · cmake/ndk · llama.cpp · gemma-3 1b · 4-bit gguf · gbnf grammars · drift · sqlite · sqlite3multipleciphers · flutter_secure_storage · android keystore · ios keychain · excel_community · pdfrx_engine · pdfium_flutter · cursor cli