Skip to content

feat(promql+sql): unified positional L3 intent algebra (PromQL & SQL lowering) - #5

Merged
zzylol merged 41 commits into
mainfrom
feat/promql-l1-l3
May 27, 2026
Merged

zzylol merged 41 commits into
mainfrom
feat/promql-l1-l3

Conversation

@zzylol

@zzylol zzylol commented May 20, 2026

Copy link
Copy Markdown
Contributor

⚠️ Update — reconciled onto the unified positional L3 IR

This supersedes the "consistency fixes" notes in the original description below.

Since this PR was opened, #4 (SQL lowering) merged to main with 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 same convert_root. origin/main is merged in; the IR conflicts were resolved in favour of the positional design (the name-based expr.rs is deleted).

What landed on top of the original PromQL work

Area Change
Scalar IR Split into L2Expr (name-based, Column(ColumnRef), front-end-emitted) and L3Expr (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 a ColumnId, so all L3 column identity — filters, projections, sort keys, join predicates — is positional, not just group keys.
AggIntent col-carrying Sum/Min/Max/Avg/StdDev/Variance { col: Option<ColumnId> } (None = the time-series sample value); StdDev/Variance kept first-class
Converter convert is bottom-up schema-aware: keys + per-aggregate input columns resolve against the converted child's output_schema (so a JOIN's concatenated schema binds correctly); relational::SourceSpec carries Option<Schema> for SQL leaves
Schema flow Join concatenates left+right (per-JoinKind nullability); Project recomputes its schema from its items; Aggregate.output_names lets a Project-over-Aggregate resolve DataFusion's generated names; both SQL GROUP BY and PromQL by(...) (over instant selectors + per-series rate/increase) now land the key in a positional Aggregate.by — one shape across the two front ends (see "L3 canonicality cleanup" below)
SQL front end #4's DataFusion lowerer re-targeted to emit relational L2 (Scan / Filter / Project / Aggregate / Sort / Limit / Distinct / Union / Join / analytic WindowFunc) + convert_root; new SqlCatalog; dropped the name-based schema_pass.rs

Status

PromQL and SQL both lower onto the positional L3 IR — 133 tests pass, clippy --all-targets -D warnings clean, fmt --check clean. See docs/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:

  • A — one operator vocabulary. BinaryOpKind no longer re-declares the arithmetic/comparison operators the scalar IR already has; it now reuses Arith(ArithOp) / Compare(CompareOp) and keeps only the PromQL-vector ops (And/Or/Unless/Pow/Atan2). Display lives once on ArithOp/CompareOp. So a comparison has exactly one representation + rendering (they had already drifted — ILike/NotILike were on only one copy).
  • E — no name-based residue in the positional IR. Distinct.cols is now Vec<ColumnId> (was Vec<ColumnRef>, resolved lazily); ColumnRef (incl. the SampleValue/Wildcard conventions) moved out of the L3 query_expr module into expr_ir, since it's a purely name-based L2 concept the converter always resolves away.
  • D — one grouped-aggregate shape. PromQL sum by (job) (…) previously parked its key in a name-based Partition (documented as a droppable L5 hint, yet load-bearing) over an empty Aggregate.by, diverging from SQL's positional Aggregate.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 and rate/increase grouped aggregates emit the same positional shape as SQL (better for workload CSE / Wire workload-level CSE into a cost model #6). Grouped *_over_time still falls back to Partition pending Converge grouped *_over_time onto positional Aggregate.by (residual of review D) #8.

L2 cleanup (post-merge review)

A second review of the L2 relational IR + its producers:

Known limitations (follow-up)

  • SQL semi/anti/mark joins and subqueries are rejected (no L3 node yet) rather than mislowered. Analytic window functions are supported, but window frames (ROWS/RANGE BETWEEN …) are not modelled (the default frame is assumed).
  • PromQL unary negation (-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 by unary_negation_is_rejected__GAP. (SQL's -x lowers correctly as -1 * x.)
  • Node-level QueryExpr<C> parameterization is intentionally not done. Unlike the scalar Expr<C> (clean — only the column type varies), the L2 and L3 node trees diverge structurally, not just in column type: L2 Source(SourceSpec) vs L3 Scan{predicates,schema}; L2 TopK folds into L3 Aggregate{TopK intent} (no L3 TopK); AggItem (func + alias) vs AggIntent (intent + accuracy); output_names is L3-only; Window fields differ. A single QueryExpr<C> would need a multi-associated-type Layer trait 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.
  • Join predicates over duplicate column names are now disambiguated (closes L3 join-predicate disambiguation via alias-qualified schema columns (3b follow-up to #5) #7). Column carries an optional table qualifier; the SQL front end qualifies a scan's columns with the table name (and re-qualifies them with the alias for a SubqueryAlias), and ColumnRef::Qualified { table, name } resolves via Schema::column_id_qualified with the bare-name lookup as fallback. So metrics JOIN hosts ON metrics.service = hosts.service binds the key to distinct positions Compare(Column(1), Eq, Column(4)), and a true self-join metrics a JOIN metrics b ON a.service = b.service resolves to Compare(Column(1), Eq, Column(5)). The predicate still flows through DataFusion's filter (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-lower crate that lowers PromQL through L1→L2→L3, ending at the shared intent_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.

  • L1 (parse) — delegated to promql-parser 0.8 (Expr AST).
  • L2 (per-language tree) — the parser's own AST. Like feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4 (which reuses DataFusion's LogicalPlan), promql-parser already hands us a typed language tree, so no separate L2 struct is materialised.
  • L3 (intent algebra)QueryExpr with intent-only AggIntents over a Source::TimeSeries leaf, in the canonical Window-over-Aggregate shape. No sketch types/params (those are L4).

PromQL → L3 mapping

PromQL L3
quantile_over_time(φ, m{f}[w]) TimeWindow{w} → Aggregate{[Quantile{φ}]} → Scan
histogram_quantile(φ, rate(m[w])) substituted to TimeWindow{w} → Aggregate{[Quantile{φ}]}
avg/min/max/sum_over_time TimeWindow → Aggregate{[Avg/Min/Max/Sum]}
stddev/stdvar_over_time Aggregate{[StdDev/Variance]}
count_over_time, changes, resets Aggregate{[Count]}
rate/irate/increase(m[w]) Aggregate{[Rate{w}/Increase{w}]} — window lives in the intent, no TimeWindow node
OUTER by/without (dims) (…) dims flow onto the inner Aggregate.by
count by (d) (…) Aggregate{by:d, [Cardinality]}
topk(k, count_over_time(…)) Aggregate{[TopK{k}]} — heavy-hitter, one pass
topk(k, <non-count>) / all bottomk generic Sort{value} → Limit{k}
m{f} Scan{TimeSeries, predicates}
a OP b BinaryOp{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 topk over count earns AggIntent::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 IR expr_ir::{L3Expr, L3Scalar, CompareOp}, a metric-aware SchemaCatalog, and Source::TimeSeries schema 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:

  1. 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 drops LabelFilter and lowers matchers to Scan.predicates: Vec<Predicate> (the same home SQL WHERE conjuncts 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 separate Filter node for non-time predicates while leaving Scan.predicates empty — recommend standardizing on one (this PR uses Scan.predicates).

  2. Source::TimeSeries.time should be Option<TimeRange>. PromQL strings carry no absolute range (it comes from the /query_range API), so time = None. This matches feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4 making Source::Table.time_range: Option<TimeRange>; the two leaves were inconsistent (time: TimeRange required vs optional). TimeRange { start_ms, end_ms } matches feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4.

  3. New CompareOp::Regex / NotRegex for PromQL =~ / !~. feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4's expr_ir already has Like/ILike for SQL; these are the regex analogues. expr_ir.rs overlaps 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.

  4. AggIntent additions: 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 adds Avg{col} / Stddev{col, population} (column-carrying, for arbitrary SQL columns). PromQL always reduces the sample value, so this PR uses unit variants. Recommend converging on a column-carrying shape (e.g. Avg{col: Option<ColumnRef>}) where None = the time-series sample value.

  5. Aggregate has no output_names. feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4 added output_names: Vec<String> to thread DataFusion's column names through. PromQL doesn't have that problem (output is value), so this PR leaves Aggregate as {child, by, aggs, having} and names agg outputs synthetically. Merge will need to add the field back; PromQL can pass vec![].

  6. Arc<L3Node> (was Rc on main). Switched to match feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4 (its async DataFusion path needs Send). Identical change in both PRs → trivial merge.

  7. VectorMatch is now concrete (on/ignoring + group_left/right) instead of an empty stub, so PromQL binary-op matching survives into L3.

  8. SchemaCatalog gains metrics: HashMap<String, MetricSchema> alongside feat(SQL): L1 to L3 SQL lowering via DataFusion with TDD #4's tables. Additive; the two halves merge cleanly.

Test plan

  • cargo test --workspace --locked — 24 lowering tests (every pattern above + schema population + batch entry point + without catalog resolution + accuracy propagation)
  • cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
  • cargo fmt --all -- --check

Design doc — Binder / positional ColumnId / CSE

Added docs/promql-lowering.md explaining the two-IR + Binder architecture this PR adopts from the asapquery-backend control plane, and why it's shaped that way:

  • Positional ColumnId — why the canonical IR resolves column names to usize positions (identity settled once → downstream refs can't dangle; Scan-carried schema makes sub-trees self-describing; matches the backend's unique_keys: Vec<Vec<usize>> wire format).
  • The Binder as an explicit pass — name resolution happens once in Binder::bind, so the L2→canonical converter is purely structural and total; SchemaCatalog policy (incl. why without(...) is rejected under UsageDerivedCatalog) swaps without touching lowering.
  • unique_keys / CSE — why cse_reuse_is_legal gates 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 → positional ColumnIds; 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_algebra in this PR is a slimmed fork of the canonical L3 in ASAPQuery-backend/control_plane. Toward consolidating onto one shared core (and porting control_plane's L4 optimizer + L5 physical/emit), added docs/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 rewriteschema.rs is already byte-identical, ASAP's node set is a subset of control_plane's, and AggIntent is a union; cost concentrates in lower.rs (port per-branch binding + accuracy threading) and expr_ir.rs (scalar superset).

Borrowable optimizer design — Thanos promql-engine

For the L4 optimizer framework, thanos-io/promql-engine (Apache-2.0) is a strong reference — its logicalplan/ is a near-match for our L2→L3 + rule-engine shape:

  • Optimizer trait + 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 of metric{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) and DistributeOptimizer (distribute.go, sharded planning) — map to our schema projection and the L5 stage-allocation / topology work.
  • Tests: 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

zzylol and others added 2 commits May 20, 2026 06:39
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>
@zzylol

zzylol commented May 20, 2026

Copy link
Copy Markdown
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 control_plane/src/intent_algebra/ architecture, while keeping this PR's PromQL semantics and intent vocabulary.

Adopted from the backend:

  • 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 = { columns, time_index, unique_keys }, ColumnId = usize, Aggregate.by: Vec<ColumnId>; cse_reuse_is_legal gates 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 Arc<L3Node>.
  • Full workload-level CSE (cse::dedupe_subtrees + LetBinding/Ref).

Kept from the earlier commit (per "others stay as current PR #5"): 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.

Notes / deltas vs. the backend:

  • Grouping (by (...)) now lands on a Partition wrapper for the single-statistic path (backend model), with Aggregate.by: Vec<ColumnId> used on the multi-agg path.
  • without (...) is now rejected with a clear error — the usage-derived schema (default UsageDerivedCatalog) can't enumerate a metric's full label set; a registry-backed SchemaCatalog would lift this.
  • serde + thiserror added to asap-control-core (the backend IR derives both).

Tests: 22 core + 22 lower pass under --locked; clippy -D warnings and fmt --check clean.

@zzylol

zzylol commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up tracked in #6 — wiring the workload-level CSE machinery (dedupe_subtrees / cse_reuse_is_legal / Schema::unique_keys) into a cost model. This PR lands the CSE scaffolding; #6 covers the cost-model integration that makes it influence planning.

zzylol and others added 24 commits May 22, 2026 08:21
…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>
@zzylol zzylol changed the title feat(promql): L1-L3 PromQL lowering to intent algebra feat(promql+sql): unified positional L3 intent algebra (PromQL & SQL lowering) May 26, 2026
…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>
zzylol and others added 11 commits May 27, 2026 07:25
… 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>
zzylol and others added 3 commits May 27, 2026 09:25
…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
zzylol merged commit e967555 into main May 27, 2026
2 checks passed
@zzylol
zzylol deleted the feat/promql-l1-l3 branch May 27, 2026 18:43
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

L3 join-predicate disambiguation via alias-qualified schema columns (3b follow-up to #5)

1 participant