Repository navigation
Description
Part of #1010. Line numbers are on main at 958778400. ps/ = crates/plan-selection/src/.
Example: what Stage 3 receives and returns
Same query as the tracking issue (avg by (job) (quantile_over_time(0.5, data[5m]) / quantile_over_time(0.5, data[5m]) + quantile_over_time(0.9, data[5m])), every minute, max_error 0.01, failure_probability 0.001). Stage 3 receives a memo of 354 groups and 1738 implementation candidates (#1008; corrected 2026-10-28: production numbers from the stage-pipeline document; earlier numbers came from a test helper without the series-identity column.) It must pick one candidate per needed group (and one producer per input), check accuracy and latency, price the plan, and return one plan.
How many candidate DAGs that memo stands for is not known: a naive enumerator stopped at 1,000,000 (#1002). So today nobody can check, even for this one query, that the selected plan is the cheapest the memo allows (#1004).
Functionality
- Reject candidates that miss an accuracy target, a latency bound or a deployment capability; pick the cheapest valid plan for the whole workload under the deployment's cost model.
- It is the only stage that uses the cost and accuracy models and the only one that discards valid candidates.
Concepts (design doc term ↔ code)
| planner-layering.md | Code on main |
|---|---|
| Resource usage | ResourceUsage (ps/lib.rs:333) |
| Cost model 1/2/3 | CostModel (ps/lib.rs:475) with ResourceUnitCost (:361), ClusterLimits (:492), ClusterObjective (:506) |
| Settings | PlanSelectionSettings (ps/lib.rs:523) |
| Choose one candidate per group (memo search) | candidate_connection_search (ps/search/route.rs:62) → search_domain (:653) → MILP (ps/search/milp/model.rs) + validation/refinement (ps/search/refinement/) |
| one selected plan | Stage1ToStage3Run (ps/search/pipeline.rs:53) holding the selected PhysicalWorkloadPlan and Selection |
| Rejection with reason | Rejection, RejectionCause (ps/lib.rs:804, :816) |
| Selected-plan validator and pricer | PlanSelector (ps/lib.rs:1214), select_plan (:1188) |
The MILP (ps/search/milp/model.rs, solved with HiGHS through good_lp): a binary candidate_selected per physical candidate configuration (:325), an integer topological_rank for acyclicity (:335), a binary connection per (consumer, input, compatible producer), a binary query-root assignment; each active consumer has exactly one producer per input (:388), each root is assigned once (:417), a selected candidate must be used (:637); the objective is a lower bound on local costs, tightened by cuts from evaluated plans and no-good cuts from rejected ones. The proposed plan is then validated and priced by PlanSelector (Stage 2 + pricing + accuracy + executor compile check), so the search is a propose–validate loop.
Interfaces: designed vs implemented
| Designed | Implemented | |
|---|---|---|
| Input | the complete memo from Stage 1 (Stage 3 "only selects", #958 decision 2); accuracy targets, accuracy model and evidence, cost model, raw-data retention, compile check | run_stage1_to_stage3_with (pipeline.rs:163) / _from_pass1 (:195) take the query roots and PlanningModels, and Stage 3 runs Pass 1, Pass 2 and the state-group expansion itself (route.rs, search_domain) |
| Search | MILP over the memo (#888) | memo MILP is the only route; SearchAlgorithm (ps/lib.rs:1139) has one variant, CandidateConnections, still exposed as --search in stage_pipeline |
| Output | one plan, cheapest among the candidates | one validated plan; optimal only when the last (Complete) pane-width round finishes within budget |
Interface decided in #1047 (2026-10-29)
Decided in #1047, the stage-boundary types. Its sub-issue is #1048. Names follow #1050 within #1047's scope. Principles: a stage or pass reads only the previous step's output type and calls no other stage; the asap-planner facade runs the stages in order; each stage prunes only what its own knowledge rules out and records it as PrunedCandidate<C> { group, candidate, reason: PruneReason }; queries are entry indices. Planning stays as fast as on main: P6 and P7 report before/after stage_pipeline runs.
| Decided | |
|---|---|
| Functionality | Check (accuracy incl. size-dependent and failure-probability resizing, latency, raw-data retention), price under the cost model, select with the joint search; prune single candidates that fail every assignment before the search; reject whole plans |
| Input | &PhysicalASAPMemoCandidates (only Stage 2's output: no Pass 1/2 or Stage 2 calls), entries, DataWorkload, PlanSelectionConstraints { accuracy, evidence, raw_data_retained }, PlanSelectionCostInputs (was PlanSelectionSettings: cost model, resource speed, measured operation costs, query horizon), PlanSearchSettings { exhaustive, deadline, max_evaluations } (no SearchAlgorithm; the total budget moves to the facade) |
| Output | Selection { selected_plan_id, selected_candidates: SelectedCandidates, weight_confirmed_candidates, cost_by_plan, accepted_accuracy_by_plan, rejected_plans, pruned_candidates, pareto_front, pane_retention_ms (keyed by PhysicalCandidateId), search_report }. plan is deleted: the facade builds the one plan |
| Function | select_plan(&physical, entries, data, constraints, cost_inputs, search). This replaces today's whole-workload select_plan over optimize_physical_asap |
| PR | #1047 P2 (SearchAlgorithm), P5–P7, P8 (Selection field renames) |
Review checklist
User's suspected problem: does the MILP find the optimal candidate?
- Budgets, not proof, by default. 30 s total (
route.rs:75,milp_time_limit_ms.unwrap_or(30_000)), at most 128 evaluated plans (:103,:168), a seed round of at most 5 s and 128/4 evaluations (:115,:123), non-final pane-width rounds of at most 5 s and 16 evaluations (:212,:217). When a budget stops the search, the status becomestime_or_evaluation_limit_with_validated_incumbent(:386). The design doc's "3. Plan selection" section says "picks the cheapest remaining plan" and does not describe the MILP, the rounds or these limits. [Refactor] One named type per stage boundary: PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates #1047: the total budget moves to the facade; Stage 3 gets adeadline. - Lower bounds must be certified for every candidate the MILP can cut: query-time scans and builds (Search: certify lower bounds for query-time scans and builds #963, [Bug] Search: a query-time scan's lower bound takes the shortest possible reader window, so the cheaper plan is never proposed (#987) #994), and a rejected candidate must be excluded itself, not its whole query closure (Search: exclude a rejected candidate itself, not only its query closure #964). memo-milp misses rh8-q1h60 at 280 s (1.34e-05 vs 3.83e-06): suspected decode cut of a valid stricter site threshold #928 is a suspected wrong cut.
- Check against enumeration on small workloads: price every candidate DAG the memo stands for and compare with the MILP's choice (Memo: convert memo-group candidates into the vector of all candidate DAGs #1002, stage_pipeline: price every enumerated candidate DAG and flag any cheaper than the selected plan #1004).
- Sizing inside the search: a sketch's size is not known when the MILP picks; decide "size once per reader set" (memo-milp: size summaries once (final config per reader set and summary count; Stage 3 never resizes) #929).
1. Boundaries
- Stage 3 runs Stage 1:
try_populate_pass1,accuracy_configurations::add_scoped_candidates(ps/search/candidates/accuracy_configurations.rs:8, a Stage 3 module that adds Stage 1 candidates),extend_local_window_compositionandexpand_concrete_summary_state_groups_before, all insearch_domain;run_with_initial_memofilters unsupported summaries (pipeline.rs:214). (Reorganize by stage: one memo-based Pass 2, Stage 1 finishes the memo, deployment inputs split by stage, asap-planner as the only entry point #958 decision 2.) [Refactor] One named type per stage boundary: PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates #1047 P3, P4, P7. - Stage 3 lists materialization modes itself (
ps/search/candidates/configurations.rs:162-222; see Stage 2 review: physical optimization and materialization per candidate #1014). [Refactor] One named type per stage boundary: PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates #1047 P5. -
query_pruning::alone_violation(ps/search/query_pruning.rs:316) runs whole-workload Pass 2 and Stage 2 per query before the joint search. [Refactor] One named type per stage boundary: PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates #1047 P6 replaces it with per-stage pruning. - Stage 3 holds sketch-library byte-size models for KLL, DDSketch and UnivMon at the end of
ps/lib.rs(about:5074-5190). Decide whether runtime library sizes belong to the deployment's cost model (Plan selection and the executor disagree on summary state memory #947, Plan selection underestimates top-k heap memory by about 9× #946, [Bug] Stage 3 sizes a sketch's memory by its full capacity, not by the samples it holds #726). - The executor compile check runs once per evaluated plan and builds a full
InstallablePlaneach time (PlannerExecutorCompileCheck,crates/planner/src/installable.rs); Remove ExecutorCompileCheck from plan selection #949 proposes removing it from plan selection. - The crate also holds the planner's public API (
OptimizationPass,optimize,PlanningModels,ps/search/mod.rs) and orchestration (pipeline.rs); see asap-planner facade review: orchestration and deployment input assembly #1017. [Refactor] One named type per stage boundary: PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates #1047 P7 / Reorganize by stage: one memo-based Pass 2, Stage 1 finishes the memo, deployment inputs split by stage, asap-planner as the only entry point #958: orchestration moves to the facade.
2. Naming and over-abstraction
- "price" names in Stage 3 (
price_nodeps/lib.rs:3717,PricedNode,NodePriceSourceinps/cost/measured.rs,supplier_price_options,direct_price_options) where the repo term is "cost". - "supplier" (
SupplierCandidates,ps/search/candidates/suppliers.rs) and "producer" (compatible_producers) name one concept;PhysicalCandidate.local_costis an objective weight;PlanSelectorvalidates and prices, the MILP selects ([Refactor] Repo-wide naming survey: overloaded and misleading names #948). -
pub use search as pass;(ps/lib.rs:297) gives one module two public paths. -
ps/lib.rsis 11,440 lines: about 5,190 production lines (settings, reports,PlanSelector, statistics bounds, accuracy checks and resizing, per-node pricing, latency, sketch byte sizes) and about 6,250 lines of tests from:5194. Split by concern.
3. Stale code
-
ps/lib.rs:30: "Dynamic-programming compression is outside the current implementation" (no DP code exists). - Whole-workload enumeration
select_planoveroptimize_physical_asap: no Stage 3 production caller; used byplanner_fuzz, integration tests and tests. [Refactor] One named type per stage boundary: PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates #1047: the nameselect_plangoes to the new Stage 3 entry overPhysicalASAPMemoCandidates; the whole-workload one is deleted. -
SearchAlgorithmwith one variant and its CLI flag; the viewer still maps oldmemo-milp/memo-brute-forcenames ([Refactor] Repo-wide naming survey: overloaded and misleading names #948). [Refactor] One named type per stage boundary: PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates #1047 P2 deletes it andstage_pipeline --search.
4. "memo" / "cache"
-
CompatibilityMemo(ps/search/candidates/factoring.rs:26) is a cache; "memo" in the viewer means the joint search ([Refactor] Repo-wide naming survey: overloaded and misleading names #948). [Refactor] Rename memo-named caches and the memo types by stage #1050:MemoGroupOutputToCompatibleProducersCache.
5. Rules over memo groups
- Stage 3 should take the memo as given: groups, candidates, and the choices several groups share (state groups today, coupling sites in the doc). List which inputs the MILP reads that are not in the memo (pane-width rounds, accuracy configurations). Decided in [Refactor] One named type per stage boundary: PerComputationLogicalMemoCandidates → LogicalASAPMemoCandidates → PhysicalASAPMemoCandidates #1047 (table above).
Cost and accuracy constraints
- Cost model correctness: top-k heap memory (Plan selection underestimates top-k heap memory by about 9× #946), summary state memory vs the executor (Plan selection and the executor disagree on summary state memory #947), sketch memory by capacity vs samples held ([Bug] Stage 3 sizes a sketch's memory by its full capacity, not by the samples it holds #726), KMV update cost (KMV update cost is not one operation per row #945), relabeled group keys (A relabeled or renamed group key is priced at its old label's declared count #898), kept-across-evaluations results almost never chosen (Stage 3 almost never selects query-time results kept across evaluations (Example 4 B3 costs 8,884.5/s vs 2.1/s) #754).
- Cost model scope: memory/disk/latency trade-offs (Stage 3: memory, disk and latency trade-offs per candidate, and cost models beyond cloud billing #660), storage I/O and hand-offs (Stage 3: evidence-driven cost (storage I/O, hand-off bytes) #615, Plan selection: price storage I/O, handoffs and cache, from deployment evidence with provenance #733), calibration (A partial measured cost table can still mis-rank plans: calibrate per operator kind #791, Plan selection: warn when the measured cost table is missing or has no Scan, TimeRange or Aggregate entry #843), validation against benchmarks (cost model: validate analytical estimates against empirical benchmark evidence #347, Feed empirical sketch error, resource usage, and data distributions into planner estimates #322).
Efficiency
- Plan every whole evaluation workload in minutes (Tracking: plan every whole evaluation workload within 3 minutes (exact pruning, memo-group search) #879, memo-milp: certify whole evaluation workloads within 300 s #927); scaling (Scaling families: planning time, rounds and gap as one sharing structure grows #918); compile check quadratic in panes ([Performance] Stage 3's compile check is quadratic in panes since #988 (scan_window, time_range_window) #993); long-offset planning time ([Performance] A 30-day offset trend exceeds 90s planning at a 30s refresh cadence #896).
Review of the group MILP (#1095, #1144), 2026-11-02
The checklist above reviews today's engine. #1144 replaces it with #1095's group MILP. That MILP becomes the default search, and cost models 2 and 3 return UnsupportedCostModel. This section reviews the group MILP against the same goals.
- Code reviewed: feat(plan-selection): group selection MILP over execution contexts as Stage 3's default search (#1095) #1144 rebased onto main, commit
582ae71f8. - Paths: relative to
crates/plan-selection/src/. - Method: read-only. Items marked unverified come from reading the code.
- Worked example: in the comment below, together with the debug-printer format.
The pipeline: select_plan_with_milp (select.rs) → prepare_milp_inputs_with (groups.rs) → build_selection_milp (milp/model.rs, rows 1–9 and cost_terms::encode_objective) → solve_selection_milp (warm start, Completion, one HiGHS solve per independent part) → decode → validate → realize → plan_cost == objective, accuracy and latency checks.
1. Code-level abstraction
-
H1, correctness:
note_inexactmixes three kinds of note, and every one of them disables the cost-equality gate.SelectionMilp::inexactcounts:- cost terms encoded approximately (scan read at more than 4 window kinds, pane retention, schedules);
- feasibility restrictions that forbid plans but are exact for cost ("(forbidden)", e.g.
AllowedOutputsover part of a query); - terms set to zero (compaction).
select.rsturnsobjective ≠ plan_costintoObjectiveDiffersFromPlanCostonly when that list is empty. So one "forbidden" note anywhere in the model turns a real cost bug into a reportedInexactObjective. The notes are not tied to a run either.- Fix: typed
InexactCost { run, reason }andLostPlans { reason }; only the first excuses a mismatch, and only by its term.
-
H2: one rule written four times. The rule maps a read of candidate
cin contextctxto its run: computed inctx, the shareable's own context, or the reading context'sI_r. Copies:decode::run_of,cost_terms::producer_run,model::node_read,warm_start::runs_reached. There are also two representations of a run.- Fix: one
RunIdand oneMilpInputs::run_read(ctx, c).
- Fix: one
-
H3: a redundant filter. The "producer not in the reader's own slot" filter is re-applied in six places (
model.rs×3,cost_terms.rs,decode.rs,warm_start.rs).CandidateLinks::newalready removes those links. Keep it ingroups.rsonly. -
H4: ownership.
groups.rsimportsmilp::cost_terms::window_union, and binds a second function to the same local namewindow_union(scan_window_read_at as window_union). These are two different rules under one name.request_msis computed in three places.- The per-query evaluation rate is computed twice, and
warm_start.rssilently maps errors to1.0. - The debug logger lives in
warm_start.rs.
-
H5:
Completionhas implicit contracts. It depends on hard-coded row-family strings (CHECK_ONLY_FAMILIES) and on "the defining variable is the one created last". Renaming a family silently changes the warm start's cost and the incumbent's recomputed objective.- Fix: record
RowRole::{Defines(var), CheckOnly}when a row is created.
- Fix: record
-
M1: large functions:
candidate_data(~490 lines);solve_selection_milp(~250);encode_objective(~200);window_terms(~195);reader_constraint_rows(~175);structural_rows(~170);execution_contexts(~160).
-
M2: test code in production. The test hook
tests_loose_big_m()changes production big-M values. Delete it; its comparison was measured in feat(plan-selection): selection MILP solved per independent part, LP gap diagnosis, tighter big-M bounds (#1095 §5 3a, §6.1) #1155. -
M3: dead family. Compaction is dead:
compaction_msalways returns 0, soK[q]is never created. Encode it or delete it. -
M4: stale docs.
groups.rs's module doc still describes the owner model (OwnerRegion, "Nothing calls it yet").- The doc of
runs_at_query_timedescribes another function. crates/types/src/stages/physical/graph.rsstill mentions "two shared instances", which O5 removed.
-
M6: names.
classholds a candidate id, a leftover of the deleted class layer.choice,selectedandreachhold thep,xandavariables, andselectedis also a set of candidates in the solution.- Latency values carry no unit (
latency_max,most,slack,units). CandidateData::usageis "the usage in the first costed context".
-
M7: tolerances. Inline constants include the latency scale
1_000.0, the objective scales1_000.0/1e7, and five different tolerances (1e-12,1e-9,1e-7,1e-6); [Feature] Stage 3: no execution-scope copies, exact candidate-level MILP, one cost formula #1095 §9 asks for one tolerance. The localMAX_SHIFTSand the warm-start budget "deadline / 5" are also inline. -
M8: duplicated derivations.
- "The output references of query q" is recomputed about 11 times; add
MilpInputs::references_of_query. select.rsclones three solution fields intoChosenCandidates.
- "The output references of query q" is recomputed about 11 times; add
-
M9: implicit precedence.
run_termschecks window, then producers, then plain + retention. A scan whose usage depends on both window and producer cannot be represented, and this is not flagged (unverified whether Stage 2 produces such a scan).
2. Variables and connections over memo groups
-
No variable or row is keyed by
MemoGroupId.pis(context, candidate); the group is implied byslot[c].- A context's options are a set of candidates, not a set of groups.
- Row 2 runs per
ResultSlot = Group(g, self_read_depth) | PrivateNode(operator node). - The
e,y,t_*andσ/δvariables are per candidate.
This matches [Feature] Stage 3: no execution-scope copies, exact candidate-level MILP, one cost formula #1095 wherever slot == group. It diverges in three places:
-
Private-node slots: principled, but in the wrong stage. A private node is an anonymous result. Stage 2 should give each operator node a result id: the group for a root, a fresh id for a private node. Then Stage 3 needs no
ResultSlot. -
Multi-slot inputs (
ebinaries): a workaround for Stage 2 factoring across implementations whose inputs differ by private node. The special case reaches rows 3–4,producer_terms, decode, the warm start andslot_reads. The upstream fix is one reader candidate per implementation, or a group for the shared sub-result.- Probable gap (unverified): slot links exist only for readers computed in a context. A shareable candidate's own multi-slot input is justified in row 4 by
x[own]for every producer in every slot, so the phantom-reader case (feat(plan-selection): group selection MILP over execution contexts as Stage 3's default search (#1095) #1144 design point 2) can come back there. It would be caught byvalidateas an error, not silently. - Test T2c below.
- Probable gap (unverified): slot links exist only for readers computed in a context. A shareable candidate's own multi-slot input is justified in row 4 by
-
Widened-scan self-reads (
self_read_depth, two rank families): a workaround.- Root cause: the widened scan's
Projectgives a different result (a different window) but sits in the scan's own memo group, which breaks acyclicity ([Feature] Stage 3: no execution-scope copies, exact candidate-level MILP, one cost formula #1095 O7). - Cost of the workaround: depth is capped at 1, which loses
Project∘Projectplans. The rank rows are big-M rows, which weaken the LP. - Fix in Stage 1: give it its own group. Then delete
self_read_depthand both rank families, and assert acyclicity inprepare_milp_inputs.
- Root cause: the widened scan's
-
Producer-dependent outputs (
ProducerChoice,t_outputs/t_producers):- Principled for scans: rows follow the window.
- Elsewhere: if O2 holds for every non-scan group, the producer table can shrink to scans plus summary configurations, and
producer_usage_ambiguousgoes away.
3. Tests for MILP correctness
What exists: about 19 tests in milp/model/tests.rs, plus groups/tests.rs, select/tests.rs and stage_pipeline tests. They check consistency: decode, validate, realize, and objective == plan_cost of that same plan. None checks optimality. The --exhaustive comparisons in the #1144 body were manual. Planned tests:
- T1, differential test against brute force on random small memos.
- Generator: 3–8 operator nodes, 2–4 groups, 1–3 queries, using the
groups/tests.rsbuilders. - Reference: enumerate every valid
DagOfSelectedCandidatesand cost each withplan_cost. Requires a tiny test realizer. - Assert: MILP ≤ the reference minimum, and equal unless the reference optimum is a plan O4/O5 deliberately excludes; tag such runs with the excluded class.
- Also:
--exhaustiveon example1, ratio-of-quantiles and rangeheavy-t5-q05-q09, in CI.
- Generator: 3–8 operator nodes, 2–4 groups, 1–3 queries, using the
- T2, row-level tests on a hand-built catalog. Fix binaries and check feasibility with
Completion, no solver needed:- (a) row 2;
- (b) row 3 boundary;
- (c) row 4 phantom on a shareable's own multi-slot input;
- (d) row 5;
- (e) row 6 from above;
- (f) each
ReaderConstraintvariant, includingforbid_reachandHighestSharedQueryMaterialization.
- T3, decode round trip. Selection → decode → validate → realize;
plan_cost== completion objective; re-encoding gives the same binaries. Negative cases: an unserved output, an input with no producer. - T4:
Completion::completeequals the HiGHS LP with the binaries fixed. - T5, infeasibility mapping. Contradictory
MinimumQueryReaders, outputs whose candidates are all pruned, and an exact target with only approximate candidates each map toNoValidCandidate/Infeasible. - T6, the equality gate (H1). A "forbidden" note plus a wrong coefficient must still give
ObjectiveDiffersFromPlanCost. - T7, metamorphic properties. Adding a dominated candidate keeps the cost; permuting queries keeps the cost; parts on and off give the same cost; tightening the latency bound to the optimum's latency keeps the plan.
- T8: A1 ⊆
satisfieson the memo examples ([Feature] Stage 3: no execution-scope copies, exact candidate-level MILP, one cost formula #1095 §4 step 5).
4. Printing and debugging
What exists today:
ASAP_STAGE3_REPORT: one JSON line of sizes per run, written after the solve, so nothing for an infeasible model.ASAP_STAGE3_DEBUG: free-text stderr.- Readable
p/xlabels: only behind thelp-diagnosisfeature. - Rows: identified by index and family only. Variables: unnamed, so there is no readable
.lpexport.
To add:
-
ASAP_STAGE3_DUMP=<path>. One text file per run, in pipeline order: memo (group → slot → candidates) → contexts → variables → labelled rows → objective terms → solution with inexact notes per run → decoded DAG withplan_costper node beside its objective share. Sample format in the comment below.- Labels move from
lp_diagnosis.rsintoDisplay for ContextKind. - A
RowLabelis kept beside each row.
- Labels move from
- On
Infeasible: print the labelled rows of an IIS, or drop row families one at a time aslp_diagnosisdoes. - On
ObjectiveDiffersFromPlanCost: print the per-run objective share next toplan_cost.per_node.
5. Data structures
- One selection type. The selection has three encodings:
SelectionMilpSolution { chosen, selected, slot_reads },decode::ChosenCandidates, andwarm_start::Plan. Use oneSelection { per_context: Vec<BTreeMap<ResultSlot, PhysicalCandidateId>>, selected, slot_reads }; keying by slot also makes row 2 a type invariant. -
CandidateDataas an enum. It has 12 fields that are mostly mutually exclusive variants, checked in an implicit order inrun_terms. Useenum UsageVariation { Fixed, PerProducer, PerScanWindow, PerRetainedPanes }plusapproximateandparts. - Split
SelectionMilp. It holds variables, topology, numerics, output and build caches. Split intoVars { p, x, a, e, L },Topology(derivable fromMilpInputs) andTermCache. - Smaller items:
ExecutionContext::optionsshould be grouped by slot, which row 2, the warm start and the printer all rebuild today.- Four structs (
IndependentPart,PartModel,SolvedPart,PartSolve) describe one concept. InexactObjective.inexact_termsshould have typed reasons, not strings.DominatedCandidate.reasonis the same text for every entry.
6. Inputs, constraints, objective and output, with an example. See the comment below. It instantiates every variable, each row family (1–9) and the cost terms on the hand-built catalog shared_middle (groups/tests.rs), extended by one recompute candidate. It then shows the decoded DagOfSelectedCandidates, and maps #1095's q1–q4 example onto the code's structures.
- Add that example as a test (
shared_middleplus thec5candidate). It is not in the suite today.
Input/output check with the DAG viewer
- Input: the expanded memo of a workload. Output: the selected candidate per group, the search report (status, rounds, evaluations, bound), and rejected candidates with reasons.
- Check: on small workloads, the selected plan's cost equals the cheapest enumerated candidate DAG (stage_pipeline: price every enumerated candidate DAG and flag any cheaper than the selected plan #1004); on the paper workloads, the status says whether the result is proven optimal.
Related issues
Sub-issues: #888, #929, #928, #927, #879, #964, #963, #994, #993, #949, #673, #946, #947, #726, #945, #898, #754, #660, #615, #733, #791, #843, #896, #918, #349, #347, #322, #250, #436, #454.
Referenced: #1002, #1004 (enumeration and pricing every candidate DAG, under #1008), #917 and #919 (planner_fuzz tooling), #958 (layout), #948 (naming).
🤖 Generated with Claude Code
Activity
9 remaining items
zzylol commentedon Oct 29, 2026
Found while reviewing #1047's Stage 3 inputs: two paths decide how accuracy is judged.
PlanningModels.accuracy(theAccuracyModel) does it, andPlanSelectionSettings::ignore_accuracy_guaranteesoverrides that model. For review here: should the override instead be anAccuracyModelthe deployment passes in, so that Stage 3 has one accuracy input (PlanSelectionModels.accuracy)?zzylol commentedon Nov 2, 2026
Reference for the group MILP review section above (#1095 / #1144 rebased onto main, commit
582ae71f8). Paths are relative tocrates/plan-selection/src/. The example was derived by hand from the code and a test catalog; it was not run.Proposed
ASAP_STAGE3_DUMPformatFormat on the extended
shared_middleexample of §6. The shape is exact; costs are symbolic, since no numbers were run.Worked example: inputs, variables, constraints, objective and output
6.1 Source of the example
The fully instantiated example is the hand-built catalog
shared_middle()(groups/tests.rs:481–503, tested byexecution_contexts_on_a_hand_built_catalog,:509–543), extended by one candidate. The extension is aNotMaterializedquery-time candidatec5of the middle operator node. It is the "recompute instead of materialize" alternative that Stage 2 emits for every operator node (one candidate per (operator node, timing, materialization),memo_candidates.rs:62–66). For the choice to reach the tops, their producer lists become{c1, c5}.The extension is not in the test suite. The variables and rows below were derived by reading
choice_variables,structural_rows,reach_rows,latency_rowsandencode_objective, not by running them.The unextended catalog's contexts are asserted by the test:
The workload is assumed, because the test passes
entries = &[]: three queries, each repeating every 60 s with no phase, exact targets, and nomax_latency_ms.6.2 Input
Memo groups and Stage 2 candidates. Node
iis in groupiand every node is an implementation root (cataloghelper,groups/tests.rs:51–79).RunKind(groups.rs:359)ResultSlotproducer_candidates_per_inputValues(1 row)Dedup(c)Dedup(c)Dedup(c)Dedup(c)Values(1 row)Query roots: q0 → G2, q1 → G3, q2 → G4. Output references r0, r1, r2.
output_candidates = [[c2], [c3], [c4]]. No reader constraints.Dominance (
groups.rs:2121): c1 and c5 differ in materialization, so they fall into different buckets, and nothing is pruned.Contexts (
execution_contexts,groups.rs:862). Expanding from the outputs through computed candidates gives:ContextKindat_query_timecontext_of_shareable = {c1: 3}. There is noI_r, because there is no ingestion-time shareable candidate.Per-candidate numbers come from
asap_cost::node_usage(groups.rs:1654–1666), shown symbolically:e(c)=cost_per_execution;s(c)=standing_cost_per_second;d(c)=own_latency_ms(=work_ms_per_executionat query time);h(c)=held_bytes.Rates (
context_rates,cost_terms.rs:834):rate(q) = 1/60per second for each query context.MatAtQuery): its readers are q0 and q1 with equal recurrence. Assumingschedule_groupsputs them in one group 𝒢 = {q0, q1} with rater(𝒢), the single level givesContextRate::Fixed(r(𝒢)), so there is noz. With different intervals there would be continuousz[c1, 𝒢, ℓ]instead (cost_terms.rs:910–926).6.3 Decision variables (as created,
model.rs:527–634,:1061–1092,:1486–1582)p (choices)binary,p[ctx,c]e (slot an input reads)x (shareable candidates)binarya (reach)continuous [0,1]may_reach[c1] = {q0, q1})L (latency of m)d(c0)+d(c1) > 0T (latency of m in q)L (latency of q)H_query,H,Wh(c) > 0(below)v,σ,δ,π,t_*,y,g,N,A,K, ranks6.4 Constraint families: formula, then instance
Notation:
u=ms_per_latency_unit= (request ms + Σ_c d(c)) / 1000 (model.rs:1492–1501), andR= request ms (ns_per_evaluation·1e-6).Row 1, coverage (
model.rs:651–658). Formula:Σ_{c∈Out(r)} p[q(r), c] = 1.Row 2, one candidate per slot per context (
:662–674). Formula:Σ_{c: slot(c)=s} p[ctx,c] ≤ 1, written only for slots with ≥ 2 options.Row 3, validity (
:676–728).Σ_{c'∈compat(c,n), slot(c')≠slot(c)} p[ctx,c'] ≥ p[ctx,c]p[ctx,m] ≤ x[m]Σ_{c'∈compat(m,n)} p[ctx(m),c'] ≥ x[m]Row 4, used only if read (
:753–787). Formula:p[ctx,c] ≤ Σ_{Reader::Candidate(r,n)} p[ctx,r] + Σ_{Output} 1 + Σ_{OwnInput} x[m] (+ reads from contexts of r(m)=r).Row 5, sharing (
:790–804). Formula:x[m] ≤ Σ_ctx p[ctx,m].Ranks (
:811–943): none, since the graph has no cycle.Row 6, reach (
:1061–1149).a[q,m] ≥ p[q,m]anda[q,c] ≥ a[q,m] + p[m,c] − 1.shareables_needing_exact_reach,:1171). None here.Row 9, reader constraints (
:1228–1401): none in this catalog. WithMinimumQueryReaders{c1, minimum 2}it would bea[0,c1] + a[1,c1] ≥ 2·x[c1](:1269–1276), and from-above reach rows for c1 would be added.Row 7, latency (
:1486–1582). All latency quantities are in units ofums.L[m] ≥ Σ_{runs in ctx(m)} d/u · indicator.T[q,m] ≥ L[m] − L̄[m]·(1 − a[q,m]), with L̄[c1] = (d(c0) + d(c1))/u.k·L[q] ≥ k·(Σ_{runs in q} d/u·p + R/u + Σ_m T[q,m] + K[q]), withk = 1unless the bound binds (:1566–1574).latency_bound_ms= min(max_latency_ms, interval) = 60 000 ms. Its rowL[q] ≤ 60 000/uis written only if 60 000/u is below the most L[q] can be (.filter(|&bound| bound < most),:1565). With 1-rowValues/Dedupit is almost surely not binding, so no row is written (unverified, depends on numbers).Row 8, accuracy (
:1588–1702): no approximate candidate, so no rows.Cost terms (
cost_terms.rs:963–1164).runs()= (0,c0), (0,c2), (0,c5), (1,c0), (1,c3), (1,c5), (2,c4), (3,c0), and c1's own run (3,c1). The boundary read p[0,c1] is not a run. Each run has one plainRunTerm(cost_terms.rs:185–190), because its usage does not depend on producers, windows or panes. Charging (:1007–1026):standing·indicator + execution_cost·rate(ctx)·indicator:Held memory, written only where
h(c) > 0at query time (:1028–1102). Hereμ=cost_per_memory_byte_secondandper_byte_ms = μ·h(c)/1000·u.H_query ≥ L[q] − L̄[q]·(1 − indicator), chargedper_byte_ms·rate(q).H[c1] ≥ L[q] − L̄[q]·(1 − a[q,c1])for q ∈ {0,1}, charged directly onH[c1]atper_byte_ms(c1)·r(𝒢). Hereslack = 0(:1087–1090).W ≥ H[c1] − H̄·(1 − p[3,c0]), chargedper_byte_ms(c0)·r(𝒢).Raw retention (
:1168–1215): no scan, so nov.Request fees (
:972–979) are constant: Σ_qcost_per_cpu_op·R/ms_per_cpu_op·(1/60). They are kept out of the objective (request_cost_per_second) and subtracted fromplan_costbefore comparing (select.rs:148).6.5 Objective
min Σ objective_terms(model.rs:2129–2132, scaled byobjective_scalefor HiGHS). Ignoring held memory, the two candidate plans cost:C_share = s(c1) + e(c1)r(𝒢) + s(c0) + e(c0)r(𝒢) + Σ_{c∈{c2,c3,c4}} (s(c) + e(c)/60)C_rec = 2·(s(c5) + e(c5)/60 + s(c0) + e(c0)/60) + Σ_{c∈{c2,c3,c4}} (s(c) + e(c)/60)Mixed plans (q0 shares, q1 recomputes) are also feasible: x[c1] = 1 with only p[0,c1] = 1, and r(𝒢) is then still the rate fixed for 𝒢 = {q0, q1}. This is a known simplification: a
Fixedcontext rate does not drop when only one of two possible readers reads it. Thezencoding applies only with several rate levels (by readingcost_terms.rs:904–909; whetherplan_costcharges the same in that case is not verified here). Row 2 forbids p[0,c1] = p[0,c5] = 1 in one context.6.6 Output
Assume the solver picks Share. Then:
chosen= [{c1, c2}, {c1, c3}, {c4}, {c0}];selected= {c1};slot_reads= {}.decode(decode.rs:54–161) runs depth first from the outputs (stack initialised fromoutputs.iter().rev(), so r0 is processed first):Result:
If Recompute is optimal:
chosen= [{c0, c2, c5}, {c0, c3, c5}, {c4}, {}] andselected= {}. Note that x[c1] = 0 forces p[3,c0] = 0 by row 4. The decoded DAG has two nodes of c5 and two of c0, one per query context. That is the "aNotMaterializedquery-time candidate selected in two contexts is two nodes" rule tested byruns_decode_one_node_per_context_and_one_per_shareable_candidate(model/tests.rs:889).select.rsthen::124);:127), which returnsRealizedPlan { plan, pane_retention_ms, operator_node_of_selected_node };plan_cost − fees == objective(:131–177),accuracy_check(:178) andlatency_violation(:185);Selection { selected_dag, cost, … }.6.7 The #1095 running example in code terms (mapping, not run)
#1095 Figure 1, as the code would represent it:
Query(0..3)for q1–q4.Ingestion(Window(10 s, phase))holding the ingestion scanS.Shared(m)for each pane chain candidate (KLL k=200, KLL k=400, DDSketch), each readingSacross the boundary withp[m,S] ≤ p[I_10s,S](row 3,model.rs:699–711).Shared(m_Σ)for theMatAtQuerysum. Its options holdavg, Sum build, Count build andTimeRange(Scan)as computed candidates. Row 2 on theTimeRange(Scan)slot gives the single scan run (Example 3.1).ResultSlot = Group(g,0), q1 and q2 each pick one in their own context, and both can be selected (twox).p[q4, m_Σ] ≤ x[m_Σ]. Rate:schedule_groups({q3, q4}).This mapping follows the issue text and the code paths named; it was not executed.
test: ignore the 7 tests the default group MILP does not pass yet (#1015