Skip to content

feat(planner): enumerate local logical alternatives without execution timing - #561

Open
zzylol wants to merge 7 commits into
stack/528-08b-query-time-defaultfrom
stack/528-05b-local-alternatives
Open

zzylol wants to merge 7 commits into
stack/528-08b-query-time-defaultfrom
stack/528-05b-local-alternatives

Conversation

@zzylol

@zzylol zzylol commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Why

#509 Stage 1 starts with Pass 1, per-computation candidate generation. docs/design_docs/proposals/planner-layering.md says:

For each eligible sub-DAG, Pass 1 identifies its computation semantics, applies rewrite rules, and generates every candidate that is not provably unable to meet its accuracy requirement.

A sub-DAG is eligible when its root is an aggregation (an Aggregate operator) whose intent has at least one realization in the summary-family capabilities F: exact accumulator state or a summary family. Nested aggregations are separate targets: in sum by (job) (rate(x[1m])), the per-series rate and the per-job sum are each a target, and their options multiply.

  • Pruning removes an invalid candidate. Any stage may prune, but only when the candidate is provably invalid (for example, a summary family that cannot meet the query's accuracy target), and every pruned candidate carries a reason.
  • Selection discards valid candidates. Only stage 3 does this, when it picks the cheapest one.

The planner today (MajorPass, #542) builds replacements and ranks them by cost in one search. There is no logical-only step that lists every valid per-computation candidate over the unified IR without a cost model.

What

A new module, asap_aware_mapping::logical_candidates, that enumerates Pass 1 local candidates for a workload of QueryRoots. It takes no cost model, accuracy model, capabilities, storage policy or materialization assignment, and does not rank, size or build replacement sub-DAGs.

  • Candidates (LocalCandidate):
    • Exact: plain exact execution of the original sub-DAG.
    • ExactAccumulator { kind, params }: an exact mergeable accumulator state (count, sum, min, max, rate, irate, increase).
    • Summary(SketchAlgorithm): a summary family, unsized. Pass 2 may share a summary and tighten it to the strictest consumer, so sizing happens later.
    • AvgAsSumCount: avg rewritten as exact sum and count accumulator states.
  • One exact option per target, listed first: the exact accumulator form (ExactAccumulator, or AvgAsSumCount for avg) when the intent has one and it can be built, otherwise Exact. Offering both would duplicate "accumulator, not materialized"; Stage 2/3 decide materialization and sharing. Summary families follow in stable catalog order, which carries no preference.
  • Pruning with reasons (PruneReason): RequiresExact (an exact request does not get approximate summaries) and NullableAvgInput (count counts rows, so avg over a possibly-null input is not rewritten; the target falls back to Exact). LocalChoices { alternatives, pruned } keeps both lists.
  • Targets (LocalLogicalTarget { target, measure, choices, filter, having }): one per measure of every reachable Aggregate whose intent has a realization besides plain exact execution (even if all of them are pruned). Each measure of a multi-measure aggregate is its own target. Nested aggregates are separate targets. Producers read by scalar roots and scalar expressions are discovered, and a shared producer is one target (pointer identity).
  • Filters and HAVING keep every candidate. The target records how they are applied; composition (feat(planner): Stage 1 whole-workload candidates and the stage_pipeline devtool #575) applies them.
    • filter: the measure's own filter, applied to the state as SummaryAgg.filter.
    • having: one HavingEntry { filter, predicate, placement } per HAVING: the aggregate's own having (no consumer Filter), then one per consumer Filter directly above the aggregate (the form the SQL frontend lowers HAVING to). Only this adjacent Filter is recorded.
    • HavingPlacement::BelowAggregate only when the predicate reads only by(...) group keys and, for a consumer Filter, every consumer of the aggregate applies the same predicate; it is then pushed below as a row predicate. Everything else is AboveOutput: a Filter above the finalize/estimate output. A global aggregate's HAVING is always AboveOutput, since its one output row exists even with no input rows.
    • Approximate candidates under a value predicate get guarantee = None through feat(planner): Stage 1 whole-workload candidates and the stage_pipeline devtool #575; multi-level push-down is Pass 1: push down filters and HAVING across levels to create more candidates (and reuse DataFusion's filter push-down) #664.
  • Input checks (LogicalCandidateError): Structure (QueryRoot::validate_structure failed), ContainsAsap (input already has ASAP operators), AssignedPhysicalAnnotation (a node already has an execution timing or a materialization), InvalidAccuracy { root, measure } (an approximate target needs epsilon and delta in (0, 1)).
  • Docs: docs/develop_docs/local-logical-candidates.md (new) describes the API. planner-layering.md now says a filter or HAVING does not prevent the accumulator or summary candidates, and Example 1's diagram says Sum has no summary family instead of showing pruned summaries.

How

enumerate_local_logical_candidates(roots) validates each root, walks every operator node reachable from its operator roots (including those referenced by scalar expressions) once, rejects physical annotations and ASAP operators, and records each node's consumers. For each Aggregate it computes the HAVING entries from the consumers, then calls local_candidates_for_intent(intent) per measure, using the existing accuracy_target, accuracy_budget and summary_candidates from replacement.rs. The roots are returned unchanged with the targets.

Public API:

pub fn local_candidates_for_intent(intent: &AggIntent) -> Result<LocalChoices, LogicalCandidateError>;
pub fn enumerate_local_logical_candidates<Id: Debug>(
    roots: Vec<(Id, QueryRoot)>,
) -> Result<LocalLogicalCandidates<Id>, LogicalCandidateError>; // { roots, targets }

Before this PR

No API lists Pass 1 candidates over OperatorNode / QueryRoot. The only candidate generation is the cost-ranked search inside MajorPass.

After this PR

Callers can get, for a lowered workload, every local target with its unranked candidates, its pruned candidates and reasons, and how its filters and HAVING are applied. Nothing in MajorPass changes; the module is not yet called by the planner.

Examples (approximate = EpsilonDelta { 0.05, 0.01 }):

Measure Alternatives Pruned
count, approximate ExactAccumulator{Count}, CMS, Count Sketch, UnivMon —
distinct count of one column, approximate (#509 Example 2) Exact, HLL, Theta, KMV, UnivMon —
distinct count of a column tuple, approximate Exact, HLL, Theta, KMV (UnivMon not declared) —
L2 / entropy, approximate Exact, UnivMon —
quantile, approximate Exact, KLL, DDSketch —
quantile, exact Exact KLL, DDSketch (RequiresExact)
TopK, approximate Exact, CMS with heap, Count Sketch with heap —
avg over a non-null input AvgAsSumCount —
avg over a nullable input Exact AvgAsSumCount (NullableAvgInput)
stddev not a target

#509 Example 1 with exact accuracy: Q1 sum by (job) (rate(http_requests_total[1m])) gives two targets, ExactAccumulator{Sum} and ExactAccumulator{Rate}; Q2 topk by (job) (10, sum_over_time(http_requests_total[1m])) gives Exact for the top-k with both heap summaries pruned RequiresExact, and ExactAccumulator{Sum} for sum_over_time.

Evidence

Execution examples: crates/asap-aware-mapping/tests/logical_candidates.rs and crates/integration-tests/tests/logical_candidates_sql.rs (SQL HAVING src = 'a' → BelowAggregate; HAVING count(*) > 10 → AboveOutput; global HAVING false → AboveOutput). Screenshots and measurements: not applicable.

Verification

  • Unit and integration tests: the unit test approximate_count_keeps_one_exact_accumulator_and_universal_choices; in tests/logical_candidates.rs: catalogs (cardinality_keeps_exact_specialized_and_universal_alternatives, frequency_statistics_keep_universal_choices, exact_quantile_stays_exact_and_approximate_keeps_both_families, topk_keeps_both_specialized_heap_choices, summary_candidates_are_unsized, avg_rewrites_to_sum_and_count_states), targets (scalar_root_producers_are_discovered_once, multi_measure_aggregate_has_one_target_per_measure, aggregate_without_realization_is_not_a_target), filters and HAVING (filtered_measure_keeps_candidates_and_records_its_filter, key_only_having_is_pushed_below_and_keeps_candidates, value_having_stays_above_output_and_keeps_candidates, having_on_one_consumer_of_a_shared_aggregate_stays_above, global_aggregate_having_stays_above), input checks (assigned_physical_annotation_asap_input_and_invalid_accuracy_are_rejected).
  • End-to-end tests: example_1_exact_q1_keeps_exact_accumulators_only and example_1_exact_q2_prunes_topk_heaps lower docs: propose workload-wide planning, summary sharing, and materialization #509 Example 1 through the PromQL frontend; the three SQL HAVING tests lower through the SQL frontend.
  • Other checks on the PR head: cargo fmt --check; workspace all-target Clippy with -D warnings; cargo test --workspace (1,549 passed, 0 failed, 2 ignored); dag-viewer test_render (29 passed); tools/verify_metricsql_external_consumer.sh OK.

Architectural decisions

Limitations and follow-up

Stack: tracker #528 · main → #542 → #665 → #666 → #543 → #667 → #561.

Human review — do not complete with an agent

  • The MVP boundary is correct.
  • New conceptual layers or public interfaces are necessary.
  • The before/after description matches the intended product behavior.
  • Human reviewer:
  • Decision and rationale:

🤖 Generated with Claude Code

https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW

@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from 102ad03 to bca78d8 Compare October 3, 2026 16:07
@zzylol
zzylol force-pushed the stack/528-05-promql branch 2 times, most recently from fb30215 to d133a1d Compare October 3, 2026 16:08
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from bca78d8 to ce188ad Compare October 3, 2026 16:08
@zzylol
zzylol force-pushed the stack/528-05-promql branch from d133a1d to d61bf7b Compare October 3, 2026 16:33
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from ce188ad to ba07130 Compare October 3, 2026 16:33
@zzylol
zzylol force-pushed the stack/528-05-promql branch from d61bf7b to 130fc54 Compare October 3, 2026 17:02
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch 2 times, most recently from 681ba3a to 6f174e1 Compare October 3, 2026 17:11
@zzylol
zzylol force-pushed the stack/528-05-promql branch 2 times, most recently from 4cd558c to 7f65a80 Compare October 3, 2026 17:23
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch 2 times, most recently from aeb762b to bc4c4d5 Compare October 3, 2026 17:29
@zzylol
zzylol force-pushed the stack/528-05-promql branch from 7f65a80 to 569b845 Compare October 3, 2026 17:29
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from bc4c4d5 to 043e1d5 Compare October 3, 2026 17:40
@zzylol
zzylol force-pushed the stack/528-05-promql branch from 569b845 to 00c2776 Compare October 3, 2026 17:40
@zzylol
zzylol marked this pull request as draft October 3, 2026 19:29
@zzylol

zzylol commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Parked as draft until Phase C (see #528). Stage 1 Pass 1 local alternatives belong to the #509 end-to-end work, which comes after finishing #511 (Phase A) and the #572 reorganization (Phase B). It will move into the logical-optimizer crate then.

🤖 Generated with Claude Code

@zzylol
zzylol force-pushed the stack/528-05-promql branch from 00c2776 to 2918b78 Compare October 3, 2026 19:32
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch 2 times, most recently from b044f4f to 7b4972b Compare October 3, 2026 19:45
@zzylol
zzylol force-pushed the stack/528-05-promql branch from 2918b78 to 21b014f Compare October 3, 2026 19:46
@zzylol
zzylol force-pushed the stack/528-08-cleanup branch from 95f1f36 to e961752 Compare October 7, 2026 23:41
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from 488c6eb to 14c6b88 Compare October 8, 2026 16:47
@zzylol
zzylol force-pushed the stack/528-08-cleanup branch from e961752 to c8594de Compare October 10, 2026 14:32
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from 14c6b88 to c84eb6f Compare October 10, 2026 14:32
@zzylol
zzylol force-pushed the stack/528-08-cleanup branch from c8594de to f21c3a1 Compare October 11, 2026 17:11
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from c84eb6f to 7c8ae7d Compare October 11, 2026 17:11
@@ -0,0 +1,39 @@
# Local logical alternatives (Pass 1)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can delete this doc.


pub mod maintained_population;

/// Local candidate generation over the unified IR. No execution timing is

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -0,0 +1,147 @@
//! Pass 1 local alternatives over the unified logical IR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zzylol added a commit that referenced this pull request Oct 12, 2026
…1 candidates (#663)

* docs: align #509/#511/#573 docs with the decided vocabulary and Pass 1 candidates

- operator-sharing.md: ReadPopulation { readout } -> EvaluatePopulation
  { evaluation } (matches crates/types/src/ir/asap.rs); "readout" ->
  "evaluation" where it names reading summary state; add the missing
  `filter` field to the SummaryAgg sketch.
- asap-primitive-schema.md: "readout" -> "evaluation" where it names the
  operation (intent table header, §6.5 heading and its anchor/table row,
  top-k evaluation, guarantee of an evaluation). Verb "read out" kept, as
  in the code comments. Coverage cache text unchanged.
- planner-layering.md: define an eligible sub-DAG at its first use; add
  the exact accumulator state candidate to the Pass 1 table and text;
  recompute Example 1 (Pass 1: 2 x 2 x 2 x 3 = 24, Pass 2:
  24 x 2 x 3 x 3 = 432, Stage 2: 24 x 52 = 1,248).
- planner-layering.md: every aggregation is its own Pass 1 target,
  including nested ones (Q1's per-series rate and per-job sum, Q2's
  per-series sum_over_time and per-job top-k), as in #561's
  enumerate_local_logical_candidates; the Algorithm 1 example and diagram
  show the four sub-DAGs, and Example 1 lists options per target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW

* docs: leave multi-measure targets out of the eligible sub-DAG definition

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW

* docs: one exact option per Pass 1 target

Each Pass 1 target gets a single exact option: exact accumulator state
when the intent has a buildable mergeable exact accumulator, otherwise
the plain exact computation. Example 1 returns to 3 / 54 / 156 with
four targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW

* docs: reflow the eligible sub-DAG paragraph

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@zzylol
zzylol force-pushed the stack/528-08-cleanup branch from f21c3a1 to 5bf690c Compare October 12, 2026 20:30
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from 7c8ae7d to 9d5ab8f Compare October 12, 2026 20:30
@zzylol
zzylol changed the base branch from stack/528-08-cleanup to stack/528-08b-query-time-default October 12, 2026 20:32
@zzylol
zzylol marked this pull request as ready for review October 12, 2026 20:32
zzylol and others added 7 commits October 12, 2026 22:21
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PromQL frontend's unified module was promoted to the crate root later
in the stack; asap_frontend_promql::unified no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r the export removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
Summary candidates are unsized (LocalCandidate::Summary carries only the
algorithm); sizing happens after Pass 2 sharing. Every pruned candidate is
recorded on its target with a PruneReason (RequiresExact,
NullableAvgInput). Each measure of an aggregate with a realization besides
exact execution is its own target; input containing ASAP operators is
rejected. avg is rewritten as exact sum/count accumulator
states. The Example 1 test runs Q1 as exact, and the dev doc places
composition in Pass 1.

- One exact option per target: the exact accumulator (ExactAccumulator,
  or AvgAsSumCount for avg) when it can be built, otherwise plain Exact
  (NullableAvgInput prune, or no accumulator form).
  The plain form would duplicate "accumulator, not materialized"; Stage
  2/3 decide materialization and sharing.
- Per-measure filters and HAVING keep every candidate. The target records
  the measure's filter (applied to the state as SummaryAgg.filter) and a
  HavingPlacement for the aggregate's having or a Filter directly above it
  (the SQL frontend's HAVING form): BelowAggregate when it reads only group
  keys (pushed below as a row predicate), else AboveOutput (a Filter above
  the finalize/estimate output; approximate candidates carry guarantee =
  None). Composition (#575) applies them; PruneReason::FilteredAggregate
  is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
…ter Pass 1 input checks

LocalLogicalTarget.having is now one HavingEntry (consumer Filter,
predicate, placement) per HAVING. A consumer Filter's key-only predicate is
pushed below only when every consumer of the aggregate applies it, and a
global aggregate's HAVING always stays above the output. Only the adjacent
form is recorded; value predicates elsewhere get guarantee None via #575,
multi-level push-down is #664.

Inputs with assigned materialization are rejected too
(AssignedPhysicalAnnotation). Approximate accuracy needs epsilon and delta
in (0, 1), and InvalidAccuracy names the root and measure. The avg
nullability check runs only for avg. Tests cover SQL HAVING placement, a
shared aggregate, a global constant HAVING and #509 Example 1 Q2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
The dev doc describes per-consumer HAVING entries, the adjacent-form scope
and that TopK Hydra is added in #605. planner-layering says a filter or
HAVING does not prevent accumulator or summary candidates, and Example 1
says Sum has no summary family rather than pruned ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
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.

1 participant