Skip to main content
The compiler turns a protocol-neutral semantic query into read-only, dialect-correct SQL. It is completely deterministic: no LLM, no wall-clock, no randomness. Given identical 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:
{
  "metrics":    ["revenue"],
  "dimensions": ["order_date", "status"],
  "filters":    ["status = 'completed'", "order_date >= '2025-01-01'"],
  "context":    "board_reporting",
  "limit":      1000
}
The query references names (metrics, dimensions), never physical tables or columns. Those are resolved against canonical bindings and semantic sources.

Pipeline stages

  1. Resolve metrics. Map each metric name → canonical binding → source/measure (or composite components). Unknown/ambiguous → UNRESOLVED/AMBIGUOUS with candidates.
  2. Resolve dimensions & filters. Bind to columns on the owning source or a join-reachable source. Unreachable → UNREACHABLE.
  3. 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.
  4. 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).
  5. 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.
  6. Enforce guardrails. AND-in mandatory filters, apply restrict_source for the active context. severity: error blocks; warn annotates.
  7. Emit SQL. Build a dialect-neutral AST, then transpile via the dialect adapter. Read-only (SELECT) only, by construction.
  8. Attach result attributes. Resolved bindings, guardrails fired, provisional/final mix, per-source freshness, and additivity handling applied.
  9. Assertion check (in benchmark/CI mode). Run the emitted SQL and compare to the matching assertion’s expected value; divergence beyond tolerance fails.
Output shape:
{
  "sql": "SELECT …",
  "dialect": "postgres",
  "resolved": { "metrics": { "revenue": "orders.total_revenue" } },
  "guardrails_fired": [{ "id": "revenue-excludes-refunds", "kind": "mandatory_filter" }],
  "finality": { "final_rows": "<=watermark", "provisional_rows": ">watermark" },
  "freshness": [{ "source": "orders", "last_validated_at": "…", "stale": false }],
  "warnings": []
}
Errors are always structured (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 where rentals 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:
canonic sl compile --metrics rental_revenue --dimensions country
error ambiguous: dimension 'country' is present on multiple join-reachable sources; qualify explicitly
  candidate 1: customers.country
  candidate 2: dropoff.country
  candidate 3: locations.country
  candidate 4: pickup.country
  hint: qualify with one of the candidates above, e.g. --dimensions customers.country
The candidates list is exact and actionable: pass one back to disambiguate, and the compiler joins precisely that path, nothing more:
canonic sl compile --metrics rental_revenue --dimensions pickup.country
dialect: sqlite

SELECT "pickup"."country" AS "country", SUM(CASE WHEN "payments"."status" = 'settled' THEN "payments"."amount" ELSE 0 END) AS "total_paid" FROM "payments" AS "payments"
LEFT JOIN "rentals" AS "rentals" ON "payments"."rental_id" = "rentals"."rental_id" LEFT JOIN "locations" AS "pickup" ON "rentals"."pickup_location_id" =
"pickup"."location_id" GROUP BY "pickup"."country"

resolved:
  rental_revenue → payments.total_paid
Only the 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’s kind (see Contracts & guardrails):
kindHow it compiles
single (additive)Deduplicate to the measure’s own grain before summing across a fanout join.
ratio / weighted_avgEach 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_additiveSums 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 / percentileRecomputed directly from base rows at the requested grain, never derived from a partial count(distinct …) or a combined quantile.
opaqueServed only at its declared native grain; any other grain is rejected outright.
The unifying rule behind the composable strategies: aggregate first, combine last; division, weighting, and ratios happen on the aggregated result, never on raw rows. Where no strategy can guarantee correctness, the compiler refuses with 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 explicit as_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.