Skip to content

fix(l3): thread the input column onto Quantile and Cardinality intents (#115) - #116

Merged
zzylol merged 1 commit into
mainfrom
fix/115-agg-intent-col
Jul 9, 2026
Merged

zzylol merged 1 commit into
mainfrom
fix/115-agg-intent-col

Conversation

@zzylol

@zzylol zzylol commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Closes #115.

The bug

AggIntent::Quantile and AggIntent::Cardinality carried no input column, so two aggregates over different columns lowered to byte-identical L3:

SELECT approx_percentile_cont(v, 0.5),   -- Quantile { q: 0.5, accuracy: Exact }
       approx_percentile_cont(w, 0.5),   -- Quantile { q: 0.5, accuracy: Exact }   <-- equal
       count(distinct v),                -- Cardinality { accuracy: Exact }
       count(distinct w)                 -- Cardinality { accuracy: Exact }         <-- equal
FROM t;

while Sum/Avg/Min/Max/StdDev/Variance all carry col: Option<ColumnId> and distinguish correctly.

Three consequences, all silent:

  1. CSE miscompilation. plan::cse dedupes on AggIntent equality, so a query computing median(v) and median(w) collapsed into one.
  2. Sketch bound to the wrong column. plan::bind picks the summarised column via input_col(). With no column it always fell through to ColumnRef::SampleValue, so every SQL COUNT(DISTINCT c) built its HLL over the wrong input.
  3. Expression arguments silently dropped. approx_percentile_cont(v * 8, 0.95) succeeded and discarded v * 8, because the column slot that would have rejected it did not exist. SUM(v * 8) correctly errors.

Root cause

crates/l2/src/lower.rs. col is already in scope and threaded onto the six reducers above it, but was simply not passed to these two — an omission at the L2→L3 boundary, not a deliberate deferral to L4 (the L2 AggFunc does carry the ColumnRef).

The fix

Tests (6 new)

test pins
quantile_carries_its_input_column distinct-column quantiles are not equal
count_distinct_carries_its_input_column ditto for cardinality
quantile_and_count_distinct_over_an_expression_are_rejected consequence 3
cse::quantiles_over_different_columns_do_not_dedupe consequence 1
bind::sketch_binds_the_intents_input_column consequence 2, incl. NoneSampleValue
agg_intent_serde_reads_pre_115_payloads #[serde(default)] back-compat

Verification

cargo test --workspace      # 363 passed, 0 failed
cargo clippy --all-targets  # clean

The diff is deliberately kept to semantic changes. main is not currently rustfmt-clean (159 pre-existing diffs), so cargo fmt --all was not run; each touched file has the same rustfmt-violation count as on main.

🤖 Generated with Claude Code

#115)

`AggIntent::Quantile` and `AggIntent::Cardinality` carried no input column, so
two aggregates over *different* columns lowered to byte-identical L3:

    approx_percentile_cont(v, 0.5) -> Quantile { q: 0.5, accuracy: Exact }
    approx_percentile_cont(w, 0.5) -> Quantile { q: 0.5, accuracy: Exact }   // equal!

Three consequences, all silent:

  1. `plan::cse` dedupes on `AggIntent` equality, so a query computing
     `median(v)` and `median(w)` collapsed to one.
  2. `plan::bind` builds the summary over `summarised_column(intent, …)`, which
     resolves `input_col()`. With no column it always fell through to
     `ColumnRef::SampleValue`, so every SQL `COUNT(DISTINCT c)` bound its HLL
     to the wrong column.
  3. `approx_percentile_cont(v * 8, 0.95)` succeeded and dropped the expression,
     because the column slot that would have rejected it did not exist.

Root cause was in `l2::lower`: `col` is already in scope and threaded onto
Sum/Avg/Min/Max/StdDev/Variance, but was not passed to these two.

Adds `col: Option<ColumnId>` to both (`#[serde(default)]`, so pre-#115 payloads
still deserialize as `None`), threads it through the converter, and includes
them in `input_col()`. `None` keeps its meaning: the PromQL sample value.

`TopK` deliberately keeps no `col` — it ranks by the aggregate output rather
than a base column (#13 / #25).

SQL-side, `approx_percentile_cont` / `COUNT(DISTINCT …)` / `approx_distinct`
now resolve their argument with `reducer_col`, so an expression argument is
rejected like `SUM(a*b)` instead of silently lowering to `col: None`. A SQL
query has no sample value to fall back on. This makes `agg_col_ref` dead; it
existed only to perform that fallback.

Verified: 363 tests pass, `cargo clippy --all-targets` clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zzylol
zzylol merged commit f58b37a into main Jul 9, 2026
1 check passed
@zzylol
zzylol deleted the fix/115-agg-intent-col branch July 9, 2026 16:35
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: AggIntent::Quantile / Cardinality / TopK drop their input column — distinct aggregates compare equal

1 participant