Skip to content

refactor(planner): plan the workload on the unified operator IR - #542

Merged
zzylol merged 18 commits into
mainfrom
stack/528-07-planner
Oct 13, 2026
Merged

zzylol merged 18 commits into
mainfrom
stack/528-07-planner

Conversation

@zzylol

@zzylol zzylol commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Why

#539–#541 (merged) add the unified frontends, the timing pass, the physical export and a runtime that compiles it, but the planner still lowers queries to the legacy QueryExpr graph and runs them through the legacy PostAsapDAG runtime. Until the planner switches, nothing in the product uses the #511 IR.

What

The planner runs end to end on the unified OperatorNode DAG. No new #509 stage logic is added here: this PR ports the legacy cost-ranked candidate search (search_workload with the ASAPStrategies), which was then the planner. #561 onward added the #509 stages, and #674 replaced the legacy search with them: on main the planner is the planner pipeline, run_stage1_to_stage3 / Stage1ToStage3Pass (Stage 1 logical ASAP-aware optimization → Stage 2 physical ASAP-aware optimization → Stage 3 plan selection).

  • Stage 0 (query-language frontends) to Stage 1. The planner lowers through the unified frontends (now the crate roots of asap_frontend_{sql,promql,metricsql}, refactor: move the unified frontends and physical planner to the crate roots #665) and gets QueryRoots; ParsedWorkload keeps scalar roots. The MetricsQL frontend input carries the planning clock and lowers through lower_metricsql_query_workload, so bare selectors get the ingestion-interval lookback (feat(promql): lower queries to unified operator and scalar IR #540).
  • The planner runs on OperatorNode. Candidate generation, selection and DAG assembly run on OperatorNode. PlanOutput exposes the whole workload DAG. Planned summary states derive their coverage from the node (feat(ir): derive summary coverage as definition + selection #646).
  • Standalone scalar queries are planned. A workload entry whose root is a scalar (scalar(quantile(0.99, rate(x[5m]))), scalar(sum(up)) + 1) used to be copied through unplanned. Per docs: define unified operators and SQL/PromQL scalar boundaries #511 §2.3 a scalar query references operator sub-DAGs; those sub-DAGs now go through the same planning as operator roots, so they can get summary candidates, and the scalar is rebuilt over the selected sub-DAGs (PlanOutput.scalar_plans).
    • Each reference is planned against the entry's accuracy target, so combinations of approximate values such as scalar(quantile(0.99, …)) - scalar(quantile(0.5, …)) keep summary candidates. The guarantee of such a combination is None until Compose accuracy guarantees through arithmetic and comparisons over approximate values #669; Stage 3 plan selection rejects it without output-level evidence. References inside Project/Filter expressions follow the same rule.
    • PlanOutput.scalar_plans holds one plan per distinct referenced node, and the export emits one ScalarRef edge per distinct referenced node. Structurally identical references (scalar(rate(x[5m])) - scalar(rate(x[5m]))) are separate nodes and are planned separately until Pass 2 (ASAP-aware CSE, Pass 2 sharing and Stage 1 coverage parity with the retired MajorPass #580).
    • vector op scalar(v) in a Project or Filter (a / scalar(b), a > scalar(b)) is lowered with one PromqlVectorToScalar input per distinct scalar reference, after the row input; its statistics carry one scalar_inputs edge per such input. These queries previously failed costing with UnsupportedQueryOperator.
    • PhysicalASAPDAG gains scalar_roots: Vec<(usize, ScalarExpr<PhysicalASAPNodeId>)>: entry i of the batch is the scalar root keyed i if there is one, otherwise the next entry of roots. Validation checks the keys and treats scalar references as roots for existence, cycles and reachability (PHYSICAL_ASAP_DAG_WIRE_VERSION 9 here; 10 on main since refactor(planner): replace the summary lifecycle with #509 materialization options #667). The workload export that emits them is added in feat(planner): export a planned workload as one physical ASAP DAG #666.
    • check_contract checks that scalar_plans holds one plan per distinct reference of each scalar root, in order and keyed by its entry. frontier_from_timing and the explanation report walk scalar references like operator inputs.
  • Candidate generation fixes found during the port (in the legacy search strategies, which stay in asap-aware-mapping for the explanation entry points and dag_export):
    • Top-k reuse (TopKLimitReuseStrategy) requires equal partition_by; a global top-10 is no longer offered as the source of a per-job top-5. Heavy-hitter promotion and the maintained-population (current-series heap) candidates require the Limit to partition like its Sort.
    • Logical rewrites are told apart by the candidate's provenance (ReplacementSubDAG::is_logical_rewrite: LogicalRewrite, CseShare, CseRecompute, AccuracyReconciliation) instead of by the replacement's shape.
    • Filtered COUNT/SUM rankings (COUNT(*) FILTER (WHERE ...), COUNT(col) over a nullable column, ordered and limited) are no longer promoted to a heavy-hitter TopK, either in IR canonicalize or in realize_keyed_additive_summary_input. This bug was on main since feat(ir): add canonicalization, sub-DAG sharing and flat DAGs on the unified IR #645.
  • Query roots are finalized. A root whose selected candidate is an exact accumulator state (sum_over_time(m[1m]), sum(rate(x[5m]))) is read through FinalizeExactAccumulator, so the exported root yields values, not a bare SummaryAgg. Scalar references are finalized the same way, since scalar(v) reads values. On main, Stage 1 composition (feat(planner): compose candidate logical ASAP DAGs from Pass 1 per-computation candidates #575) builds the finalize into every exact-accumulator candidate.
  • Search errors instead of panics. The legacy candidate search on input that already contains ASAP operators returns SearchError::AsapInput, and the explanation entry points pass it on. On main the planner pipeline rejects such input in Stage 1 Pass 1 (PerComputationCandidateError::ContainsAsap, reported as OptimizeError::PerComputationCandidates).
  • The legacy physical planner is removed. It read the planner's QueryExpr output, so it is deleted with its expression compiler, its PromQL fallback path and its raw sources. Every caller uses feat(runtime): export timed plans as PhysicalASAPDAG and compile them #541's physical planner and sources (physical_planner / sources since refactor: move the unified frontends and physical planner to the crate roots #665).
  • Evaluation vocabulary (docs: ASAP primitive schema and summary coverage (definition + selection) #573). readout names become evaluation: the readout module becomes evaluation, ReadoutQuery → SummaryEvaluation, ExactReadout → ExactEvaluation, GuaranteeSource::SketchReadout → SketchEvaluation, EstimatorContract::ClassicHll { max_distinct_per_evaluation }.
  • Dead code removed: the never-constructed VectorScalar / ScalarVector operand modes and the unused ParsedWorkload::new / entries.

How

The change is a port: every planner module that took QueryExpr / SummaryNode now takes Rc<OperatorNode>, using the same strategies and cost model. Tests that drive the planner (frontend planning tests, physical-operator tests that plan before executing, integration tests, devtools) are ported to the unified entry points. The legacy frontend modules and the QueryExpr / SummaryNode IRs stayed compiled in this PR; #665 moved the unified modules to the crate roots, and #543 removed the legacy IRs.

Before this PR

e2e_plan lowered to QueryExpr, planned and costed SummaryNodes, and the runtime compiled a PostAsapDAG. The unified IR from #539–#541 was only reachable from its own tests. Standalone scalar queries were copied through unplanned, an exact-state root was exported as a bare SummaryAgg, and a filtered COUNT/SUM ranking could be planned as a heavy-hitter sketch over every row.

After this PR

e2e_plan and the planner produce and run unified OperatorNode plans; the runtime path is PhysicalASAPDAG → the physical planner. Public API changes for planner callers:

  • Frontends return OperatorNode / QueryRoot; FrontendInput::Metricsql carries now_ms.
  • search_workload, search_workload_with, search_workload_with_targets, explain_replacements and explain_replacements_with return Result (SearchError).
  • is_logical_rewrite is now the method ReplacementSubDAG::is_logical_rewrite.
  • PlanOutput.scalar_plans and PhysicalASAPDAG.scalar_roots are new.
  • The readout → evaluation renames listed above.
  • The legacy physical planner, its sources and its PromQL fallback path are gone.

Later changes: #665 moved the unified frontends and physical planner to the crate roots; #667 removed the summary maintenance lifecycle (wire version 10); #674 retired the legacy search as the planner in favor of run_stage1_to_stage3 / Stage1ToStage3Pass (#675 names), which does not merge identical producers or scalar references until Pass 2 (#580); the scalar tests were renamed accordingly.

Evidence

Execution examples: the planner, integration and devtools tests run on the unified IR. crates/integration-tests/tests/operator_design_examples.rs (new) plans, exports and executes the #511 worked examples; operator_sharing.rs (new) checks that non-ASAP operators above and below summary operators can share inputs with them (#468). Screenshots and measurements: not applicable.

Verification

  • Unit and integration tests: ported to the unified entry points; subquery_time_shift_is_retained (a subquery's offset stays a TimeShift) and does_not_promote_a_filtered_count_ranking were carried over from the legacy tests.
  • Scalar queries (names on main): scalar_queries_read_planned_sub_dags (["1", "scalar(quantile(0.99, rate(x[5m])))", "quantile(0.99, rate(x[5m]))"] keeps entry order and the scalar reads its planned sub-DAG), combined_scalar_references_keep_summary_candidates, identical_scalar_references_satisfy_the_plan_contract, scalar_reference_in_an_expression_is_a_scalar_input, scalar_references_are_one_input_per_distinct_reference, contract_rejects_a_scalar_reference_without_its_plan, contract_checks_scalar_plan_order_entry_and_count, frontier_from_timing_includes_ingestion_scalar_reference, a_quantile_read_by_a_scalar_expression_is_located, and scalar_roots_export_in_batch_order_and_reference_nodes (also rejects duplicate and descending keys).
  • Finalized roots: exact_state_roots_are_finalized (summary_sharing.rs) and assemble_selected_query_finalizes_only_an_exact_state_root (legacy search).
  • Candidate generation and search fixes: global_limit_is_not_reused_for_a_per_group_limit, search_rejects_a_root_with_an_asap_operator, filtered_count_ranking_is_not_a_heavy_hitter (through the SQL frontend).
  • End-to-end tests: crates/planner/tests/e2e_plan.rs, summary_sharing.rs.
  • Other checks on the PR head: cargo fmt --check; workspace all-target Clippy with -D warnings; cargo test --workspace (2,010 passed, 0 failed, 2 ignored); dag-viewer test_render (31 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-06-native branch from 75903b5 to b22fa53 Compare October 2, 2026 18:32
@zzylol
zzylol force-pushed the stack/528-07-planner branch 2 times, most recently from 2a3bcd0 to a03efc2 Compare October 2, 2026 19:40
@zzylol
zzylol force-pushed the stack/528-06-native branch from b22fa53 to d82bcde Compare October 2, 2026 19:40
@zzylol
zzylol force-pushed the stack/528-07-planner branch from a03efc2 to 2c708f3 Compare October 2, 2026 21:14
@zzylol
zzylol force-pushed the stack/528-06-native branch 2 times, most recently from 337a0ff to a94bd59 Compare October 2, 2026 21:22
@zzylol
zzylol force-pushed the stack/528-07-planner branch from 2c708f3 to 03166e7 Compare October 2, 2026 21:22
@zzylol
zzylol force-pushed the stack/528-06-native branch from a94bd59 to d84830e Compare October 2, 2026 21:25
@zzylol
zzylol force-pushed the stack/528-07-planner branch 2 times, most recently from 4b84314 to d9da0f9 Compare October 2, 2026 21:56
@zzylol
zzylol force-pushed the stack/528-06-native branch from d84830e to b631137 Compare October 2, 2026 21:56
@zzylol
zzylol force-pushed the stack/528-07-planner branch from d9da0f9 to ec9f8cb Compare October 3, 2026 02:31
@zzylol
zzylol force-pushed the stack/528-06-native branch 2 times, most recently from 075d8e2 to d9dc49c Compare October 3, 2026 02:39
@zzylol
zzylol force-pushed the stack/528-07-planner branch from ec9f8cb to 95eef55 Compare October 3, 2026 02:39
@zzylol
zzylol force-pushed the stack/528-06-native branch from d9dc49c to 04f9db2 Compare October 3, 2026 19:55
@zzylol
zzylol marked this pull request as draft October 3, 2026 20:02
@zzylol
zzylol force-pushed the stack/528-06-native branch from 04f9db2 to 0ebce24 Compare October 3, 2026 20:17
zzylol added a commit that referenced this pull request Oct 3, 2026
Cut today's planner over to the unified OperatorNode IR. Frontends return
OperatorNode / QueryRoot, ParsedWorkload keeps scalar roots, Pass 1
(ASAPStrategies), the existing identical-sub-DAG sharing, selection, DAG
assembly and lifecycle costing all run on OperatorNode, and PlanOutput exposes
the whole workload DAG. The native compiler from #541 becomes the canonical
physical_planner and consumes the PhysicalASAPDAG export.

Ported from the earlier #542 (95eef55) without new #509 stage logic. Legacy
QueryExpr/SummaryNode modules stay compiled for their own tests but are no
longer re-exported from post_asap; the cleanup PR removes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zzylol
zzylol force-pushed the stack/528-07-planner branch from 95eef55 to 74b4884 Compare October 3, 2026 20:31
zzylol added a commit that referenced this pull request Oct 3, 2026
Cut today's planner over to the unified OperatorNode IR. Frontends return
OperatorNode / QueryRoot, ParsedWorkload keeps scalar roots, Pass 1
(ASAPStrategies), the existing identical-sub-DAG sharing, selection, DAG
assembly and lifecycle costing all run on OperatorNode, and PlanOutput exposes
the whole workload DAG. The native compiler from #541 becomes the canonical
physical_planner and consumes the PhysicalASAPDAG export.

Ported from the earlier #542 (95eef55) without new #509 stage logic. Legacy
QueryExpr/SummaryNode modules stay compiled for their own tests but are no
longer re-exported from post_asap; the cleanup PR removes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zzylol
zzylol force-pushed the stack/528-07-planner branch from 74b4884 to e629370 Compare October 3, 2026 20:48
@zzylol
zzylol force-pushed the stack/528-06-native branch 2 times, most recently from aaa8467 to 672a187 Compare October 5, 2026 06:21
@zzylol
zzylol force-pushed the stack/528-07-planner branch from e629370 to 0c62c4a Compare October 5, 2026 06:21
A workload entry whose root is a standalone scalar (`scalar(quantile(0.99,
rate(x[5m])))`, `scalar(sum(up)) + 1`) was copied through unplanned. Per
#511 §2.3 it has no query node and reads operator sub-DAGs by reference;
per operator-sharing §3 those sub-DAGs get the same validation and accuracy
assessment as operator roots.

MajorPass now searches, selects, assembles and lifecycle-plans each
distinct operator sub-DAG a scalar root reads, against the scalar's entry
and accuracy target, alongside the operator roots, and rebuilds the scalar
over the selected sub-DAGs. PlanOutput::scalar_plans holds their lifecycle
plans. PhysicalASAPDAG gains `scalar_roots`: each standalone scalar query
as a `ScalarExpr<PhysicalASAPNodeId>` keyed by its position in the batch,
so query i is the scalar root keyed i, or else the next of `roots`.
Validation checks positions and treats scalar references as roots for
existence, cycles and reachability. Wire version 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
@zzylol
zzylol force-pushed the stack/528-07-planner branch from 19e77b0 to f0afd89 Compare October 12, 2026 20:30
@zzylol
zzylol changed the base branch from stack/528-06-native to main October 12, 2026 20:32
zzylol and others added 8 commits October 12, 2026 20:57
A standalone scalar hands its entry's accuracy target to its operator
reference only when it passes that one reference through (`scalar(v)`, or
arithmetic/comparisons with literals). Otherwise each reference is planned
exact: independently approximate references leave a combination such as a
difference of two quantiles unbounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
check_contract now verifies that `scalar_plans` holds one plan per
distinct reference of each scalar root, in order and keyed by its entry.
The scalar_roots doc says keys are batch positions, equal to entry
indices because the contract requires dense indices. A validation test
covers duplicate and descending scalar-root keys. The never-constructed
VectorScalar/ScalarVector operand modes and the unused
ParsedWorkload::new/entries are removed.

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

`a / scalar(b)` and `a > scalar(b)` failed costing with
UnsupportedQueryOperator. A Project or Filter now takes one
PromqlVectorToScalar input per distinct operator reference its
expressions read, after its row input; its statistics carry one
`scalar_inputs` edge per such input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
TopKLimitReuseStrategy offered a global top-10 as the source of a per-job
top-5. It now requires equal `partition_by`, and the maintained
population recognizer requires the limit to partition like its sort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
`is_logical_rewrite` inferred a logical rewrite from the replacement's
shape (no guarantee, no ASAP operator). It is now
`ReplacementSubDAG::is_logical_rewrite`, read from the candidate's
provenance (LogicalRewrite, CseShare, CseRecompute,
AccuracyReconciliation); the free function is removed.

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

The search entry points asserted that no workload root contains an ASAP
operator. They now return `SearchError::AsapInput`; MajorPass reports it
as `OptimizeError::Search`, and the explanation entry points pass it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
The explanation report located only relational children, so a target
read through `scalar(...)` in a predicate or projection had no
breadcrumb; it now visits every operator input, as target discovery does.
`frontier_from_timing` also puts an ingestion-time node a standalone
scalar root reads on the frontier, like an operator root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
The IR canonicalize pass ignored measure filters, so `COUNT(*) FILTER
(WHERE ...)` or `COUNT(col)` over a nullable column ranked by ORDER BY
... LIMIT became a heap sketch over every row. It now keeps Sort + Limit
for a filtered measure (the legacy #466 guard), and the keyed heap-sketch
input rule refuses a filtered inner aggregate. The legacy
does_not_promote_a_filtered_count_ranking test is ported to the IR and
through the SQL front end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
zzylol and others added 8 commits October 13, 2026 00:35
Combinations of approximate values may read summaries: each scalar
reference is planned against the entry's accuracy target again. Composing
the guarantee of such a combination is left to #575 and Stage 3's
accuracy model.

This reverts commit c44ecda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
A global Limit over a partitioned Sort keeps k rows overall, not k per
group, so neither the heavy-hitter promotion nor the current-series heap
candidates apply to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
Cost lowering reads a node referenced several times from one operator's
expressions once; the export now matches, deduping by node id (one id
per pointer).

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

Project/Filter with two scalar references and one read twice lower and
cost; the maintained population requires the Limit to partition like its
Sort; the keyed heap input refuses a filtered count; check_contract
rejects scalar plans out of order, under another entry, or extra; a
guarantee-less summary realization is not a logical rewrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
Front ends build identical references as separate nodes and planning
merges them, so pointer-distinct references left the rebuilt scalar with
one reference but two scalar plans, failing the contract. References are
now distinct by structure. Document that each reference keeps its summary
candidates and that the combined guarantee is composed later (#669, #575).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
MajorPass assembled roots with assemble_selected_dag, so a root whose
selected candidate is an exact state came out as a bare SummaryAgg. The
lifecycle plan now finalizes its root after costing (the finalize wraps
the shared state, so sharing and cost evidence stay), scalar references
included since scalar(v) reads values. Query-root callers and tests use
assemble_selected_query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
@zzylol
zzylol merged commit eb2799f into main Oct 13, 2026
4 checks passed
zzylol added a commit that referenced this pull request Oct 13, 2026
…#667 materialization

Stage 2 takes query roots, scalar ones included (#542), times every sub-DAG
with Materialization::NotMaterialized (#667) and keeps the guarantee Stage 1
composed on every node it implements; a MaintainPopulation is no longer
rejected, since it now runs at query time when not materialized.

Stage 3 accepts a query's output when its root guarantee meets the target,
or when the deployment's accuracy model shows this candidate's output does
(AccuracyModel::output_guarantee, none by default). A certified estimate
inside the plan is not enough: a difference of two quantile estimates stays
listed as invalid, with a reason, under the built-in models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
zzylol added a commit that referenced this pull request Oct 13, 2026
…#667 materialization

Stage 2 takes query roots, scalar ones included (#542), times every sub-DAG
with Materialization::NotMaterialized (#667) and keeps the guarantee Stage 1
composed on every node it implements; a MaintainPopulation is no longer
rejected, since it now runs at query time when not materialized.

Stage 3 accepts a query's output when its root guarantee meets the target,
or when the deployment's accuracy model shows this candidate's output does
(AccuracyModel::output_guarantee, none by default). A certified estimate
inside the plan is not enough: a difference of two quantile estimates stays
listed as invalid, with a reason, under the built-in models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
zzylol added a commit that referenced this pull request Oct 13, 2026
…#667 materialization

Stage 2 takes query roots, scalar ones included (#542), times every sub-DAG
with Materialization::NotMaterialized (#667) and keeps the guarantee Stage 1
composed on every node it implements; a MaintainPopulation is no longer
rejected, since it now runs at query time when not materialized.

Stage 3 accepts a query's output when its root guarantee meets the target,
or when the deployment's accuracy model shows this candidate's output does
(AccuracyModel::output_guarantee, none by default). A certified estimate
inside the plan is not enough: a difference of two quantile estimates stays
listed as invalid, with a reason, under the built-in models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
zzylol added a commit that referenced this pull request Oct 13, 2026
…r candidate logical ASAP DAGs (#576)

* feat(planner): #509 Stage 2 physical candidates and Stage 3 plan selection (MVP)

Stage 2 implements one logical candidate physically: exact Aggregate{[TopK]}
becomes Limit(Sort) per group, summaries stay build -> estimate, every node
runs at query time, and the timed roots export as one PhysicalASAPDAG.

Stage 3 is the only stage that computes cost. It rejects candidates whose
summary misses its query's accuracy target (accuracy model, analytical
guarantee) or uses Count-Min over weights not proven non-negative, prices
every valid candidate node by node with analytical_cost (illustrative
statistics), selects the cheapest and reports the rest as costlier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(devtools): stage_pipeline emits all four stages for Example 1

Add stage2_physical_asap (no cost) and stage3_selection (costs, selected,
every other candidate rejected as invalid or costlier) per the v2 contract.
PromQL rows carry the series identity from Stage 0, as runtime state needs.
Regenerate tools/dag-viewer/examples/planner-layering-example1.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(planner): read Operator<NodeId> payloads in Stage 2/3 after the export removal

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

* fix(planner): Stage 2/3 on #575's composed candidates, query roots and #667 materialization

Stage 2 takes query roots, scalar ones included (#542), times every sub-DAG
with Materialization::NotMaterialized (#667) and keeps the guarantee Stage 1
composed on every node it implements; a MaintainPopulation is no longer
rejected, since it now runs at query time when not materialized.

Stage 3 accepts a query's output when its root guarantee meets the target,
or when the deployment's accuracy model shows this candidate's output does
(AccuracyModel::output_guarantee, none by default). A certified estimate
inside the plan is not enough: a difference of two quantile estimates stays
listed as invalid, with a reason, under the built-in models.

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

* test(devtools): regenerate the four-stage Example 1 fixture on the new #575

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

* docs(planner): name Stage 2/3 by the planner-layering stage terms

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

* fix(devtools): prune a logical candidate Stage 2 cannot implement, with its reason

A Stage 2 error no longer fails the whole candidate set: stage_pipeline lists
that candidate under stage2_physical_asap.pruned and plans the rest. Full
materialization enumeration is left to #604.

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

* fix(planner): read empirical output accuracy from deployment evidence in Stage 3

The output-level hook moves from AccuracyModel to AccuracyEvidenceProvider:
AccuracyModel stays theoretical, Stage 1 composes root guarantees with the
built-in model, and deployment evidence enters only in Stage 3. A scalar
root that is exactly scalar(v) inherits v's guarantee; any other scalar
expression over an approximate operand has none until #669.

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

* refactor(planner): name Stage 2/3 code and pipeline keys after the stage names

physical_candidates -> physical_asap_optimization (stage2_physical ->
optimize_physical_asap, Stage2Error -> PhysicalASAPOptimizationError),
stage3_select -> select_plan, and the stage_pipeline keys
stage2_physical_asap -> physical_asap_optimization and stage3_selection ->
plan_selection. The fixture is regenerated.

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

* test(planner): plan selection tests after Top-K estimates lost their bound

#575 gives Top-K and signed Count-Min estimates guarantee None, so the
CountSketch+heap candidate of the top-k fixture no longer carries an
analytical guarantee: plan selection rejects it for missing evidence rather
than for missing the target. The "misses target" case now uses a quantile
summary planned at a looser target, and the per-node cost test admits the
CountSketch+heap candidate through deployment evidence.

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

* fix(planner): exact top-k sorts by the ranked measure in physical optimization

Rank a one-measure grouped child by its measure (the SQL heavy-hitter and
PromQL `topk(k, sum by ..)` shapes) and per-series rows by the sample
value, instead of looking up a `value` column. A node reading the top-k
gets a Project restoring the logical [keys.., topk_k] columns over the
rows. An Aggregate{[TopK]} with other measures, a filter or HAVING now
fails, so its candidate is pruned with the reason.

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

* fix(devtools): stage_pipeline emits plan_selection when selection fails

With no valid candidate the document lists the rejections with a null
`selected` (and the Stage 2 pruned list as before); any other selection
error is reported under `error`, instead of failing the run.

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

* docs(planner): plan selection checks Count-Min only under a target; note MVP costing limits

Reword the Count-Min test to the decided behavior and test that a query
without a target admits the Count-Min candidate. Document that a cost
error aborts selection (only from bad declared rates) and that binary,
join and concat nodes are priced from their first input only.

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

* fix(planner): exact top-k ranks by the measure of a composed Stage 1 child

After Pass 1 composition a top-k reads its one-measure aggregate as
Project(FinalizeExactAccumulator(SummaryAgg)) or Project(SummaryEstimate(..)),
not as an Aggregate, so ranking fell back to the sample value: a key named
`value` was sorted on, and an integer or multi-column key pruned the
candidate. Look through renaming Projects, Filters, finalize and estimate to
the SummaryAgg or Aggregate; a grouped one ranks by its last column. The SQL
test now composes every Pass 1 choice under exact and approximate targets.

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

* fix(planner): exact top-k roots output the logical [keys.., topk_k] columns

An exact top-k root returned its child's raw rows (Example 1's P1 leaked
`ts`, `value` and `$promql_series_identity`), while summary candidates and
the frontend give `[keys.., topk_k]`. The restoring Project now sits over
the Limit at a root too, so one implementation serves roots and consumers.
`topk_k` carries the ranked value's type and nullability, not the frontend's
Utf8 placeholder. Regenerate the Example 1 fixture: P1 gains the root
Project (node 12).

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

* fix(planner): charge a NotMaterialized shared node once per consuming query

Plan selection prices each node once per computation: a materialized node
once, a NotMaterialized node once per query (or materialized node) that
reaches it, not counting through a materialized ancestor. Per-node costs
carry the multiplier. Also list the per-entity case among the exact top-k
shapes Stage 2 cannot rewrite.

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

* docs(planner): nodes below a materialized node are charged once for it

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>
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