Correctness is a compiler problem.

Language models guess. Compilers do not. iDash puts the correctness guarantee in the query plan, where it can be checked.

The failure mode

A join can inflate a total.

Join a customer to their orders and the customer appears once per order. Count them and the count is wrong.

This is called fan-out, and it is the most common way a business intelligence tool returns a confident wrong answer. The join itself is correct. Every row it produces belongs there. The problem is that a customer with twelve orders now occupies twelve rows, so anything measured on the customer side gets counted twelve times.

Nothing errors. The query runs, the chart draws, the total looks plausible, and it is out by roughly the average number of orders per customer. In a review meeting nobody catches a number that is five times too large if they do not already know the right one.

Sum a revenue column that lives on the order side and the total is fine. Sum a lifetime value column that lives on the customer side across the same join and it is not. The damage is per measure, which is exactly why a human eyeballing the result cannot be the control.

Three customers, six orders

Every row this join produces belongs there. That is what makes the wrong total so hard to catch by eye.

customers3 rows, one per person
customer_idchannellifetime_value
c_1041Organic search$6,180
c_1042Paid social$2,940
c_1043Referral$5,120
orders6 rows, one per purchase
order_idcustomer_idnet_total
o_88401c_1041$2,310
o_88402c_1042$2,940
o_88403c_1041$1,940
o_88404c_1043$3,180
o_88405c_1041$1,930
o_88406c_1043$1,940
Joined on customer_id
customers joined to orders6 rows, each customer repeated once per order
customer_idlifetime_valueorder_idnet_total
c_1041$6,180o_88401$2,310
c_1041$6,180Repeatedo_88403$1,940
c_1041$6,180Repeatedo_88405$1,930
c_1042$2,940o_88402$2,940
c_1043$5,120o_88404$3,180
c_1043$5,120Repeatedo_88406$1,940

Sum of lifetime value across the join

$31,720

Three repeated rows carried their customer total a second time.

Sum after dedup on customer_id

$14,240

One row per customer, then add the column up.

The join is not the mistake. Adding up a customer column across it is. At three customers you can see it. At 1,842 you cannot.

One question, two answers

1,842 customers who have placed 9,610 orders between them, asked for lifetime value by acquisition channel.

MetricJoined naivelyVerified by iDash
CustomersCounted once per order9,6101,842
Lifetime valueEach customer summed once per order$47.5M$9.1M
OrdersMeasured on the inflated side, so unaffected9,6109,610

The order count was never wrong. Only a plan that reads the shape of each aggregation can tell which measures need fixing and which do not.

The fix is a plan, not a prompt.

Before any SQL is written, iDash computes a dedup plan from the shape of the aggregation. Same query, same plan, every time.

  1. Read the shape

    The compiler looks at which models are being aggregated, which joins the question needs, and which of those joins multiply rows on the measured side. This is arithmetic on the model graph, not an opinion.

  2. Dedupe on the primary key

    Where a model is inflated, the compiler emits a step that reduces it to one row per primary key before the aggregation happens. The measure is then summed over rows that exist once.

  3. Compile for your dialect

    The plan becomes real SQL for the source you connected: PostgreSQL, MySQL, SQL Server, ClickHouse, Trino or a file. The dialect differences are the engine's problem, not yours.

  4. Check the result again

    A separate rule inspects the generated SQL for fan-out afterwards, without knowing what was asked. Two independent passes have to agree before a number reaches you.

The function that produces the plan is pure. It takes the shape of an aggregation and returns either a plan or a refusal. It cannot see the conversation, and it cannot be talked out of a refusal.

Three questions it will not answer.

A refusal is a feature. Each one names the reason and tells you how to ask a version of the question that can be proved.

  • The join path is ambiguous

    There is more than one way to connect two tables, and the routes disagree. Picking one silently would mean choosing an answer on your behalf.

    What the compiler says

    There are two paths from customers to revenue, one through orders and one through invoices, and they do not agree. Tell me which one this question means.

    Name the path, or curate the relationship in the model so the choice is made once.

  • The table has no primary key

    Deduping means keeping one row per key. With no key there is no definition of the same row, so there is no safe way to collapse the inflation.

    What the compiler says

    The events table has no primary key, so I cannot remove the duplicate rows this join creates. Add a key, or ask for a count of events instead.

    Add a primary key upstream, or ask for a measure that lives on the inflated side.

  • Two models are inflated at once

    One inflated table can be deduped in place. Two independently inflated tables in a single question need a plan that is not built, so the query stops rather than approximating.

    What the compiler says

    Orders and support tickets both multiply the customer row. Ask for one at a time and I can answer both.

    Split the question in two. Both halves compile.

A refusal names the table and the reason, so it reads as a work item rather than a dead end.

Checked twice, in different places.

The plan is built in the compiler and verified again after the SQL is generated, by a rule that has never seen the question.

Compiled query planVerified

Question

What was revenue by region last quarter?

Resolved

model
orders
measure
sum(orders.net_revenue)
dimension
regions.region_name
grain
order_id

Join path

orderscustomersregions
  • customers.customer_id · many to one
  • regions.region_id · many to one

Primary key dedup applied on order_id

order_items multiplies each order row 3.4 times on average. Revenue is measured on the order grain, so the compiler dedups on the primary key before aggregating.

Compiled plan

WITH orders_dedup AS (
  SELECT DISTINCT ON (o.order_id)
         o.order_id, o.customer_id, o.net_revenue, o.placed_at
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.order_id
)
SELECT r.region_name,
       SUM(od.net_revenue) AS net_revenue
  FROM orders_dedup od
  JOIN customers c ON c.customer_id = od.customer_id
  JOIN regions   r ON r.region_id   = c.region_id
 WHERE od.placed_at >= DATE '2026-05-01'
   AND od.placed_at <  DATE '2026-08-01'
 GROUP BY r.region_name
 ORDER BY net_revenue DESC
  • Join path resolved, 2 hops, no ambiguity
  • Primary key dedup applied on orders.order_id
  • One inflated model in the query, single dedup CTE is sufficient
  • Read only connection, statement timeout 30s
  • 5 rows · 412ms
Compiled query planRefused

Question

What was revenue by campaign last quarter?

Ambiguous join path

Both paths are valid and they return different numbers. Rather than pick one and hand you a number that looks fine, the compiler stops.

  • ordersattributionscampaigns

    last touch

  • orderssessionscampaigns

    session source

Compiler

ambiguous join path from 'orders' to 'campaigns': 2 equally short paths exist. Query one of them directly as the base model instead.

How to get an answer

  • Ask for one of them by name, for example revenue by last touch campaign.
  • Mark one relationship as the default in the semantic model.

A function, not a conversation

The dedup decision comes from a pure function over the query shape. There is no phrasing that makes it lenient, because it never reads the phrasing.

A second opinion after compilation

Once the SQL exists, an independent rule reads it looking for fan-out. It reaches its conclusion from the SQL alone, so it can disagree with the plan that produced it.

The risky part runs on its own

The query engine is a separate process. A failure inside it is contained there instead of taking the application down with it.

Language models are qualified, not assumed

A model is run through the full semantic loop before it is marked verified for production use. Models that have not been through it are usable, and are flagged.

Every number carries its receipt.

Two labels, on every result. Semantic means the compiler produced it. Raw means it came from fallback SQL. There is no third, quiet option.

VerifiedProduction Postgres · 5 rows · 412ms

semantic

Compiled from the semantic model

The question resolved into a typed query, the compiler built a dedup plan, and the SQL that ran was generated from that plan. This is the path iDash prefers and the one you should expect.

Unverified SQLProduction Postgres

raw

Read-only SQL, labelled as such

Some questions fall outside the model, and some sources do not get a semantic model at all. For those, iDash writes read-only SQL and marks the result raw. It is still SELECT only and still single statement, but it does not carry the dedup guarantee.

The label travels with the result. It is on the answer in chat, on the chart, and on the tile after the chart is pinned, so a number cannot lose its history by being moved somewhere convenient.

This matters more than it sounds. The dangerous version of an AI analyst is not the one that fails loudly. It is the one that quietly drops to a different method and presents the output the same way.

What it will never do.

The guarantees are narrow on purpose. Here is what iDash does not claim, so you know exactly what you are trusting.

The join graph comes from your keys. If the graph does not support the question, the answer is a refusal, not an improvisation.

Test it on a number you know.

Connect a read-only replica, ask something you already have the answer to, and compare.

Book a demo