Klarix Book a call
Case study 02 of 03 · Semantic AI benchmark

Before an AI agent uses your metrics, prove the metrics are right.

I rebuilt the semantic layer from case study 01 on the stack most mid-sized data teams already run: dbt Core and MetricFlow, on a single DuckDB file. Same frozen data, same five planted problems, same metric contract. This part tests the layer itself against independent ground truth, which is the precondition for trusting any agent built on top of it.

dbt Core 1.12 MetricFlow 0.213 DuckDB Claude Opus 5 theLook eCommerce

41 / 41

layer checks within tolerance of ground truth; all but one exact to floating-point rounding

0 of 44

aggregates differ from the BigQuery star schema built in case study 01

70 tests

in a 93-node dbt build that goes green from an empty file in about five seconds

€0

cloud cost to run the stack; it runs offline on a laptop, only the LLM is paid

Scope, stated up front

Everything here was measured: layer correctness, build determinism, engine parity and one end-to-end smoke test. The full paid agent evaluation (about $55 in API tokens) was built and tested but deliberately not run, so this page contains no agent pass rates for this stack and nothing on it implies otherwise.

My role: solo build, covering the dbt project, the MetricFlow model, the backend, the tests and the comparison tooling.

dbt MetricFlow vs Truth on DuckDB: 41 of 41 checks within tolerance of ground truth, 0 of 44 aggregates differ from BigQuery

The question

Most teams will not adopt a new semantic layer. They will put AI on the dbt project they already have.

Case study 01 tested BigQuery with Cube, a separate service with its own model files. Here the metric definitions live in the same repository as the transformation code, reviewed in the same pull requests and checked by the same CI. That changes where "governed" actually lives.

The thesis stays the same: a semantic layer makes answers consistent, but it is only as correct as the judgment written into it. Building the same layer twice also shows something one build cannot: each stack has its own ways to get a metric subtly wrong.

Same benchmark, second stack

Only the stack changed. Everything that decides the score stayed frozen.

The data, the planted problems, the 20 questions, the scorers, the judge and the system prompt are identical to case study 01, checked against a git tag of its final state.

Case study 01 Case study 02
WarehouseBigQuery (EU)DuckDB, one local file
TransformationSQL views applied by a scriptdbt Core with dbt-duckdb
Semantic layerCube CoreMetricFlow
Agent modelGemini on Vertex AIClaude (Opus 5)
Cloud cost for the stackBigQuery and Vertex AINone; API tokens only

How it is built

From flawed Parquet files to governed metrics, with truth kept outside the system.

  1. Step 1 · Sources

    The flawed copy is the raw layer

    The observed Parquet files, with five planted problems, are declared as dbt sources directly. There is no separate load step.

  2. Step 2 · Staging

    Seven typed views, no business logic

    Names, types and UTC timestamps are fixed here, so everything downstream reads the same columns in the same time zone.

  3. Step 3 · Star schema

    Ported, not redesigned

    The case study 01 SQL was the reference: same grains, columns and rules, rebuilt as dbt tables with a cohort mart and 70 data tests.

  4. Step 4 · Semantic views

    Six thin views for MetricFlow

    MetricFlow needs a real date column in each model. Thin views supply it, so the star schema checked against BigQuery stays untouched.

  5. Step 5 · Metrics

    18 metrics, 15 dimensions, one contract

    The same catalog names as case study 01. Descriptions are the catalog text copied verbatim, and a test compares them.

  6. Step 6 · Backend

    The LLM still never writes SQL

    The agent asks for metrics by name; MetricFlow compiles the SQL, and the backend runs it on a read-only connection and maps the columns back to catalog names.

The planted problems

The same five traps, handled the dbt way.

Some are fixed by design, one is deliberately left open, and one cannot be fixed at all. Saying which is which is part of the work.

ID What is wrong in the data Handled? How, in dbt and MetricFlow
P1 "Revenue" is ambiguous. Gross includes returned items, net does not. Left open Both metrics exist under separate names, so the test is whether the agent notices the split.
P2 A category is renamed mid-window (Sweaters to Knitwear). By design A hand-maintained dbt seed maps both names to one category family; a test checks the seed against the benchmark settings.
P3 Consent loss nulls the acquisition channel and session source. Visible only NULL becomes an explicit "Unattributed", and a coverage metric exposes the gap. The true source is unrecoverable by design.
P4 Internal and test accounts inflate order counts and distort repeat rate. By design A rule-based flag, filtered on every metric. A dbt test checks it flags exactly the 1,000 injected users. The raw-table baseline cannot exclude them.
P5 A small, real cohort returns far more items, invisible at channel level. Structurally A cohort mart lets the question be asked at the right grain. The agent still has to choose to look there.

Governance in dbt

Every dbt test can pass for a net revenue that forgets to subtract returns.

Structure is guaranteed at build time

Keys are unique and not null, every foreign key resolves, the fact table has the source's row count, and gross revenue reconciles to the source within one cent.

Conventions are enforced by tests

MetricFlow has no model-wide default filter, so each metric carries its own internal-user exclusion. A contract test walks the metric graph and fails if any metric is missing it.

The agent sees exactly the catalog

Descriptions are compared to the catalog verbatim, helper metrics are hidden, and a hygiene test fails if any agent-visible text names a planted problem.

What none of this proves

That a definition is right. Correctness needs an independent source of truth and a test against it, which is the next section and the part most teams skip.

Results

The layer matches ground truth, and it matches the first stack.

Every catalog metric over the full 24-month window, compared with a number recomputed from the clean world by separate code. The same suite runs against case study 01's Cube layer. No LLM is involved.

Layer Checks Largest deviations from truth Tolerance
MetricFlow 41 Repeat purchase rate 0.22%; every other check about 1e-14 (floating-point rounding) 0.5%
Cube (case study 01) 41 Repeat purchase rate 0.22%, orders 0.077%, purchasing customers 0.051%, the rest near exact 0.5%

The 0.22% gap is the same in both stacks: the shared cohort rule counts a second order within 90 calendar days, while truth uses exact timestamps. MetricFlow is exact on orders and purchasing customers because they are defined on order items; Cube defines them on the orders table and is off by a few zero-price orders. Neither is wrong; the test shows which one is exact.

Deterministic build

93 nodes, green in about five seconds

One seed, 13 views, 9 tables and 70 tests from an empty file. Two fresh builds gave identical row counts and content fingerprints.

Engine parity

Same model, two engines, same numbers

44 aggregates (row counts, measure sums, key sums, flag counts) compared with the BigQuery star schema: 0 mismatches.

Same baseline

"Naive" means the same thing in both studies

The raw-table baseline was refactored to support both engines. Its SQL for 20 fixed queries stayed byte-identical, and both engines return the same numbers.

Row counts in the dbt build
fct_order_items175,804dim_customer97,447 (1,000 internal)
fct_orders119,574dim_product30,858
fct_sessions663,100dim_date2,829
mart_customer_cohorts97,446dim_distribution_center11

One question, end to end

The same question, asked once of Claude on each backend.

A smoke test, not a result: it shows the mechanism, not a pass rate. Truth is 4,633 orders.

Raw tables · naive DuckDB

"How many orders did we have last month?"

5,898 (+27%)

Stated with high confidence and no mention of internal accounts, which this backend can neither see nor exclude.

Governed · MetricFlow

"How many orders did we have last month?"

4,633 (exact)

The layer excludes internal and test accounts by construction, and the agent's stated definition says so.

Two stacks, same benchmark

What building the same layer twice showed.

MetricFlow joins what Cube could not

Orders (from items) and new customers (from orders) answer in one query through the shared time axis. In Cube that pair needed an internal alias and routing code.

Harder setup, easier joins

MetricFlow needs a time spine, a real date column per model and one source per dimension name. Once entities are declared, no join paths are hand-written.

Both need the same safety convention

Internal-user exclusion is a per-metric filter in both stacks, and both are only as safe as the test that enforces it.

A metric's time axis is a design decision

Repeat purchase rate is catalogued by signup date but aggregates by cohort month in both layers. The backend has to know that, and warns when a query uses the other axis.

Descriptions drift unless tested

The Cube text in case study 01 was written separately and drifted; one description even named a planted problem. Here the catalog text is copied verbatim and compared by a test.

No new infrastructure for dbt teams

DuckDB and dbt run free and offline. The BigQuery stack needed a cloud project, credentials and a container.

Engineering findings

Found while building, not in the documentation.

LLM filter values can become code

MetricFlow renders where-clauses as Jinja, so a value like a template expression from the model would be evaluated. The backend rejects template and control characters, NaN and non-ISO dates, and escapes quotes.

A tolerance can hide a missing day

A BETWEEN on timestamps stopped at midnight and dropped 31 August, about 0.14%, well inside the 0.5% tolerance. Now the upper bound is exclusive.

Dates depended on the laptop's time zone

DuckDB derives dates from Parquet timestamps in the local zone; BigQuery always uses UTC. Staging casts to UTC and the profile pins the zone.

Rebuilds were quietly non-deterministic

An arbitrary-value aggregate and 91 users with two orders at the same instant made rebuilds differ. A deterministic aggregate and an order id tie-breaker fixed it.

An expected failure hid a crash

A strict xfail reported success while the test itself crashed. It surfaced only by listing what was actually collected; the test now asserts it found something.

The docs example did not parse

In dbt-core 1.12.5 the aggregation time dimension sits beside the semantic model, not inside it, and MetricFlow needs a time spine, derived here from the date dimension.

Honest limitations

What this case study cannot tell you yet.

Whether MetricFlow helped the agent

The four planned runs (raw tables with Claude, MetricFlow with Claude, MetricFlow with Gemini as a control, and a larger tool budget) are built and tested but not run. The layer is proven correct, which is the precondition.

Scale and noise

DuckDB is single-node, so nothing here says how MetricFlow behaves on a large warehouse. One repeat per question is planned, so run-to-run noise would stay unmeasured.

A label leaked in case study 01

One Cube description named a planted problem, and that text reached the agent in those runs. Case study 01 is frozen; the hygiene test marks it as a known failure, and this stack passes.

Pinned, not stable

MetricFlow's Python API is not declared stable upstream, so every version is pinned exactly. The pinned MetricFlow release is Apache 2.0 licensed.

If your team runs dbt

Test your metrics against truth before an agent reads them.

dbt tests protect structure: keys, joins, row counts. They do not tell you whether "net revenue" means what finance means. That takes an independent calculation and a test that compares the two.

Put the conventions an agent depends on, like excluding test accounts, under a contract test. Nothing else stops the next metric from skipping them.

Keep the text an agent reads under version control and test it too. It drifts faster than the SQL.

If you are planning to put an AI analyst on your dbt project, I am glad to walk through what this kind of correctness harness would look like on your models.

Privacy

Everything runs locally except the LLM calls. The agent receives metric metadata and aggregated results only, never raw rows. Claude is called through the Anthropic API under its commercial terms. The data is Google's public, synthetic theLook dataset; no real customer data is involved at any point.

The series

01: the same benchmark on BigQuery and Cube, answered by Gemini, with full agent results. 03: Power BI with the model defined in TMDL. Same data, same questions, same judge, so the stacks can be compared fairly.