DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

Migrating a Prompt That Depends on the Model Doing Arithmetic

AssertionError: total mismatch: expected 119940, got 119939. One cent, on roughly one invoice in two hundred, on a prompt that has not been touched. The extraction is correct, every line item is correct, and the sum is not.

The symptom: one cent

The prompt asks the model to read an invoice PDF, return the line items, and return the total including VAT. It worked. It passed a hundred fixtures on the old model and shipped a year ago. After a migration the same fixtures pass at ninety-eight per cent, and the two failures are both rounding: a VAT line computed from a per-item rounded amount rather than the rounded sum, and a total that is one cent under.

Two things make this worse than a two per cent error rate suggests. First, the error is plausible — nothing about a total that is one cent off looks like a malfunction, so it survives review. Second, it is not deterministic in the useful sense: the same input produces the same wrong answer at temperature zero, so a re-run does not expose it and a “we could not reproduce” is available to anyone who wants one.

Why arithmetic is a model property, not a prompt property

A model does not compute a sum. It predicts the tokens of a sum, and whether those tokens are right is a property of the model — of how it was trained, of its tokenizer’s handling of digit sequences, of how many intermediate steps it is willing to spend. None of that is something your prompt controls, and none of it is stable across models. A model that carries eleven-digit multiplications reliably and one that does not can be adjacent releases from the same vendor.

Three things people reach for, and what each is actually worth:

  • Temperature zero. Makes the answer repeatable. Does not make it correct. It converts an intermittent failure into a permanent one for the affected input, which is arguably an improvement because it is at least findable.
  • Extra reasoning. Asking the model to work step by step, or enabling a reasoning mode, moves the error rate. It does not establish a bound, and the new rate is again a property of the model, so it has to be re-measured on the next one. It is a mitigation with a recurring cost.
  • “Be careful with the arithmetic”. Costs tokens, changes nothing you can measure. Delete it.

The durable move is to notice that this is a task with a deterministic answer, and that you own a computer.

Change the contract: operands in, totals in code

Remove the computed fields from the schema. Do not make them optional — remove them, so that the model has nowhere to put a total and no instruction can put one back:

{
  "type": "object",
  "required": ["currency", "line_items", "vat_rate_bps"],
  "properties": {
    "currency": { "enum": ["EUR", "GBP", "USD"] },
    "vat_rate_bps": { "type": "integer", "minimum": 0, "maximum": 10000 },
    "line_items": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["description", "quantity", "unit_price_minor"],
        "properties": {
          "description": { "type": "string" },
          "quantity": { "type": "integer", "minimum": 1 },
          "unit_price_minor": { "type": "integer" }
        },
        "additionalProperties": false
      }
    }
  },
  "additionalProperties": false
}
Enter fullscreen mode Exit fullscreen mode
// The only place a total is produced.
function totals(doc) {
  const net = doc.line_items.reduce(
    (acc, li) => acc + li.quantity * li.unit_price_minor, 0,
  );
  const vat = Math.round((net * doc.vat_rate_bps) / 10000);
  return { net_minor: net, vat_minor: vat, gross_minor: net + vat };
}
Enter fullscreen mode Exit fullscreen mode

Two details in that schema are doing quiet work. Every money value is an integer in minor units, so no float rounding enters anywhere; and the VAT rate is basis points rather than a percentage with a decimal point, for the same reason. A model that has to emit 0.21 and a model that has to emit 2100 fail in different ways, and only one of those failures is caught by an integer type check.

What is left for the model is transcription and classification, which is what it is good at. The rounding rule — round the VAT on the total rather than per line — is now a line of code that a finance person can be shown, rather than a behaviour the model was doing correctly for reasons nobody wrote down.

When you cannot remove the number

Sometimes the number is genuinely in the source document and the task is to read it, not to compute it — the invoice states a total and you want that total. Then keep the field, but treat it as a value to be reconciled rather than trusted:

const stated = doc.stated_gross_minor;   // read from the document
const computed = totals(doc).gross_minor; // derived from the line items

if (stated !== computed) {
  // Do not pick one. The disagreement is the finding.
  metrics.inc("invoice_total_mismatch");
  return {
    status: "needs_review",
    delta_minor: stated - computed,
    stated, computed,
  };
}
Enter fullscreen mode Exit fullscreen mode

The rule that makes this work is that a mismatch never resolves silently. Picking the stated total because “the document is authoritative” hides a bad extraction; picking the computed total hides a missing line item. Both are real and you cannot tell them apart from inside the function, so route to a human with the delta attached. One retry with the arithmetic instruction removed is reasonable before you do; two is a retry loop pretending to be a fix.

The arithmetic nobody notices

Sums are the obvious case. The same reliability question applies to every operation people forget is arithmetic, and these are the ones that survive a migration review because nobody thinks to test them:

  • Comparison and ordering. “Which of these three quotes is cheapest”, “sort by date”, “is this over the limit”. All computable, all quietly delegated to the model in prose-heavy prompts.
  • Counting. “How many items mention X”. Ask for the list; count its length.
  • Date arithmetic. “Is this within thirty days” involves a calendar, a timezone and a definition of “day”, none of which the model shares with your database.
  • Unit conversion. Especially between currencies, where the rate is a fact with a date and the model does not have it.

The test for all of them is the same question: could a function compute this from fields the model can transcribe? If yes, that is where it belongs, permanently, regardless of how well the current model does it.

What re-testing actually requires

For the arithmetic you decide to keep, a migration means re-measuring, and the sample size matters more here than for most checks because the rates involved are close to one. A model that is right on 199 of 200 fixtures and one that is right on 197 are not distinguishable at that sample size — the derivation in the conformance-scoring page gives the arithmetic, and the paired design there is what makes the comparison affordable.

Generate the fixtures rather than collecting them. Arithmetic fixtures are the rare case where synthetic data is better than production data: you can enumerate the hard cases (values that round at the halfway point, quantities that push the total past a digit boundary, rates that produce recurring decimals) instead of waiting for them to occur. Keep them in the same dataset as the rest of your migration fixtures — see migrating a prompt test dataset — and label them so that a failure names the arithmetic rather than the document.

Related

Top comments (0)