Skip to content
Nafiz Iqbal
← Engineering Log

, 2 min read. AI integration, Product engineering

Statistics decide, the model narrates

How ThoughtExpense keeps a language model away from anything that has to be correct, and what it is still allowed to do.

ThoughtExpense shows people insights about their spending: an unusual week, a category creeping up, a single large purchase worth a second look. The obvious design is to hand the transactions to a language model and ask it to write those insights. ThoughtExpense deliberately does not do that.

The problem

A finance app can be vague, but it cannot be wrong about a number. Language models are very good at producing fluent, plausible text, and a plausible-but-incorrect total is the worst possible output for this product. Users notice once, and stop trusting every other screen.

Root cause

Asking a model for insights gives it two jobs at once: decide what is true about the data, and explain it. The first job is arithmetic and thresholds. The second is writing. Only the second plays to a model's strengths.

The split

  • Detection is code. Anomalies, trends, large-transaction flags, and waste detection are pure functions over currency-normalised data, with conservative thresholds written down next to the code.
  • The model receives facts, not transactions. It gets a small structured object describing what the functions found, including the exact figures.
  • The prompt forbids new numbers. Every figure in the output must be derivable from the facts it was given.
type Finding = {
  kind: "anomaly" | "trend" | "large_transaction";
  category: string;
  current: number;   // minor units, already currency-normalised
  baseline: number;
  periodDays: number;
};

// Pure and unit-tested: same input, same findings.
export function detectFindings(expenses: Expense[]): Finding[] { /* ... */ }

// The model only turns findings into sentences.
export async function narrate(findings: Finding[]): Promise<string[]> {
  if (findings.length === 0) return [];
  return model.write({ findings, rules: NARRATION_RULES });
}
Simplified illustration of the boundary, not production code.

Trade-offs

This design gives something up. The model does not spot patterns nobody wrote a detector for, and each new insight type now needs code and tests. In exchange, every number on screen comes from a function that can be tested, and a model outage degrades the explanation instead of the facts.

Verification

Detection functions are tested like any other pure code: fixed inputs, expected findings, edge cases around thresholds and currency conversion. Narration needs a different check: every number in the generated text can be compared against the findings it was given, and a sentence containing a figure that is not in the findings should be treated as a failed generation.

The general rule: let models do language, and keep anything you would put in a unit test out of their hands.