Skip to content

Never Let the Model Do the Maths

Updated: at 10:00 AM

A borrower types: “My EMI went up this month. Why? And if I prepay ₹50,000 today, how much interest do I save?”

Paste the loan details into a capable language model and you will get a fluent, confident answer with a rupee figure in it. Often the figure is close. Sometimes it is off by a few thousand rupees, because the model assumed the wrong compounding, forgot the prepayment charge, or skipped the GST on that charge. In most products a near miss is fine. In lending, a plausible wrong number told to a borrower is a misstatement, and it is worse than saying nothing.

So in early November 2026 I built Mira, an AI customer-service agent for loan and credit-card servicing, around one rule: the model is not allowed to do the maths. Not some of it, not “simple” sums, none of it. It runs over text and live voice, for a fictional lender I called Kestrel Finance, and the code is public at github.com/guptasiddharth/mira-ai-servicing. It is my own reference build for what a regulated lender’s servicing agent should look like, not a product anyone runs in production.

If you read my post on Proof of Mandate, this is the same instinct pointed at a different problem. There I argued that you should not trust a model to refrain from a bad action; you should make the action un-signable. Here the bad action is a wrong number, and the fix has the same shape: take the number away from the model entirely.

Table of contents

Open Table of contents

The rule: the model orchestrates, code computes

The first principle in the architecture doc is one sentence: the LLM orchestrates; code computes. The model decides what to look up and how to explain it. Balances, EMIs, savings, eligibility and charges all come from deterministic, unit-tested functions. It is enforced at three levels, because a prompt alone is a suggestion.

The prompt says it. Grounding rule two reads: “Never do arithmetic or financial projections yourself. Use the calculation tools and quote their numbers exactly.” Rule one says every account-specific number must come from the prefetched customer context or a tool result.

The tools make it unnecessary. There are 17 typed tools with zod schemas, and the ones that matter return finished numbers. explain_emi_change does not hand the model two schedules to diff. It returns the before and after instalment, the rate change, the reset reason and a mechanism string like “EMI recalculated on outstanding ₹X over the remaining N months; tenure unchanged.” The model narrates facts. It does not infer them.

The UI makes it visible. Every tool returns a typed view alongside the data the model sees, rendered as a card: an EMI comparison with a step chart, a prepayment comparison with a balance-over-time chart, a failed-payment timeline. The model writes a short takeaway; the card carries the numbers, and the card’s numbers came from code.

A side effect: the thinking level is set to minimal. When correctness comes from tools rather than reasoning, you can buy latency back by letting the model think less.

Why this is not RAG

The reflex for a support bot is retrieval: embed the policy PDFs, retrieve, generate. That works for “what is your foreclosure policy?” It cannot produce a correct EMI delta or interest saving, which need customer data, business rules, arithmetic and conversation state together. So the query engine is layered:

LayerWhat it does
Context prefetchProfile, loans, recent payments and open requests load in parallel at session start, so “what’s my next EMI?” needs zero tool calls
Typed toolsArguments validated before execution, so the model can only ask for things that exist
Deterministic mathsReducing-balance EMI, schedules with floating-rate resets, payoff projection, prepayment, foreclosure quote with accrued interest and GST
Rules engineLock-in, prepayment caps, financial-year limits, charge slabs, bounce-reversal eligibility, each returning rule IDs
Policy textA clause book generated from the same parameters the rules engine uses, cited by clause ID

There is no vector database in the critical path. One would earn its place for long-tail FAQ content, as one more tool behind the same registry, not as the core.

The maths lives in boring functions

The amortization, prepayment and foreclosure modules plus the rules engine come to 383 lines. That is the point: pure functions you can read in one sitting and test exhaustively.

The prepayment simulator returns both standard options side by side: keep the EMI and close earlier, or keep the tenure and lower the EMI, each with months, total interest, interest saved, and net saving after charges and GST. When the borrower asks “what if I reduce the EMI instead?”, the model recalculates nothing. The answer was already in the result.

The rules engine is where the clause IDs come from. Every decision returns the rules it relied on:

if (f.emisPaid < P.lockInEmis) {
  violations.push({
    ruleId: "POL-PL-001",
    message: `Lock-in: ${P.lockInEmis} EMIs must be paid before prepayment; ${f.emisPaid} paid so far.`,
  });
}
if (amount > maxAllowedAmount) {
  violations.push({
    ruleId: "POL-PL-002",
    message: `A single part-prepayment cannot exceed ${
      P.maxPartPrepaymentPctOfOutstanding
    }% of outstanding (${rupees(maxAllowedAmount)}).`,
  });
}

Kestrel’s fictional policy has a six-EMI lock-in, a ₹10,000 minimum part-prepayment, a cap of 25% of the outstanding per prepayment, at most two per financial year, fixed-rate foreclosure charges of 4% up to 23 EMIs paid, 2% up to 35 and nothing after, and 18% GST on charges. In the demo, a fixed-rate customer asks to prepay ₹50,000; the engine blocks it under POL-PL-002 and returns the maximum they are allowed. A refusal that arrives with the allowed alternative, computed, is better service than a refusal.

The decision I am proudest of is the smallest. The policy book’s human-readable clauses are generated from the same parameter object the rules engine reads. Change the lock-in from six EMIs to nine and the rule and the cited clause change together. The citation cannot disagree with the decision, because they are the same number.

The citation guard

Telling a model to cite clause IDs creates a new failure mode: it will invent plausible ones. POL-PAY-04 looks exactly like a real clause. So citations are verified, not trusted.

The prompt says to cite only IDs that appear verbatim in a tool result. Then the runtime checks anyway. After each answer it extracts every POL-* reference and compares it against the clause IDs the tools actually returned, plus the conversation history. Anything left over increments a guard.unverified_citations metric and is logged with the turn ID. In the UI, a verified citation becomes a chip you can hover to read the clause; an unverified one carries an “unverified” label.

A unit test covers exactly this: given known IDs POL-PL-006 and POL-PL-008, an answer citing POL-PAY-04 must be caught. You do not stop the model hallucinating. You make the hallucination visible and countable.

Actions the model cannot confirm by itself

Answering questions is half the job. The other half is doing things: booking a part-prepayment, disputing a bounce charge, blocking a card. Anything that changes state is two-phase.

prepare() validates against policy and computes a preview with no side effects, and the UI renders a confirmation card. The customer confirms by pressing the button or by saying “yes, go ahead”, in which case the model calls confirm_action. The guard that matters: a model-initiated confirmation is rejected if it happens in the same turn the action was prepared. The model cannot prepare a dispute and confirm it on the customer’s behalf in one breath. Pending actions reach the next turn’s prompt from server state, never from the client.

commit() uses the action ID as the idempotency key, so confirming twice returns the original reference. Because a button click and a spoken “yes” can land on different serverless instances at once, a cross-instance claim in Redis lets only one of them commit.

Identity follows the same pattern. customerId comes only from the signed session and is injected into every tool; no tool takes it as an argument. A request for another customer’s loan returns FORBIDDEN, and a test checks it.

Five flaky backends behind one gateway

A lender’s data lives in systems never designed to be called by a chatbot. To keep the demo honest, Mira talks to five simulated systems of record (CRM, Loan Management, Payments, Policy and Service Ops), plus a sixth for cards, each with its own latency profile and failure rate.

Every call goes through one Integration Gateway: per-call timeouts, retries with exponential backoff and jitter, a circuit breaker per system that opens after five consecutive failures and probes half-open after a cooldown, a read-through cache invalidated on any write for that customer, and singleflight so identical concurrent reads collapse into one. Each system’s policy matches its SLOs: Payments gets a 2.2 second timeout, Policy gets 600 ms and a ten-minute cache.

When a system is down, the model gets SYSTEM_UNAVAILABLE and is told to say so plainly, share what it knows from other systems, and offer a ticket. Never invent data to fill the gap.

Voice: get out of the audio path

Voice uses Gemini Live with native audio, and the biggest latency decision was keeping my servers out of the audio entirely. The browser streams microphone audio straight to Gemini and plays the reply. My servers only see tool calls.

To do that without handing the browser an API key, the voice session endpoint builds the same instruction and tool declarations as text, then mints a single-use, 30-minute ephemeral token with the model, prompt and tools locked server-side. Tool calls relay back to my API and take exactly the same path as text. Barge-in flushes queued audio the moment the customer talks over it. Slow tools like Payments run non-blocking, so the model says “let me pull that up” and keeps talking. Text and voice share one transcript, so you can switch mid-conversation.

Measured from the last voiced microphone frame to the first audio played, which includes end-of-speech detection and is what the customer feels:

Spoken turnPathVoice response
“What’s my next EMI?”prefetched context1,341 ms
“Actually, check my last payment first.”context, after a topic change1,412 ms
“Would paying ₹20,000 extra reduce my tenure?”simulate_prepayment tool (233 ms)3,062 ms

The tool took 233 ms. Most of the extra time is the model deciding to call it and then composing speech around the result.

The numbers, and what they mean

All of these were measured in November 2026 on a MacBook against local next dev, with Gemini on the free tier over the public internet.

Backend at scale. npm run bench drives 1,000 concurrent conversations, each a distinct customer running four turns through the real registry and gateway against fault-injecting simulators. No LLM is involved; this tests everything except the model.

ModeResult
Realistic (1 to 8% failure rates)6,000 tool calls in 5.1 s, 0 failed after retries; tool turn p50 409 ms, p95 1,385 ms
Degraded (2.5x latency, 5x failure rates)0.82% failed after retries; tool turn p50 649 ms, p95 3.0 s
Payments at 100% failurePayments breaker opened; only the 1,000 payments-dependent calls failed, every other tool kept working

The outage case is my favourite. Tool turn p95 actually fell to 1.2 s, because calls failed fast instead of hanging. In degraded mode the breakers correctly stayed closed: intermittent failure is a retry problem, not an outage.

Text chat. A scripted benchmark plays 4 journeys and 12 turns, from an EMI question through prepayment and booking, failed payments to a dispute, foreclosure options, and a duplicate card charge. All 12 turns completed correctly, including next-turn confirmations. Time to first token was 3.3 s at p50 with fresh quota.

That 3.3 s is honest and not great. Almost all of it is model time on a shared free tier; my own overhead is single-digit milliseconds once cached. A tool turn needs two model calls, one to plan and one to answer. On a paid tier with prompt caching I estimate full tool turns at 2 to 2.5 s, but that is an estimate, not a measurement. The free tier also shaped the runtime: it fails over across a chain of models, and since a later pass, a model that produces nothing within 9 seconds is abandoned for the next one, inside a 45 second turn budget.

The maths itself. npm test runs 34 unit tests covering finance functions, policy rules, gateway resilience, authorisation, two-phase actions and the citation guard, in about 2.5 seconds. That is the quiet benefit of keeping numbers in code: you can test them.

One platform, a second workflow

To find out whether this was a loan bot or a platform, I added credit-card servicing: deterministic duplicate-charge detection, card disputes with provisional credit, card block and replace. A workflow is a manifest of persona, rules, a tool allow-list, a context loader and account panels. The card workflow took roughly 125 lines of manifest, 175 lines of new tools and a 40-line adapter, and reused about 2,500 lines unchanged. The allow-list is enforced at execution, not just in the prompt.

What is simulated, and what is not

What is real: the maths, the rules engine, the citation guard, the gateway under injected faults, the two-phase actions, the locked voice tokens, and a deployment on Vercel with state in Upstash Redis, both pinned to Mumbai.

What I took away from it

  1. A model that never produces a number cannot produce a wrong one. Most of Mira’s safety is not a guardrail on output. It is the absence of any path by which a model-generated figure reaches the customer.
  2. Generate the explanation from the same source as the decision. Clause text built from the rules engine’s own parameters cannot drift.
  3. Verify citations; do not merely request them. Asking for sources gets you citation-shaped text. Checking each one gets you citations.
  4. Design for flaky backends first. The impressive demo is the one where Payments goes down and the assistant still helps, and that comes from a gateway, not a prompt.
  5. In voice, the architecture is the latency. Leaving the audio path did more than any prompt tweak.

The same rule, that no number a model produces should become something a person relies on, matters even more when the number is a price and the next step is a payment. That is what I want to write up next.