The AI-Readiness Repair Matrix
Simple questions make AI analysts look ready. Real business questions combine several primitives, and small errors compound. From 1,488 graded runs, a repair method for AI-ready data: find the primitive that failed, find where its grounding lives, and move that grounding where the agent cannot skip it.
Simple questions make AI analysts look ready. Count users. Filter one segment. Pull one metric.
Real business questions are different. They combine several primitives. Each primitive is another place where the analyst can be right or wrong.
That is why reliability does not degrade slowly. It compounds. A system that is excellent on one-primitive questions can become unreliable once the question asks for four or five things to be resolved at once.
The standard response is a shopping question: a semantic layer? A better model? After 1,488 graded runs against eight versions of one warehouse, I think readiness is more specific than that. It is a property of each question you ask, and repairing it is a routine, not a purchase:
- Find the primitive that failed. Decompose the question into the primitives it forces; one of them broke.
- Find where its grounding lives. The fact that settles that primitive is stored somewhere, or nowhere.
- Move it to the cheapest place the agent cannot skip.
Earlier in this series I measured how much structure buys, what the agent does when there is no answer, and how to make single claims inspectable. All three treated the question as a unit. This piece takes it apart: the routine above, with the measured evidence behind each step.
The short version#
A question is a list of primitives#
Take one ordinary business question and read it the way the agent has to.
How muchmeasurebilled revenuecame fromsegmentPaid SearchgraincustomersincoverageJune?
and with no words in the question
additivity
Can annual cash be summed?
join path
Which customer relationship?
segment default
Do staff accounts count?
Each highlighted piece is a primitive: one irreducible choice that must be resolved before the query means anything. (The test harness uses the same word, so this piece matches the repo.) The gloss shows what makes such questions dangerous: some primitives have no words. "Billed revenue" does not announce that annual plans bill once, as a full year's cash. Nothing says whether staff accounts count. The agent resolves the silent primitives anyway, with defaults nobody chose.
To resolve a primitive correctly, the agent needs grounding for it: the piece of business knowledge that settles the primitive. Staff accounts are not customers settles the segment. Annual amounts enter monthly recurring revenue as one twelfth settles additivity. The data ends on 12 July settles coverage. A primitive with no grounding available does not go unanswered; it gets resolved by a guess that looks like a default. My first experiment measured grounding in bulk, one warehouse against another. This piece assigns it per primitive, because the agent does not need a well-grounded warehouse in general. It needs one specific fact, for one specific primitive, at the moment it writes the clause.
Two consequences carry the whole method.
Errors compound. At 90% per primitive, a five-primitive question comes out right 59% of the time (0.9⁵). A system that looks excellent on simple questions can become unreliable on real business questions without anything visibly breaking.
And a failure has an address. When the answer is wrong, some specific primitive failed. The useful question is which primitive failed and where its grounding lives.
Grounding lives in one of five places#
The homes are concrete: a column, a table's grain, a comment, a wiki page, a metric definition, a lookup table that cleans raw values, or a runtime check. Stack them the way a real warehouse does, and write the same grounding, staff accounts are not customers, at every layer.
before serving: the number must be thegoverned metric result — otherwise refuseactive_customers includes user_type = 'customer'sits between the answer and the user; there is no other route out
metric: active_customers filter: user_type = 'customer'a separate route: the agent can write raw SQL instead and never meet the definition
dim_users.user_type → 'staff' | 'customer'on the route itself: query the table and the column arrives with the data; being wrong now means ignoring a value in front of it
comment on column users.internal: 'true for staff and test accounts'rides beside the data: the table can be queried without reading or applying it
| id | internal | |
|---|---|---|
| 184 | anna@internal-test.com | 1 |
| 185 | mark@gmail.com | 0 |
the values are visible but nothing says what they mean; the fact must be guessed
skippable = the layer is beside the agent's route · not skippable = the layer is the route
The chip on each layer says whether the agent can ignore that fact and still answer. A comment can go unread. An optional metric can be bypassed. A runtime check can stop the answer. I will call that property skippable, and it explains much of the matrix later in the article.
The repair algorithm#
That is all the vocabulary the routine needs. Here it is on one page, built to be scanned; the three sections after it walk each step with the tools, the traps, and the measured evidence.
1Find the primitive that failed
Which part of the question broke?
do- split the question into primitives
- wrong answerscalculate the answer with one primitive removed
- SQL traceconfirm the suspect in the query
- the peelno trace? ask a simpler version of the question
the failed primitive
What fact would ground that primitive?
do- name the factwrite the fact in one sentence
- find where it liveslook for it in values, comments, structure, metrics, checks
- the skip testask whether the agent can answer without using it
the grounding fact, its location, and the verdict: absent, mis-housed, or incomplete
if nobody can write the sentence, the definition does not exist yet
3Move it where it cannot be skipped
Where is the cheapest reliable home for that fact?
recipes- the decodevocabulary → clean value in the model
- the declared graingrain → name what one row is; expose both counts
- the label on the dimensionattribute → on the dimension, not behind a join
- the stated defaultdefault → clear sentence in the docs
- the snapshot and the rulestock or unit → snapshot table and governed metric
- the published windowcoverage → date window or runtime check
a repair you can retest
Step one: find the primitive that failed#
When a number comes back wrong, you have three instruments, ordered by how much of the agent's work you can see.
Calculate the wrong answers#
Do not only calculate the correct answer. For the failed question, also calculate the answers you would get if one primitive were missing.
The reason is simple: a wrong number is often the right answer to a broken version of the same question. Match the agent's wrong number to that broken version, and you know which primitive to inspect first.
For example, take: How much MRR came from Paid Search customers in June? Suppose the correct answer is $12k, but the agent returns $48k.
| Broken version you calculate | Answer | If the agent matches it |
|---|---|---|
| MRR in June, without the Paid Search filter | $40k | Segment is probably not the first problem. |
| Paid Search revenue, but annual plans counted as full cash | $48k | Additivity is the failed primitive. |
| Paid Search revenue joined through current customer state | $15k | Join path is probably not the first problem. |
| Paid Search rows counted instead of customers or MRR | 180 | Grain or measure is probably not the first problem. |
In this example, the agent's $48k is not random. It is exactly the number you get when annual plan cash is summed as if it were monthly recurring revenue. That tells you where to look next: the additivity grounding. Does the model expose a customer-month snapshot? Is the MRR metric enforced? Or is the rule only written in prose where the agent can skip it?
This is triage, not final proof. It narrows the repair from "the answer is wrong" to "inspect this primitive first".
Confirm it in the SQL trace#
The wrong-answer test gives you a suspect. The SQL trace confirms whether that suspect actually failed.
Continue the example above. The agent returned $48k, and $48k is what you get when annual cash is summed directly. Open the SQL trace and ask one question: did the query use the monthly MRR snapshot or governed MRR metric, or did it sum raw billing cash?
If the trace says sum(r.amount) from fct_billing, additivity failed in the aggregate expression
and the fact table. If the trace uses fct_customer_month_snapshot and mrr_amount, additivity is
probably not the first failure; go back to segment, join path, or coverage.
Use the same check for every primitive:
| suspected primitive family | open the trace and ask | failure looks like |
|---|---|---|
| Segment | Is the `WHERE` filter the right population? | where raw_channel = 'Paid Search' |
| Grain | Is it counting the right entity? | select count(*) as customers |
| Join path | Are the `JOIN`s the declared relationship? | join dim_customer c
on u.current_customer_id = c.customer_id |
| Measure / aggregation | Is the aggregate the governed measure? | select count(*) as active_customers
from events |
| Additivity | Is it using the right snapshot or roll-up? | select sum(r.amount) as mrr from fct_billing r |
| Answerability gate | Did the trace check period and scope? | where month = '2026-08'
-- no coverage check |
The result is not just "the SQL is wrong". It is more precise: now you know whether additivity actually failed, and where it failed.
The peel: remove one primitive and ask again#
If you only have the chat answer, simplify the question and ask again. Remove the segment. Remove the join. Remove the date window. When the answer starts working, the primitive you just removed is where to inspect.
In the same example, peel the question into a shorter version each time:
1
How much additivityMRR came from segmentPaid Search graincustomers in coverageJune?
$48kincorrect
Incorrect.
2
How much additivityMRR came from graincustomers in coverageJune?
$160kincorrect
Still incorrect. Segment is probably not the first failure.
3
How much measurebilled revenue came from graincustomers in coverageJune?
$160kcorrect
Correct. The primitive just removed is the suspect: additivity.
The peel does not prove the repair. It gives you a fast next move when all you have is the chat window.
This is the manual version of what the evidence graph automates: every claim is tied back to the query result that supports it.
Step two: find where the grounding lives#
Once you know which primitive failed, the usual advice turns vague: improve the model, add more context. Three tools make it mechanical.
Name the grounding fact#
Before you can repair the failure, name the fact the agent needed but did not use. Keep it concrete.
- Staff accounts are not customers.
ppc,paid-search, andPaid Searchare the same channel.- Annual amounts enter monthly recurring revenue as one twelfth.
- The data ends on 12 July.
Find where it lives#
Now look for that fact in the system. Is it in the raw values? In a comment or wiki? In the data model? In a metric definition? In a runtime check?
Sometimes it is not in the system at all. It only lives in someone's head. Then the agent cannot use it reliably.
The common failure is not missing knowledge. It is knowledge in the wrong place. In my test, the additivity rule was written above the revenue column, and the agent still summed annual cash as if it were monthly revenue. The fact existed, but it lived somewhere the agent could skip.
The skip test: can it answer without using the fact?#
By skip, I mean this: the agent can produce and serve an answer without using the grounding fact you found. It may not read the comment. It may choose raw SQL instead of the metric. It may join through another path. The fact exists, but it is not on the route the answer must travel.
Ask one practical question: can the agent still answer if this fact is ignored?
| Where you found the fact | Can the agent skip it? | Why |
|---|---|---|
| Comment or wiki | Yes | It only helps if the agent reads it and applies it correctly. |
| Optional metric definition | Yes | The agent can still write its own SQL unless the metric route is required. |
| Data model structure | Harder | If the agent queries the modelled table, the cleaned value, grain, or join path is already there. |
| Runtime check | No | This is an enforced guardrail inside the agent path: the answer is blocked unless the check passes. |
Step two ends with a verdict, and there are only three:
| what you found | the grounding is | step three's verb |
|---|---|---|
| nobody can write the fact down | absent: the business has not defined it | author it, or make sure the agent refuses questions that depend on it |
| the fact exists, in a place the agent can skip | mis-housed: the usual case in this study | move it |
| the fact is in the right place, and the answer is still wrong | incomplete: the artifact is missing a piece | augment it, like the label beside the key |
Step three implements the verbs.
Step three: move the fact where it cannot be skipped#
Do not fix this with another prompt note. Use the result of step two:
- If the fact is missing, define it.
- If the fact is in a skippable place, move it.
- If the fact is incomplete, add the missing piece.
The type of fact usually tells you where it should go. These are the repair patterns this study measured.
| the fact sounds like | which is | the recipe | where the grounding moves |
|---|---|---|---|
ppc and Paid Search mean the same channel | a vocabulary | the decode | Data model: one cleaned value |
| this table is one row per subscription term | a grain | the declared grain | Data model: named grain |
| platform is a user attribute | an attribute's home | the label on the dimension | Data model: dimension the query already uses |
| include all accounts unless asked otherwise | a default | the stated default | Docs: clear default |
| annual cash must become monthly revenue | a stock or unit | the snapshot and the rule | Semantic layer: snapshot plus enforced metric |
| the data ends on 12 July | a boundary | the published window | Guardrail: coverage window plus refusal |
The six recipes below are reference material, not stages: read the one your fact matched. Each follows the same pattern: the situation, the move, and what it measured on the bench.
The decode: put the vocabulary in the data#
The situation: the same business value appears in several forms. ppc, paid-search, and
Paid Search all mean the same channel. FR means France.
The repair is a small data modelling exercise with exactly two moves:
- Normalise the code. Store one machine value:
paid_search. - Store the name beside it. Store one human label: "Paid Search". This is Kimball's thirty-year-old rule: keep the decode beside the code.
Both columns live on the dimension the agent already queries. Nothing is renamed, nothing breaks; the repair is pure addition.
spend.chanraw
- ppc
- paid-search
- Paid Search
- paid_search
- organic
- Organic
- referral
- Referral
the tinted values are one channel, four spellings
dim_channelmodelled
| channel | channel_name |
|---|---|
| paid_search | Paid Search |
| organic | Organic |
| referral | Referral |
one row per channel: the key for machines, the name for people
Why two columns, and not just clean values? Because I built the one-column version, and it is a trap.
What it measured. A vocabulary written only in a comment did not work. A vocabulary built into
the data model did. Synonym questions went from 3% with prose to 75–100% with modelled values. Code
questions, such as FR meaning France, showed the same pattern.
Four kinds of filter value, measured separately“Filter to the right people” is four skills, not one — and prose handles exactly one of them. Read it →
The segment questions split by how much repair the filter's value needs before it can be matched against the data.
This is one slice of the bench described later; the questions and runs are in the harness repository.
| repair job | raw | raw + docs | star | star + docs | verdict |
|---|---|---|---|---|---|
| stated“excluding staff accounts” — the filter is in the question | 53% | 89% | 92% | 97% | prose is enough |
| caseANDROID / Android / android — nine spellings | 81% | 39% | 58% | 86% | prose hurts; structure must be findable |
| codeFR must become “France” | 56% | 58% | 92% | 94% | structure required |
| synonymppc = paid-search = “Paid Search” | 11% | 3% | 75% | 100% | structure required; prose useless |
When the question states the filter in words ("excluding staff accounts"), a comment is enough: 53% jumps to 89%. That is the case people imagine when they say documentation will fix their warehouse for AI, and there they are right. Every other row needs the value repaired first, and prose contributes almost nothing. A vocabulary has to be in the data model.
The declared grain: people are not rows#
The situation: the fact says what one row of a table means. "One row is a subscription term, not a customer".
The repair is a modelling exercise with two moves:
- Name the grain in the table itself. A table where one row is one subscription term is
called
subscription_terms, notsubscriptions. The name is grounding the agent cannot avoid reading; declaring the grain is a core Kimball dimensional modelling rule. - Expose both counts as separate metrics. Where "how many" has two honest readings, define both: one metric counts rows, one counts people.
subscriptionsgrain unstated
| user_id | started | amount |
|---|---|---|
| 184 | 2026-03-02 | 96 |
| 184 | 2026-06-14 | 12 |
| 185 | 2026-05-01 | 12 |
user 184 appears twice: rows are terms, not people
“how many customers?” → count(*) = 3 ✗
subscription_termsgrain named
metric: subscription_terms
count(*) = 3
metric: subscribers
count(distinct user_id) = 2
two questions, two metrics: the choice is visible
What it measured. Grain failures look like this: you ask for people, and the agent counts rows. Naming the table grain and exposing both counts moved grain accuracy from 56% on raw tables to 86% on the star and 94% with star + docs. This is still basic modelling: no new rule, no semantic layer, just a clear grain and the right metrics.
The label on the dimension: keep the attribute on the route#
The situation: the question filters by an attribute, but the readable label lives one join away. For example, users have a platform key, and the platform name sits in a lookup table.
The repair is a modelling exercise with two moves:
- Put common labels on the dimension the query already reads. If most questions start from
users,
platform_namebelongs ondim_users. - Keep lookup tables for real vocabularies. A channel can have its own
dim_channel. A small platform lookup can still exist, but the agent should not need an extra join just to read the label.
This is the Kimball avoid-snowflaking
guidance
applied to an AI analyst: if it describes the user, put it on dim_users. If it is its own
business thing, give it its own dimension.
dim_users · dim_platformsnowflaked
dim_users(user_id, plan, platform_key)dim_platform(platform_key, platform_name)the name is one join away, and joins are where these arms failed
dim_userslabel on the dimension
dim_users(user_id, plan, platform, platform_name)the name arrives with every query of the dimension
What it measured. I tested two versions of the same star: one with the platform label on
dim_users, and one with the label one join away. The label-on-dimension version did better
(89% vs 75%). The gap is close to the noise floor, so I would not over-read the exact numbers. The
lesson is simpler: put common labels where the agent already looks.
The stated default: document what to do, not just what it means#
The situation: the question leaves something unsaid. Should staff accounts count? Should test accounts count? The agent will still pick a default.
The repair is simple:
- Describe the column. Keep the normal definition.
- State the default. Say what to do when the question is silent.
comment on users.internaldescribes the column
“true for staff and test accounts”
read as an instruction: staff excluded from questions that never asked
comment on users.internalstates the default
“true for staff and test accounts Every account counts by default; do not exclude staff unless the question asks”.
the failing questions flipped; removing the sentence brought them back
What it measured. A comment that only describes the column was not enough. The missing piece was the default: what to do when the question says nothing. Adding that sentence fixed the failing questions; removing it brought the failures back. Document the action, not only the meaning.
Documentation amplifies whatever base it lands onThe same comments, written onto two different warehouses, with opposite signs. Read it →
raw → raw + docs
49%→47%-2pp
silent error 47% → 49%
star → star + docs
74%→85%+11pp
silent error 18% → 9%
Practitioner conclusion 1: do not use documentation to compensate for raw structure. On raw tables, comments can give the agent more ways to be wrong.
Practitioner conclusion 2: documentation works after the model is clean. Use it for defaults, row meaning, and coverage; do not ask it to repair grain, joins, or messy values.
The snapshot and the rule: additivity cannot live in prose#
The situation: the sentence says an amount must be transformed before it may be summed. A stock, a unit, an annual amount.
This repair has three parts:
- Build the snapshot. Put MRR on a periodic snapshot fact: one row per customer per month. The annual amount is already converted into a monthly value.
- Define the metric there. Put
mrron that snapshot in the semantic layer. MetricFlow'snon_additive_dimensionis the relevant dbt field: MRR can be summed across customers, but not blindly across time. - Enforce the metric route. If the agent can ignore the metric, it will. The guardrail makes the policy explicit: no governed metric result, no answer.
billinga flow: one row per charge
| user_id | plan | amount |
|---|---|---|
| 184 | annual | 144 |
| 185 | monthly | 12 |
“June MRR?” → 144 + 12 = 156 ✗
one row holds a year, the other holds a month; a plain sum adds them anyway
fct_customer_month_snapshota stock: customer × month
| user_id | month | mrr | |
|---|---|---|---|
| 184 | 2026-06 | 12 | = 144 ÷ 12 |
| 185 | 2026-06 | 12 | billed monthly |
metric mrr → 12 + 12 = 24 ✓
the metric reads the snapshot, and the rule makes it the only route to a served number
What it measured. Additivity was the cleanest failure in the study. Raw tables and raw + docs got every MRR question wrong: 0 of 12 in both cases. The comment explained the rule, but the agent still summed annual amounts as if they were monthly revenue.
The fix was structural:
| version | MRR questions | what changed |
|---|---|---|
| raw | 0 / 12 | annual amounts summed as stored |
| raw + docs | 0 / 12 | the rule was written down, but skippable |
| star | 10 / 12 | monthly snapshot added |
| star + docs | 12 / 12 | snapshot plus explanation |
| + semantic | 11 / 12 | metric defined, but still optional |
| + guardrails | 12 / 12 | governed metric route enforced |
Practitioner conclusion: prose does not fix additivity. Put the monthly value in the model, define the metric on that model, and require the agent to use it. A metric the agent can ignore is still advice; an enforced metric is policy.
The published window: answerability needs fact and restraint#
The situation: some questions are outside the data. The period may not exist yet. The term may not be defined. The agent needs to know the boundary, and it needs a rule for what to do when the question crosses it.
There are two repairs:
- Publish the boundary as data. Compute the min and max dates for each dataset and expose them as a lookup the agent can check. Recompute it with each load. This is metadata, not a wiki sentence someone maintains by hand.
- Enforce refusal. If the requested period, term, metric, or path is outside the governed contract, the agent should refuse. This is a runtime check inside the agent: no governed path, no answer.
the agent's viewno boundary stated
“How many habits were completed in August?”
answer: 0 completions in August ✗
the data ends 12 July; a zero is a confident answer to a question the warehouse cannot address
coveragecomputed from the data
habits: 2026-01-03 → 2026-07-12
answer: “August is outside the data I hold” ✓
those questions went from 0 of 6 to 6 of 6
What it measured. In the mini matrix, data modelling made answerable questions much stronger. It did not make the agent good at saying no. On the no-answer questions, most warehouse versions stayed weak: 36–47% correct. The provenance guardrail reached 78%.
The lesson is simple: accuracy and honesty are different controls. A better data model helps the agent answer. A runtime check defines when it must stop.
The Repair Matrix#
The matrix is built from four repair areas: data modelling, documentation, semantic layer design, and agent guardrails. Each area has a different owner and a different reference point.
data modelling
modelledrepairs it ownsthe decode · the declared grain · the label on the dimension · the snapshot
the canonKimball essentials · snowflaked dimensions · fact tables
documentation
documentedrepairs it ownsdefaults · row meaning · edge cases, on top of a clean star
the canonDatabricks grounding hierarchy
semantic layer
declaredrepairs it ownsgoverned metrics · member mappings · non-additive rules
the canonMetricFlow · dbt measures · Cube data model
guardrails & agent architecture
enforcedrepairs it ownsthe provenance rule · enforced refusal · the coverage window as a tool
the canonrefusal experiment · OpenAI guardrails · Anthropic agent patterns
Some repairs stay in one area. Decode values, name the grain, or state the default. Those are cheap. Other repairs cross areas. Measure/aggregation needs a clear fact table and an enforced metric; additivity also needs a snapshot. Answerability needs computed coverage and refusal. This is the repair matrix: for each measured primitive family or subcase, find the cheapest location that worked.
| raw | raw+docs | star | +semantic | +guardrails | ||
|---|---|---|---|---|---|---|
| primitive family | implicit | documented | modelled | declared | enforced | fix |
| segment: filter | ✗ | ✓ | ✓ | ✓ | ✓ | Document the filter meaning and default |
| segment: member | ✗ | ✗ | ✓ | ✓ | ✓ | Data modelling: normalise raw values into a clean dimension |
| grain | ✗ | ✗ | ✓ | ✓ | ✓ | Data modelling: clear fact table grain and metrics |
| join path | ✗ | ✗ | ✓ | ✓ | ✓ | Data modelling: clear fact/dim tables and join keys |
| measure / aggregation | ✗ | ✗ | ✗ | ✗ | ✓ | Data model + Semantic + Guardrail: clear fact table, enforced metric definition and aggregation |
| additivity | ✗ | ✗ | ✓ | ✗ | ✓ | Data model + Semantic + Guardrail: clear snapshot table and enforced metric definition |
| answerability gate | ✗ | ✗ | ✗ | ✗ | ✓ | Guardrail: check coverage, definitions, and scope before answering |
✓ works · [✓] reliable fix
Read it as a budget. Most rows are fixed by ordinary data modelling. The right edge is needed for three rows: measure/aggregation, additivity, and answerability. The left side, where many facts live today, is usually too easy for the agent to skip.
The same four areas define ownership. A failed primitive should land in the backlog of the layer that can actually fix it.
Where each location lives on the big platformsThe recipes are written in dbt and Kimball vocabulary; the locations translate to every major stack. Read it →
| location | Databricks | Snowflake | Power BI / Fabric | Looker |
|---|---|---|---|---|
| documented | table and column comments; Genie instructions | comments; Cortex Analyst descriptions | descriptions in the semantic model | LookML descriptions |
| modelled | Unity Catalog tables | dynamic tables and views | tables in the semantic model | views and PDTs |
| declared | Unity Catalog metric views | Cortex Analyst semantic views | semantic model measures | LookML measures and Explores |
| enforced | trusted assets: an approved query or Unity Catalog function runs, and the reply is labelled Trusted | AI_VERIFIED_QUERIES on the semantic view; the API reports whether a verified query was used | verified answers, matched to a saved visual by trigger phrase | Conversational Analytics compiles from LookML, so there is no raw SQL path |
These controls are not equivalent. Some remove the raw-SQL bypass. Others still generate freely when no saved answer matches. The guardrail in this study checked the agent trace before serving: no governed result, no number.
One thing this bench does not test is permissions. Segment logic and row-level security can both
affect the WHERE clause, but they answer different questions: who counts in the
metric, and who is allowed to see the rows. A production system has to apply permissions first.
Run it as an AI-readiness audit#
The same anatomy that repairs a broken answer tells you, in advance, which answers will break. That audit is what I would ship.
Start from the questions, not the schema. Collect what people really ask: board, growth, finance, support questions. Decompose each one the way the gloss at the top does, and ask which primitive would fail first on your stack.
Build ladders, not a question list. Add one primitive per rung, the way the SQL figure climbs. The first rung should be almost boring; the fourth should read like a line from a board memo. The rung where answers start failing names the primitive, and the wrong answers confirm it.
Put in questions that have no answer. A period you do not hold, a term nobody defined, a false premise. Without them you can only measure whether the analyst is right, never whether it answers when it should not, and that second failure is the one that reaches a board deck.
Ask everything three times, and score by primitive, not by average. Errors compound as primitives stack. Here is why the ladder matters more than the question count:
| arm | 1 primitive | 2 primitives | 3 primitives | 4 primitives |
|---|---|---|---|---|
| raw | 100% | 67% | 40% | 53% |
| raw + docs | 100% | 100% | 87% | 80% |
| star | 100% | 100% | 100% | 80% |
| + semantic | 100% | 100% | 93% | 93% |
| + guardrails | 100% | 100% | 73% | 100% |
| spread, best to worst | 0pp | 33pp | 60pp | 47pp |
a spread of 0pp means the test cannot tell the best warehouse from the worst
On one-primitive questions, every warehouse looked perfect. The failures appeared only when questions combined multiple primitives. That is why a demo suite built from simple questions can look safe while real business questions still fail.
The audit should return a repair backlog, not just a score. Each failed primitive should point to one kind of work:
- Data cleaning: raw values need normalising.
- Data modelling: grain, joins, or snapshots need to move into the model.
- Documentation: defaults and edge cases need to be stated clearly.
- Semantic layer: governed metrics and aggregations need to be declared.
- Agent guardrails: the agent needs a runtime rule for refusal or provenance.
The bench behind the numbers#
The numbers come from one test warehouse, rebuilt eight ways. The data stayed the same. Only what the agent could see changed. Six versions carry the argument and appear in every figure below. The other two are structural probes of the star that move one label between a dimension and a lookup table; they are in the totals and in no chart.
| arm | what it is |
|---|---|
| raw | Application-style tables: coded columns, abbreviations, and multiple spellings for the same value. |
| raw + docs | The same raw tables, with table and column comments added. |
| star | A cleaned dimensional model: clearer names, resolved codes, and one grain per table. |
| star + docs | The cleaned star model, with documentation added. |
| + semantic | Governed metric definitions in MetricFlow. The agent could still choose raw SQL instead. |
| + guardrails | A runtime rule inside the agent: served numbers had to trace to governed metric results. |
| + LLM judge | A verifier inside the agent flow checked the guarded answer before serving. This is a probe, not a ladder step. |
Main run: 62 questions × 3 repetitions × 8 warehouse versions, using gpt-5-mini: 1,488 graded
runs. Most questions combine one to four primitives. Twelve have no honest answer. The LLM-judge
arm is a separate probe.
I also reran the headline scorecard once on gpt-5.6-terra. Read it for direction, not exact cell
values.
Two caveats: the warehouse is synthetic, and repeated gpt-5-mini runs disagree in about 25% of
question-version cells. The method and data are on GitHub.
How runs were graded, and what the rule enforcesDeterministic grading, the definition of a silent error, and the operational meaning of “bypass”. Read it →
- Grading was deterministic. The expected answer was computed before the run. A numeric answer counted as correct if it was within 2% of that value.
- Silent error means confidently wrong. The agent served a wrong number, and nothing in the answer warned the user.
- No-answer questions had their own grading. A refusal, a clarification request, or a correction of a false premise counted as handled.
- Each question appears in one matrix row. The row is the main primitive that question was built to test.
- The provenance rule was enforced in code. A served number had to match a governed metric result in the trace. Otherwise the agent refused or retried through the governed route.
- Bypass means raw SQL only. In the
+ semanticarm, the agent had both options: governed metrics and raw SQL. A bypass run used only raw SQL. In the+ guardrailsarm, the agent could still inspect data with raw SQL. But before showing a number to the user, it had to reproduce that number through a governed metric. If it could not, it refused.
The scoreboard#
Every arm, on the three metrics this series always reports.
| arm | coverage | silent error | balanced accuracy |
|---|---|---|---|
| the modelling axis | |||
| raw | 97% | 47% | 45% |
| raw + docs | 99% | 49% | 43% |
| star | 95% | 18% | 64% |
| star + docs | 99% | 9% | 71% |
| + semantic | 100% | 17% | 63% |
| enforcement | |||
| + guardrails | 99% | 5% | 86% |
| + LLM judgeprobe | 71% | 2% | 77% |
Two things to notice in the mini run. First, coverage stays high until the judge, where refusals increase. Second, silent error changes much more than balanced accuracy. Two arms can look close on accuracy while one serves many more confident wrong answers.
What each improvement bought#
The same numbers, read as investments. Each row is one step up the ladder, and the chips are what that step changed.
| the investment | Δ balanced accuracy | Δ silent error | verdict |
|---|---|---|---|
| raw → raw + docs | -2pp | +2pp | made it worse |
| raw → star | +19pp | -29pp | the big accuracy buy |
| star → star + docs | +7pp | -9pp | amplifies a clean base |
| star + docs → + semantic | -8pp | +8pp | optional route was not enough |
| + semantic → + guardrails | +23pp | -12pp | enforcement improved both dials |
| + guardrails → + LLM judge | -9pp | -3pp | probe: coverage fell by 28pp |
green = the dial moved the healthy way · balanced accuracy up, silent error down
On the mini run, the largest structural gain came from raw tables to a star model. Documentation helped on the star and hurt on raw tables. The optional semantic route did not improve this run. The largest overall gain came when the governed route was enforced.
Per primitive family: the matrix itself#
The third perspective explains everything the scoreboard averages: every measured primitive family against every version of the warehouse.
| primitive family (n) | raw | raw + docs | star | star + docs | + semantic | + guardrails | + LLM judge |
|---|---|---|---|---|---|---|---|
| segment48 | 48% | 58% | 85% | 94% | 90% | 98% | 56% |
| grain36 | 56% | 44% | 86% | 94% | 92% | 83% | 75% |
| join path36 | 56% | 44% | 67% | 92% | 86% | 97% | 75% |
| measure / aggregation12 | 75% | 67% | 67% | 100% | 58% ⚠ | 100% | 75% |
| additivity12 | 0% | 0% | 83% | 100% | 92% | 100% | 75% |
| answerability gate36 | 39% | 36% | 47% | 47% | 39% | 78% | 83% |
And the same grid on the other dial, how often each cell misled instead of answering:
| primitive family (n) | raw | raw + docs | star | star + docs | + semantic | + guardrails | + LLM judge |
|---|---|---|---|---|---|---|---|
| segment48 | 50% | 40% | 13% | 6% | 10% | 0% | 2% |
| grain36 | 42% | 56% | 6% | 3% | 8% | 17% | 3% |
| join path36 | 42% | 56% | 33% | 8% | 14% | 3% | 0% |
| measure / aggregation12 | 25% | 33% | 8% | 0% | 42% ⚠ | 0% | 0% |
| additivity12 | 92% | 100% | 8% | 0% | 8% | 0% | 0% |
| answerability gate36 | 56% | 44% | 33% | 25% | 31% | 11% | 6% |
Read the matrix by row. The segment rows, grain and join path mostly need modelling. Additivity needs a snapshot. Answerability needs refusal handling. A single score hides this; the matrix shows which repair to make.
What a stronger model changes#
After the main run, I reran the headline scorecard on a stronger model: gpt-5.6-terra. I would
not put the full primitive-by-primitive Terra table in the article. It is useful audit detail, but
it asks the reader to compare too many cells. The top-line comparison is enough.
| arm | coverage | silent error | headline score | governed use |
|---|---|---|---|---|
| raw | 97% → 100% | 47% → 19% | 48% → 78% | — |
| raw + docs | 99% → 100% | 49% → 35% | 52% → 72% | — |
| star | 95% → 100% | 18% → 26% | 73% → 74% | — |
| star + docs | 99% → 100% | 9% → 3% | 85% → 92% | — |
| + semantic | 100% → 100% | 17% → 2% | 78% → 96% | 74% → 48% |
| + guardrails | 99% → 84% | 5% → 5% | 95% → 89% | 100% → 100% |
| + LLM judge | 71% → 66% | 2% → 2% | 82% → 82% | 100% → 100% |
Three things changed.
Terra raises the floor. The raw warehouse got much better, and silent error fell sharply. A stronger model can extract more from weak structure.
Clean structure still matters. The best unguarded result is not raw plus a stronger model. It is the clean star, documented, with governed metric definitions available.
Governance is a product choice, not just an accuracy trick. With the semantic layer optional, Terra scored well, but used the governed route less often. When the route was enforced, governed use stayed at 100%, but coverage fell. So the choice is explicit: allow more answers, or require every served number to have a governed trace.
Limits of this studyWhat I would not generalise from this bench. Read it →
- This is not a model leaderboard. The repair matrix is the
gpt-5-minirun; Terra is a sensitivity check. - The warehouse is small and synthetic. It does not include production scale, permissions, messy history, or slowly changing dimensions.
- Repeated runs moved in 25% of question-arm cells. Treat small gaps as noise.
- The findings I would defend are the wide ones: one-primitive questions looked solved, additivity hit zero in prose arms, synonyms needed data modelling, coverage needed a published window, optional metrics were bypassed, and enforcement changed the tradeoff.
- Documenting what to do when the question is silent worked in one focused test. I would test it again before treating it as a general rule.
- The recipes were developed against the same 62 questions that score them.
- A question is scored under one primitive, but a different primitive can still be the real reason it failed.
- I did not measure token cost.
- These figures are not comparable with earlier articles in the series: different questions, different denominators.
There is no AI-ready warehouse#
There is no single switch that makes a warehouse AI-ready. A question can be ready for one primitive and fragile for another. Segment can be fine while additivity is broken. A metric can be defined while the agent is still allowed to ignore it.
The practical work is concrete: clean the values, name the grain, state the defaults, publish the coverage window, model stocks on snapshots, and enforce governed metric routes where silent errors are costly.
The cleanest definition I can now give: AI-ready data means every question primitive people rely on has a governed path, and the agent refuses when that path does not exist. The repair matrix is the checklist for finding which path is missing.
Sources & further reading
- Agentic Analytics: How Much Does Grounding Actually Buy You? — Dmitry Ustimov, Decision Spine
- Agentic Analytics: Teaching an AI Analyst to Say I Don't Know — Dmitry Ustimov, Decision Spine
- The Evidence Graph: Teaching an AI Analyst to Show Its Work — Dmitry Ustimov, Decision Spine
- AI analytics harness: the agent, the questions, and every number in this piece — Dmitry Ustimov (MIT-licensed, on GitHub)
- The 10 Essential Rules of Dimensional Modeling — Margy Ross, Kimball Group
- Snowflaked Dimension — Kimball Group
- Fact Tables — Ralph Kimball, Kimball Group
- Semantic Layer vs. Text-to-SQL: 2026 Benchmark Update — dbt Labs
- About MetricFlow — dbt Labs
- Measures — dbt Labs
- Cube: data modeling overview — Cube
- AI/BI Genie best practices (grounding hierarchy) — Databricks
- Guardrails — OpenAI Agents SDK
- Building effective agents — Anthropic
Read next
- Agentic Analytics20 min
The Evidence Graph: Teaching an AI Analyst to Show Its Work
I tried to answer a simple product question: how can a user trust an AI analyst's answer? The answer was not more explanation. It was a typed graph of claims, query results, and support checks.
Read - Agentic Analytics34 min
Agentic Analytics: Teaching an AI Analyst to Say I Don't Know
My last experiment pushed an AI analyst from 41% to 92% by adding structure. This time I measured what it does when there is no answer to give: structure alone still left it confidently wrong 31% of the time, and nine guardrails took that to 2%.
Read - Agentic Analytics15 min
Agentic Analytics: How Much Does Grounding Actually Buy You?
Every vendor says their AI analyst works, and the benchmark numbers are high. So I built one in a controlled lab and added grounding one layer at a time (semantic layer, verified examples, knowledge base, metric tree) to get honest numbers on what each layer actually buys.
Read
Want to build a clearer decision system?
Tell us where the numbers feel murky and we'll show you what a trustworthy decision system looks like for your team.
