Models narrate. Code computes.
The rule that keeps AI features from lying to users about their own money — and how we enforce it in Spanzo.
Ask a language model to add up a column of numbers and it will give you an answer. The answer will be confident, plausibly formatted, and — often enough to matter — wrong.
This is fine when the stakes are a blog draft. It is not fine when the subject is somebody's rent.
Spanzo is a personal finance product. Its whole value proposition is that it can tell you something true about your money that you didn't already know. So we adopted a rule early, and it has survived every feature we've built since:
The model never computes a number it reports.
What the rule means in practice
Every figure that reaches a user is calculated deterministically, in code, before inference happens. The model's job is narration: it receives computed results and turns them into a sentence a human wants to read.
The pipeline has a hard boundary in the middle:
def answer(question: str, user: User) -> Answer:
# 1. Resolve the question to a set of *deterministic* queries.
plan = planner.plan(question, schema=LEDGER_SCHEMA)
# 2. Execute them. This is SQL and arithmetic. No model involved.
facts = ledger.execute(plan, user_id=user.id)
# 3. The model sees only computed facts, and is told it may not do math.
return llm.narrate(question=question, facts=facts, policy=NARRATE_ONLY)The model chooses what to ask. It never decides what is true.
Why not just let the model use a calculator tool?
Tool use narrows the problem without closing it. A model with a calculator still decides which numbers to feed it, and it can still summarise the result incorrectly on the way out. You have moved the arithmetic into a trustworthy place while leaving the interpretation in an untrustworthy one.
Our planner emits a typed query against a known schema, and the executor is ordinary code with ordinary tests. The set of things the model can express is bounded by that schema — it cannot ask for a number that doesn't exist, and it cannot receive one we didn't compute.
Grounding, and being able to prove it
Every claim Spanzo makes links back to the transactions behind it. If the app says "restaurant spend is up 34% because you started ordering delivery on weeknights," tapping that sentence shows you the specific transactions that produced both the 34% and the weeknight pattern.
This is not only a trust feature. It is our regression test. If a claim cannot be traced to rows, the claim is a bug, and we can detect that automatically:
def test_every_claim_is_grounded(answer: Answer) -> None:
for claim in answer.claims:
assert claim.source_ids, f"ungrounded claim: {claim.text}"
assert ledger.exists(claim.source_ids), "claim cites rows that don't exist"An ungrounded claim fails CI in exactly the way a null pointer dereference would. That is the standard we think AI features should be held to, and it is entirely achievable — but only if you decide, up front, that the model is not allowed to be the source of truth.
The general principle
Use the model for the part of the problem that is genuinely linguistic: understanding a vague question, choosing what's worth saying, saying it well. Keep the part of the problem that has a correct answer in code, where correct answers live.
It is a smaller role than the demos suggest. It is also the one the technology is actually good at.
Engineering notes, occasionally.
What we learned building the things we ship. No cadence, no marketing — we write when there's something worth saying.