Home / Blog / Engineering
EngineeringWe deleted our natural language expense parser
Our rules parser lasted a week. Four ordinary sentences put money on the wrong person, and one refusal rule replaced the whole guard stack.
We deleted a parser we had just finished writing. Dimesum's first attempt at natural language expense parsing was a rules engine, and four ordinary sentences retired it: dinner 12-08 400 was read as ₹12, ravioli 600 put a member named Ravindra into the split, kirana 500 charged a member named Kiran, and refund -483.50 became a ₹483.50 charge.
Understanding a sentence is the model's job and nothing else's. What replaced the rules engine is one rung called amount_only: a single number, returned only when the text holds exactly one unambiguous money candidate, and never a claim about people. Two candidates means no amount. One rule replaced an accumulating pile of guards.
Four sentences ended the rules parser
The deleted parser covered the whole entity set our AI design doc specifies: member and nickname matching, an exclusion lexicon, payer inference, a category lexicon, and tuned per-field confidences. Each of the four failures below had an obvious fix. Each fix was a new guard with its own blind spot.
| What the user typed | What the parser did | Why it happened |
|---|---|---|
dinner 12-08 400 | Read the amount as ₹12 | A reader sees a date and a total. A regex sees three numbers and takes the first. |
ravioli 600 | Put a member named Ravindra into the split | Fuzzy nickname matching scored a dish against a person. |
kirana 500 | Charged a member named Kiran | The same matcher, with a shop name this time. |
refund -483.50 | Posted a ₹483.50 charge | The digits survived and the sign did not, so the money pointed the opposite way. |
Two of the four are one bug wearing different clothes. Fuzzy matching cannot tell a dish from a person or a shop from a person, because at the character level ravioli and Ravindra really do look alike. Demand a longer prefix match and you break ravi, which is the case the matcher exists for.
Every guard you add surfaces two more phrasings
A rules parser fails in one specific shape: it answers confidently and wrongly. A blank field costs a user one tap. A wrong split costs trust in the ledger, and in a money share tracking app the ledger is the product. The four rows above are not near-misses, they are money bugs.
The treadmill is the real argument, not any single defect. Add a date guard and the order-number case arrives. Add an order-number guard and flat numbers arrive, then quantities, then table numbers. The guard list grows and never converges, because natural language has no finite set of phrasings to enumerate.
Natural-language entry is an AI feature, and nothing in the Dimesum repo pattern-matches it. The decision landed on 2026-08-20, written down with the four failures, so nobody rebuilds the guards by accident.
What shipped is one number and a refusal
amount_only is the degraded rung of our AI design doc implemented literally. That rung reads "the plain form with client-side amount regex pre-fill", so the tier returns an amount and nothing else: no description, no category, no payers, no participants, no exclusions. It costs nothing and calls nobody, which is why test and CI run on it.
Ambiguity is a refusal, not a tie-break
The entire rule lives in one function, extract_amount_minor. A figure comes back only when the text holds exactly one candidate for it, so order 90210 dinner 400 and flat 402 rent 15000 return blank instead of picking a winner. A currency-marked figure counts as unambiguous even beside bare numbers, which is why split 3 ways ₹1,200 still reads ₹1,200.
A negated figure is no amount at all rather than its absolute value. -500, minus 200 and the accountant's (500) all come back empty, because the field is a charge and keeping the digits while dropping the sign points the money the opposite way from the text. Parentheses count only when they close on the figure itself, so (500 each) stays a parenthetical.
The currency decides the arithmetic
Minor units are the only representation money takes in Dimesum, so the tier converts with an ISO 4217 exponent table rather than a hardcoded multiplication by 100. The Japanese yen has no sub-unit at all, and ×100 inflates a ¥1,200 receipt by a hundred times. A figure finer than the currency's smallest unit is refused rather than rounded, because rounding an amount somebody typed is inventing one.
Indian number words are part of writing a figure, not part of understanding a sentence. 1.2k, 2 lakh and 500/- all resolve, like 1,200. Anything past the ledger's maximum amount comes back blank, so a mistyped figure blanks one field instead of overflowing an integer downstream.
| Field | Rules parser (deleted) | amount_only (live) | Model tier (wired, keyless) |
|---|---|---|---|
| Amount | Guessed from several numbers | One unambiguous figure, else blank | Read in context |
| Description, category | Lexicon match | Always null | Extracted from the sentence |
| Participants, exclusions | Fuzzy name matching | Always empty | Resolved to real member ids |
| Payer | Inferred from phrasing | Always empty | Named, with a nullable amount |
| Overall confidence | Tuned per field | Fixed at 0.3 | Per parse |
| Prompt reported | None existed | Null, no prompt was read | Prompt id and version |
The live rung never clears the 0.6 usable gate
Our parse contract abandons a parse below 0.6 overall confidence and drops the user to the plain form. amount_only reports 0.3 on every answer, and the constant is structural rather than tuned. A lone amount is not a parse, so the rung sits at half the gate whatever it found. One constant for every answer is what keeps it there: a per-case score is a score somebody eventually nudges upward.
The tier also reports no prompt at all. Both prompt_id and prompt_version come back null, because the rung read no prompt. Naming one would attribute every eval result to a prompt version the tier never saw, and the eval harness is the only instrument allowed to promote a rung to one-tap suggestion, at 95% precision on amount and participants together.
Two further tiers are declared and neither is live. The cheap-fast tier has a Groq adapter for openai/gpt-oss-120b and no key; the mid tier has no adapter. Selecting either fails at startup rather than at a user's first request, because an LLM bills per call and a billable dependency must fail closed.
Nothing auto-posts, so a blank costs one tap
A blank field costs so little only because no capture in Dimesum can write money. A capture creates a suggestion, a person confirms it, and the confirm is what creates the expense. Brief decision D5 states the rule, and .go-arch-lint.yml enforces it: the ingestion context is denied any dependency on expense or ledger, so a capture cannot post a journal even by mistake. CI fails the import, which we checked by adding one.
The capture keeps the raw text whatever the parser does, and names which fields are unresolved. The client highlights those blanks rather than showing an invented draft, which is the difference between a parser that says nothing and one that guesses. Confirm derives its expense id from the suggestion id, so a double tap replays instead of charging twice.
The rule worth stealing
Count your guards, not your bugs. A guard list that grows every week is telling you the job is comprehension, and comprehension belongs to a model. Our next step is running the golden set in contracts/parse_expense/eval/ against a real model tier, because nothing here becomes a one-tap suggestion without that verdict.
Common questions
Why did Dimesum delete its rules-based expense parser?
Dimesum deleted it because four ordinary sentences produced money bugs and every guard added surfaced two more phrasings. dinner 12-08 400 was read as ₹12, ravioli 600 added a member named Ravindra, kirana 500 charged a member named Kiran, and refund -483.50 became a ₹483.50 charge. Understanding a sentence is the model's job.
What does the amount_only tier actually return?
The amount_only tier returns one number and nothing else. It answers with an amount only when the text holds exactly one unambiguous money candidate, and it never names a participant, a payer, a description or a category. Two candidates means no amount at all. Everything else in the parse contract waits for a model tier.
Why does amount_only report 0.3 confidence instead of a real score?
The 0.3 is structural, not tuned. Our parse contract abandons a parse below 0.6 and drops the user to the plain form, and a lone amount is not a parse, so the rung sits at half that gate whatever it found. One fixed constant for every answer stops a per-case score being nudged upward later.
Can a natural language capture write to the ledger without a human?
No. A capture creates a suggestion and a person confirms it, per Brief decision D5. The rule is enforced in .go-arch-lint.yml, which denies the ingestion context any dependency on expense or ledger, so CI fails the import if a capture ever reaches for a journal. Confirming is what creates the expense.
What happens to natural language expense parsing when no model tier is available?
Dimesum degrades to amount_only and shows blanks. The cheap-fast tier is wired to Groq's openai/gpt-oss-120b and ships without a key, and the mid tier has no adapter, so selecting either fails at startup rather than at a user's first request. The capture keeps the raw text either way.
Popular posts
- The append-only ledger that keeps split-expense balances exact6 min read
- Why editing a shared expense must restate the split5 min read
- Six money bugs in multi-currency expense splitting6 min read
- Settle up: how to clear group expenses in fewer transfers6 min read
- How to split an itemized bill when one dish wasn't shared6 min read