semantics/, contracts/, and query, it emits byte-identical SQL every time.
The semantic query
The input the compiler resolves, produced by adapters (CLI/MCP), never by plain language:Pipeline stages
- Resolve metrics. Map each metric name → canonical binding → source/measure (or composite components). Unknown/ambiguous →
UNRESOLVED/AMBIGUOUSwith candidates. - Resolve dimensions & filters. Bind to columns on the owning source or a join-reachable source. Unreachable →
UNREACHABLE. - Plan the join graph. From the metric’s source, find the minimal join path to every referenced source using only declared
joins. No path →UNREACHABLE; more than one valid path →AMBIGUOUS_JOIN_PATH; the compiler never guesses a shortest path or invents a cross join. - Fanout & additivity analysis. Detect when a join fans out the grain relative to a measure’s source grain, and dispatch to the binding’s compilation strategy (below).
- Apply finality & coalescing. If the metric has a finality rule, select the right source(s) for the requested time window and tag output rows
final/provisional. - Enforce guardrails. AND-in mandatory filters, apply
restrict_sourcefor the activecontext.severity: errorblocks;warnannotates. - Emit SQL. Build a dialect-neutral AST, then transpile via the dialect adapter. Read-only (
SELECT) only, by construction. - Attach result attributes. Resolved bindings, guardrails fired, provisional/final mix, per-source freshness, and additivity handling applied.
- Assertion check (in benchmark/CI mode). Run the emitted SQL and compare to the matching assertion’s expected value; divergence beyond tolerance fails.
code, message, candidates?), never free text, so a caller can act on them programmatically instead of parsing prose.
No guessing
Stage 2 and stage 3 share one rule: if there is more than one way to satisfy a query, the compiler refuses rather than picking one silently. It doesn’t matter that a join could be inferred; if the semantic model doesn’t say which one, the compiler asks instead of guessing. A concrete case: a car-rental model whererentals joins locations twice (once as pickup, once as dropoff) and country is a declared dimension on both locations and customers. Asking for country without saying which one is ambiguous:
candidates list is exact and actionable: pass one back to disambiguate, and the compiler joins precisely that path, nothing more:
pickup join was added: customers and dropoff never appear in the SQL, because nothing in the query needed them. The same discipline applies one level up: when a join path itself has more than one valid route (AMBIGUOUS_JOIN_PATH), the compiler returns candidate routes and expects via to pick one; see Resolving ambiguous for both cases end to end.
Fanout & additivity
A join can multiply rows relative to a measure’s grain (one-to-many, many-to-many). What’s safe to do about that depends entirely on the measure’s compilation strategy, driven by the binding’skind (see Contracts & guardrails):
kind | How it compiles |
|---|---|
single (additive) | Deduplicate to the measure’s own grain before summing across a fanout join. |
ratio / weighted_avg | Each component (itself another metric) is planned as an independent sub-query through the full pipeline, then combined with a divide step on the shared grain, never divided row-by-row. |
semi_additive | Sums normally over dimensions the query groups by; collapses the non-additive dimension (e.g. snapshot_date) via a window function (last/first/avg/min/max) when the query doesn’t group by it. |
distinct_count / percentile | Recomputed directly from base rows at the requested grain, never derived from a partial count(distinct …) or a combined quantile. |
opaque | Served only at its declared native grain; any other grain is rejected outright. |
UNSUPPORTED_MEASURE or FANOUT_UNSAFE and a rationale, rather than emit a silently wrong number.
When a metric is composed from parts, the result inherits the most conservative signal across them: stale if any component is stale, provisional if any component is provisional, and guardrails_fired is the union across every leaf.
Dialect adapter
The compiler builds one dialect-neutral SQLGlot AST; a dialect adapter transpiles it to the target engine: type mapping, identifier quoting,LIMIT injection, and the read-only guarantee. Adding a database and supporting its SQL dialect are independent concerns.
Dialects shipped today: PostgreSQL, DuckDB, SQLite, matching the queryable connectors. An unrecognized dialect name that SQLGlot itself understands still works via a generic adapter; anything else falls back to the PostgreSQL adapter to preserve existing behavior.
Determinism & headless
No part of the compiler consults an LLM or the wall clock (beyond an explicitas_of on relative dates). In headless/CI invocation, each error class maps to a distinct process exit code (UNRESOLVED, AMBIGUOUS, UNREACHABLE, AMBIGUOUS_JOIN_PATH, UNSUPPORTED_MEASURE, FANOUT_UNSAFE, GUARDRAIL_BLOCK, VALIDATION_FAILED, ASSERTION_FAILED), see canonic assert for how this backs the accuracy CI gate.