Skip to content

[Refactor] One named type per stage boundary: PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates #1047

Open
0 of 1 issue completed

Description

@zzylol

Example

A two-query PromQL workload:

q1: quantile_over_time(0.9, latency[5m])
q2: quantile_over_time(0.5, latency[5m])

On main (cef05d2), these queries pass through these values between stages:

Vec<(Id, QueryRoot)>                                                                     Stage 0 output
LogicalMemo                      groups: latency[5m], q1, q2                             Stage 0 → 1
PerComputationCandidates<Id>     targets: q1 -> [Exact, Summary(KLL), ...], q2 -> [...]  Pass 1
CandidateMemo                    same groups, Pass 1 candidates attached                 Pass 1 (again)
CandidateMemo                    + pane candidates                                       Pass 2
CandidateMemo                    + shared group S = "KLL build over latency[5m]",        Pass 2 (end)
                                   q1/q2 gain "read S" candidates
CandidateConfigurationInventory  physical candidates x materialization/timing            Stage 2 (private to plan-selection)
Selection                                                                                Stage 3

Three problems are visible here:

  • One type, CandidateMemo, stands for three different stage outputs.
  • Stage 2's real output is crate-private inside Stage 3's crate.
  • Stage 3 calls Pass 2 and Stage 2 itself.

The target is one named type per stage boundary, with each stage or pass calling no other stage:

PlanningWorkload
   │  Stage 0: frontends + hash-consing
   ▼
DagOfLogicalMemoGroups
   │  Stage 1, Pass 1: per-computation candidates
   ▼
PerComputationLogicalMemoCandidates
   │  Stage 1, Pass 2: sharing across computations
   ▼
LogicalASAPMemoCandidates
   │  Stage 2: physical implementation, timing, materialization
   ▼
PhysicalASAPMemoCandidates
   │  Stage 3: check, price, select
   ▼
Selection

Status (2026-10-31)

Stages 0–2 are done on refactor/stage-boundaries. The blocks below show the code on that branch; where it differs from what was decided on 2026-10-29, the block says so with a // code: note. Stage 3 internals are out of scope here: the #1095 owner-scoped MILP redesign was removed in #1133 (kept at the tag stage3-owner-milp-redesign), the old engine is Stage 3's only engine, and only the frozen select_plan interface below is part of this issue.

Open (Stages 0–2):

  • Memo layout across crates (D1). MemoCandidates (renamed from CandidateMemo in refactor: #1047 remaining renames (budget_share → accuracy_budget_parts, CandidateMemo → MemoCandidates) #1137) is a public struct with public fields groups, query_roots, candidates, and it is the public memo field of PerComputationLogicalMemoCandidates and LogicalASAPMemoCandidates. The issue's crate-private MemoCandidates { groups, candidates_by_group } inside newtypes is impossible as written, because asap-types defines it and the logical crate builds it. Decide: keep it as is (and update this text), or give the newtypes private fields with constructors and accessors in asap-types.
  • Gates before merging to main: planner and sketch-plan snapshots, plus a retro-check of main vs the branch head (plans and costs identical or each difference explained; planning time and peak RSS within 10%; guarantees both ignored and enforced). See "Planner performance (acceptance)".

Closed by a decision (no code change): MemoGroup.parents / queries stay pub fields with the "Stage 1 maintains" doc comment, and parent_groups() / reached_by_queries() are the read API (D3, #1146). Stage 2's pruned key is PhysicalCandidateKey { operator_node, timing, materialization }. OperatorNodeOfCandidateForMemoGroup.accuracy_budget_parts stays as part of the boundary (#1133).

Concrete maintenance problem

  1. Pass 1 has two output shapes. PerComputationCandidates is a list of targets. Deployment pruning, early query pruning, Stage1ToStage3Run and run_stage1_to_stage3_from_pass1 use it. CandidateMemo is the same set of candidates attached to memo groups, and it is what Pass 2 reads. try_populate_pass1 converts between the two from inside route.rs.

  2. One type serves three boundaries. CandidateMemo is the Pass 1 output, an intermediate value inside Pass 2, and the Pass 2 output. Nothing at compile time stops a memo that Pass 2 has not processed from reaching Stage 2.

  3. Stage 2's output is private to Stage 3's crate. Stage 3 searches over CandidateConfigurationInventory, which is pub(in crate::search) in asap-plan-selection. PhysicalWorkloadPlan reads like the Stage 2 output but is rebuilt only for an assignment the search has evaluated.

  4. Stages call each other. About a dozen non-test files in asap-plan-selection call Stage 1 or Stage 2 functions (optimize_physical_asap*, try_populate_pass1, extend_local_window_composition, expand_concrete_summary_state_groups*, …). Two examples:

    • Stage 3 runs Pass 2 inside its pane-width search loop.
    • prune_query_candidates runs Pass 2, Stage 2 and Stage 3 checks right after Pass 1.
  5. Pruned candidates are recorded in four shapes, or not at all. The four shapes are:

    • ComputationTarget.choices.pruned (Pass 1)
    • QueryCandidatePruningReport.removed (crate-private; only the first 8 are kept)
    • PrunedCandidate { from_logical, reason } (Stage 2; only with keep_every_candidate)
    • Selection.rejected (Stage 3)

    Stage 2's structural pruning keeps only counts.

  6. Names don't say what they hold. Examples: expand_concrete_summary_state_groups, MemoGroup.roots_of, budget_share (which holds n, not 1/n), Selection.front.

Before and after structure

Decided 2026-10-29.

Overview

Step Functionality Input Output Today
Stage 0 Lower each query to the logical IR; hash-cons identical sub-expressions into memo groups PlanningWorkload + frontend catalog DagOfLogicalMemoGroups Vec<(Id, QueryRoot)>, then LogicalMemo built in pipeline.rs
Stage 1 Pass 1 Per computation: how each aggregate measure can be computed (exact, exact accumulator, or a summary at each accuracy-scoped size); prune what Stage 1 knowledge rules out DagOfLogicalMemoGroups PerComputationLogicalMemoCandidates PerComputationCandidates<Id> + Pass 1 CandidateMemo
Stage 1 Pass 2 Across computations: pane chains, shared builds, rollups, top-k reuse, shared scans; ends by adding shared summary-state groups PerComputationLogicalMemoCandidates LogicalASAPMemoCandidates CandidateMemo → StateGroupExpansion, driven from route.rs
Stage 2 Physical operator implementation, timing and materialization for each implementation candidate; prune what cannot run or be kept LogicalASAPMemoCandidates PhysicalASAPMemoCandidates CandidateInventory → CandidateConfigurationInventory (crate-private in asap-plan-selection)
Stage 3 Check accuracy, latency and retention; price under the cost model; select one assignment PhysicalASAPMemoCandidates Selection Selection
Facade Run the stages in order, including the pane-width rounds; rebuild the selected assignment as a DAG; build the installable plan UserInput PlanOutput, PlanningRun run_stage1_to_stage3* in asap-plan-selection

Principles

  • No calls across stages.
  • Stage crates never depend on each other ([Refactor] Crate organization by stage: per-crate functionality, cross-stage code into asap-accuracy / asap-cost / asap-types, enforced crate boundaries, public vs private #1054). Every type in this issue's "Shared types" and every stage output type lives in asap_types::stages. A stage may use the shared asap-accuracy and asap-cost crates. A cross-stage call is therefore a compile error.
  • Each stage prunes only what its own knowledge rules out, or what is provably dominated under the shared cost model (asap-cost). Dominated means the pruned candidate's cost lower bound is ≥ the kept candidate's upper bound, for the same output, a guarantee at least as strong, and the same sharing (the memo design's "keep the cheapest per sharing signature").
    • It records each removal in its own pruned_candidates, using one shape: PrunedCandidate<C>.
    • Stage 0 and Pass 2 have none: Stage 0 fails with an error, and Pass 2 only adds candidates.
    • This replaces prune_query_candidates.
  • Queries are entry indices. Every type identifies a query by its index in QueryWorkload::entries(). The caller's Id stays in the facade.
  • Stage outputs move by value. Memos are cloned only when inspection (exhaustive, memo steps) is on.

Shared types

Names follow #1050 within #1047's scope. PR #1052 implements #1050's renames, which are the type names below plus the cache renames. #1047 renames only the fields and functions listed in P2–P4:

Name on main Name here
LogicalMemo DagOfLogicalMemoGroups
ImplementationCandidate CandidateForMemoGroup
CandidateOrigin OriginOfCandidateForMemoGroup
MemoChoice ChosenCandidatePerMemoGroup

ComputationCandidate becomes HowToComputeAggregateMeasure, as in #1050. It is an enum of computation kinds (Exact, ExactAccumulator(kinds), Summary(family)), not a concrete, sized candidate, so it does not take the *Candidate suffix. The concrete candidates are the CandidateForMemoGroups built from it. (today: …) comments use the names on main before #1050.

pub struct MemoGroupId(pub u32);

/// One distinct logical sub-expression of the workload.
pub struct MemoGroup {
    pub logical_node: Rc<OperatorNode>,       // children are other groups' nodes      (today: node)
    pub child_groups: Vec<MemoGroupId>,       //                                       (today: children)
    // Derived indexes (built once, O(1) lookups). code: public fields, so Stage 1,
    // which builds and extends the memo in another crate, can maintain them;
    // later stages read them through the methods (D3, decided for #1146).
    pub parents: Vec<MemoGroupId>,            // inverse of child_groups
    pub queries: Vec<usize>,                  // queries whose DAG reaches this group
    // `roots_of` deleted: derived from the memo type's `query_roots`
}
impl MemoGroup {
    pub fn parent_groups(&self) -> &[MemoGroupId];
    pub fn reached_by_queries(&self) -> &[usize];
}

/// One implementation candidate: index `index` in group `group`'s candidates.
pub struct CandidateForMemoGroupId { pub group: MemoGroupId, pub index: usize }

/// One way to implement a group.
pub struct CandidateForMemoGroup {
    pub fused_groups: Vec<MemoGroupId>,       // groups a fused computation also implements; empty otherwise
                                              // (today: implements, whose first entry is the owner group)
    pub root_node: Rc<OperatorNode>,          // its nodes down to `input_groups` are private (today: node)
    pub input_groups: Vec<MemoGroupId>,       // implementations chosen separately     (today: children)
    pub origin: OriginOfCandidateForMemoGroup, // Pass1 | SharedBuild | SummaryResult | SummaryInput
                                              // | Panes | Rollup | TopKReuse | WidenedScans
                                              // (today: origin: CandidateOrigin)
    pub accuracy_budget_parts: usize,         // n: its summaries are sized for 1/n of the budget
                                              //                                       (today: budget_share)
}

/// One candidate a stage removed, and why. `C` is the stage's candidate type.
pub struct PrunedCandidate<C> {
    pub group: MemoGroupId,
    pub candidate: C,
    pub reason: PruneReason,
}

/// One enum for every stage. It merges today's Pass 1 `PruneReason`
/// (RequiresExact, NullableAvgInput, UnsupportedByDeployment, …) and
/// Stage 3's `RejectionCause` (AccuracyTargetMissed, …).
pub enum PruneReason { /* … */ }

/// Shared layout of the memo-candidate types (was CandidateMemo; renamed in #1137).
/// code: public, with public fields, since asap-types defines it and the
/// logical crate builds it; open question D1 (Status).
pub struct MemoCandidates {
    pub groups: Vec<MemoGroup>,
    pub query_roots: Vec<QueryRoot<MemoGroupId>>,         // one per query, over groups
    pub candidates: Vec<Vec<CandidateForMemoGroup>>,      // index = MemoGroupId
}
impl MemoCandidates {
    /// Groups in dependency order; None when the reads form a cycle, a group reading itself included (#1138).
    pub fn topological_order(&self) -> Option<Vec<MemoGroupId>>;
}

Stage 0: query-language frontends and hash-consing

Functionality

  1. Lower each query entry to one Pre-ASAP QueryRoot. This is language-specific and needs no deployment input.
  2. Hash-cons all roots into DagOfLogicalMemoGroups, with one group per distinct logical sub-expression.

Input

Input Type Used for
Workload PlanningWorkload { query_workload, data_workload } query text, language, PromQL sample_interval
Frontend catalog HistogramCatalog (PromQL, optional) / SqlCatalog (SQL) / none (MetricsQL) name resolution

Output: DagOfLogicalMemoGroups

// asap_types::stages (moved from the logical crate in #1146)
pub struct DagOfLogicalMemoGroups {
    pub groups: Vec<MemoGroup>,                          // children before parents
    /// One per query, over groups, built during hash-consing.
    /// (today: original_roots, as nodes)
    pub query_roots: Vec<QueryRoot<MemoGroupId>>,
    // `group_of` (node pointer → group) is a private field: Pass 1 walks
    // the memo groups (an Aggregate group is a target), not nodes (#1012).
    group_of: HashMap<*const OperatorNode, MemoGroupId>,
    // `build_duration` deleted: no non-test code reads it
}
impl DagOfLogicalMemoGroups {
    /// Builds it from its parts; only Stage 1's hash-consing calls it.
    pub fn new(groups: Vec<MemoGroup>, query_roots: Vec<QueryRoot<MemoGroupId>>,
               group_of: HashMap<*const OperatorNode, MemoGroupId>) -> Self;
    pub fn group_for_node(&self, node: &Rc<OperatorNode>) -> Option<MemoGroupId>;
    pub fn roots_over_groups(&self) -> Vec<QueryRoot>;
}

Functions

fn lower_promql_query_workload(&PlanningWorkload /*, HistogramCatalog */) -> Result<Vec<QueryRoot>, PromqlError>; // unchanged
// asap_logical_asap_optimization: a free function, since the type is in asap-types and
// hash-consing (`share_common_sub_dags`) stays in the logical crate (#1146; was the
// `from_roots` method, whose `Id` parameter #1139 dropped). Query i is roots[i].
pub fn generate_dag_of_logical_memo_groups(query_roots: &[QueryRoot]) -> DagOfLogicalMemoGroups;

Prunes: nothing. A query that cannot be lowered fails planning with an error.

Today → after: LogicalMemo::from_roots is called from run_stage1_to_stage3_with in asap-plan-selection. After the change, the facade calls generate_dag_of_logical_memo_groups as part of Stage 0.


Stage 1, Pass 1: per-computation candidates

Functionality

For every computation target, meaning one measure of an Aggregate with a realization other than plain exact execution, Pass 1:

  1. Lists how it can be computed (HowToComputeAggregateMeasure):
    • Exact
    • ExactAccumulator(Vec<ExactKind>), e.g. [Sum, Count] for avg
    • Summary(SketchAlgorithm)
    • a fused computation that absorbs the targets beneath it
  2. Turns each into sized CandidateForMemoGroups on the target's memo group. All sizing happens here ([Refactor] Crate organization by stage: per-crate functionality, cross-stage code into asap-accuracy / asap-cost / asap-types, enforced crate boundaries, public vs private #1054): ε, the budget share accuracy_budget_parts 1…n, and the whole-result δ split over the group count G. The δ split moves here from add_scoped_candidates (plan-selection) and from Stage 3's resize_for_failure_probability. Pass 2 only inherits sizes (panes) or takes the strictest reader's size (shared builds), and Stage 3 never resizes. The sizing functions are in asap-accuracy.
  3. Prunes:
    • summaries the deployment does not support
    • candidates provably unable to run or to meet the requirement (RequiresExact, NullableAvgInput, …)
    • candidates whose composed guarantee cannot meet the query's accuracy target in any combination within that query. This check is new and moves here from prune_query_candidates. code: without_summaries_no_query_can_use (feat: Pass 1 prunes summaries no reading query can use for its accuracy target #1116, fix(stage3): A1 accuracy rows match satisfies; Pass 1 prunes unusable summaries; parts record the sized shares (#1095) #1129), reason NoErrorBoundForQueryTarget; it composes each summary with every other target exact (the most favorable combination), runs only when the accuracy model and evidence cannot accept an output without its guarantee, and keeps a summary another aggregate over the same source may reuse in Pass 2.
      For example, take quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m]) with max_error 0.01 and no operand-domain evidence. No summary combination has a guarantee at the root, so only Exact remains.

Pass 1 does not consider sharing across computations; that is Pass 2.

Input

Input Type Used for
Stage 0 output DagOfLogicalMemoGroups (moved in) its groups and query_roots become the output's; Pass 1 finds targets by walking the groups (#1012)
Accuracy targets &[AccuracyTarget] (one per query) summary sizes; per-query accuracy pruning (new)
Accuracy model, evidence &dyn AccuracyModel, &dyn AccuracyEvidenceProvider per-query accuracy pruning (new)
Data facts &DataWorkload accuracy-scoped sizes (read today too)
Supported summaries Option<&[SupportedSummary]> the Stage 1 part of DeploymentCapabilities (#958)

Output: PerComputationLogicalMemoCandidates

// asap_types::stages, with ComputationTarget, FusedComputation, HavingEntry, HavingPlacement
// and HowToComputeAggregateMeasure (moved from the logical crate in #1146)
pub struct PerComputationLogicalMemoCandidates {
    /// Groups moved (not copied) from DagOfLogicalMemoGroups; every candidate has origin = Pass1.
    /// code: `memo.query_roots` holds the query roots (one per query, over groups: an operator
    /// query is `Operator(group)`; a scalar query (`sum(a) / sum(b)`) is `Scalar(expression)`
    /// referencing several groups); there is no separate `query_roots` field (D1).
    pub memo: MemoCandidates,
    /// One per aggregate measure: what Pass 1 chose among. Kept for inspection
    /// and `combinations()`; whether it can be removed is decided in #1012.
    /// Most of it is already in the implementation candidates: the measure
    /// filter as SummaryAgg.filter, HAVING as a Filter above, sizes as candidates,
    /// absorbed targets as fused_groups.
    pub computation_targets: Vec<ComputationTarget>,  // (today: PerComputationCandidates::targets)
    pub pruned_candidates: Vec<PrunedCandidate<HowToComputeAggregateMeasure>>,
}

pub struct ComputationTarget {
    pub aggregate_group: MemoGroupId,             // (today: aggregate: Rc<OperatorNode>)
    pub measure_index: usize,                     // (today: measure)
    pub ways_to_compute: Vec<HowToComputeAggregateMeasure>, // (today: choices.candidates; choices.pruned → pruned_candidates)
    pub measure_filter: Option<Predicate>,        // (today: filter)
    pub having_filters: Vec<HavingEntry>,         // (today: having)
    pub fused_computation: Option<FusedComputation>, // (today: fused)
    // `raw_data_samples_of` removed from the output (#1139): composition derives it
    // from the aggregate's input
}

pub struct FusedComputation {
    pub absorbed_targets: Vec<usize>,             // indices into computation_targets (today: absorbs)
    pub ways_to_compute: Vec<HowToComputeAggregateMeasure>, // (today: candidates; pruned → pruned_candidates)
}

This is also the Pass 2 input; there is no separate Pass 2 input type.

Function

pub fn generate_per_computation_logical_memo_candidates(
    dag_of_logical_memo_groups: DagOfLogicalMemoGroups,
    accuracy_targets: &[AccuracyTarget],
    accuracy: &dyn AccuracyModel,
    evidence: &dyn AccuracyEvidenceProvider,
    data: &DataWorkload,
    supported_summaries: Option<&[SupportedSummary]>,
    deadline: Option<Instant>,                          // code: the sizing step stops at the deadline
) -> Result<PerComputationLogicalMemoCandidates, PerComputationLogicalMemoCandidatesError>;
// code: the error enum (#1139) is PerComputation(PerComputationCandidateError) (bad input),
// Population(CandidatePopulationError) and Sizing(SchemaDerivationError) (internal bugs),
// Timeout(CandidateGenerationTimeout) (budget ran out).

Today → after

Today After
generate_per_computation_candidates{,_with_memo} → PerComputationCandidates<Id> generate_per_computation_logical_memo_candidates → PerComputationLogicalMemoCandidates
without_unsupported_summaries, prune_query_candidates, try_populate_pass1, add_scoped_candidates, called from route.rs inside Pass 1
Pass 1 CandidateMemo removed
PerComputationCandidates (the target list) code: Pass 1's crate-private working form; reachable only through test_support (feature test-support) for other crates' tests (#1139, #1145)

Stage 1, Pass 2: ASAP-aware sharing across computations

Functionality

The single entry generate_logical_asap_memo_candidates calls each rule explicitly, in this order. Today steps 2–9 run hidden inside expand_concrete_summary_state_groups*.

  1. Local window composition: pane candidates for this round's pane widths (extend_local_window_composition).
  2. Scan widening (scan_labels::add_scan_label_candidates).
  3. Summary inputs (summary_inputs::factor_summary_inputs).
  4. Top-k reuse (topk::add_topk_candidates).
  5. Finer aggregate (finer_aggregate::add_finer_aggregate_candidates).
  6. Rollup (rollup::add_rollup_candidates).
  7. Window rollup (rollup::add_window_rollup_candidates).
  8. Summary results (summary_results::factor_summary_results).
  9. Shared configurations (shared_configuration_readers::add_state_configuration_candidates_in_domain).
  10. Last step, add_shared_summary_state_groups: give each summary build its own group (S in the example), and add a "read S" candidate in each reader group, so that Stage 2 and Stage 3 can choose one shared build. It does only this step.

Pass 2 only adds groups and candidates. It prunes nothing.

Input

Input Type Used for
Pass 1 output code: &mut Pass2RoundState, built once by Pass2RoundState::new(PerComputationLogicalMemoCandidates) (moved in); it keeps the memo after window composition and before the rules between pane-width rounds (P4, #1084; #1048 decides whether to keep the incremental rounds) the memo to extend
Workload entries &[QueryWorkloadEntry] recurrence and windows, for pane rules
Pane widths PaneWidthDomain (Complete / SeedGrids / Expanding { max_parts }) which pane widths this round generates
Deadline Option<Instant> generation budget

Output: LogicalASAPMemoCandidates

// asap_types::stages
pub struct LogicalASAPMemoCandidates {
    /// The Pass 1 groups plus the groups Pass 2 adds (pane chains, shared
    /// summary states). Per group: the Pass 1 candidates plus every Pass 2
    /// rule's (`origin`), including "read the shared state" candidates.
    /// code: public, and `memo.query_roots` holds the query roots passed
    /// through from Pass 1 (D1).
    pub memo: MemoCandidates,
    // `StateGroupExpansion` deleted; Pass 2 returns the memo directly. Its
    // `state_groups` and `added_reader_candidates` are read only by tests, and a
    // shared-state group is recognized by its node (`SummaryAgg` / `PaneChain`)
}

Functions

pub fn generate_logical_asap_memo_candidates(
    round: &mut Pass2RoundState,                 // code: was `pass1: &PerComputationLogicalMemoCandidates`
    entries: &[QueryWorkloadEntry],
    domain: PaneWidthDomain,
    deadline: Option<Instant>,
) -> Result<LogicalASAPMemoCandidates, LogicalASAPMemoCandidatesError>;

// Inside Pass 2, after the rules 1–9 above:
fn add_shared_summary_state_groups(memo: &mut MemoCandidates, pane_widths, deadline) -> Result<(), …>;

Today → after

Today After
extend_local_window_composition + expand_concrete_summary_state_groups_before, called from route.rs generate_logical_asap_memo_candidates, the single Pass 2 entry
expand_concrete_summary_state_groups{,_before,_in_domain}, which runs rules 2–9 and then adds the shared-state groups rules 2–9 called by the Pass 2 entry; add_shared_summary_state_groups, one function doing only the last step
CandidateMemo / StateGroupExpansion LogicalASAPMemoCandidates
unexpanded memo kept across rounds in DomainProgress.local_memo code: Pass2RoundState keeps it (P4); whether to go stateless per round is #1048

Stage 2: physical ASAP-aware optimization

Functionality

For every implementation candidate, Stage 2:

  1. Implements its operators physically.
  2. Enumerates the allowed timing (ingestion or query time) and materialization:
    • not materialized
    • at ingestion
    • at query time, shared within one evaluation
    • at query time, kept across evaluations
  3. Records which candidates can produce each input, and the legality that depends on which readers are selected.
  4. Prunes:
    • candidates it cannot implement
    • materializations the data or deployment cannot support (no ingesting data, query-time results not retained)

Stage 2 does not price anything.

Input

Input Type Used for
Pass 2 output &LogicalASAPMemoCandidates candidates to implement
Workload entries &[QueryWorkloadEntry] recurrence, for materialization eligibility
Data facts &DataWorkload arrival: ingestion-time builds need ingesting data
Query-time retention bool (query_time_results_retained) the Stage 2 part of DeploymentCapabilities (#958)
Raw-data retention bool (raw_data_retained) no query-time scans of raw data the deployment does not keep
Deadline Option<Instant> generation budget

Output: PhysicalASAPMemoCandidates

pub struct PhysicalASAPMemoCandidates {
    /// One per operator node of an implementation, before Stage 2 chose its timing and materialization.
    pub operator_nodes_of_candidates_for_memo_groups: Vec<OperatorNodeOfCandidateForMemoGroup>,
    /// One per (operator node, timing, materialization): what Stage 3 selects; Stage 3 decides how many times it runs.
    /// (today: CandidateDependencyGraph::candidates + CandidateConfigurationInventory::configurations
    ///  + CandidateOperatorTemplates)
    pub candidates: Vec<PhysicalCandidate>,
    pub query_roots: Vec<QueryRoot<MemoGroupId>>,                // passed through from Pass 2
    pub reader_constraints: Vec<ReaderConstraint>,               // legality depending on the selected readers
    /// What Stage 2 pruned, with Stage 2's PruneReasons (P6, #1104). A pruned candidate has no
    /// PhysicalCandidateId (kept candidates are renumbered), so its key names what it would have been.
    pub pruned_candidates: Vec<PrunedCandidate<PhysicalCandidateKey>>,
    implementation_roots_of_group: Vec<Vec<PhysicalCandidateId>>, // private index over `candidates`, built once by `new`
    output_reference_groups: Vec<MemoGroupId>,                    // private: each output reference's group
}

/// The key of a Stage 2 pruned candidate: unique, since Stage 2 makes no copies.
pub struct PhysicalCandidateKey {
    pub operator_node: OperatorNodeOfCandidateForMemoGroupId,
    pub timing: ExecutionTiming,
    pub materialization: Materialization,
}
impl PhysicalASAPMemoCandidates {
    /// Builds the private index. Stage 3's component search uses it to build one smaller catalog per component.
    pub fn new(/* the public fields */) -> Self;
    /// The implementation roots of `group`. (today: CandidateInventory::group_candidates)
    pub fn implementation_roots(&self, group: MemoGroupId) -> &[PhysicalCandidateId];
    /// The candidates that may produce output reference `output_reference`
    /// (each group reference of `query_roots`, in order; a scalar query has
    /// several): its group's roots with `may_produce_query_output`.
    /// (today: graph.query_roots)
    pub fn query_output_candidates(&self, output_reference: usize) -> impl Iterator<Item = PhysicalCandidateId>;
}

/// One operator node of one or more candidates for a memo group, before Stage 2
/// chose its timing and materialization. (today: an inventory candidate + its template)
pub struct OperatorNodeOfCandidateForMemoGroup {
    pub candidates_for_memo_group: Vec<CandidateForMemoGroupId>, // every candidate of the group this node can
                                                  // implement: identical terminal readers are factored into one
                                                  // node; the selected producers decide which one it implements
    pub is_implementation_root: bool,             // false for the implementation's private nodes
    pub physical_node: Rc<OperatorNode>,          // one operator node, with Stage 2's physical implementation
    pub accuracy_budget_parts: usize,             // how many equal shares of a query's accuracy budget this node's
                                                  // sketch sizes meet (CandidateForMemoGroup::accuracy_budget_parts;
                                                  // the largest among a factored node's candidates, whose sizes are
                                                  // equal; 1 when exact). Kept as part of the boundary for
                                                  // Stage 3's redesign (#1095); no production reader yet (#1133)
}

/// One operator node with one timing and materialization: what Stage 3 selects (no scope copies; 2026-10-30).
pub struct PhysicalCandidate {
    pub operator_node: OperatorNodeOfCandidateForMemoGroupId, // (today: CandidateConfiguration::source_candidate)
    pub timing: ExecutionTiming,                  // IngestionTime | QueryTime
    pub materialization: Materialization,         // NotMaterialized | MatAtIngestion(Medium) | MatAtQuery(Medium) | …
                                                  // both kept: a node that is not materialized under an
                                                  // ingestion-time build still runs at ingestion time
    pub producer_candidates_per_input: Vec<ProducerCandidates>, // per input, the candidates that may produce it
                                                  // (today: producer_requirements); depends on timing
                                                  // (code: "producer", not "supplier", #1100)
    pub may_produce_query_output: bool,           // a root at query time whose output matches its group's
                                                  // logical node (today: computed into graph.query_roots)
    pub may_keep_across_evaluations_per_sole_reader_query: Vec<bool>, // per query: may this pane chain be kept
                                                  // across evaluations when that query is its only reader (#1054 X3;
                                                  // today: may_keep_with_sole_reader)
}

Functions

pub fn generate_physical_asap_memo_candidates(
    logical: &LogicalASAPMemoCandidates,
    entries: &[QueryWorkloadEntry],
    data: &DataWorkload,
    query_time_results_retained: bool,
    raw_data_retained: bool,             // no raw data → no query-time raw scans (deployment cannot support them)
    deadline: Option<Instant>,
) -> Result<PhysicalASAPMemoCandidates, ErrorGeneratingPhysicalASAPMemoCandidates>;

// Called by the facade after Stage 3, not by Stage 3. Builds the one copy of
// the selected plan: its DAG and its query roots (PlanOutput's roots are
// derived from it), with Stage 3's confirmed weight proofs applied.
// superseded 2026-10-30: the facade calls AssignmentRealizer::realize (Stage 3 section)
pub fn build_selected_physical_plan(
    physical: &PhysicalASAPMemoCandidates,
    selection: &Selection,               // selected_candidates + weight_confirmed_candidates
) -> Result<PhysicalWorkloadPlan, PhysicalASAPOptimizationError>;

Today → after

Today After
build_candidate_inventory_before + enumerate_candidate_configurations_before + refinement::pruning::prune, pub(in crate::search) in asap-plan-selection generate_physical_asap_memo_candidates in asap-physical-asap-optimization
PhysicalCandidate.local_cost and CandidateDependencyGraph.resource_budgets (zero placeholders until pricing) moved to Stage 3's private DagOfPhysicalCandidatesWithCosts (plan-selection), built by lower bounds from PhysicalASAPMemoCandidates; the MILP reads it. Stage 2 output carries no cost, and the realizer does not check cost
PrunedCandidate { from_logical, reason } (only with keep_every_candidate); PruningMetrics (counts only) pruned_candidates, always filled
reconstruct_selected_candidates (plan-selection) + optimize_physical_asap_assigned (Stage 2), called by Stage 3 Stage2AssignmentRealizer (the AssignmentRealizer of the Stage 3 section), built by the facade (#1097, #1107); reconstruct_selected_candidates and optimize_physical_asap_assigned are test-support only (#1135)
optimize_physical_asap (whole workload) the devtools reference pricer's Stage 2; code: stays in the physical crate behind the reference-pricer feature, which devtools and integration-tests enable (#1135)

PhysicalWorkloadPlan (one evaluated assignment rebuilt as a DAG) is not a stage-boundary type, and it keeps its name.


Stage 3: plan selection

Functionality

  1. Check each physical candidate and each assignment:
  2. Price valid assignments under the cost model: cost model 1, 2 or 3, with measured operation costs and calibration.
  3. Select one assignment with the joint search (MILP over the memo).
  4. Prune single candidates that fail every assignment before the joint search, and reject whole plans that fail a check or lose under the cost model.

Input

Input Type Used for
Stage 2 output &PhysicalASAPMemoCandidates what to choose among
Workload entries &[QueryWorkloadEntry] accuracy target, latency bound, recurrence
Data facts &DataWorkload sizing and resource usage
Accuracy check inputs &AccuracyCheckInputs validity: accuracy model, accuracy evidence (raw-data retention is a Stage 2 input)
Cost inputs (objective) &CostInputs comparing valid plans: cost model, resource speed, measured operation costs, query horizon
Realizer &dyn AssignmentRealizer: a trait in asap_types::stages, implemented by the Stage 2 crate (which depends only on asap-types) and passed in by the facade, the only crate that depends on both stages realize each proposed assignment lazily, with no crate edge to Stage 2 (#1054 X1)
Deadline Option<Instant> the planning budget

Decided interface (2026-10-30 naming and redundancy review; the same block is in #1047 and #1095). Implemented in P7 (#1107) and frozen; Stage 3's internals behind it are the old engine (#1133) until the Stage 3 redesign.

// asap-plan-selection: the only public entry
pub fn select_plan(
    physical: &PhysicalASAPMemoCandidates,
    entries: &[QueryWorkloadEntry],              // accuracy target, latency bound, recurrence per query
    data: &DataWorkload,                         // with its bounds (rows, G, source rates)
    accuracy_checks: &AccuracyCheckInputs<'_>,
    cost_inputs: &CostInputs,                    // asap-cost
    realizer: &dyn AssignmentRealizer,           // asap_types::stages, implemented by Stage 2, built by the facade
    deadline: Option<Instant>,                   // the only budget: one MILP solve, one realization
) -> Result<Selection, ErrorSelectingPlan>;      // Err: infeasible, no plan by the deadline, or the realizer
                                                 // rejected the decoded plan (RealizerRejected: a Stage 2
                                                 // catalog bug, never a cut and re-solve)

/// Inputs of the composed-accuracy check. `raw_data_retained` is a Stage 2 input only
/// (decided 2026-10-30); `ignore_accuracy_guarantees` is an accept-all `model` from the facade.
pub struct AccuracyCheckInputs<'a> {
    pub model: &'a dyn AccuracyModel,
    pub evidence: &'a dyn AccuracyEvidenceProvider,
}

pub struct Selection {
    pub selected_dag: DagOfSelectedCandidates,
    /// Selected candidates whose open count-min weight requirement Stage 3 proved;
    /// the facade applies the proof when it builds the plan.
    pub nodes_with_proven_non_negative_weights: Vec<SelectedNodeId>,
    pub cost: PlanCost,                                   // asap_cost::plan_cost (was CandidateCost)
    pub accepted_accuracy_per_query: Vec<AcceptedAccuracy>,
    pub pruned_candidates: Vec<PrunedCandidate<PhysicalCandidateId>>,
    pub search_report: SearchReport,
    // deleted: selected_plan_id, plan, cost_by_plan / costs, accepted_accuracy_by_plan / accepted,
    // front / pareto_front (C4), pane_retention_ms (moves to RealizedPlan),
    // rejected / rejected_plans (one validation; a realizer rejection is an Err)
}

pub struct SearchReport {
    pub status: SearchStatus,                             // Optimal | DeadlineReached (HiGHS time limit:
                                                          // validated incumbent, gap > 0)
    pub cost_lower_bound_per_second: Option<f64>,         // HiGHS dual bound; relative_gap() is a method
    pub milp_variables: usize,                            // one model, one solve
    pub milp_constraints: usize,
    pub model_construction_ms: u64,
    pub solver_ms: u64,
    pub validation_ms: u64,
    // deleted: evaluations / candidates_checked / refinements, milp_solves, EvaluationLimitReached
    // and max_evaluations (one solve, no evaluation loop), runner_up_cost_per_second (no runner-up),
    // rejected_by_cause and search_rejected (now pruned_candidates), pane_widths_pending (the facade
    // reads Pass 2's round state)
}

// asap_types::stages: the selection is DagOfSelectedCandidates (#1095 §2.0, "Stage 2 emits no copies")

/// One method. The facade builds the realizer with the planning context (original roots,
/// entries, data, query_time_results_retained). Stage 3 calls it once to validate its plan, and the
/// facade calls the same method to build the final plan (replaces build_selected_physical_plan).
pub trait AssignmentRealizer {
    fn realize(&self, physical: &PhysicalASAPMemoCandidates, selected: &DagOfSelectedCandidates)
        -> Result<RealizedPlan, PhysicalCandidateError>;
}
/// was SelectedPhysicalCandidate; holds the PhysicalWorkloadPlan and pane_retention_ms
/// (keyed by node id, derived from the plan and its readers' range spans)
pub struct RealizedPlan { /* … */ }

Today → after

Today After
Stage 3 runs Pass 2 per pane-width round and calls Stage 2 (route.rs) reads only PhysicalASAPMemoCandidates; the facade runs the rounds
reads all of PlanningModels AccuracyCheckInputs (validity) + CostInputs (objective; renamed from PlanSelectionSettings, in asap-cost so earlier stages can prune by cost). The stage_pipeline flag is --cost-inputs <file> and the planner field PlanningModels.cost_inputs (with_cost_inputs) (#1141)
Stage1ToStage3Search deadline: Option<Instant> (no evaluation loop, #1095)
prune_query_candidates (Stage 3 checks run after Pass 1) size-, latency- and retention-dependent pruning here, before the joint search
Selection.plan (logical roots with confirmed weights) beside the plan Stage 3 rebuilds deleted; nodes_with_proven_non_negative_weights + selected_dag, and the facade builds the one plan with AssignmentRealizer::realize
Selection.pane_retention_ms moved to RealizedPlan (keyed by node id, derived from the plan)
keep_every_candidate + exhaustive deleted from select_plan; the whole-workload reference pricer (stage_pipeline --exhaustive, plan-selection's reference-pricer feature) enumerates
Selection field names renamed (P7); P8 (#1147) renamed devtools' reference_pricing JSON keys to match: selected → selected_candidate, costs → cost_per_candidate, accepted → accepted_accuracy_per_candidate, rejected → pruned_candidates (entries name their candidate, was id), search_rejected → pruned_candidates_not_enumerated, rejected_by_cause → pruned_candidate_count_by_cause; the DAG viewer reads the new names

Facade (asap-planner)

Functionality

  1. Validate the input.
  2. Run Stage 0 → Pass 1.
  3. For each pane-width round, run Pass 2 → Stage 2 → Stage 3. Stop when Stage 3 reports the domain complete or the budget runs out.
  4. Keep the best validated selection across rounds, together with that round's Pass 2 and Stage 2 outputs: its PhysicalCandidateIds are valid only in the PhysicalASAPMemoCandidates of the round that produced it.
  5. Call AssignmentRealizer::realize (Stage 2's Stage2AssignmentRealizer) with the selected round's Stage 2 output (the one copy of the plan: DAG and query roots; pane_retention_ms mapped to node ids), then installable_plan_of.
  6. Own the total time budget and the caller's query Ids.
  7. Copy memos only when inspection is on (PlanSearchSettings::inspect, refactor(planner): copy memos only when inspecting (#1047) #1140).

Input: UserInput { workload, frontend_specific, models: PlanningModels, pass }, unchanged. The facade splits PlanningModels and DeploymentCapabilities by stage; for Stage 3 into AccuracyCheckInputs and CostInputs.

Output

pub async fn e2e_plan(input: UserInput<'_>) -> Result<PlanOutput, PlanError>;   // unchanged signature

/// How the facade searches.
pub struct PlanSearchSettings {
    pub time_limit_ms: Option<u64>,   // the whole planning budget; default 30 s
    pub inspect: bool,                // keep per_computation_candidates and memo_steps; default false (#1140)
}

/// Every stage's output, for stage_pipeline. (today: Stage1ToStage3Run<Id>)
pub struct PlanningRun {
    // no `logical_memo`: Stage 0's groups are per_computation_candidates' groups without candidates
    /// Pass 1's output; None when Pass 1 failed or without `inspect` (#1139, #1140). (was: pass1)
    pub per_computation_candidates: Option<PerComputationLogicalMemoCandidates>,
    /// The selected round's (or the last round's when none is selected), moved, not copied;
    /// None when no round ran that far. (was: pass2, stage2)
    pub logical_asap_candidates: Option<LogicalASAPMemoCandidates>,
    pub physical_asap_candidates: Option<PhysicalASAPMemoCandidates>,
    pub selection: Result<Selection, SelectionError>,
    pub selected_physical_plan: Option<RealizedPlan>,   // the plan with its pane retention
    pub selected_plan: Option<Vec<QueryRoot>>,          // the roots to run, with Stage 3's weight proofs applied
    /// The memo after each step of the selected round, and the selected plan's choice
    /// over it; only with `inspect`.
    pub memo_steps: Option<MemoSteps>,
    pub report: PlanningReport,                         // per round: counts and Pass 2 / Stage 2 / Stage 3 times
    // `evaluated_plans` deleted: the devtools reference pricer enumerates plans itself
}

/// The memo after each step of the selected round (only with `inspect`).
pub struct MemoSteps {
    pub pass2: MemoCandidates,                          // after local window composition
    pub concrete_states: MemoCandidates,                // the memo Stage 2 implements
    pub stage2_operator_nodes: Vec<OperatorNodeOfCandidateForMemoGroup>,
    pub stage2_pruned_candidates: Vec<PrunedCandidate<PhysicalCandidateKey>>,
    pub selected_chosen_candidates: Result<ChosenCandidatePerMemoGroup, String>,
    // deleted: stage0 (= per_computation_candidates' groups), pass1 and pass1_pruned_candidates
    // (= per_computation_candidates) (#1139, #1140)
}

Today → after:

  • run_stage1_to_stage3{,_with,_from_pass1}, candidate_connection_search and search_domain (in asap-plan-selection) become facade orchestration.
  • The stage work they do now moves into the stage functions above.

Behavior that must remain unchanged

  • Selected plans and their costs stay the same.
  • A pruned candidate may now be recorded in the stage that rules it out, instead of in today's crate-private report.
  • stage_pipeline documents change only in field names and in the new per-stage pruned_candidates lists.

Planner performance (acceptance)

The refactor must not make planning slower or larger.

Measure before and after on the same host with a release stage_pipeline, on:

  • every workload under tools/dag-viewer/memo-examples/: promql/ratio-of-quantiles-5m.json, the five promql/rangeheavy-*.json, and sql/rollupheavy-t11-service-endpoint-5m.json (with sql/tables.json)
  • the random mixed workloads behind tools/dag-viewer/examples/random-mixed-seed-862-*.json

Report:

  • the selected plan and its cost: must be identical
  • total planning time and the time of each stage
  • peak RSS
  • memo groups and candidates after each step

Threshold: total planning time and peak RSS within 10% of main. (code: planning time waived for the Stages 0–2 merge, see Status.)

Performance constraints:

Why the proposed structure is minimally complex

There is one type per stage boundary, and each name says what it holds:

  • DagOfLogicalMemoGroups → PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates → Selection
  • *Candidates for anything the planner chooses among

Removed:

  • CandidateMemo
  • the separate target-list form of the Pass 1 output
  • fields no non-test code reads: LogicalMemo.build_duration, StateGroupExpansion::state_groups, and raw_data_samples_of in the output
  • derived copies:
    • MemoGroup.roots_of
    • the owner group in implements
    • Stage 2's group_candidates and query_roots (they become methods over candidates)
    • Selection.plan (the facade builds the one plan)
    • PlanningRun.logical_memo
    • LogicalMemo's node-pointer map in the public type (Pass 1 walks groups; Stage 1 Pass 1 review: per-computation candidates over memo groups #1012)
    • PhysicalCandidate.group + an implicit index, replaced by one CandidateForMemoGroupId
  • SearchAlgorithm, which has a single value
  • keep_every_candidate, which every caller sets equal to exhaustive

Derived indexes that rules need often (parent_groups, reached_by_queries, Stage 2's per-group index) are read through methods, and lookups stay O(1). Stage 2's per-group index is a private field. MemoGroup's two indexes are public fields named parents and queries, because Stage 1 maintains them across the crate boundary (D3).

No new layers are added.

Existing unit and end-to-end protection

  • asap-logical-asap-optimization and asap-plan-selection --lib
  • asap-integration-tests
  • stage_pipeline document tests in asap-devtools

Changed modules need their own tests to pass before merge.

Compatibility or migration impact

  • Public types and fields are renamed with no compatibility aliases.
  • Stage1ToStage3Run<Id> becomes PlanningRun.
  • Selection field renames change JSON keys read by ASAPQuery-backend and the DAG viewer.
  • PerComputationCandidates / CandidateMemo are referenced today in logical-asap-optimization (17 files), plan-selection (6), integration-tests (6) and devtools (5).

Execution plan (decided 2026-10-29). Each PR must:

  • keep the planner snapshots unchanged
  • pass the tests of the parts it changes, plus fmt, clippy and asap-plan-selection --lib
  • pass an independent review that also checks names against this issue

PRs:

No separate performance-baseline PR for now. P6 and P7 report before/after stage_pipeline runs on the workloads listed above.

Related

Activity

  1. milindsrivastava1997 commented on Oct 29, 2026

    @milindsrivastava1997
    Collaborator

    #1050 renames the memo types and caches. Three points affect this issue:

    1. LogicalMemo → DagOfLogicalMemoGroups. This issue keeps LogicalMemo and builds its stage-type names on it. [Refactor] Rename memo-named caches and the memo types by stage #1050 renames it so the name says it is a DAG of MemoGroups. Please use DagOfLogicalMemoGroups in the stage chain, and adjust the derived names (PerComputationLogicalMemoCandidates, LogicalASAPMemoCandidates, PhysicalASAPMemoCandidates) to match.
    2. Other names this issue lists as unchanged: ImplementationCandidate → CandidateForMemoGroup, CandidateOrigin → OriginOfCandidateForMemoGroup, ComputationCandidate → HowToComputeAggregateMeasure.
    3. Left to this issue: [Refactor] Rename memo-named caches and the memo types by stage #1050 does not rename CandidateMemo, CandidatePopulationError, StateGroupExpansionError, try_populate_pass1 or generate_per_computation_candidates_with_memo, because this issue removes or renames them. Two inputs for the Pass 2 last step:
      • StateGroupExpansion { memo, state_groups, added_reader_candidates } can be removed: the production caller (plan-selection/src/search/route.rs:912) keeps only .memo; only tests read the other two fields.
      • expand_concrete_summary_state_groups* runs every remaining Pass 2 rule (scan widening, summary inputs, top-k, finer aggregate, rollup, window rollup, summary results, shared configurations) before adding the state groups, so add_shared_summary_state_groups describes only its last step.
  2. self-assigned this
    on Oct 29, 2026
  3. zzylol commented on Oct 29, 2026

    @zzylol
    ContributorAuthor

    Decided 2026-10-29: the realizer is option D. A trait AssignmentRealizer lives in asap_types::stages; the Stage 2 crate implements it, and the facade passes that implementation to Stage 3. The search algorithm is unchanged. The MILP algorithm itself may change later (an efficiency discussion is pending). Option B, where Stage 2 precomputes combination-level work so that realizing is only assembly, stays a candidate. Data point: in a profile of the 6 memo-example workloads, Stage 2 rejected no assignments in 5 of them; ratio-of-quantiles had 46 rejections, all no_accuracy_guarantee.

  4. zzylol commented on Oct 30, 2026

    @zzylol
    ContributorAuthor

    P3c and #1089 are linked: Pass 1 decides key-only HAVING placement per occurrence, but builds a memo group's candidates from the first occurrence. A query that shares the aggregate without the filter can therefore read filtered rows. This is confirmed end to end in #1089. The P3c decision (one target per occurrence vs. per group) should include the fix: a predicate goes below a shared aggregate only when every reader of the group applies it.

  5. 2 remaining items

  6. zzylol commented on Oct 30, 2026

    @zzylol
    ContributorAuthor

    Progress since P5b (#1090). P5c merged to main in #1093, so P5 is done. Everything after it is on the integration branch refactor/stage-boundaries, which merges to main in #1108 once #1095 and one final test round are done.

    Step PR What
    P5c #1093 (main) Stage 2 outputs PhysicalASAPMemoCandidates, with two levels (OperatorNodeOfCandidateForMemoGroup, PhysicalCandidate). Costs live in Stage 3's private DagOfPhysicalCandidatesWithCosts.
    P6 (Stage 1/2) #1104 PrunedCandidate/PruneReason are in asap_types::stages. Stage 2 records pruned_candidates. The per-entity and raw-data rules move into Stage 2.
    P6b #1105 The legacy whole-workload Pass 2 path and early query pruning are deleted.
    P3c #1106 Pass 1 walks memo groups. HAVING is placed per group (#1089). Fusions are memo candidates.
    P7 #1107 The facade runs the stages. Stage 3 has the frozen select_plan interface. plan-selection no longer depends on logical or physical.
    Pass 1 accuracy pruning #1116 NoErrorBoundForQueryTarget.
    Tests #1109, #1110, #1111, #1113, #1114 Tests fixed on the branch, plus regressions found there: decoded-output validation, the reference enumeration's raw-data rule, the quantile operand count after memo sharing, the Pass 1 deadline error, and the MetricsQL public API.

    Still open: P8 (DAG viewer clean-up: pass1_pruned, the Stage 2 pruned keys).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

optimizerWorkload optimization and plan selectionrefactor

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions