Skip to content

feat(planner): compose candidate logical ASAP DAGs from Pass 1 per-computation candidates - #575

Merged
zzylol merged 16 commits into
mainfrom
stack/509-c2-stage1-workload
Oct 13, 2026
Merged

zzylol merged 16 commits into
mainfrom
stack/509-c2-stage1-workload

Conversation

@zzylol

@zzylol zzylol commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Why

The planner-layering design doc defines the output of Stage 1 (logical ASAP-aware optimization) as candidate logical ASAP DAGs: each is one logical ASAP DAG for the whole workload. Pass 1 (#561, merged) only produces per-computation candidates (ComputationChoices per ComputationTarget). It builds no DAG, so nothing after Pass 1 has a whole-workload candidate to work on, and there is no way to look at what the frontends and Stage 1 produce.

What

  • compose_logical_candidate(inventory, choice) in asap-aware-mapping/src/per_computation_candidates.rs: Algorithm 1's SUBSTITUTE. It takes one Pass 1 per-computation candidate per target and builds one whole-workload candidate logical ASAP DAG.
  • Every node of a composed candidate gets a theoretical guarantee from the built-in model.
  • Summary candidates are kept for combinations of approximate values (comparisons now, as arithmetic before).
  • Top-K item identity for composed summaries.
  • FinalizeExactAccumulator output naming fix.
  • stage_pipeline devtool, which writes stage0_query_language_frontends and stage1_logical_asap_optimization documents (read by its devtools test; the viewer support is feat(dag-viewer): redesign the viewer around the stage pipeline #574), plus the regenerated tools/dag-viewer/examples/planner-layering-example1.json fixture.
  • Moved here from Pass 1: single-measure filtered aggregates #608: the rule that a measure filter becomes SummaryAgg.filter.

How

choice[i] indexes targets[i].choices.candidates. The targets (measures) of one aggregate are grouped. An aggregate whose chosen candidates are all Exact is kept as is. Otherwise each measure is substituted on its own over the aggregate's input:

  • ExactAccumulator: SummaryAgg with the exact family → FinalizeExactAccumulator.
  • Summary: SummaryAgg → SummaryEstimate.
  • AvgAsSumCount: the finalized sum and count accumulators, divided. For PromQL this is a vector BinaryOp; for SQL the two states are joined on the group keys (NULL keys match) and divided, with CASE count = 0 THEN NULL for a filtered or global avg.
  • Exact (or a measure that is not a target): a single-measure Aggregate.

Then:

  • Multi-measure targets: the measures are joined back on the group keys (NULL keys match, as grouping treats them).
  • Column names: the substituted output keeps the aggregate's column names (quantile_0_99 no longer becomes quantile, nor avg become sum). A renaming projection keeps its child's guarantee.
  • Measure filters become SummaryAgg.filter, so a group with no matching row stays. ASAPOp schemas mark a filtered quantile and a filtered SUM/MIN/MAX as nullable, and the exact Aggregate now marks a filtered quantile nullable too, so every candidate has the same nullability. Subqueries inside a measure filter or HAVING are substituted like any other target.
  • Key-only HAVING (BelowAggregate) is pushed below the state as a Filter, only when every consumer applies it. Its key columns are renumbered to the input's (ScalarExpr::map_columns), and the consumer Filter it came from is dropped. It is not a SummaryAgg.filter, which would keep the dropped groups.
  • Value HAVING (AboveOutput) stays a Filter above the output; its guarantee is None when the measure is approximate (exact candidates stay exact).
  • Sizing: Pass 1 summaries are unsized. Composition sizes each summary for its own measure's accuracy requirement. This is the post-Pass-2 sizing step with no sharing, because Pass 2 (ASAP-aware CSE) is not implemented yet.
  • Coverage: summary coverage is derived from the subtree.
  • Guarantees (composed_guarantee): exact over exact inputs. A SummaryEstimate over exact input gets the sized family's bound, except for Top-K (a heap sketch bounds item frequencies, not membership) and for Count-Min over weights not proven non-negative, which get None. Anything computed from an approximate value, including value HAVING and any predicate at any depth, gets None. Composing guarantees through operators is Compose accuracy guarantees through arithmetic and comparisons over approximate values #669.
  • Combination candidates (replacement.rs): is_supported_exact_binary now accepts comparisons (Compare, CompareBool) as well as arithmetic, so quantile(0.99, …) > quantile(0.5, …) keeps its summary candidates. Certified inputs are not enough for automatic selection, since a difference or ratio of them can be unbounded. Such candidates stay, but are selected automatically only with output-level evidence: a root guarantee that meets the target, or the deployment's empirical output-level accuracy model. The plan-selection part is feat(planner): physical ASAP-aware optimization and plan selection for candidate logical ASAP DAGs #576.
  • Top-K item identity: a child that carries the PromQL series identity ranks that column. A closed PromQL child (for example topk(3, sum by (job) (rate(m[5m])))) ranks the tuple of its labels outside the partition, as the legacy current-series and counter rules do.
  • FinalizeExactAccumulator named its output state, so a consumer reading the sample value (for example sum by (job) over a finalized per-series rate) found no value column. It now takes the name of the aggregate it realizes.
  • stage_pipeline (crates/devtools/src/bin/stage_pipeline.rs) writes an asap-stage-pipeline/v1 document. stage0_query_language_frontends is the frontends' workload DAG. stage1_logical_asap_optimization holds one composed candidate per choice of per-computation candidate for every target, in enumeration order. Every combination is enumerated (no enumeration cap); the document shows the first --max-displayed candidates (default 64) plus the selected one, with combinations and capped (whether some are not shown). A candidate that cannot be composed is recorded with its error, and the run continues.

Key code interfaces added / modified

crates/asap-aware-mapping/src/per_computation_candidates.rs

/// Why Pass 1 or composition rejected its input.
pub enum PerComputationCandidateError {
    /// Deriving a node's schema failed: malformed input DAG.
    Structure(#[from] SchemaDerivationError),
    /* ... existing variants ... */
    /// `choice` has the wrong length or an index past a target's candidate
    /// list: bad input from the caller.
    InvalidChoice,               // new: `choice` does not name one listed candidate per target
    /// The chosen combination needs a substitution composition cannot build
    /// yet; the message names the unsupported shape.
    Unsupported(&'static str),   // new: a substitution composition cannot build
}

/// Where a target's `HAVING` is applied during composition.
pub enum HavingPlacement {
    /// Key-only predicate every consumer applies; pushed below the aggregate
    /// as a row predicate.
    BelowAggregate,   // now applied as a `Filter` on the aggregate's input (drops the group)
    /// Predicate on aggregate values; stays a `Filter` above the
    /// finalize/estimate output, so approximate candidates get `guarantee = None`.
    AboveOutput,
}

/// Builds one whole-workload logical candidate by substituting the chosen
/// candidate for every target; callers enumerate `choice` vectors over the inventory.
// new: Algorithm 1's SUBSTITUTE; choice[i] indexes inventory.targets[i].choices.candidates.
// Sizes each summary for its own measure and sets a guarantee on every node.
pub fn compose_logical_candidate<Id: Clone>(
    inventory: &PerComputationCandidates<Id>,
    choice: &[usize],
) -> Result<Vec<(Id, QueryRoot)>, PerComputationCandidateError>;

crates/asap-aware-mapping/src/replacement.rs

/// The `ColumnRef` naming a schema field (qualified when it has a table);
/// now shared with composition.
pub(crate) fn column_ref(column: &Field) -> ColumnRef;   // was private
/// What a summary consumes per input row for an intent (one column, or the
/// whole tuple for multi-column `COUNT(DISTINCT)`); errors if a tuple leg is missing.
pub(crate) fn summarised_input(                           // was private
    intent: &AggIntent,
    child_schema: &Schema,
) -> Result<SummaryInputExpr, RealizationError>;

/// Whether a binary operator over summary operands keeps its candidates.
fn is_supported_exact_binary(root: &OperatorNode) -> bool;   // now also Compare / CompareBool, not only Arithmetic

crates/types/src/ir/scalar.rs

impl<C> ScalarExpr<C> {
    /// Rebuilds the expression with `f` applied to every operator node it
    /// reads, e.g. to turn `Rc` references into node ids.
    pub fn map_operator_refs<D>(&self, f: &mut impl FnMut(&C) -> D) -> ScalarExpr<D>;   // now delegates to map_parts
    /// Returns the expression with every column reference renumbered by
    /// `column`; operator nodes it reads keep their own column scope.
    // new: renumbers column references; used to push key-only HAVING below the state
    pub fn map_columns(&self, column: &mut impl FnMut(ColumnId) -> ColumnId) -> ScalarExpr<C>
    where
        C: Clone;
}

crates/types/src/ir/asap.rs, non_asap.rs

// Signatures unchanged; schema derivation changes:
// - SummaryEstimate of a Quantile over a filtered SummaryAgg: output field nullable
// - FinalizeExactAccumulator: keeps the SummaryAgg filter (nullable SUM/MIN/MAX) and takes the realized aggregate's column name
// - Aggregate: a filtered Quantile measure is nullable

crates/devtools/src/bin/stage_pipeline.rs (new binary)

# Writes the first two planner stages of a workload as JSON for tools/dag-viewer.
#   --example planner-layering-1  built-in example workload (instead of --promql)
#   --promql <query>              a PromQL query; repeatable
#   --epsilon / --delta           accuracy for every --promql query; omitted means exact; --delta needs --epsilon
#   --interval-ms <u64>           source cadence PromQL needs
#   --max-candidates <n>          cap on listed logical candidates
#   --out <file>                  output path; required
stage_pipeline (--example planner-layering-1 | --promql <query>... [--epsilon <f64> --delta <f64>] [--interval-ms <u64>]) [--max-candidates <n>] --out <file>
# --max-candidates defaults to 64; --interval-ms defaults to 15000
{
  // Document format tag.
  "format": "asap-stage-pipeline/v1",
  // The input queries with their language, text and requirements.
  "workload": { "queries": [{ "id": "q1", "language": "promql", "text": "...", "requirements": { "accuracy": "exact" } }] },
  // The frontends' workload DAG, one root per query.
  "language_specific_frontends": { "dag": { /* FlatDag */ } },
  // Pass 1 whole-workload candidates, one per choice combination, in enumeration order.
  "logical_asap_optimization": {
    // Total number of combinations (product of per-target candidate counts).
    "combinations": 4,
    // True when combinations exceed --max-candidates and the list is truncated.
    "capped": false,
    // Each candidate has a `dag`, or an `error` when composition could not build it.
    "candidates": [{ "id": "L1", "label": "...", "dag": { /* FlatDag */ } }, { "id": "L2", "label": "...", "error": "..." }]
  }
}

Usage:

let inventory = generate_per_computation_candidates(roots.into_iter().enumerate().collect())?;
let choice = vec![0; inventory.targets.len()];   // the exact option of every target
let candidate: Vec<(usize, QueryRoot)> = compose_logical_candidate(&inventory, &choice)?;

Before this PR

Pass 1 lists per-computation candidates for each target, and nothing turns them into candidate logical ASAP DAGs. A comparison of approximate values loses its summary candidates. A finalized exact accumulator's column is called state. Top-K over rows with series identity, or over a closed PromQL child, has no usable summary item. Nothing shows the frontends' DAG or the Stage 1 output.

After this PR

A choice of one per-computation candidate per target composes into a whole-workload candidate logical ASAP DAG, except for a few shapes that return Unsupported and are recorded with their error by stage_pipeline (Top-K with no label outside its partition or without, a multi-measure aggregate with without keys, a non-Float64 per-series avg, and SQL count(*) over rows with no column known to be non-null). Composition never returns an invalid DAG: every composed root passes validate_structure, otherwise the choice returns an error. A composed candidate has the queries' output column names, types and nullability, a sized summary per measure, and a theoretical guarantee on every node. stage_pipeline writes the frontends' DAG and these candidates. Stage 2 (physical ASAP-aware optimization) and Stage 3 (plan selection) consume the candidates in #576.

Evidence

  • crates/integration-tests/tests/stage1_logical_asap_optimization.rs: every candidate of a set of SQL and PromQL queries composes into a valid DAG with the query's output columns (names, types, nullability), including filtered quantiles; subqueries in measure filters are substituted; SQL avg executes like the original (NULL and '' groups stay separate). Count states: sql_avg_counts_its_own_input, sql_count_star_counts_a_non_null_column, sql_count_star_without_a_non_null_column_is_unsupported, sql_count_of_a_nullable_column_counts_its_values; HAVING subqueries: own_having_subquery_is_substituted. Top-K estimates carry no frequency bound while a unit-count CMS estimate keeps its bound. Top-K over a closed PromQL child ranks its labels.
  • crates/asap-aware-mapping/tests/per_computation_candidates.rs: substitution of the chosen target, per-measure sizing, filters and HAVING, avg as sum/count, a multi-measure join, and guarantees.
  • crates/planner/tests/summary_sharing.rs: combined_vector_quantiles_keep_unselected_summary_candidates.
  • crates/devtools/tests/stage_pipeline.rs: the Example 1 document is valid and the committed fixture is current, the display cap is recorded and keeps the selection, and an uncomposable candidate is recorded with its error. Example 1 has three candidates (one exact option per target).

Verification

  • cargo fmt --all --check: OK
  • cargo clippy --workspace --all-targets -- -D warnings: OK
  • cargo test --workspace: 1591 passed, 0 failed, 2 ignored
  • dag-viewer test_render: OK
  • verify_metricsql_external_consumer.sh: OK

Architectural decisions

  • Composition lives next to Pass 1 in per_computation_candidates.rs. Its helpers are named after Algorithm 1's SUBSTITUTE.
  • Sizing happens during composition, per measure, as the post-Pass-2 sizing step. When Pass 2 sharing lands, sizing moves after it, so a shared summary takes the strictest requirement among its consumers.
  • A measure filter is realized as SummaryAgg.filter. A key-only HAVING becomes a Filter below the state only when every consumer applies it. A value HAVING stays above, with guarantee None.
  • Guarantees on composed candidates come only from the built-in model. Where it gives none, the candidate is kept and plan selection decides, never this stage.

Later changes: #577 removed the enumeration cap (--max-candidates) in favor of the display cap --max-displayed; #674 renamed the document keys and test file by stage number and name (stage0_query_language_frontends, stage1_logical_asap_optimization, stage1_logical_asap_optimization.rs) and made composition part of the planner pipeline (run_stage1_to_stage3 / Stage1ToStage3Pass, #675 names); #675 renamed the candidate label to display_name.

Limitations and follow-up

Stack: tracker #528 · main → #575 → #576 → #577 → …

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:

@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from 517cd55 to 488c6eb Compare October 5, 2026 06:21
@zzylol
zzylol force-pushed the stack/509-c2-stage1-workload branch 2 times, most recently from 10f0989 to 4c700d6 Compare October 8, 2026 16:47
@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/509-c2-stage1-workload branch 2 times, most recently from 0150354 to db2f3c4 Compare October 11, 2026 17:11
zzylol added a commit that referenced this pull request Oct 12, 2026
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
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from 7c8ae7d to 9d5ab8f Compare October 12, 2026 20:30
zzylol added a commit that referenced this pull request Oct 12, 2026
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
zzylol added a commit that referenced this pull request Oct 12, 2026
…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
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from 9d5ab8f to 8eccd53 Compare October 12, 2026 22:55
zzylol added a commit that referenced this pull request Oct 13, 2026
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
zzylol added a commit that referenced this pull request Oct 13, 2026
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
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch from 8eccd53 to d18125b Compare October 13, 2026 01:43
zzylol added a commit that referenced this pull request Oct 13, 2026
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
zzylol added a commit that referenced this pull request Oct 13, 2026
…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
zzylol added a commit that referenced this pull request Oct 13, 2026
* refactor(planner): plan one shared unified workload DAG

Cut today's planner over to the unified OperatorNode IR. The planner calls
the unified frontends (`asap_frontend_*::unified`) and returns OperatorNode /
QueryRoot; ParsedWorkload keeps scalar roots; Pass 1 (ASAPStrategies), the
identical-sub-DAG sharing, selection, DAG assembly and lifecycle costing all
run on OperatorNode; and PlanOutput exposes the whole workload DAG.
Planned summary states derive their coverage from the node (#646).

The legacy physical planner read the planner's QueryExpr output, so it is
removed here together with its expression compiler and raw sources; every
caller uses the native compiler from #541 (`unified_physical_planner`,
`unified_sources`), which reads the PhysicalASAPDAG export. Operator names
follow the evaluation vocabulary (readout -> evaluation).

The MetricsQL frontend input carries the planning clock and lowers through
`lower_metricsql_query_workload`, so bare selectors get the workload's
ingestion-interval lookback (#540).

The legacy frontend modules and the QueryExpr/SummaryNode IRs stay compiled
but are no longer re-exported from post_asap. The next PR moves the unified
modules to the crate roots; a later one removes the legacy IRs.

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

* feat(planner): plan and export standalone scalar queries

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

* fix(planner): plan non-pass-through scalar references exact

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

* fix(planner): check scalar plans in the contract; remove dead code

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

* fix(planner): lower scalar(v) in Project/Filter expressions as scalar 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

* fix(planner): reuse a top-k only over the same partitions

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

* refactor(planner): tell logical rewrites apart by provenance

`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

* fix(planner): return an error when search input already holds ASAP operators

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

* fix(planner): walk operator nodes read by scalar expressions

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

* fix(ir): do not promote a filtered count ranking to a heavy-hitter TopK

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

* Revert "fix(planner): plan non-pass-through scalar references exact"

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

* fix(planner): rank top-k only when the Limit partitions like its Sort

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

* fix(ir): export one ScalarRef edge per distinct referenced node

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

* test(planner): cover scalar inputs, top-k partitions, scalar plan contract, 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

* test(planner): a difference of two scalar quantiles reads summaries

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

* fix(planner): plan structurally identical scalar references once

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

* test(planner): reject a per-job Limit over a global Sort; doc cleanups

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

* fix(planner): read exact-state query roots through their finalize

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
zzylol added a commit that referenced this pull request Oct 13, 2026
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
zzylol added a commit that referenced this pull request Oct 13, 2026
…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
@zzylol
zzylol force-pushed the stack/528-05b-local-alternatives branch 2 times, most recently from 8817d84 to d3e39fe Compare October 13, 2026 02:57
zzylol and others added 8 commits October 13, 2026 04:12
…DAG documents

Replays #575's devtools commits on the #561 Pass 1 API: the frontends section is the
frontends' workload DAG and logical_asap_optimization holds one composed whole-workload
candidate per choice of a per-computation candidate for every target, as flat DAGs,
capped by --max-candidates. Example 1 has three candidates (one exact
option per target), so the cap test cuts at two.

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

Composition substitutes every #561 Pass 1 choice. A measure filter
becomes SummaryAgg.filter (the realization rule #608 carried, with its
filtered-output nullability in ASAPOp schemas); a key-only HAVING that
every consumer applies becomes a Filter below the state (SummaryAgg.filter
would keep the dropped groups); a value HAVING stays a Filter above the
output. avg divides its finalized sum and count accumulators, and the
measures of a multi-measure aggregate are substituted one by one and joined
back on the group keys. ScalarExpr::map_columns renumbers a predicate's
columns.

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

Every node of a composed candidate gets a guarantee: exact over exact
inputs, a summary estimate's sized family bound over exact input, and None
for anything computed from an approximate value, including a value HAVING
or any other predicate at any depth (#669 defines composition; plan selection's
accuracy model decides). The Example 1 fixture shows them.

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

A comparison of approximate operands (quantile(0.99, …) > quantile(0.5, …))
now gets the same BinaryOp candidate over summary operands as arithmetic
does, so no combination loses its summary candidates. Automatic selection
still needs evidence about the candidate's output: a root guarantee that
meets the target, which composition (#669) supplies, or the deployment's
empirical output-level accuracy model (plan selection, #576). Certified inputs are
not enough, since a difference or ratio of them can be unbounded, so such
candidates are kept but not selected under the built-in models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
The composition of per-computation candidates into whole-workload
candidates is Algorithm 1's SUBSTITUTE; its helpers are named so. The
module doc describes it and states that summaries are sized here per
measure because Pass 2 sharing is not implemented yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
A heap sketch's bound is per item frequency, not Top-K membership, and a
Count-Min bound assumes non-negative weights. A composed Top-K estimate,
and a Count-Min estimate over weights not proven non-negative, now get no
guarantee; plan selection's accuracy model decides them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
topk(3, sum by (job) (rate(m[5m]))) listed heap candidates that composition
could not substitute: the closed child [job, value] has no series identity.
Its item is now the tuple of labels outside the partition, as in the legacy
current-series and counter rules. stage_pipeline records a candidate it
cannot compose with its error instead of aborting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
An estimate or accumulator names its value after the operation
(quantile_0_99 became quantile, avg sum, a per-series count value count).
A single-measure substitution is now renamed to the aggregate's columns,
as join_measures already did; a renaming projection keeps its child's
guarantee. The logical_asap_optimization test compares column names too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
@zzylol
zzylol changed the base branch from stack/528-05b-local-alternatives to main October 13, 2026 04:12
@zzylol
zzylol force-pushed the stack/509-c2-stage1-workload branch from ec00b3c to 03ebf0e Compare October 13, 2026 04:12
@zzylol zzylol changed the title feat(planner): Stage 1 whole-workload candidates and the stage_pipeline devtool feat(planner): compose candidate logical ASAP DAGs from Pass 1 per-computation candidates Oct 13, 2026
zzylol and others added 3 commits October 13, 2026 04:24
A subquery in a substituted aggregate's measure filter or own HAVING now
reads its rewritten plan, and a summary state's guarantee requires every
input, its filter's subqueries included, to be exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
As its summary estimate is: no row passing the filter gives NULL. The
filtered quantile queries join the SQL candidate composition test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
A PromQL BinaryOp matches Utf8 labels: it merges NULL and '' groups and
rejects other key types. For SQL rows the finalized sum and count are
joined on the group keys (NULL keys match) and divided as SQL, NULL where
the count is 0 (a filtered-empty group, an empty global input). PromQL
keeps the BinaryOp.

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
zzylol added a commit that referenced this pull request Oct 13, 2026
…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
zzylol and others added 2 commits October 13, 2026 04:54
The count state behind avg-as-sum/count and SQL count read the sample
value, which for SQL rows is `value` or the only numeric column: avg(c)
divided by count(value), count(*) counted a nullable column, and with
several numeric columns the composed DAG was invalid. The runtime skips
NULL updates, so:

- avg's count reads the averaged column (non-null, or the candidate is
  pruned);
- SQL count reads a column its filter proves non-null (count(col)), else
  a non-nullable column; with none it is Unsupported;
- an unresolvable state input is Unsupported, and a composed DAG is
  validated before it is returned.

PromQL counts still read the sample value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
@zzylol
zzylol marked this pull request as ready for review October 13, 2026 05:24
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
zzylol added a commit that referenced this pull request Oct 13, 2026
…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
@zzylol
zzylol merged commit 156ffb8 into main Oct 13, 2026
7 of 8 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
zzylol added a commit that referenced this pull request Oct 13, 2026
…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
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