Skip to content

Stage 3 review: plan selection, the MILP over the memo, cost and accuracy constraints #1015

Open
4 of 32 issues completed

Description

@zzylol

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?

1. Boundaries

2. Naming and over-abstraction

  • "price" names in Stage 3 (price_node ps/lib.rs:3717, PricedNode, NodePriceSource in ps/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_cost is an objective weight; PlanSelector validates 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.rs is 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

4. "memo" / "cache"

5. Rules over memo groups

Cost and accuracy constraints

Efficiency

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.

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_inexact mixes three kinds of note, and every one of them disables the cost-equality gate. SelectionMilp::inexact counts:

    • 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. AllowedOutputs over part of a query);
    • terms set to zero (compaction).

    select.rs turns objective ≠ plan_cost into ObjectiveDiffersFromPlanCost only when that list is empty. So one "forbidden" note anywhere in the model turns a real cost bug into a reported InexactObjective. The notes are not tied to a run either.

    • Fix: typed InexactCost { run, reason } and LostPlans { 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 c in context ctx to its run: computed in ctx, the shareable's own context, or the reading context's I_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 RunId and one MilpInputs::run_read(ctx, c).
  • 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::new already removes those links. Keep it in groups.rs only.

  • H4: ownership.

    • groups.rs imports milp::cost_terms::window_union, and binds a second function to the same local name window_union (scan_window_read_at as window_union). These are two different rules under one name.
    • request_ms is computed in three places.
    • The per-query evaluation rate is computed twice, and warm_start.rs silently maps errors to 1.0.
    • The debug logger lives in warm_start.rs.
  • H5: Completion has 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.
  • 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_ms always returns 0, so K[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_time describes another function.
    • crates/types/src/stages/physical/graph.rs still mentions "two shared instances", which O5 removed.
  • M6: names.

    • class holds a candidate id, a leftover of the deleted class layer.
    • choice, selected and reach hold the p, x and a variables, and selected is also a set of candidates in the solution.
    • Latency values carry no unit (latency_max, most, slack, units).
    • CandidateData::usage is "the usage in the first costed context".
  • M7: tolerances. Inline constants include the latency scale 1_000.0, the objective scales 1_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 local MAX_SHIFTS and 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.rs clones three solution fields into ChosenCandidates.
  • M9: implicit precedence. run_terms checks 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.

    • p is (context, candidate); the group is implied by slot[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 (e binaries): 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 and slot_reads. The upstream fix is one reader candidate per implementation, or a group for the shared sub-result.

  • Widened-scan self-reads (self_read_depth, two rank families): a workaround.

    • Root cause: the widened scan's Project gives 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∘Project plans. The rank rows are big-M rows, which weaken the LP.
    • Fix in Stage 1: give it its own group. Then delete self_read_depth and both rank families, and assert acyclicity in prepare_milp_inputs.
  • 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_ambiguous goes 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.rs builders.
    • Reference: enumerate every valid DagOfSelectedCandidates and cost each with plan_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: --exhaustive on example1, ratio-of-quantiles and rangeheavy-t5-q05-q09, in CI.
  • 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 ReaderConstraint variant, including forbid_reach and HighestSharedQueryMaterialization.
  • 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::complete equals 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 to NoValidCandidate / 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 ⊆ satisfies on 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/x labels: only behind the lp-diagnosis feature.
  • Rows: identified by index and family only. Variables: unnamed, so there is no readable .lp export.

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 with plan_cost per node beside its objective share. Sample format in the comment below.
    • Labels move from lp_diagnosis.rs into Display for ContextKind.
    • A RowLabel is kept beside each row.
  • On Infeasible: print the labelled rows of an IIS, or drop row families one at a time as lp_diagnosis does.
  • On ObjectiveDiffersFromPlanCost: print the per-run objective share next to plan_cost.per_node.

5. Data structures

  • One selection type. The selection has three encodings: SelectionMilpSolution { chosen, selected, slot_reads }, decode::ChosenCandidates, and warm_start::Plan. Use one Selection { per_context: Vec<BTreeMap<ResultSlot, PhysicalCandidateId>>, selected, slot_reads }; keying by slot also makes row 2 a type invariant.
  • CandidateData as an enum. It has 12 fields that are mostly mutually exclusive variants, checked in an implicit order in run_terms. Use enum UsageVariation { Fixed, PerProducer, PerScanWindow, PerRetainedPanes } plus approximate and parts.
  • Split SelectionMilp. It holds variables, topology, numerics, output and build caches. Split into Vars { p, x, a, e, L }, Topology (derivable from MilpInputs) and TermCache.
  • Smaller items:
    • ExecutionContext::options should 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_terms should have typed reasons, not strings.
    • DominatedCandidate.reason is 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_middle plus the c5 candidate). It is not in the suite today.

Input/output check with the DAG viewer

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

  1. added
    cost-modelCost formulas, statistics, ranking, and selection
    optimizerWorkload optimization and plan selection
    searchCandidate-plan search space and algorithms
    on Oct 28, 2026
  2. 9 remaining items

  3. zzylol commented on Oct 29, 2026

    @zzylol
    ContributorAuthor

    Found while reviewing #1047's Stage 3 inputs: two paths decide how accuracy is judged. PlanningModels.accuracy (the AccuracyModel) does it, and PlanSelectionSettings::ignore_accuracy_guarantees overrides that model. For review here: should the override instead be an AccuracyModel the deployment passes in, so that Stage 3 has one accuracy input (PlanSelectionModels.accuracy)?

  4. zzylol commented on Nov 2, 2026

    @zzylol
    ContributorAuthor

    Reference for the group MILP review section above (#1095 / #1144 rebased onto main, commit 582ae71f8). Paths are relative to crates/plan-selection/src/. The example was derived by hand from the code and a test catalog; it was not run.

    Proposed ASAP_STAGE3_DUMP format

    Format on the extended shared_middle example of §6. The shape is exact; costs are symbolic, since no numbers were run.

    == memo ==
    G0  slot G0#0   c0  Values      QueryTime NotMaterialized  PerContext  inputs: -
    G1  slot G1#0   c1  Dedup       QueryTime MatAtQuery(Mem)  Shareable   in0: {c0}
                    c5  Dedup       QueryTime NotMaterialized  PerContext  in0: {c0}
    G2  slot G2#0   c2  Dedup       QueryTime NotMaterialized  PerContext  in0: {c1,c5}  out r0 (q0)
    G3  slot G3#0   c3  Dedup       QueryTime NotMaterialized  PerContext  in0: {c1,c5}  out r1 (q1)
    G4  slot G4#0   c4  Values      QueryTime NotMaterialized  PerContext  inputs: -     out r2 (q2)
    pruned: none
    
    == contexts ==
    #0 q0     rate 1/60 s   G0:{c0*}  G1:{c1→x[c1], c5*}  G2:{c2*}        (* computed here)
    #1 q1     rate 1/60 s   G0:{c0*}  G1:{c1→x[c1], c5*}  G3:{c3*}
    #2 q2     rate 1/60 s   G4:{c4*}
    #3 m=c1   rate r(G)     G0:{c0*}                                 reach: q0,q1
    
    == variables ==
    p   p[q0,c0] p[q0,c1] p[q0,c2] p[q0,c5] p[q1,c0] p[q1,c1] p[q1,c3] p[q1,c5] p[q2,c4] p[m1,c0]
    x   x[c1]
    a   a[q0,c1] a[q1,c1]                                 (continuous [0,1])
    L   L[m1] L[q0] L[q1] L[q2]   T[q0,c1] T[q1,c1]       (latency units, 1 unit = u ms)
    H   H_query[...] H[m1] W[m1,...]                      (only where held_bytes > 0)
    
    == rows ==
    [0]  1 coverage            r0: p[q0,c2] = 1
    [3]  2 one per slot        q0/G1: p[q0,c1] + p[q0,c5] <= 1
    [4]  3 validity (boundary) q0: p[q0,c1] <= x[c1]
    [5]  3 validity            q0: c2.in0  p[q0,c1] + p[q0,c5] >= p[q0,c2]
    ...
    [k]  5 sharing             x[c1] <= p[q0,c1] + p[q1,c1]
    
    == solution ==  status Optimal  objective (completed) = <C>  HiGHS = <C'>  dual = <D>
    x[c1]=1  p[q0,c1]=1 p[q0,c2]=1 p[q1,c1]=1 p[q1,c3]=1 p[q2,c4]=1 p[m1,c0]=1  a[q0,c1]=1 a[q1,c1]=1
    inexact: none
    
    == decoded DAG ==
    n0 = (q0, c2)   n1 = (shared, c1)   n2 = (m1, c0)   n3 = (q1, c3)   n4 = (q2, c4)
    edges: n1→n0.in0  n2→n1.in0  n1→n3.in0
    outputs: r0→n0  r1→n3  r2→n4
    cost per node: n1 objective s(c1)+r(G)e(c1)+held…  plan_cost …   Δ = 0
    

    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 by execution_contexts_on_a_hand_built_catalog, :509–543), extended by one candidate. The extension is a NotMaterialized query-time candidate c5 of 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_rows and encode_objective, not by running them.

    The unextended catalog's contexts are asserted by the test:

    [(Query(0), [middle, top_0]), (Query(1), [middle, top_1]), (Query(2), [lone]), (Shared(middle), [leaf])]
    

    The workload is assumed, because the test passes entries = &[]: three queries, each repeating every 60 s with no phase, exact targets, and no max_latency_ms.

    6.2 Input

    Memo groups and Stage 2 candidates. Node i is in group i and every node is an implementation root (catalog helper, groups/tests.rs:51–79).

    Candidate Operator node (group) Operator Timing / materialization RunKind (groups.rs:359) ResultSlot producer_candidates_per_input Output
    c0 0 (G0) "leaf" Values (1 row) QueryTime / NotMaterialized PerContext Group(G0,0) — —
    c1 1 (G1) "middle" Dedup(c) QueryTime / MatAtQuery(Memory) Shareable Group(G1,0) in0: {c0} —
    c5 (added) 1 (G1) "middle" Dedup(c) QueryTime / NotMaterialized PerContext Group(G1,0) in0: {c0} —
    c2 2 (G2) "top_0" Dedup(c) QueryTime / NotMaterialized PerContext Group(G2,0) in0: {c1, c5} (was {c1}) r0 (q0)
    c3 3 (G3) "top_1" Dedup(c) QueryTime / NotMaterialized PerContext Group(G3,0) in0: {c1, c5} (was {c1}) r1 (q1)
    c4 4 (G4) "lone" Values (1 row) QueryTime / NotMaterialized PerContext Group(G4,0) — r2 (q2)

    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:

    Index ContextKind at_query_time Options (computed in it = *)
    0 Query(0) true c0*, c1 (boundary read), c2*, c5*
    1 Query(1) true c0*, c1 (boundary read), c3*, c5*
    2 Query(2) true c4*
    3 Shared(c1) true (c1 is query time) c0*

    context_of_shareable = {c1: 3}. There is no I_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_execution at query time);
    • h(c) = held_bytes.

    Rates (context_rates, cost_terms.rs:834):

    • rate(q) = 1/60 per second for each query context.
    • Context 3 (MatAtQuery): its readers are q0 and q1 with equal recurrence. Assuming schedule_groups puts them in one group 𝒢 = {q0, q1} with rate r(𝒢), the single level gives ContextRate::Fixed(r(𝒢)), so there is no z. With different intervals there would be continuous z[c1, 𝒢, ℓ] instead (cost_terms.rs:910–926).

    6.3 Decision variables (as created, model.rs:527–634, :1061–1092, :1486–1582)

    Family (code label) Variables Count
    p (choices) binary, p[ctx,c] p[0,c0], p[0,c1], p[0,c2], p[0,c5]; p[1,c0], p[1,c1], p[1,c3], p[1,c5]; p[2,c4]; p[3,c0] 10
    e (slot an input reads) none: each input's producers lie in one slot (G1) 0
    x (shareable candidates) binary x[c1] 1
    a (reach) continuous [0,1] a[0,c1], a[1,c1] (may_reach[c1] = {q0, q1}) 2
    L (latency of m) L[c1], created iff d(c0)+d(c1) > 0 ≤1
    T (latency of m in q) T[0,c1], T[1,c1] 2
    L (latency of q) L[0], L[1], L[2] 3
    H_query, H, W only for runs with h(c) > 0 (below) —
    v, σ, δ, π, t_*, y, g, N, A, K, ranks none (no scan, no kept pane chain, no producer-dependent usage, no approximate candidate, no reader constraint, no cycle) 0

    6.4 Constraint families: formula, then instance

    Notation: u = ms_per_latency_unit = (request ms + Σ_c d(c)) / 1000 (model.rs:1492–1501), and R = request ms (ns_per_evaluation·1e-6).

    Row 1, coverage (model.rs:651–658). Formula: Σ_{c∈Out(r)} p[q(r), c] = 1.

    • p[0,c2] = 1
    • p[1,c3] = 1
    • p[2,c4] = 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.

    • ctx 0, slot G1: p[0,c1] + p[0,c5] ≤ 1
    • ctx 1, slot G1: p[1,c1] + p[1,c5] ≤ 1

    Row 3, validity (:676–728).

    • (computed c, input n): Σ_{c'∈compat(c,n), slot(c')≠slot(c)} p[ctx,c'] ≥ p[ctx,c]
      • c2.in0: p[0,c1] + p[0,c5] ≥ p[0,c2]
      • c5.in0: p[0,c0] ≥ p[0,c5]
      • c3.in0: p[1,c1] + p[1,c5] ≥ p[1,c3]
      • c5.in0: p[1,c0] ≥ p[1,c5]
    • (boundary read of shareable m): p[ctx,m] ≤ x[m]
      • p[0,c1] ≤ x[c1]
      • p[1,c1] ≤ x[c1]
    • (m's own input): Σ_{c'∈compat(m,n)} p[ctx(m),c'] ≥ x[m]
      • p[3,c0] ≥ x[c1]

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

    • ctx 0:
      • p[0,c0] ≤ p[0,c5]
      • p[0,c1] ≤ p[0,c2]
      • p[0,c5] ≤ p[0,c2]
      • p[0,c2] ≤ 1
    • ctx 1:
      • p[1,c0] ≤ p[1,c5]
      • p[1,c1] ≤ p[1,c3]
      • p[1,c5] ≤ p[1,c3]
      • p[1,c3] ≤ 1
    • ctx 2: p[2,c4] ≤ 1
    • ctx 3: p[3,c0] ≤ x[c1]

    Row 5, sharing (:790–804). Formula: x[m] ≤ Σ_ctx p[ctx,m].

    • x[c1] ≤ p[0,c1] + p[1,c1]

    Ranks (:811–943): none, since the graph has no cycle.

    Row 6, reach (:1061–1149).

    • From below: a[q,m] ≥ p[q,m] and a[q,c] ≥ a[q,m] + p[m,c] − 1.
      • a[0,c1] ≥ p[0,c1]
      • a[1,c1] ≥ p[1,c1]
    • From above: only for shareables named by a counting reader constraint (shareables_needing_exact_reach, :1171). None here.

    Row 9, reader constraints (:1228–1401): none in this catalog. With MinimumQueryReaders{c1, minimum 2} it would be a[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 of u ms.

    • Latency of m: L[m] ≥ Σ_{runs in ctx(m)} d/u · indicator.
      • L[c1] ≥ (d(c0)/u)·p[3,c0] + (d(c1)/u)·x[c1]
    • T through m: T[q,m] ≥ L[m] − L̄[m]·(1 − a[q,m]), with L̄[c1] = (d(c0) + d(c1))/u.
      • T[0,c1] ≥ L[c1] − L̄[c1]·(1 − a[0,c1])
      • T[1,c1] ≥ L[c1] − L̄[c1]·(1 − a[1,c1])
    • Latency of each query: k·L[q] ≥ k·(Σ_{runs in q} d/u·p + R/u + Σ_m T[q,m] + K[q]), with k = 1 unless the bound binds (:1566–1574).
      • k·L[0] ≥ k·( (d(c0)p[0,c0] + d(c2)p[0,c2] + d(c5)p[0,c5])/u + R/u + T[0,c1] )
      • k·L[1] ≥ k·( (d(c0)p[1,c0] + d(c3)p[1,c3] + d(c5)p[1,c5])/u + R/u + T[1,c1] )
      • L[2] ≥ d(c4)/u·p[2,c4] + R/u
    • Bound: latency_bound_ms = min(max_latency_ms, interval) = 60 000 ms. Its row L[q] ≤ 60 000/u is written only if 60 000/u is below the most L[q] can be (.filter(|&bound| bound < most), :1565). With 1-row Values/Dedup it 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 plain RunTerm (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:

    Run Indicator Coefficient
    (0,c0), (0,c2), (0,c5) p[0,·] s(c) + e(c)/60
    (1,c0), (1,c3), (1,c5) p[1,·] s(c) + e(c)/60
    (2,c4) p[2,c4] s(c4) + e(c4)/60
    (3,c0) p[3,c0] s(c0) + e(c0)·r(𝒢)
    (3,c1) own x[c1] s(c1) + e(c1)·r(𝒢)

    Held memory, written only where h(c) > 0 at query time (:1028–1102). Here μ = cost_per_memory_byte_second and per_byte_ms = μ·h(c)/1000·u.

    • A run in a query context: H_query ≥ L[q] − L̄[q]·(1 − indicator), charged per_byte_ms·rate(q).
    • c1's result (own, plain, Fixed rate): H[c1] ≥ L[q] − L̄[q]·(1 − a[q,c1]) for q ∈ {0,1}, charged directly on H[c1] at per_byte_ms(c1)·r(𝒢). Here slack = 0 (:1087–1090).
    • c0 in context 3: W ≥ H[c1] − H̄·(1 − p[3,c0]), charged per_byte_ms(c0)·r(𝒢).

    Raw retention (:1168–1215): no scan, so no v.

    Request fees (:972–979) are constant: Σ_q cost_per_cpu_op·R/ms_per_cpu_op·(1/60). They are kept out of the objective (request_cost_per_second) and subtracted from plan_cost before comparing (select.rs:148).

    6.5 Objective

    min Σ objective_terms (model.rs:2129–2132, scaled by objective_scale for HiGHS). Ignoring held memory, the two candidate plans cost:

    • Share (x[c1] = 1, p[0,c1] = p[1,c1] = 1, p[3,c0] = 1, tops and lone):
      C_share = s(c1) + e(c1)r(𝒢) + s(c0) + e(c0)r(𝒢) + Σ_{c∈{c2,c3,c4}} (s(c) + e(c)/60)
    • Recompute (x[c1] = 0; p[q,c5] = p[q,c0] = 1 for q ∈ {0,1}):
      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 Fixed context rate does not drop when only one of two possible readers reads it. The z encoding applies only with several rate levels (by reading cost_terms.rs:904–909; whether plan_cost charges 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 from outputs.iter().rev(), so r0 is processed first):

    Step Pop New node Its reads
    1 Computed(0,c2) n0 = c2 in0 → run_of(0,c1) = Shared(c1)
    2 Shared(c1) n1 = c1 in0 in ctx 3 → Computed(3,c0)
    3 Computed(3,c0) n2 = c0 —
    4 Computed(1,c3) n3 = c3 in0 → Shared(c1) (exists)
    5 Computed(2,c4) n4 = c4 —

    Result:

    DagOfSelectedCandidates {
      candidate_of_node: [c2, c1, c0, c3, c4],
      edges: [ {producer: n1, consumer: n0, input: 0},
               {producer: n2, consumer: n1, input: 0},
               {producer: n1, consumer: n3, input: 0} ],     // sorted by (consumer, input)
      node_of_output_reference: [n0, n3, n4],
    }
    

    If Recompute is optimal: chosen = [{c0, c2, c5}, {c0, c3, c5}, {c4}, {}] and selected = {}. 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 "a NotMaterialized query-time candidate selected in two contexts is two nodes" rule tested by runs_decode_one_node_per_context_and_one_per_shareable_candidate (model/tests.rs:889).

    select.rs then:

    • validates the DAG (:124);
    • realizes it once (:127), which returns RealizedPlan { plan, pane_retention_ms, operator_node_of_selected_node };
    • checks plan_cost − fees == objective (:131–177), accuracy_check (:178) and latency_violation (:185);
    • returns 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:

    • Contexts: Query(0..3) for q1–q4.
    • One Ingestion(Window(10 s, phase)) holding the ingestion scan S.
    • Shared(m) for each pane chain candidate (KLL k=200, KLL k=400, DDSketch), each reading S across the boundary with p[m,S] ≤ p[I_10s,S] (row 3, model.rs:699–711).
    • Shared(m_Σ) for the MatAtQuery sum. Its options hold avg, Sum build, Count build and TimeRange(Scan) as computed candidates. Row 2 on the TimeRange(Scan) slot gives the single scan run (Example 3.1).
    • K = {KLL k=200, k=400} are two candidates of one group. Since ResultSlot = Group(g,0), q1 and q2 each pick one in their own context, and both can be selected (two x).
    • The q4 roll-up reads m_Σ via 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    cost-modelCost formulas, statistics, ranking, and selectionoptimizerWorkload optimization and plan selectionsearchCandidate-plan search space and algorithms

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions