feat(promql+sql): unified positional L3 intent algebra (PromQL & SQL lowering) - #5
Merged
Merged
Conversation
Add an `asap-control-lower` crate that lowers PromQL through all three language-independent layers, ending at the shared intent-algebra IR: - L1 parse is delegated to `promql-parser` 0.8. - L2 is that parser's own AST (no separate tree), mirroring how the SQL path (PR #4) reuses DataFusion's `LogicalPlan` as a free L2. - L3 lowers to `intent_algebra::QueryExpr` with intent-only `AggIntent`s over a `Source::TimeSeries` leaf, in the canonical Window-over-Aggregate shape. Heavy-hitter `topk(k, count_over_time(..))` becomes the first-class `AggIntent::TopK`; ranking by any other value (and all `bottomk`) lowers to generic `Sort + Limit`, per the L3 design rule. Fills in the core types the layer needs (previously stubs): concrete `MetricRef`/`ColumnRef`/`GroupKey`/`Predicate`, a language-independent `L3Expr` scalar IR with PromQL regex compare ops, a metric-aware `SchemaCatalog`, and `Source::TimeSeries` schema derivation. Label matchers ride on `Scan.predicates` rather than a PromQL-flavored `LabelFilter`, keeping every node above the leaf data-model-agnostic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructure the L1-L3 plumbing to mirror the ASAPQuery-backend control
plane while keeping this PR's PromQL semantics and intent vocabulary:
- Two IRs + Binder: the PromQL parser now emits a Layer-2
`relational::QueryExpr`; `lower::convert_root` runs the `Binder`
(name -> positional ColumnId resolution against a self-contained
Schema) and folds single-statistic aggregates into canonical shapes.
- Positional Schema: `Schema { columns, time_index, unique_keys }` with
`ColumnId = usize`; `Aggregate.by: Vec<ColumnId>`; `cse_reuse_is_legal`
gating shared-producer reuse on `unique_keys`.
- `Source::TimeSeries { metric: String }` with the schema carried on the
`Scan` node (Binder-built), not on an L3Node edge wrapper.
- `Box<QueryExpr>` tree (DAG fan-in via LetBinding/Ref) replaces the
Arc<L3Node> schema-per-edge model.
- Full workload-level CSE (`cse::dedupe_subtrees` + LetBinding/Ref).
Kept from the prior PromQL work: the AggIntent vocabulary
(adds StdDev/Variance; no Frequency/archive intents), the heavy-hitter
TopK vs generic Sort+Limit split, no redundant Window node for
rate/increase, L3Expr label-matcher predicates with CompareOp::Regex,
and the typed LoweringError.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Author
Update: restructured to mirror the ASAPQuery-backend control-plane IR (commit fb17ce9)Per review feedback, the L1–L3 plumbing now matches the backend's Adopted from the backend:
Kept from the earlier commit (per "others stay as current PR #5"): the Notes / deltas vs. the backend:
Tests: 22 core + 22 lower pass under |
Contributor
Author
…ced example
Add docs/promql-lowering.md documenting the two-IR + Binder architecture:
why column identity is positional (ColumnId), why name resolution is an
explicit Binder pass, and why unique_keys gates workload-level CSE. Includes
a full trace of `topk by (service) (10, count_over_time(requests{env="prod"}[1m]))`
through all four stages (parse → L2 names → Binder → canonical positions),
ending with the CSE legality gate refusing to share under the default
usage-derived catalog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend docs/promql-lowering.md with a "unique_keys propagation" section: a per-operator propagation table (Scan verbatim, Window/Filter/etc. pass-through, Aggregate replaces with re-based group keys, Distinct adds) plus a bottom-to-top trace of the worked-example tree under a registry catalog that declares (ts, service) unique. Shows the leaf key flowing through Window unchanged and Aggregate re-basing it from ColumnId 2 to 0 — which is why the CSE gate flips green under a registry catalog but stays empty under UsageDerivedCatalog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clarify why the same `service` column is ColumnId 2 below an Aggregate and 0
above it: input and output edges are different schemas, so a ColumnId is a
position within one schema and is re-based per edge. Adds two sample-row tables
(input: service@2, unique_keys [[0,2]]; output: service@0, unique_keys [[0]])
and a ColumnId-vs-unique_keys distinction ("which column" pointer vs "which
columns are jointly unique" fact written using ColumnIds).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The diagram mixed layer names (L1/L2) with a pass name (Binder) and a property (canonical), so L3 looked absent. Label the boxes L1/L2/L3 directly, state that the Binder is the L2→L3 pass (not a layer), and align the worked-example stage headers (Stage 4 = L3 canonical, Stage 3 = Binder pass on the L2→L3 edge). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y, per-branch binding
Four review findings, all with regression tests:
1. Outer aggregation over an inner range-vector func dropped the outer op.
`sum(rate(m[w]))` lowered to just `Aggregate{Rate}`, silently losing the
sum. `build()` now emits a two-level aggregate (outer op over inner func)
for both `Outer::Plain` and the `Outer::Count` sibling. This also fixes the
same latent bug for `sum by (..)(quantile_over_time(..))` and
`count by (..)(count_over_time(..))` — the two existing tests that asserted
the collapsed shape are updated to the correct two-level structure.
2. `histogram_quantile(φ, sum by (le)(rate(..)))` errored — `extract_matrix`
couldn't see through the `sum by (le)` aggregate. `histogram_quantile` is
now special-cased in `walk`: it lowers its argument in full (preserving the
`sum by (le)` / `rate` structure) and wraps it in `Aggregate{[Quantile]}`.
3. CSE used `format!("{child:?}")` as a structural key. Replaced with grouping
by `PartialEq` over collected candidates — no reliance on Debug being an
injective, stable identity.
4. `convert` threaded a single root schema (derived from the left leaf) to both
sides of `BinaryOp`/`Join`/`SetOp`, so the right branch could resolve columns
against the wrong metric's schema. Each branch is now bound independently via
`convert_root`.
Tests: 22 core + 27 lower pass under --locked; clippy -D warnings and fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add crates/lower/tests/promql_conformance.rs: 32 tests mapping canonical
PromQL queries (from the Prometheus querying-basics docs, the PromLabs cheat
sheet, and the Prometheus promqltest corpus) to their documented semantics,
asserting the L3 lowering encodes the same intent.
Because we lower (not execute), each test asserts the L3 *structure* matches
the semantic rather than numeric results. Tests are grouped by category
(selectors, counters, cross-series aggregation, two-level sum(rate), over-time,
histograms, binary/set ops, topk/sort, subqueries, time-shift modifiers,
unsupported functions) and cite the source + the matching Prometheus .test file.
The suite also pins, rather than hides, where we diverge from PromQL — each
flagged with a `__GAP` test name:
- `group(v)` lowered as Sum (PromQL: constant 1 per group)
- scalar/number-literal operands rejected (`v > 10*1024*1024`)
- `topk(k, sum by(..)(rate(..)))` rejected (no nested-aggregate arg)
- `max_over_time(rate(..)[1h:])` rejected (no subquery range-vector arg)
- `offset` / `@` modifiers silently dropped by vs_parts
- unsupported funcs (time, timestamp, absent, deriv, delta, predict_linear,
label_replace, clamp_max) cleanly rejected
All 32 pass; clippy -D warnings and fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…osed
Add crates/lower/tests/promql_equivalence.rs (10 tests) proving the lowering is
a sound normalizer against the PromQL spec / cheat sheet / Prometheus engine
tests: equivalence classes collapse to one canonical L3, distinct meanings stay
distinct, and nothing distinct is silently merged.
Writing it surfaced five real divergences; each is now fixed in promql.rs:
- Label-matcher order: `{a,b}` and `{b,a}` select the same series but lowered to
different predicate orders. vs_parts now canonicalises matchers by (name,value).
- Group-key order: `by(a,b)` ≡ `by(b,a)`; resolve_group now sorts+dedups keys.
- `changes` / `resets`: were aliased to `count_over_time` → Count (wrong count).
Now rejected (distinct semantics, no intent yet).
- `group`: was folded onto `Sum` (sum of values, not constant-1 presence).
Now rejected.
- `offset` / `@`: were silently dropped by vs_parts, changing the query's
meaning. Now rejected (no intent-algebra representation).
`rate` ≡ `irate` is kept as an intentional intent-level equivalence (the
avg-vs-last-two-samples difference is an L4 estimation method, not an L3 intent)
and documented as such.
Conformance suite updated to match: the formerly-silent `group`/`offset`/`@`
GAP tests now assert clean rejection; `changes`/`resets` added to the rejected
list; the sum-by key-order expectation is normalised.
Tests: 22 core + 27 lowering + 32 conformance + 10 equivalence pass under
--locked; clippy -D warnings and fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the full set of PromQL query strings from all three sources as lowering inputs, plus a data-driven totality test: - tests/data/promql_corpus_docs.txt — verbatim example queries from the Prometheus querying-basics docs and the PromLabs cheat sheet (49 queries). - tests/data/promql_corpus_testdata.txt — every `eval` expression (deduped, source-tagged) from the Prometheus engine test suite, 1823 queries (Apache-2.0, attributed in the file header). - tests/promql_corpus.rs — runs all ~1870 strings through `lower_promql`. The property proven is TOTALITY: for every real-world PromQL string the lowerer returns Ok or a clean Err and never panics (a panic in the loop fails the test). Current breakdown — docs: 29 lowered / 20 rejected; testdata: 530 lowered / 899 cleanly rejected / 394 unparseable (native-histogram syntax etc.). A coverage floor guards against a change silently tanking how much we can lower. No panics found across the entire corpus. Tests: 22 core + 27 lowering + 32 conformance + 10 equivalence + 1 corpus pass under --locked; clippy -D warnings and fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ses) The PromQL corpus is extracted from Prometheus `main`, which uses grammar newer than our pinned parser. Bumping promql-parser 0.8 -> 0.9 is API-compatible (no source changes needed) and recovers 78 previously-unparseable corpus queries: testdata corpus: 530 -> 574 lowered, 394 -> 316 unparseable. 0.9 adds `limitk`/`limit_ratio` (limit.test: 29 parse-failures -> 0) and the `fill`/`fill_left` modifiers (fill-modifier.test: 44 -> 0), among others. Still unparseable on 0.9 (no Rust-parser release supports them yet): native histograms (114), `anchored`/`smoothed` range modifiers (61), duration expressions like `[26m+4m]` (49), and experimental functions incl. `histogram_quantiles`, `mad_over_time`, `info`, `sort_by_label`. These remain a parser-version ceiling, not a lowering issue. Corpus coverage floor bumped 450 -> 520 to match the new baseline. All tests (22 core + 27 lowering + 32 conformance + 10 equivalence + 1 corpus) pass under --locked; clippy -D warnings and fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mql-parser) Switch the promql-parser dependency from crates.io "0.9" to a git dependency on a private mirror of GreptimeTeam/promql-parser (Apache-2.0) under the ProjectASAP org, so we can carry local PromQL grammar/function additions ahead of upstream releases. Cargo.lock pins the exact rev. The mirror's main is currently API-identical to 0.9.0 — no source changes needed; all tests (22 core + 27 lowering + 32 conformance + 10 equivalence + 1 corpus) pass, clippy -D warnings and fmt clean. NOTE: builds now require read access to the private repo. Local dev works with `gh auth setup-git`; CI needs CARGO_NET_GIT_FETCH_WITH_CLI=true plus a token / deploy key with access to ProjectASAP/promql-parser. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion) Record the direct third-party crates and their licenses (all permissive, MIT/Apache-2.0), and document the vendored Apache-2.0 `promql-parser` private mirror: what Apache-2.0 permits, the §4 redistribution obligations (only triggered on external distribution), and how to sync the mirror with upstream. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… CI auth
- crates/lower: pin promql-parser to the private mirror's `asap` branch, which
adds 12 experimental functions missing from upstream (mad_over_time,
first_over_time, ts_of_{first,last,max,min}_over_time, histogram_quantiles,
info, max_of, min_of, step, range). They now parse instead of erroring; corpus
unparseable drops 316 -> 235. They still have no L3 intent, so they parse-then-
reject (lowered stays 574) — by design. `start()`/`end()` deferred (reserved
lexer keywords for the @ modifier; need grammar work).
- CI: rust.yml authenticates to the private dep via secret CARGO_PRIVATE_GIT_TOKEN
+ CARGO_NET_GIT_FETCH_WITH_CLI in both jobs.
- THIRD_PARTY.md: document main(pristine)/asap(edits) branch model, upstream-sync
flow, the §4(b) modification, and the required CI secret.
All tests pass under --locked; clippy -D warnings and fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI can't fetch the private promql-parser dependency until the CARGO_PRIVATE_GIT_TOKEN secret exists, so the push/pull_request triggers are commented out (workflow_dispatch only) to avoid a perpetually-red check. The auth step + CARGO_NET_GIT_FETCH_WITH_CLI scaffolding stays in place; re-enable the triggers once the credential is configured. Documented in THIRD_PARTY.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l_plane) File-by-file plan to unify the two diverged intent_algebra copies into one shared L3 (base = control_plane's richer IR + ASAPController's fixes), as the first step of the L4/L5 consolidation. Includes the drift evidence, three sign-off decisions (StdDev/Variance representation, scalar-IR location, shared types home), per-file tasks with effort/risk, and a recommended order. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ntent, SQL-node schema flow Phase A of the #4⇄#5 intent_algebra reconciliation (positional IR as base). Extends the positional L3 IR so both language front ends can target it, keeping the PromQL path green (99 tests). - expr_ir: L3Expr/CompareOp extended to the SQL∪PromQL superset (Arith/Cast/InList/FunctionCall/Case/IsNull/IsNotNull + Like/ILike, keeping Regex/NotRegex). [D2] - agg_intent: Sum/Min/Max/Avg/StdDev/Variance carry col: Option<ColumnId> (None = PromQL sample value); first-class StdDev/Variance kept. [D1/D4] - query_expr: Aggregate schema derivation binds each reducer to its own input column; Project recomputes schema from its items (was passthrough); Join concatenates left+right with per-JoinKind nullability (was left-only); SetOp drops unique_keys. + unit tests for each. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`convert` now resolves each relational `AggItem.col` (a name/SampleValue) to a
positional `ColumnId` and threads it onto the L3 reducer, so a SQL-shaped
`SUM(bytes), AVG(latency)` lowers to `[Sum{col:1}, Avg{col:2}]` and the derived
output schema types each result off its own input column. PromQL's SampleValue
stays `col: None`. Unit-tested both paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The converter previously threaded one Binder-built root schema everywhere — correct for single-leaf PromQL but wrong for SQL, where a JOIN's schema is the concatenation of both sides and table leaves carry real typed columns. - `relational::SourceSpec` gains `schema: Option<Schema>` — SQL leaves carry their DataFusion-resolved schema (→ `Source::Table`); PromQL leaves stay `None` (→ `Source::TimeSeries`, Binder-synthesized). - `convert` resolves `Aggregate` keys + per-reducer input columns and `TopK` keys against the **converted child's** `output_schema`, not a single root schema — so names bind to the right positions across joins/projects. - `Window.output_schema` now passes the child schema through (was erroring on the canonical Window-over-Aggregate fused shape, which has no time_index). Unit test: `SELECT region, SUM(bytes), COUNT(*) FROM logs JOIN meta GROUP BY region` resolves region→col 3 and bytes→col 1 of the concatenated schema. 102 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reconcile #4's name-based L3 IR with #5's positional IR, keeping #5's design (positional ColumnId + Binder) as the base per the agreed direction. Conflict resolution: - intent_algebra/{expr_ir,mod,schema}.rs, sketch_algebra/expr.rs: keep #5's positional IR (expr_ir already merged to the SQL∪PromQL superset). - intent_algebra/expr.rs: stays deleted (split into query_expr/agg_intent/…). - lower/{Cargo.toml,error.rs,lib.rs}: keep PromQL-only for now. #4's DataFusion SQL front end (crates/lower/src/sql/*, schema_pass.rs) is brought in but PARKED — not declared as a module, so it doesn't compile yet. It will be re-targeted to emit relational L2 + convert_root in follow-up commits. #4's name-based tests (core/tests/{expr_ir,schema_derivation}.rs, lower/tests/sql_lowering.rs) removed — they assert the deleted name-based API; fresh positional SQL tests come with the re-target. Workspace green: 102 tests (PromQL path intact). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
relational L2 lacked a Project node (PromQL never needs one); SQL SELECT lists
do. Adds `relational::Project { cols, input }` (re-using the shared ProjectItem),
its walk/source_name traversal, and the convert arm → L3 `Project`. Column refs
resolve by name against the child schema in L3 schema derivation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tional IR) Re-points #4's SQL lowerer from name-based L3 onto the unified positional IR: both PromQL and SQL now lower through the same convert_root. - sql/mod.rs: walks DataFusion's LogicalPlan and emits relational::QueryExpr (L2). TableScan → Source carrying the catalog's resolved Schema; aggregates → AggItem{AggFunc, col} (the converter binds col→ColumnId + applies accuracy); projection → the new L2 Project; filter/sort/limit/distinct/union/topk as L2. JOIN / subquery / window funcs are rejected (no L3 analytic node yet). - sql/types.rs: SqlCatalog (table → L3 Schema) + Arrow⇄L3 DataType bridges. - sql/expr.rs: reused verbatim except ColumnRef → ColumnRef::Named. - Dropped schema_pass.rs (schema flows via Binder/SourceSpec now) and sql/time.rs (time-range pushdown isn't on #5's Source::Table). - error.rs: union PromQL + SQL variants; lib.rs: lower_sql / lower_sql_batch. - Cargo: add datafusion 43 + tokio dev-dep. Fresh sql_lowering tests (positional): WHERE folds onto Scan; multi-agg GROUP BY binds SUM(bytes)→col 3 / AVG(latency)→col 2 / service→col 1; COUNT(*)→Count; COUNT(DISTINCT)→Cardinality; JOIN rejected. Workspace green: 107 tests, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- intent-algebra-reconciliation.md: note the intra-repo (#4⇄#5) reconciliation is complete — both front ends lower onto one positional IR — so the ASAP⇄control_plane plan is now the unblocked next step. - sketch_algebra/expr.rs: doc comment said Logical(Rc<L3Node>); the type is Logical(Box<QueryExpr>) (L3Node no longer exists). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The L2→L3 converter already supported joins (concatenated-schema derivation + positional binding); this connects the SQL front end to it. - lower_join: maps DataFusion JoinType (Inner/Left/Right/Full) → JoinKind; builds the L2 join predicate from the equijoin `on` pairs (left = right) AND-ed with any non-equi `filter`. Semi/anti/mark joins are rejected (no L3 counterpart yet). - Tests: INNER JOIN lowers to Join over two Scans; GROUP BY a right-table column over a join binds against the concatenated schema (region→col 5, bytes→col 3); IN-subquery (semi-join) rejected. 109 tests green. Note: the L3 join predicate stays name-based (unqualified ColumnRef), so a self-join's same-named keys are ambiguous — a qualified-column follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…te resolves Closes the output_names gap (PR #5 item 5). DataFusion names aggregate outputs in its plan schema (e.g. "sum(metrics.bytes)") and the enclosing Projection references them by those names; the L3 Aggregate named its outputs synthetically ("sum"), so the Project's column refs didn't resolve and fell back to Utf8. - query_expr: Aggregate gains output_names: Vec<String> (parallel to aggs); a non-empty entry overrides the synthetic AggIntent::output_column name. Schema derivation (+ output_schema_for_aggregate helper) honors it. - lower: convert threads AggItem.alias → output_names in all three Aggregate constructions (fused / multi-agg / topk). - sql: lower_aggregate sets each AggItem.alias to DataFusion's aggregate output field name (schema.fields() past the group cols) — the names the Projection uses. - promql: aggregate alias is now empty (was "value") → keeps intent-keyed output names ("sum", "quantile_0_99", …); PromQL output naming unchanged. - cse: carry output_names through the shared-producer rewrite. Test: SELECT SUM(bytes), AVG(latency) → the root Projection's output schema now resolves to Int64 / Float64 (was the Utf8 fallback). 110 tests green, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ey in schema)
The single-aggregate fused path wraps GROUP BY keys in a name-based Partition
(the PromQL streaming-sketch canonical shape) whose output schema is the child's
— so the group key is not an output column. That's correct for time series but
wrong for SQL, where `SELECT k, agg(...) GROUP BY k` projects `k`.
The fused path is now gated on a *time-series* leaf (`relational::leaf_is_tabular`
= the leftmost Source carries a resolved schema, i.e. Source::Table). Tabular
single-agg GROUP BY falls through to the positional `Aggregate.by` path, so the
key lands in the output schema and the enclosing SELECT projection resolves it.
PromQL's fused-Partition shape is unchanged.
- relational: factor out `leaf_source`; add `leaf_is_tabular`; `source_name` now
delegates to `leaf_source`.
Test: `SELECT service, SUM(bytes) GROUP BY service` → Aggregate.by=[1],
Sum{col:3}, and the root projection schema is [service:Utf8, sum:Int64]. 111
tests green, clippy -D warnings clean, fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nal) [3a] Phase 3a of going fully positional (option B): the canonical L3 scalar expression is now positional, so column identity in filters/projections/sort keys/join predicates is unambiguous (no name lookup downstream). - expr_ir: two expression types — L2Expr (Column(ColumnRef), front-end-emitted) and L3Expr (Column(ColumnId), canonical). Shared L3Scalar/CompareOp/ArithOp. - relational L2: Filter/Aggregate.having/Join.pred use L2Expr; new L2ProjectItem / L2SortKey for Project/Sort. - query_expr L3: Predicate/ProjectItem/SortKey carry positional L3Expr; infer_expr_type + default_proj_name work on Column(ColumnId). - converter: new column_resolution::resolve_expr maps L2Expr→L3Expr against the in-scope schema; applied to scan predicates, filter, project, sort, having, and the join predicate (against the concatenated left++right schema). Binder now also seeds filter/project/sort/having/join column names into the usage-derived PromQL leaf so they resolve. - resolve_column_ref: SampleValue falls back to the sole non-timestamp column when "value" is absent (an aggregate renames it, e.g. topk over avg). - front ends emit L2Expr (promql matchers; sql df_expr_to_l2). Self-join duplicate-name disambiguation (qualifiers) is the 3b follow-up; for now such names resolve to the first match. 111 tests green, clippy/fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… path Found by self-review of #5. All produced a tree that silently meant something different from the query. 1. `ORDER BY <expr> DESC LIMIT k` over GROUP BY became a frequency heavy-hitter regardless of the ranking expression — `lower_as_topk` discarded the aggregate and emitted AggIntent::TopK. Now gated by `heavy_hitter_topk`: only a single non-DISTINCT COUNT, ranked DESC by that count's output column, becomes TopK (mirrors the PromQL `topk over count_over_time` rule). Every other ranking (SUM/AVG/… or a group key) keeps the real Aggregate under a generic Sort+Limit. (Aliased/ordinal count keys safely fall back to generic.) 2. `SUM/AVG/MIN/MAX/STDDEV/VAR(DISTINCT x)` silently lowered as non-distinct (AggItem.distinct was set but never read). L3 has no distinct value-reducer, so these are now rejected (UnsupportedAggregate) — only COUNT(DISTINCT) maps (to Cardinality). 3. A value reducer over a non-column expression (`SUM(a*b)`) mapped its arg to Wildcard → col:None → reduced an arbitrary probe column. Value reducers now require a real column (`reducer_col`) and are rejected otherwise. Related: `resolve_agg_col` now errors on an unresolved Named column instead of silently returning None (matching resolve_named_keys' strictness). Tests: count-ranked topk → heavy-hitter; AVG-ranked LIMIT keeps the aggregate; SUM(DISTINCT)/SUM(expr) rejected. 115 tests green, clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g params More self-review (#5) fixes — reject rather than silently mis-lower: #6 extract_matrix no longer descends through an arbitrary Call to find the matrix selector. `rate(abs(m[5m]))` previously lowered as `rate(m[5m])` (wrapper silently stripped); it is now rejected. #7 topk/bottomk k is validated as a non-negative integer (count_param) instead of `as u64` silently truncating `topk(2.7,…)`→2 / saturating negatives→0. #9 quantile φ (PromQL quantile / quantile_over_time / histogram_quantile, and SQL approx_percentile_cont) is validated to be finite and in [0,1] (quantile_param), so φ=NaN/2.0 is rejected rather than producing a bogus intent and `quantile_NaN`/`quantile_1_5` output-column name. Tests: fractional/negative topk k rejected; out-of-range φ rejected across all three quantile forms; function-wrapped range vector rejected. 118 tests green, clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nesting depth (#8) Final self-review (#5) fixes. #5 The non-fused Aggregate arm resolved HAVING against the aggregate's *input* schema, but HAVING references the aggregate's *output* columns (group keys + agg results). It now resolves against the derived output schema (output_schema_for_aggregate). Latent today (front ends emit having:None; SQL HAVING arrives as a Filter-over-Aggregate) but now correct for when an L2 Aggregate.having is populated. #8 PromQL lowering recursed over the parser AST (walk + mutually-recursive helpers, extract_matrix, lower_inner) with no depth limit — a pathologically nested query could overflow the stack. A bounded `check_depth` pass now rejects nesting beyond MAX_DEPTH (256) up front (the check itself recurses at most MAX_DEPTH frames). SQL nesting is already bounded by DataFusion's parser recursion limit. Tests: HAVING `n` resolves to the count output column (index 2), not the input schema; 300-deep nested parens return an error, not a crash. 120 tests green, clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-introduces the analytic-window node #4 had (dropped in the re-target), now positional + with the output-name fix #4 left as a TODO. - query_expr: `WindowFuncKind` (RowNumber/Rank/DenseRank/Lag/Lead/FirstValue/ LastValue/NthValue/Sum/Avg/Count/Min/Max) + `WindowFunc { func, args, partition_by: Vec<ColumnId>, order_by, output_name, child }`. Schema derivation = child schema + one window-output column typed per func. - relational L2: name-based `WindowFunc { …, partition_by: Vec<String>, … }`; walk/leaf_source updated. - converter: resolves args / partition_by / order_by positionally against the child schema. - sql front end: `lower_window` (re-targeted to L2) + `lower_window_func_kind`; `LogicalPlan::Window` no longer rejected. `output_name` is taken from the Window plan's schema (the name an enclosing Projection references) — fixing #4's hardcoded-name TODO. NthValue's N lifted from the literal 2nd arg. One window function per node; frames not modelled (default frame assumed). Tests: ROW_NUMBER() OVER (PARTITION BY service ORDER BY bytes DESC) → WindowFunc with partition_by=[1], order_by=[Column(3) DESC], Int64 output column resolved in the root projection; SUM(bytes) OVER (…) → WindowFunc{Sum, args:[Column(3)]}. 122 tests green, clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#7) A join over columns that share a name (`metrics.service = hosts.service`, or any self-join) previously collapsed both refs onto the first matching position, because `ColumnRef` resolution was purely name-based. Carry the table/alias qualifier through the schema and resolve it: - `Column` gains `table: Option<String>` (+ `Column::new` / `with_table`); `#[serde(default)]` keeps the field backward-compatible. - `Schema::column_id_qualified(table, name)` matches on both, with the bare-name lookup as fallback for unqualified schemas. - `ColumnRef::Qualified { table, name }` (emitted by `df_expr_to_l2` from DataFusion's relation qualifier) resolves via the qualified lookup. - The SQL front end qualifies a scan's columns with the table name, and a `SubqueryAlias` over a table re-qualifies them with the alias, so a self-join's two sides are distinguishable. Tests: `metrics JOIN hosts ON metrics.service = hosts.service` binds to distinct positions [1,4]; `metrics a JOIN metrics b ON a.service = b.service` binds to [1,5]. Full workspace green (124 tests), clippy + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`-expr` flips the sign of every sample (`-rate(...)` negates the rate), but the PromQL walker passed `Expr::Unary` straight through to its operand, computing `+expr` — a wrong result, not a clean gap. `promql_parser::UnaryExpr` is built only by negation (`Neg`): unary `+` folds to identity and `-<literal>` folds into a negated `NumberLiteral`, so a `Unary` node always wraps a vector expression needing a sign flip. The L2 PromQL path has no negate/scalar node to model this (`walk` rejects bare scalar operands, so there's no `-1 * x` form), so reject it as an `UnsupportedFeature` gap — consistent with how offset/`@`/`without`/`group` are handled. This reclassifies ~25 corpus queries from silently-mislowered to cleanly-rejected (testdata lowered 574→549, still above the regression tripwire); SQL's `Expr::Negative` path is unaffected (it models `-1 * x`). Adds `unary_negation_is_rejected__GAP` to the conformance suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`LoweringError::UnsupportedFeature` is raised by both front ends (PromQL
offset/`@`/`without`/negation; SQL join type/subquery/derived table), but its
Display hardcoded "unsupported PromQL feature: {m}" — so a SQL user saw
"unsupported PromQL feature: subquery". Make the label neutral; the message
string already carries the specifics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#2, #3) Locks behavior introduced by the recent fixes, +5 tests (125→130): - promql_conformance: nested unary negation propagates rejection (`a - -b`, `sum(-x)`), not just top-level; and count→Cardinality threads the AccuracyTarget (Exact stays exact, Epsilon carried) — pins review #2. - sql_lowering: a qualified WHERE on the *duplicated* join column (`WHERE hosts.service = ...`) binds to the qualified position (4), not the first `service` (1) — extends the #7 disambiguation past the join key. - error.rs: `UnsupportedFeature` Display is language-neutral (no "PromQL") — pins the #3 fix without depending on DataFusion plan shapes. - schema.rs: `Column.table` deserializes to `None` when the key is absent (the `#[serde(default)]` backward-compat contract), and a qualified column round-trips. Full workspace green (130 tests), clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`BinaryOpKind` (query-level `BinaryOp`) and the scalar IR's `ArithOp`/`CompareOp` each defined Add/Sub/Mul/Div/Mod, Eq/Ne/Lt/Le/Gt/Ge, and Like/Regex twice — and had already drifted (`CompareOp` had ILike/NotILike that `BinaryOpKind` lacked; only `BinaryOpKind` had a `Display`). So a comparison had two representations and its rendering depended on which copy you held. `BinaryOpKind` now *reuses* the scalar ops — `Arith(ArithOp)` / `Compare(CompareOp)` — and keeps only the PromQL-vector ops with no scalar counterpart (And/Or/Unless/ Pow/Atan2). `Display` lives once on `ArithOp`/`CompareOp` and `BinaryOpKind` delegates, so every operator has exactly one spelling and one rendering. `binop` (PromQL token→op) and the affected tests updated; no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…review E)
Two leftovers contradicted the "fully positional L3" thesis:
1. `QueryExpr::Distinct.cols` was `Vec<ColumnRef>` (name-based, resolved lazily
in `output_schema_in`) unlike every other L3 column reference. It's now
`Vec<ColumnId>`; the converter resolves the L2 dedup keys against the child
schema up front (`resolve_column_refs`), and `output_schema_in` just adds
them as a unique key.
2. `ColumnRef` (incl. the `SampleValue`/`Wildcard` front-end conventions) lived
in the L3 `query_expr` module though it's purely an L2 / name-based concept
that the converter always resolves away. Moved it to `expr_ir` next to
`L2Expr` (its only structural user), breaking the query_expr⇄expr_ir import
cycle; re-exported from the crate root so external paths are unchanged.
No behavior change (SQL's `Distinct::All` still emits empty `cols`). Adds a test
pinning `SELECT DISTINCT` → `Distinct { cols: Vec<ColumnId> }`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ate.by (review D)
The headline non-canonicality: PromQL `sum by (job) (…)` parked its group key in
a name-based `Partition { keys: By(["job"]) }` over an `Aggregate { by: [] }`,
while SQL `GROUP BY job` produced `Aggregate { by: [<job>] }`. Two unrelated
shapes for the same work (blocking workload CSE/#6), and worse — `Partition` is
documented as a *droppable* L5 sharding hint, yet it was the **only** place the
PromQL group key lived, so an L5 consumer honoring that docstring would compute a
global aggregate.
Root cause: in the two-level `sum by(x)(rate(m[w]))` shape, `x` exists only in the
leaf Scan schema; the inner `Rate` aggregate (`by: []`) dropped it, so the outer
`Sum` had no positional column to group on. Fix: model range-vector functions as
what they are — **per-series, label-preserving** reductions (one value per series,
all labels retained, the sample value replaced and kept named `value`). Then the
outer cross-series aggregate resolves its keys positionally into `Aggregate.by`,
exactly like SQL.
- `AggIntent::is_per_series()` (Rate/Increase); `output_schema_in`'s Aggregate arm
branches per-series reductions to label-preserving schema derivation.
- The converter's fused path resolves group keys to `Aggregate.by` for
non-windowed reductions (instant selectors + per-series rate/increase). A
*windowed* reduction here is per-series (e.g. `avg_over_time`) — its keys belong
to an enclosing level — so it keeps the legacy name-based `Partition` fallback
rather than folding them into a per-series `by` (which would also break
downstream sample-value resolution, e.g. `topk by(h)(avg_over_time(…))`).
So instant-vector and rate/increase grouped aggregates (the dominant + all tested
patterns) now emit the same positional shape as SQL; grouped `*_over_time` still
falls back to `Partition` pending a follow-up (its window-hoisting interaction).
Tests updated to the positional shape; adds a unit test pinning rate's
label-preserving schema. Full suite green (132).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iguate joins (L2 review #1) The issue-#7 qualifier fix reached scalar predicates but not the grouping channels: `Aggregate.keys`, `TopK.by`, and `WindowFunc.partition_by` were plain `Vec<String>`, and `expr_to_group_name` discarded the relation qualifier, so `resolve_named_keys` resolved by first-match `column_id`. On a self-join, `GROUP BY a.k` and `GROUP BY b.k` both bound to column 0 → `b.k` silently grouped by `a.k` (wrong result). Same bug class #7 closed for predicates, still open for keys. Fix: the three group-key channels now carry `ColumnRef` (qualified-capable) like the scalar path, and resolve via `resolve_column_refs` → `column_id_qualified` with the bare-name fallback. `expr_to_group_ref` (SQL) preserves `col.relation`; PromQL emits `ColumnRef::Named` (labels have no qualifier). The Binder seeds the bare names; `resolve_named_keys` is removed (subsumed by `resolve_column_refs`). Confirmed by a self-join test: `GROUP BY b.service` → `Aggregate.by = [5]`, `GROUP BY a.service` → `[1]`. Full suite green (133), clippy + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… mark reserved nodes (L2 review #3/#4/#6) - #4: remove `AggItem.distinct` — a write-only field the converter never read, redundant with `AggFunc::CountDistinct` (COUNT(DISTINCT) sets the func; value DISTINCT is rejected up front). It encoded "distinct" a second way, consumed zero ways. - #6: `AggItem.alias` is now `Option<String>` (was a `""` sentinel), matching its sibling `L2ProjectItem.alias`. The converter maps `None → ""` for L3's `output_names` sentinel; PromQL emits `None`, SQL `Some(name)`. - #3: doc-mark the L2 nodes no front end produces as **Reserved** — `Ref`/`LetBinding` (CSE is L3), `Merge` (UNION→SetOp), L2 `Partition` (PromQL emits `Aggregate.keys`), and `PartitionKeys::Without` (rejected up front) — so the dead converter arms read as intentional, not oversights. No behavior change. Full suite green (133), clippy + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#2, scalar) The two scalar IRs were byte-identical 13-variant enums differing only in the column-reference type, with duplicated `conjuncts`/`disjuncts`/`columns_referenced` — maintained twice. Collapse them into a single `Expr<C>`: - `type L2Expr = Expr<ColumnRef>` (name-based, front-end-emitted) - `type L3Expr = Expr<ColumnId>` (positional, resolved) The helpers are now one generic `impl<C> Expr<C>` (`columns_referenced` returns `Vec<&C>`, unifying the L2 `&ColumnRef` and L3 owned-`ColumnId` variants — the sole caller is the L2 Binder). Because the aliases preserve variant construction/pattern syntax (`L2Expr::Compare { .. }` etc.), every front end, the converter's `resolve_expr` map, and all tests compile unchanged. This is the clean, low-risk half of the QueryExpr<C> parameterization the review recommended starting with. No behavior change. Full suite green (133), clippy + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zzylol
added a commit
that referenced
this pull request
Aug 24, 2026
Restructure the flat 19-section developer guide into the three-part structure the doc owner asked for: Part 1 - Code Architecture, Part 2 - Interfaces and Definitions, Part 3 - How to Add X, Y, Z (each ending in how to verify). Content is moved, not rewritten: Part 1 (Mental model first, per doc-owner follow-up, then a new whole-PR architecture diagram, then "How the current pieces fit together"): - old #1 Mental model -> Part 1 #1 - new: whole-PR architecture diagram (TargetSubDAG's two entry points through ReplacementStrategy, PlanSpace/cost_sorted, explanation.rs, to a downstream consumer) -> Part 1 #2 - old #3 How the current pieces fit together -> Part 1 #3 Part 2: - old Terminology's "Implementation" definition merged into the Glossary as one more entry (### Implementation), next to ReplacementStrategy - old #2 Glossary -> Part 2 #1 (plus the merged Implementation entry and old #10 Matcher, retitled to match glossary-entry style) - old #10 Matcher (implementation.rs) -> ### Matcher inside the Glossary; implementation.rs no longer exists, so the stale title is fixed - old #19's definitional content (ReplacementExplanation/ ExplanationKind shapes, node_hash, why there's no ExplanationRule trait, location-text ownership) -> Part 2 #2 Part 3: - old #4, #5, #6, #7, #13, #14 -> Part 3 #1, Adding a new ReplacementStrategy (ending in Testing a new strategy) - old #8, #9, #15 -> Part 3 #2, Adding or customizing a CostModel (ending in Testing a new cost model) - old #12 -> Part 3 #3, Adding a new sketch algorithm, with its stale implementation.rs/binder references fixed to replacement.rs/ construct_summary vocabulary, plus a new "Verifying a new sketch algorithm" close grounded in the existing coverage-matrix tests - old #11, #16, #17, #18 -> Part 3 #4-#7 (capstone + closing reference material); #18's extension-map table's implementation.rs row fixed to replacement.rs - old #19's "Using it"/"Adding a new kind" content -> Part 3 #8, Using and extending explanation.rs cargo build --workspace --all-targets is clean (docs-only change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Since this PR was opened, #4 (SQL lowering) merged to
mainwith a name-based L3 IR, which conflicted structurally with this PR's positional design (ColumnId+Binder). Per the agreed direction — workload-level reuse / CSE needs positional identity to prove row-stability — this branch now reconciles both onto one positional IR: PromQL and SQL lower through the sameconvert_root.origin/mainis merged in; the IR conflicts were resolved in favour of the positional design (the name-basedexpr.rsis deleted).What landed on top of the original PromQL work
L2Expr(name-based,Column(ColumnRef), front-end-emitted) andL3Expr(positional,Column(ColumnId)) — both the SQL ∪ PromQL superset (Arith / Cast / InList / FunctionCall / Case / IsNull + Like/ILike + Regex/NotRegex). The converter resolves every L2 column ref to aColumnId, so all L3 column identity — filters, projections, sort keys, join predicates — is positional, not just group keys.AggIntentSum/Min/Max/Avg/StdDev/Variance { col: Option<ColumnId> }(None= the time-series sample value);StdDev/Variancekept first-classconvertis bottom-up schema-aware: keys + per-aggregate input columns resolve against the converted child'soutput_schema(so a JOIN's concatenated schema binds correctly);relational::SourceSpeccarriesOption<Schema>for SQL leavesJoinconcatenates left+right (per-JoinKindnullability);Projectrecomputes its schema from its items;Aggregate.output_nameslets aProject-over-Aggregateresolve DataFusion's generated names; both SQLGROUP BYand PromQLby(...)(over instant selectors + per-seriesrate/increase) now land the key in a positionalAggregate.by— one shape across the two front ends (see "L3 canonicality cleanup" below)WindowFunc) +convert_root; newSqlCatalog; dropped the name-basedschema_pass.rsStatus
PromQL and SQL both lower onto the positional L3 IR — 133 tests pass,
clippy --all-targets -D warningsclean,fmt --checkclean. Seedocs/intent-algebra-reconciliation.md(the ASAP ⇄ control_plane plan; this intra-repo #4⇄#5 step is now done).L3 canonicality cleanup (post-merge review)
A review of the fused IR found redundancy and multiple spellings of the same thing; fixed in three commits:
BinaryOpKindno longer re-declares the arithmetic/comparison operators the scalar IR already has; it now reusesArith(ArithOp)/Compare(CompareOp)and keeps only the PromQL-vector ops (And/Or/Unless/Pow/Atan2).Displaylives once onArithOp/CompareOp. So a comparison has exactly one representation + rendering (they had already drifted —ILike/NotILikewere on only one copy).Distinct.colsis nowVec<ColumnId>(wasVec<ColumnRef>, resolved lazily);ColumnRef(incl. theSampleValue/Wildcardconventions) moved out of the L3query_exprmodule intoexpr_ir, since it's a purely name-based L2 concept the converter always resolves away.sum by (job) (…)previously parked its key in a name-basedPartition(documented as a droppable L5 hint, yet load-bearing) over an emptyAggregate.by, diverging from SQL's positionalAggregate.by. Range-vector funcs are now modeled as per-series, label-preserving reductions, so the outer cross-series aggregate resolves its keys positionally — instant-vector andrate/increasegrouped aggregates emit the same positional shape as SQL (better for workload CSE / Wire workload-level CSE into a cost model #6). Grouped*_over_timestill falls back toPartitionpending Converge grouped *_over_time onto positional Aggregate.by (residual of review D) #8.L2 cleanup (post-merge review)
A second review of the L2
relationalIR + its producers:Aggregate.keys/TopK.by/WindowFunc.partition_bywere unqualifiedVec<String>, so the issue-L3 join-predicate disambiguation via alias-qualified schema columns (3b follow-up to #5) #7 qualifier fix didn't reach GROUP BY / PARTITION BY: a self-join'sGROUP BY b.kandGROUP BY a.kboth bound to the firstk(wrong result). They now carryColumnRefand resolve viacolumn_id_qualifiedwith the bare-name fallback, like the scalar path. Pinned by a self-join test (GROUP BY b.service → by=[5],a.service → [1]).Expr<C>. The byte-identicalL2Expr/L3Expr13-variant twins are now one genericExpr<C>withL2Expr = Expr<ColumnRef>/L3Expr = Expr<ColumnId>and one set of helpers. (Node-levelQueryExpr<C>was assessed and not pursued — see limitations.)AggItem.distinctfield (redundant withAggFunc::CountDistinct); alignedAggItem.aliastoOption<String>; doc-marked the L2 nodes no front end produces (Ref/LetBinding/Merge/Partition,PartitionKeys::Without) as Reserved.Known limitations (follow-up)
ROWS/RANGE BETWEEN …) are not modelled (the default frame is assumed).-rate(...),-metric) is rejected — the L2 PromQL path has no negate/scalar node to express the sign flip (no-1 * x, since bare scalar operands are themselves rejected). Pinned byunary_negation_is_rejected__GAP. (SQL's-xlowers correctly as-1 * x.)QueryExpr<C>parameterization is intentionally not done. Unlike the scalarExpr<C>(clean — only the column type varies), the L2 and L3 node trees diverge structurally, not just in column type: L2Source(SourceSpec)vs L3Scan{predicates,schema}; L2TopKfolds into L3Aggregate{TopK intent}(no L3TopK);AggItem(func + alias) vsAggIntent(intent + accuracy);output_namesis L3-only;Windowfields differ. A singleQueryExpr<C>would need a multi-associated-typeLayertrait and still couldn't unify the divergent variant sets — net more complexity for little gain, while the converter stays a real transformation (accuracy threading, TopK/window folds, Filter→Scan fold). Left as two enums by design.Columncarries an optionaltablequalifier; the SQL front end qualifies a scan's columns with the table name (and re-qualifies them with the alias for aSubqueryAlias), andColumnRef::Qualified { table, name }resolves viaSchema::column_id_qualifiedwith the bare-name lookup as fallback. Sometrics JOIN hosts ON metrics.service = hosts.servicebinds the key to distinct positionsCompare(Column(1), Eq, Column(4)), and a true self-joinmetrics a JOIN metrics b ON a.service = b.serviceresolves toCompare(Column(1), Eq, Column(5)). The predicate still flows through DataFusion'sfilter(no switch to the optimized plan), so existing SQL lowering behaviour is unchanged.Original PR description (PromQL L1–L3 design notes)
Summary
Adds an
asap-control-lowercrate that lowers PromQL through L1→L2→L3, ending at the sharedintent_algebra::QueryExpr. This is the PromQL sibling of the SQL work in #4 and follows the same shape: a thin per-language front end over a free parser AST, lowering to the language- and deployment-independent intent algebra.promql-parser0.8 (ExprAST).LogicalPlan),promql-parseralready hands us a typed language tree, so no separate L2 struct is materialised.QueryExprwith intent-onlyAggIntents over aSource::TimeSeriesleaf, in the canonical Window-over-Aggregate shape. No sketch types/params (those are L4).PromQL → L3 mapping
quantile_over_time(φ, m{f}[w])TimeWindow{w} → Aggregate{[Quantile{φ}]} → Scanhistogram_quantile(φ, rate(m[w]))TimeWindow{w} → Aggregate{[Quantile{φ}]}avg/min/max/sum_over_timeTimeWindow → Aggregate{[Avg/Min/Max/Sum]}stddev/stdvar_over_timeAggregate{[StdDev/Variance]}count_over_time,changes,resetsAggregate{[Count]}rate/irate/increase(m[w])Aggregate{[Rate{w}/Increase{w}]}— window lives in the intent, noTimeWindownodeOUTER by/without (dims) (…)dimsflow onto the innerAggregate.bycount by (d) (…)Aggregate{by:d, [Cardinality]}topk(k, count_over_time(…))Aggregate{[TopK{k}]}— heavy-hitter, one passtopk(k, <non-count>)/ allbottomkSort{value} → Limit{k}m{f}Scan{TimeSeries, predicates}a OP bBinaryOp{vector_match}expr[r:res]TimeWindow{Sliding, r, res}The TopK-vs-Sort+Limit split directly implements the L3 design rule: only frequency ranking has a heavy-hitter sketch primitive, so only
topkovercountearnsAggIntent::TopK; everything else is generic ordering.Core types filled in (were stubs on
main)To lower into real L3 the following moved from empty stubs to concrete types:
MetricRef(String),ColumnRef(String),GroupKey(String),Predicate(L3Expr), plus a new language-independent scalar IRexpr_ir::{L3Expr, L3Scalar, CompareOp}, a metric-awareSchemaCatalog, andSource::TimeSeriesschema derivation (HasSchema).Input/output struct notes & proposed consistency fixes
Per the request, here are the struct decisions and the inconsistencies found relative to #4 — flagged for reconciliation when both land:
Label matchers are language-independent filters, not a PromQL leaf field. The doc's
Source::TimeSeries { …, labels: LabelFilter }puts a PromQL-flavored type on the leaf. Since L3 is meant to be language-independent, this PR dropsLabelFilterand lowers matchers toScan.predicates: Vec<Predicate>(the same home SQLWHEREconjuncts would use). Every node above the leaf stays data-model-agnostic. Note: feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4's SQL path emits a separateFilternode for non-time predicates while leavingScan.predicatesempty — recommend standardizing on one (this PR usesScan.predicates).Source::TimeSeries.timeshould beOption<TimeRange>. PromQL strings carry no absolute range (it comes from the/query_rangeAPI), sotime = None. This matches feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4 makingSource::Table.time_range: Option<TimeRange>; the two leaves were inconsistent (time: TimeRangerequired vs optional).TimeRange { start_ms, end_ms }matches feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4.New
CompareOp::Regex/NotRegexfor PromQL=~/!~. feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4'sexpr_iralready hasLike/ILikefor SQL; these are the regex analogues.expr_ir.rsoverlaps with feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4 — recommend converging on feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4's richer superset (Arith/Case/InList/Cast/FunctionCall) plus these two regex ops.AggIntentadditions:Avg,StdDev{population},Variance{population}.main's 9-variant set has no mean/stddev. feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4 addsAvg{col}/Stddev{col, population}(column-carrying, for arbitrary SQL columns). PromQL always reduces the samplevalue, so this PR uses unit variants. Recommend converging on a column-carrying shape (e.g.Avg{col: Option<ColumnRef>}) whereNone= the time-series sample value.Aggregatehas nooutput_names. feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4 addedoutput_names: Vec<String>to thread DataFusion's column names through. PromQL doesn't have that problem (output isvalue), so this PR leavesAggregateas{child, by, aggs, having}and names agg outputs synthetically. Merge will need to add the field back; PromQL can passvec![].Arc<L3Node>(wasRconmain). Switched to match feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4 (its async DataFusion path needsSend). Identical change in both PRs → trivial merge.VectorMatchis now concrete (on/ignoring+group_left/right) instead of an empty stub, so PromQL binary-op matching survives into L3.SchemaCataloggainsmetrics: HashMap<String, MetricSchema>alongside feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4'stables. Additive; the two halves merge cleanly.Test plan
cargo test --workspace --locked— 24 lowering tests (every pattern above + schema population + batch entry point +withoutcatalog resolution + accuracy propagation)cargo clippy --workspace --all-targets --all-features --locked -- -D warningscargo fmt --all -- --checkDesign doc — Binder / positional
ColumnId/ CSEAdded
docs/promql-lowering.mdexplaining the two-IR + Binder architecture this PR adopts from theasapquery-backendcontrol plane, and why it's shaped that way:ColumnId— why the canonical IR resolves column names tousizepositions (identity settled once → downstream refs can't dangle;Scan-carried schema makes sub-trees self-describing; matches the backend'sunique_keys: Vec<Vec<usize>>wire format).Binder::bind, so the L2→canonical converter is purely structural and total;SchemaCatalogpolicy (incl. whywithout(...)is rejected underUsageDerivedCatalog) swaps without touching lowering.unique_keys/ CSE — whycse_reuse_is_legalgates shared-producer reuse on a provable unique key (structural identity finds candidates; unique keys prove row-identity stability), and how that connects to the Wire workload-level CSE into a cost model #6 cost-model follow-up.It ends with a full trace of
topk by (service) (10, count_over_time(requests{env="prod"}[1m]))through all three layers — L1 (Expr AST) → L2 (relational::QueryExpr, names) → L3 (query_expr::QueryExpr, positions) — with the Binder shown explicitly as the L2→L3 pass (it resolves names → positionalColumnIds; it is not a layer of its own). Includes the schema-flow output and the CSE gate refusing to share the windowed scan under the default usage-derived catalog.Consolidation — unifying L3 toward L4/L5 (follow-up planning)
The
intent_algebrain this PR is a slimmed fork of the canonical L3 inASAPQuery-backend/control_plane. Toward consolidating onto one shared core (and porting control_plane's L4 optimizer + L5 physical/emit), addeddocs/intent-algebra-reconciliation.md: a file-by-file plan to unify the two L3 copies (base = control_plane's richer IR + this PR's fixes), with drift evidence, three sign-off decisions (StdDev/Variance representation, scalar-IR location, shared types home), per-file tasks/effort/risk, and a recommended order. Verdict: a contained merge, not a rewrite —schema.rsis already byte-identical, ASAP's node set is a subset of control_plane's, andAggIntentis a union; cost concentrates inlower.rs(port per-branch binding + accuracy threading) andexpr_ir.rs(scalar superset).Borrowable optimizer design — Thanos
promql-engineFor the L4 optimizer framework,
thanos-io/promql-engine(Apache-2.0) is a strong reference — itslogicalplan/is a near-match for our L2→L3 + rule-engine shape:Optimizertrait + pipeline + generic traversal (logicalplan/plan.go) — a clean shape for our L4 rule engine.MergeSelectsOptimizer(logicalplan/merge_selects.go) — matcher-subsumption reuse (metric{a,c}shares the base select ofmetric{a}+ a residual filter) — a generalization of our CSE beyond identical subtrees.PropagateSelectorsOptimizer/sort_matchers.go— predicate propagation + matcher canonicalization (we already added matcher sorting).projection.go(label/column pushdown) andDistributeOptimizer(distribute.go, sharded planning) — map to our schema projection and the L5 stage-allocation / topology work.promqlsmith-driven generative fuzzing + differential vs the Prometheus reference engine (engine/enginefuzz_test.go) — a strong upgrade over our fixed corpus.🤖 Generated with Claude Code