Skip to content

feat(core)!: collapse L3 grouping — remove Partition node, unify on GroupKeys (#12, #13) - #18

Merged
zzylol merged 4 commits into
mainfrom
chore/partition-l3-reframe
Jun 19, 2026
Merged

feat(core)!: collapse L3 grouping — remove Partition node, unify on GroupKeys (#12, #13)#18
zzylol merged 4 commits into
mainfrom
chore/partition-l3-reframe

Conversation

@zzylol

@zzylol zzylol commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Collapses L3 grouping to one representation, closing #12 and tying off the #13 discussion. Grouping had two problems: a Partition node that duplicated Aggregate.by, and — once that was resolved — grouping keys living in three unrelated Vec<ColumnId> fields. This PR fixes both.

End state — grouping has one home per concept, all sharing one type:

concept home type
reducing GROUP BY Aggregate.by GroupKeys
per-group ranking (topk by, bottomk) Sort.partition_by GroupKeys
per-group window (OVER (PARTITION BY …)) WindowFunc.partition_by GroupKeys
parallel / sharding split L5 stage allocator — (physical, not in the IR)

Commits

  1. docs(core) — reframe: Partition is split-without-reduce (PARTITION BY), not a 2nd GROUP BY; reconcile the contradictory design.md text; add a regression test pinning that reducing GROUP BY → Aggregate.by.
  2. feat(core)! — remove the L3 (and L2) Partition node + PartitionKeys. The sole producer was generic topk by (…); its grouping is semantic (changes which rows rank within which group), so it moves onto the operator that consumes it — a new Sort.partition_by — rather than an ambiguous standalone node. The windowed reduction beneath stays label-preserving (by: []).
  3. refactor(core) — unify the three grouping-key fields into a shared GroupKeys newtype over Vec<ColumnId>.

Algebra changes

New type (L3, query_expr.rs):

#[serde(transparent)]
pub struct GroupKeys(pub Vec<ColumnId>);   // + Deref<[ColumnId]>, IntoIterator,
                                           //   From<Vec<…>>, FromIterator, PartialEq<Vec<…>>

L3 QueryExpr nodes (query_expr.rs):

// changed — field type Vec<ColumnId> → GroupKeys
Aggregate  { by: GroupKeys, aggs: Vec<AggIntent>, output_names, having, child }
// changed — NEW field `partition_by` (per-group ranking; row-preserving)
Sort       { keys: Vec<SortKey>, partition_by: GroupKeys, child }
// changed — field type Vec<ColumnId> → GroupKeys
WindowFunc { func, args, partition_by: GroupKeys, order_by, output_name, child }

// REMOVED
- Partition { keys: PartitionKeys, child }
- enum PartitionKeys { By(Vec<String>), Without(Vec<String>) }

L2 relational::QueryExpr (relational.rs):

// changed — NEW field `partition_by` (name-based; resolved to GroupKeys at L3)
Sort { keys: Vec<L2SortKey>, partition_by: Vec<ColumnRef>, input }
// REMOVED
- Partition { keys: PartitionKeys, input }

New lowering error (lower.rs):

// guards the now-unreachable "group keys on a per-series windowed reduction"
// shape (generic topk routes its grouping to Sort.partition_by instead)
ConvertError::WindowedReductionKeys

Why a node was the wrong fix (and a shared type is the right one)

A standalone group/partition node is exactly the Partition we removed: with nothing consuming it, it's a row-preserving pass-through whose meaning (semantic grouping vs physical sharding) is ambiguous — and grouping's semantics are defined by the consumer (reduce vs rank vs window), so the keys belong on the operator. The real duplication (keys in three places) is fixed by a shared parameter type, not a node.

GroupKeys and positional ColumnId

GroupKeys(pub Vec<ColumnId>) is a transparent newtype — grouping stays positional (an index into the Scan schema), unchanged. #[serde(transparent)] keeps it wire-compatible with the bare arrays it replaces, and Deref<[ColumnId]> + IntoIterator for &GroupKeys + From<Vec<…>> + PartialEq<Vec<…>> keep all read/iterate/compare/construct sites working — hence no behavior change.

Ties off #13

AggIntent::TopK stays a first-class intent (it's the sketchable heavy-hitter operator, distinct from exact Sort + Limit). Its grouping rides on Aggregate.by; generic topk/bottomk grouping rides on Sort.partition_by — both now GroupKeys. So every grouping concept has one spelling, and the lowering remains one-canonical-form-per-query.

Compatibility

Breaking IR change (feat!): QueryExpr::Partition / PartitionKeys are gone, Sort gains partition_by, and the three grouping fields are now GroupKeys. partition_by is #[serde(default)] and GroupKeys is #[serde(transparent)], so older serialized forms still deserialize. No front end emitted Partition directly (converter-only fallback), so PromQL/SQL surface behavior is unchanged — topk by now lowers to Limit{Sort{partition_by,…}}.

Test plan

  • cargo build — clean, no warnings
  • cargo test -p asap-control-core -p asap-control-lower -p asap-e2e193 tests pass, incl. the rewritten topk_over_avg_is_generic_sort_limit, new generic_topk_grouping_lowers_to_sort_partition_by, reducing_group_by_lowers_to_aggregate_by, and the SQL count_ranked_topk_is_heavy_hitter
  • swept the workspace — no remaining Partition / PartitionKeys references

Closes #12. Resolves the #13 discussion (closed: keep TopK).

🤖 Generated with Claude Code

@zzylol zzylol changed the title docs(core): reframe L3 Partition as split-without-reduce, not a 2nd GROUP BY (#12) feat(core)!: remove L3 Partition node — per-group ranking → Sort.partition_by (#12) May 28, 2026
@zzylol zzylol changed the title feat(core)!: remove L3 Partition node — per-group ranking → Sort.partition_by (#12) feat(core)!: collapse L3 grouping — remove Partition node, unify on GroupKeys (#12, #13) May 28, 2026
zzylol and others added 4 commits June 19, 2026 10:01
…Y), not a 2nd GROUP BY (#12)

`Partition` and `Aggregate.by` are different operations, not two ways to do
one. `Aggregate` groups-and-reduces (N rows → 1, schema rewritten to
`by ++ aggs`, closed); `Partition` is row-preserving (schema pass-through) —
it's `PARTITION BY`, not `GROUP BY`.

The #12 duplication is already resolved: after #8 + the tabular-GROUP-BY
convergence, every reducing GROUP BY (SQL and PromQL) lowers to `Aggregate.by`.
No query emits a `Partition` wrapping a reducing aggregate. The surviving
`Partition` only records per-group structure over a per-series (label-
preserving) reduction — e.g. `topk by (host) (avg_over_time(cpu[5m]))`.

This commit reconciles the docs to that reality and pins it with a test; it
does NOT remove the node (deferred — see below).

- docs/design.md: kill the stale "Partition … (`GROUP BY`)" framing that
  contradicted the schema-flow table's "logical-only marker / sharding hint".
- query_expr.rs / relational.rs / lower.rs: document `Partition` as
  transitional split-without-reduce, slated to move to a rank `partition_by`
  (L3) / L5 stage-allocation artifact per the layering contract (L3/L4
  symbolic, L5 commits placement). Note the removal blocker: per-series schema
  detection keys off `Aggregate.by.is_empty()`, so the windowed-reduction keys
  can't simply move into `by` without flipping the node to a cross-series
  reduce and breaking the open, label-preserving schema.
- promql_lowering.rs: regression test — reducing GROUP BY (ungrouped, grouped,
  and over a label-preserving `rate`) lowers to `Aggregate.by` with no
  `Partition` anywhere in the tree.

No behavior change. Full node removal tracked as the #12 follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion_by (#12)

Completes issue #12: there are no longer two ways to express grouping. The
`Partition` node — which conflated a semantic per-group split with a physical
sharding hint — is removed from both L2 and L3. Grouping now has exactly one
home per concept:

- reducing GROUP BY        → `Aggregate.by`        (already true)
- per-group *ranking*      → `Sort.partition_by`   (new; this commit)
- parallel/sharding split  → L5 stage allocator    (physical, not in the IR)

The only producer of the old `Partition` node was the generic (non-heavy-hitter)
`topk by (…)` / `bottomk` path: its grouping couldn't go in `Aggregate.by`
without flipping the windowed reduction from per-series (label-preserving) to a
cross-series reduce — per-series detection keys off `Aggregate.by.is_empty()`.
That grouping is semantic (it changes which rows rank within which group), so it
belongs in L3 — but on the operator that consumes it, not as an ambiguous node.

Changes:
- L2 (`relational.rs`): drop the `Partition` variant; add `partition_by:
  Vec<ColumnRef>` to `Sort`.
- L3 (`query_expr.rs`): drop the `Partition` variant and the `PartitionKeys`
  type; add `partition_by: Vec<ColumnId>` (`#[serde(default)]`) to `Sort`
  (row-preserving, schema pass-through).
- `promql.rs`: generic `topk by (…)` routes its keys to `Sort.partition_by`;
  the windowed reduction beneath stays `by: []`.
- `lower.rs`: resolve `Sort.partition_by` positionally; the windowed-reduction-
  with-keys path is now a hard `ConvertError::WindowedReductionKeys` (it can no
  longer be reached by valid PromQL) instead of a `Partition` wrap.
- `binder.rs`: seed `Sort.partition_by` names into the usage-derived leaf schema.
- `sql/mod.rs`: SQL `ORDER BY` is a global sort (`partition_by: []`).
- docs/design.md: remove the `Partition` node + reconcile the schema-flow table.
- tests: `topk_over_avg` now asserts the `Sort.partition_by` shape;
  `generic_topk_grouping_lowers_to_sort_partition_by` pins the new routing;
  helper match arms updated.

Verified: `cargo build` clean; `cargo test -p asap-control-core
-p asap-control-lower -p asap-e2e` — all suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
, #13)

The three "operate per group" L3 operators carried their grouping as three
ad-hoc `Vec<ColumnId>` fields — `Aggregate.by`, `Sort.partition_by`,
`WindowFunc.partition_by`. Replace all three with one `GroupKeys` newtype, so
"per-group keys" has a single spelling and a single home to evolve (e.g. a
future qualified-key or `without(...)` representation).

- `GroupKeys(pub Vec<ColumnId>)` — `#[serde(transparent)]` (wire-compatible with
  the bare arrays it replaces); `Deref<Target=[ColumnId]>`, `IntoIterator for
  &GroupKeys`, `From<Vec<ColumnId>>`, `FromIterator`, and `PartialEq<Vec<…>>` so
  existing read/compare/iterate sites are unchanged.
- This is the unification #12 called for (grouping is a *parameter type* shared
  across operators, not a standalone node) and it ties off #13: heavy-hitter
  `topk` groups via `Aggregate.by`, generic `topk`/`bottomk` via
  `Sort.partition_by` — both now `GroupKeys`.

No behavior change. `cargo test -p asap-control-core -p asap-control-lower
-p asap-e2e` — 193 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AggIntent::TopK { k, accuracy } is a pure L3 intent (k most frequent by
value, to accuracy ε), built from the lowering accuracy context exactly
like Quantile/Cardinality. Its doc claimed it "is served by a dedicated
sketch (SpaceSaving, CMS-with-heap) in one pass" and "never materialises-
then-sorts" — physical-strategy language that belongs to L4 and is false
for accuracy: Exact. Reframe the docs as intent-only and defer the
exact-vs-sketch realisation to L4, matching the module's own rule that the
HashAgg/SortAgg/SketchAgg choice is an L4 cost-aware decision. Keep the
legitimate lowering-time split (heavy-hitter intent vs generic Sort+Limit).

Doc/comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zzylol
zzylol force-pushed the chore/partition-l3-reframe branch from 31bf28b to 5687733 Compare June 19, 2026 16:02
@zzylol
zzylol merged commit a54b45d into main Jun 19, 2026
1 check passed
@zzylol
zzylol deleted the chore/partition-l3-reframe branch June 19, 2026 16:04
@zzylol

zzylol commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Answers to the review questions on this PR:

1. "Partition is removed, and now Sort has an optional partition key?" Yes. The L3 Partition node is gone; Sort now carries partition_by: GroupKeys (crates/core/src/intent_algebra/query_expr.rs) — empty = global order-by, non-empty = rank-within-group. GroupKeys is the single shared spelling for Aggregate.by, Sort.partition_by, and WindowFunc.partition_by.

2. "What about aggregate and group by?" Reducing GROUP BY stays on Aggregate.by — the γ+α operator that collapses N→1-per-group and rewrites/freezes the schema. That's structurally different from Sort.partition_by / WindowFunc.partition_by, which are row-preserving (rank/window without reducing). All three share GroupKeys. Heavy-hitter AggIntent::TopK carries no keys of its own — its grouping rides on the enclosing Aggregate.by.

3. "Can we represent topk(3, http_requests_total) by (job) (top-3 per job)?" The L3 shape — Sort { keys:[value desc], partition_by:[job] } → Limit{3} — is exactly what Sort.partition_by was designed for, no Partition node needed. But the lowering had a bug for the bare-selector case: it inserted an implicit cross-series Sum that collapsed the job label before partition_by could resolve it (and PromQL topk ranks raw samples, it doesn't sum).

Filed as #30 and fixed in #31topk(3, http_requests_total) by (job) now lowers to Limit{3} → Sort{value desc, partition_by:[job]} → Scan. (Range-function args like topk(3, avg_over_time(m[5m])) by (job) already worked.)

zzylol pushed a commit that referenced this pull request Jul 2, 2026
…ng by-labels (#30)

`topk(k, <bare instant selector>) by (labels)` — e.g.
`topk(3, http_requests_total) by (job)` ("top-3 series per job") — mislowered.
The non-heavy-hitter path in `build`'s `Outer::TopK` else-branch defaulted a
bare selector argument (`inner.func == None`) to an implicit cross-series
`AggFunc::Sum`. That reducing `Sum` collapsed every label (including the `by`
partition keys) into a single `sum` column, so `Sort.partition_by = [job]` no
longer resolved at L3 — a regression surfaced reviewing the Partition →
`Sort.partition_by` reframe (#12, PR #18). It was also semantically wrong:
PromQL `topk` ranks the raw instant-vector samples, it does not sum them.

A bare selector now ranks over the `filtered_source` directly (label-
preserving), so `Sort.partition_by` ranks within each group. A range-vector-
function argument (`topk(k, rate(m[5m]))`) still reduces per series first —
also label-preserving — so those paths are unchanged, as are the heavy-hitter
`count_over_time` and `bottomk` cases.

Expected: `topk(3, http_requests_total) by (job)`
→ `Limit{3} → Sort{value desc, partition_by:[job]} → Scan`.

Tests: bare-selector topk by-label (ranks per group, no implicit Sum) and
bare-selector topk without `by` (ranks raw samples).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zzylol added a commit that referenced this pull request Jul 2, 2026
…ng by-labels (#30) (#31)

`topk(k, <bare instant selector>) by (labels)` — e.g.
`topk(3, http_requests_total) by (job)` ("top-3 series per job") — mislowered.
The non-heavy-hitter path in `build`'s `Outer::TopK` else-branch defaulted a
bare selector argument (`inner.func == None`) to an implicit cross-series
`AggFunc::Sum`. That reducing `Sum` collapsed every label (including the `by`
partition keys) into a single `sum` column, so `Sort.partition_by = [job]` no
longer resolved at L3 — a regression surfaced reviewing the Partition →
`Sort.partition_by` reframe (#12, PR #18). It was also semantically wrong:
PromQL `topk` ranks the raw instant-vector samples, it does not sum them.

A bare selector now ranks over the `filtered_source` directly (label-
preserving), so `Sort.partition_by` ranks within each group. A range-vector-
function argument (`topk(k, rate(m[5m]))`) still reduces per series first —
also label-preserving — so those paths are unchanged, as are the heavy-hitter
`count_over_time` and `bottomk` cases.

Expected: `topk(3, http_requests_total) by (job)`
→ `Limit{3} → Sort{value desc, partition_by:[job]} → Scan`.

Tests: bare-selector topk by-label (ranks per group, no implicit Sum) and
bare-selector topk without `by` (ranks raw samples).

Co-authored-by: zz_y <zz_y@node0.zz-y-308294.softmeasure-pg0.clemson.cloudlab.us>
Co-authored-by: Claude Opus 4.8 (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>
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.

There seem to be 2 ways to express a GROUP BY + Aggregate

1 participant