Delivery11 min read
Testing AI answers without calling a model
Put every figure behind a deterministic engine, then test it with no model: invariants every answer must keep and known questions graded against source.
Mercury Labs
Put every figure behind a deterministic engine, then test it with no model: invariants every answer must keep and known questions graded against source.
You can regression-test most of an AI system's numbers without calling a model at all. Split the work so that the model decides which questions to ask and how to phrase the answer, while a deterministic engine computes every figure. Then test the engine directly, the way the agent calls it: with properties every answer must keep, and with known questions graded against truth worked out from the source systems. Model calls are kept for the thin layer that needs them, and even those answers are graded by code.
Model-free testing checks an AI system's figures by calling its deterministic parts through the same interface the agent uses, so each test is cheap, fast and gives the same result every time it runs on the same data.
This is the practical sequel to our guide on evaluating AI systems at scale, which argued for deterministic checks wherever a check can be deterministic. Here is what that looks like for the analytics agents in the Company Twin, including the faults it found that a model-graded evaluation would have missed.
In the Twin, figures come from a graph engine written in Rust that holds an organisation's semantic model and synced data in memory. It calls no model. Every table it returns carries a certificate saying when its data was read, and any figure can be traced to the records behind it. The task agent plans the queries and writes the answer; it never does the arithmetic.
That split turns one hard question, "is the agent right?", into two easier ones. Is the number right? That is a question about the engine, and it has a deterministic answer. Did the agent ask for the right number and say it properly? That needs a model, and it is a much smaller surface. Testing the two together, through full agent sessions, is slow, costs model calls on every run, varies from run to run, and cannot tell you which half failed.
The key practical step is that tests reach the engine exactly as the agent does. The Twin CLI exposes the same structured queries the agent writes, so a test is a query and a comparison:
# How many sales invoices are overdue? The same query the agent would send.twin graph query '{"measures":["invoice_count"], "where":[{"field":"invoice_type","op":"eq","value":"ACCREC"}, {"field":"status","op":"eq","value":"AUTHORISED"}, {"field":"due_date","op":"lt","value":"2026-11-11"}]}' --json # The answer comes back as a table with its certificate: no model was called.The answer suite started as a day of hand-made known-answer tests. Engineers listed the records behind about 130 questions across four organisations, worked out each answer, then asked the question as an agent would. They found faults a demo would never show: money out that missed bills paid, credit notes that mixed customers and suppliers, operating expenses that included tax, and deal stages nobody could read. All were fixed in the engine and its models.
But those tests ran once, from scratch files, and most took their truth from the graph itself, so they could not catch rows lost on the way in, a stale sync or a fault shared by both paths. The standing suite keeps the questions and gives them stronger truth. Each test is target configuration: a question in the business's words, the queries an agent would send, which measure makes the answer, a tolerance, and how to work out the truth at run time.
{ "id": "overdue-sales-invoices-count", "question": "How many sales invoices are overdue?", "category": "receivables", "ask": [{ "measures": ["invoice_count"], "where": [ ... , { "field": "due_date", "op": "lt", "value": "{today}" }] }], "answer": { "query": 0, "measure": "invoice_count" }, "truth": { "kind": "sql", "sql": "SELECT count(*) AS invoice_count FROM ... WHERE ... due_date < {today}" }, "tolerance": 0}Relative dates such as {today} and {last_month.to} keep a test current without editing. Anything that would name a person, a customer's own term or an amount is a placeholder filled from a local file at run time, so a definition names nobody and holds no figures. Truth comes from the strongest source available:
| Truth | How it is worked out |
|---|---|
| Report | The source system's own report, such as the ledger's P&L or aged receivables |
| Source | A figure read from the source system's API by an operator into the run's parameters |
| SQL | One read-only SELECT over the tenant's own tables, run on its server at test time |
| Query | Another graph query that must agree, such as receivables against the control account |
| Listing | The records listed by id, filtered and summed: the last resort |
| Pending | No truth yet: the queries run and are logged, but nothing is graded |
The SQL truth runs inside a read-only transaction with a 60-second limit. The database is read now and the graph at its last refresh, so when they disagree the runner reads both again once and fails the test only if they still differ. A test with an SQL truth reports but never gates a release on its own, which keeps a flaky comparison from blocking a fix.
Two design choices proved their worth early. The first SQL truth passed, and the same SQL with one sign flipped failed, which is how you know a test can fail. And some tests are deliberately traps: the obvious query an agent would send gives the wrong answer, for example a city filter that misses values written as "City, ST". A trap passes while the obvious answer is still wrong, and fails the day it becomes right, which tells us the trap has gone and the test should change.
An answer suite only covers what someone thought to ask. Invariants cover the rest. An invariant is a property every answer must keep in every organisation, whatever its data, checked by asking the engine the same thing two ways and comparing. The idea is old (it is what property-based testing and metamorphic testing formalise); what is new is applying it to every measure an AI agent can quote.
| Invariant | Holds |
|---|---|
| Groups | The groups of a grouped figure add up to the ungrouped figure, blank key included |
| Periods | The two halves of a year add up to the year for a flow; a snapshot reads one date and says which |
| Partition | A filter on one value and its negation add up to everything, as do null and not null |
| Distinct | A distinct count is never more than the entity's rows |
| Currency | A figure over several currencies is refused, naming them; with one currency it answers |
| Order | The top five are the first five of the full ordering, and a cut table says rows were left out |
| Links | Following a chain of links counts what the two-step query counts |
| Grain total | A flow by year adds up to its total row, or says what it left out |
| Empty cause | An answer emptied by a condition names the condition |
The checks are generated from each organisation's catalogue and asked of every measure in it, so a new measure, a new model or an engine release is covered without anyone writing a test. The first runs found three faults. The next day, a coding agent asked to probe the engine by asking one thing two ways found 24 more: time groups short of their total, hour windows read as whole days, empty answers with no reason, measures the catalogue had set aside answered as if they existed, and sums across currencies. Each probe became a standing invariant, so the fix is proven by the next run.
Both suites run after each release reaches production and after every change to an organisation's semantic model. They are not yet automatic: the agent session that shipped the release or the model change starts them. They are read-only and model-free, and they go through the same CLI as everyone else, each call pinned to its organisation, no faster than one call a second. Up to six queries travel in one call, so a check's two readings see the same data; if the data moves between readings anyway, the check is asked again once and skipped if it moves again. A run across every organisation takes minutes.
The results hold an organisation's real figures, so they stay where the run happened. Only pass and fail counts reach the shared task board our team and agents work from, and a failure is filed with the invariant, the measure and the shape of the query: no values, figures or names. Part of the suite also runs in CI against the model files themselves, refusing, for example, a measure over amounts on an entity that names no currency field.
At the top of the pyramid, and only there. Whether an agent picks the right measure, asks a follow-up, refuses a question it should refuse and states the figure without garbling it, only a real agent run can show. We cap these: a handful of agent sessions per validation, after the model-free checks have passed.
Those sessions are graded by code, not by another model. An answer grader reads the agent's final text and checks each expected fact from the same run-time truth: within tolerance or the answer's own stated rounding, in the right currency, in the right order for a ranking, with an itemisation that adds up to its total. A contradiction makes the answer wrong; a fact it cannot find makes it inconclusive, never correct. A judge model is useful for tone and helpfulness, as the earlier article describes; it is the wrong tool for deciding whether a figure matches the ledger.
One lesson from building a reproducible task benchmark for our own models is worth repeating. Its first version graded only the final answer, and a correct answer reached by unauthorised SQL or by reading the wrong organisation still passed. The second version grades the path as well: what was in scope, what each step returned and what it did. Six unchanged runs reproduced identical metrics, and review found two currency-grading errors in the grader itself. Test your grader as carefully as the system it grades. Our product team's view of the same principle is in a skill is a hypothesis.
08 stages
Groups add up to the ungrouped figure, including the blank group
Watchpoint
Grouping silently drops rows whose key is empty
Periods add up to the whole for flows; snapshots read one stated date
Watchpoint
Balances summed across months
A filter and its negation partition the total; so do null and not null
Watchpoint
Nulls fall out of both sides
Amounts in different currencies are never added
Watchpoint
A total that mixes pounds and euros looks plausible
Top N equals the first N of the full ordering, and a cut table says so
Watchpoint
A row limit applied before the sort
Following a link equals the two-step query
Watchpoint
Joins that duplicate or drop the linked rows
An empty answer names what emptied it
Watchpoint
Zero reported where the data is missing
Every check can fail: flip one sign and watch it go red
Watchpoint
A suite that has never failed proves nothing
Start by finding the deterministic core of your AI system: the SQL, the semantic layer, the calculation service or the API that produces the figures. If the model is doing arithmetic or writing free-form SQL against your warehouse, move that behind a governed interface first; the five controls in five gates to a governed answer describe the target. Then:
In our experience, the first two steps find more real faults in a week than months of reading model transcripts, and they keep finding them after every change.
Every figure the Twin's agents quote comes from an engine we test without a model. See the Company Twin →