dimesum

Home / Blog / Engineering

Engineering

We deleted our natural language expense parser

· 6 min read ·

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.

The four failures that retired Dimesum's rules parser, recorded 2026-08-20 in docs/tech/10-ai-design.md.
What the user typedWhat the parser didWhy it happened
dinner 12-08 400Read the amount as ₹12A reader sees a date and a total. A regex sees three numbers and takes the first.
ravioli 600Put a member named Ravindra into the splitFuzzy nickname matching scored a dish against a person.
kirana 500Charged a member named KiranThe same matcher, with a shop name this time.
refund -483.50Posted a ₹483.50 chargeThe 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.

Before and after: a growing stack of guards, replaced by one refusal rule BEFORE: RULES PARSER (DELETED) dinner 12-08 400 amount regex date guard order-number guard nickname matcher exclusion lexicon reads ₹12, and posts it Each fix surfaced two more phrasings. AFTER: amount_only dinner 12-08 400 exactly one unambiguous money candidate? no, three numbers amount stays blank yes, one figure returned in paise The rung says nothing about people, so it cannot put money on the wrong person.
The deleted parser answered every sentence. The rung that replaced it answers a number or nothing.
The call, dated

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.

How amount_only decides between one figure and no answer collect every figure that could be money none found, or any of them signed negative? no exactly one currency-marked figure, such as ₹1,200 or 500/-? no no marks at all, and exactly one bare number? yes amount_minor: integer minor units, via the ISO 4217 exponent yes no no amount the field renders blank yes
Three questions, two of which end in a blank. Choosing between two plausible amounts produced the ₹12 dinner.

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.

What each parse rung is allowed to assert, as of 29 August 2026.
FieldRules parser (deleted)amount_only (live)Model tier (wired, keyless)
AmountGuessed from several numbersOne unambiguous figure, else blankRead in context
Description, categoryLexicon matchAlways nullExtracted from the sentence
Participants, exclusionsFuzzy name matchingAlways emptyResolved to real member ids
PayerInferred from phrasingAlways emptyNamed, with a nullable amount
Overall confidenceTuned per fieldFixed at 0.3Per parse
Prompt reportedNone existedNull, no prompt was readPrompt 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.