Skip to content

Memo MILP search: clusters, solution decoding, warm start, accuracy rules and term cuts (opt-in --search memo-milp) - #930

Merged
zzylol merged 122 commits into
mainfrom
feat/memo-pr-b
Oct 23, 2026
Merged

zzylol merged 122 commits into
mainfrom
feat/memo-pr-b

Conversation

@zzylol

@zzylol zzylol commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Part of #879

Why

--search memo-milp (experimental, #915) searches the sub-expression memo with a MILP lower bound and a lazy loop, but on main it could not plan whole evaluation workloads: every round enumerated every plan of a group choice in Stage 3 (q24h60 checked 1.31M candidates; RangeHeavy grew past 59 GB), coefficient derivation spent ~10 min in the rollup rules on RollupHeavy, a time-limited run with no evaluated round returned no plan (RollupHeavy t11-5m and t10: "time limit ... before finding a valid plan" after 1800 s), and the bound was weak enough that small rows took hundreds of rounds (q24h60 689 s, rh8-q1h60 772 s, i840 not certified after 1800 s). #879 needs each whole workload to return a plan with an honest gap within 300 s, and the components to certify.

What

--search memo-milp (still opt-in; the default search is unchanged and still legacy) now:

  • splits the memo into independent clusters (queries sharing no group, coupling site, candidate read or disk-charged raw source) and runs one model and lazy loop per cluster, solves in parallel; under cost model 1 the workload cost is the sum, each cluster reported in search.milp.clusters;
  • decodes each MILP solution into one physical plan and evaluates it in Stage 3 once per round (solution decoding + incumbent evaluation), instead of enumerating every plan of the group choice;
  • starts each cluster from a warm-start incumbent (every query planned alone by the legacy search), so a time-limited run always returns a valid plan and its gap;
  • keeps the solver's proven bound when a solve stops at the time limit, so a stopped run reports its gap without an unlimited relaxation solve;
  • encodes which child choices keep each choice's accuracy guarantee (pair and failure-probability rules, read at the largest size Stage 3 can give) as constraints, instead of cutting them one at a time;
  • charges more of each plan's real cost in the model so the bound is tighter, and still a lower bound: scans read by built-once nodes or slots at the union rate, per-query latency per node key, held-memory shares over a node's readers, ingestion-time parts at their ingestion price, reach as flows, an incumbent cutoff within the solver's tolerance;
  • adds term cuts after each round for the decoded plan's underestimated terms (a source's scans, a query's held memory, a built-once group's ingestion copy), each valid for every plan sharing that term; the no-good cut stays as the fallback;
  • computes the memo's rollup and pane-chain rules per coarse build, cached, instead of for every subset (RollupHeavy coefficient derivation 662 s -> 15 s).

The selection report gains search.milp.decode_failures and search.milp.clusters.

How

search::milp::model::search builds the memo, clusters::split partitions its queries by union-find, and search_clusters runs one ClusterLoop per cluster (new -> offer_independent_plan warm start -> next_round / take_solution -> finish). run_rounds solves the clusters' models on scoped threads; incumbent evaluation stays on the main thread. take_solution calls decode::decode, which composes the chosen choice versions into one CandidateLogicalASAPDAG (identical non-scan nodes one node via with_shared_candidates, plus the widened-scans variant), materializes it with optimize_physical_asap_chosen from the solution's built-once and slot choices, and offers each variant to the Stage 3 selector. The loop then adds cuts::term_cuts_of / held_cuts_of / ingestion_copy_cuts_of and the no-good cut of the decoded choice set. clusters::combine puts the clusters' plans back in workload order and checks that the whole-workload cost equals the sum (else the memo is searched as one).

Key code interfaces added / modified

crates/plan-selection/src/lib.rs

/// The MILP search's report (`search.milp`). The type stays.
pub struct MilpReport {
    /// Last MILP lower bound. // unchanged
    pub lower_bound: Option<f64>,
    /// First round's lower bound. // unchanged
    pub first_lower_bound: Option<f64>,
    /// (incumbent − lower bound) / incumbent, at least 0. // unchanged
    pub gap: Option<f64>,
    /// Lazy-loop rounds (summed over clusters). // unchanged
    pub rounds: u32,
    /// Model variables (summed over clusters). // unchanged
    pub variable_count: u64,
    /// Model constraints (summed over clusters). // unchanged
    pub constraint_count: u64,
    /// Wall time of the solves in ms. // unchanged
    pub solver_time_ms: u64,
    /// Whether the search stopped at its time limit. // unchanged
    pub time_limit_reached: bool,
    /// Solutions decoding could not compose into a plan; each keeps its
    /// objective as a lower bound so the gap stays honest. Default 0. // new
    pub decode_failures: u32,
    /// Each independent cluster's search, ordered by first query; empty
    /// (not serialized) when the memo was searched as one. // new
    pub clusters: Vec<MilpClusterReport>,
}

/// One independent cluster of the MILP search. New type.
pub struct MilpClusterReport {
    /// Its queries, by workload index. // new
    pub queries: Vec<usize>,
    /// The memo groups of its own workload. // new
    pub groups: usize,
    /// Its lazy loop's rounds. // new
    pub rounds: u32,
    /// Its gap, at least 0. // new
    pub gap: Option<f64>,
    /// Its MILP lower bound at the end. // new
    pub lower_bound: Option<f64>,
    /// Its selected plan's cost per second. // new
    pub incumbent_cost: Option<f64>,
    /// Wall ms its solves took, together. // new
    pub solver_time_ms: u64,
    /// Whether it stopped at the time limit. // new
    pub time_limit_reached: bool,
    /// Solutions decoding could not compose into a plan. // new
    pub decode_failures: u32,
}

crates/physical-asap-optimization/src/lib.rs

/// The caller's materialization per candidate: (logical node it implements,
/// its reader queries, its options) -> the chosen option. // new
pub type ChooseMaterialization<'a> =
    dyn FnMut(*const OperatorNode, &[usize], &[Materialization]) -> Materialization + 'a;

/// Stage 2 for one logical DAG and one materialization assignment chosen
/// node by node; `None` when a choice is not an option or not down-closed.
/// Used by solution decoding to build one plan, not every assignment. // new
pub fn optimize_physical_asap_chosen(
    from_logical: &str,
    roots: &[QueryRoot],
    entries: &[QueryWorkloadEntry],
    data: &DataWorkload,
    deployment: &DeploymentCapabilities,
    choose: &mut ChooseMaterialization<'_>,
) -> Result<Option<PhysicalCandidate>, PhysicalASAPOptimizationError>;

crates/logical-asap-optimization/src/asap_aware_cse.rs

/// `roots` with the sharing candidates `subset` selects made one node each:
/// one identical-expression Pass 2 candidate, without listing every closed set. // new
pub fn with_shared_candidates(
    roots: &[QueryRoot],
    candidates: &[SharingCandidate],
    subset: &[bool],
) -> Vec<QueryRoot>;

/// The window-composition Pass 2 candidates in which every one of
/// `candidates` is in panes and reads a shared chain, without the other subsets'. // new
pub fn window_composition_shared_chain_candidates(
    roots: &[QueryRoot],
    candidates: &[PaneCandidate],
) -> Vec<WindowCompositionPass2Candidate>;

/// The rollup candidate of one coarse build, computed for it alone. // new
pub fn rollup_candidate_for(
    roots: &[QueryRoot],
    coarse: *const OperatorNode,
) -> Option<RollupCandidate>;

/// Whether adding `root` may change `coarse`'s rollup candidate; false lets
/// the memo reuse a cached one. // new
pub fn may_change_rollup_candidate(coarse: &Rc<OperatorNode>, root: &Rc<OperatorNode>) -> bool;

No CLI flag changes: --search memo-milp and --milp-time-limit-s are as on main.

Usage

# w840: the #840 workload (four queries over one metric `data`, two of them reading
# the same scan), raw data retained, default cost model, MILP search, 280 s limit.
stage_pipeline --workload w840.json \
  --deployment-capabilities '{"raw_data_retained":true,"query_time_results_retained":false}' \
  --search memo-milp --milp-time-limit-s 280 --out w840.out.json
# Stage 1 builds the memo; search::milp::model::search splits it into clusters,
# each ClusterLoop offers the warm start, then each round solves the model,
# decodes the solution into one plan (decode::decode -> optimize_physical_asap_chosen),
# evaluates it in Stage 3 and adds term cuts + the no-good cut.
# w840.out.json -> stage3_plan_selection.search (measured on 0f8bf09f):
#   incumbent_cost 0.1477616 (= --exhaustive), milp { rounds 15, gap 1.3e-06 (certified,
#   <= 1e-4), variable_count 1135, constraint_count 2008, decode_failures 0, clusters [...] },
#   plus the selected plan, its sizes and cost, and each candidate's accepted or
#   rejected line, as before.

Major algorithm changes

Per-cluster MILP

  • Problem: one model over the whole memo, even when queries share nothing; W-Mixed's two halves (disjoint sources) were solved as one.
  • Before / after: before: one model_input_of + one lazy loop. After: (1) clusters::split union-finds queries over shared groups, candidate reads, coupling sites and disk-charged raw sources; (2) one ClusterLoop per cluster, solves on threads; (3) clusters::combine merges plans and checks cost = sum.
  • Correctness: under cost model 1 no cost term spans clusters, so the sum of per-cluster optima is the optimum; combine returns None (search as one) unless the combined cost equals the sum within 1e-9. Test: three_way_on_two_independent_clusters, clusters unit tests.
  • Complexity: model size per cluster instead of whole; RangeHeavy and RollupHeavy remain one cluster each (one metric / one table).
  • Assumptions and limits: cost models 2/3 do not run the MILP (Memo search solver: MILP over the memo for all cost models #888); calibrated measured_operation_costs put everything in one cluster.

Solution decoding and incumbent evaluation

  • Problem: each round's exact evaluation enumerated every plan of the chosen group choice (offer_group_choice): q24h60 1,310,528 candidates; RangeHeavy 59 GB in identical-expression variants.
  • Before / after: before: enumerate all compositions + Stage 2 assignments of a group choice. After: decode::decode builds one logical DAG from the chosen versions, shares identical nodes the solution shares (with_shared_candidates), materializes per the solution (optimize_physical_asap_chosen), plus the widened-scans variant; nodes no variable fixes get every Stage 2 option combination up to 16.
  • Correctness: the cut excludes exactly the decoded choice set; when a set has more than 16 free combinations or fails to decode, its objective is kept as unevaluated_bound and the reported bound is min(bound, that), so no false certificate. Tests: decode and model unit tests (site threshold, shared-scan readers, built-once fidelity, unevaluated bound), 20-seed three-way.
  • Complexity: one Stage 3 evaluation (a few variants) per round instead of up to millions; RangeHeavy peak ~1.1 GB instead of 59 GB.
  • Assumptions and limits: widened scans and free materializations are a small local set, not model variables.

Warm start and time-limit bound

  • Problem: a time-limited run with no evaluated round returned no plan (t11-5m, t10 on main).
  • Before / after: after: ClusterLoop::offer_independent_plan offers every query's own legacy plan before round 1; a solve stopped at the time limit keeps HiGHS's proven bound lowered by its tolerance.
  • Correctness: the warm start is only an incumbent; certification still compares bound with incumbent; the gap is None when no valid bound exists. Test: a_time_limited_memo_milp_run_returns_the_warm_start_plan.
  • Complexity: RollupHeavy 1.3 s, RangeHeavy ~16 s extra before round 1.
  • Assumptions and limits: a query the legacy search cannot plan leaves no warm start.

Accuracy rules as constraints

  • Problem: the loop cut one whole group choice per round on AccuracyTargetMissed (RollupHeavy t10: every round failed on main).
  • Before / after: after: guarantee_requirements and the rollup/pair rules require child choices that keep each choice's guarantee, read at the largest summary size Stage 3 can give (resized_to_the_largest); exact compositions and custom accuracy models are skipped; a guarantee lost only at the largest size rules nothing out.
  • Correctness: a combination is ruled out only if it fails at the largest size, so no valid plan is lost. Tests: lower-bound tests, d990e77a's largest-size test.
  • Complexity: t10 constraints 4966 -> 5115; ~4 s per solve instead of ~1.1 s.
  • Assumptions and limits: pairwise only; three-level failures are still cut lazily. Per-size pair checks are in "Known open problems".

Tighter lower bound and term cuts

  • Problem: weak bounds: w840_4 / i840 took ~180 rounds and did not certify at 280 s (gap 0.0175); j839 raw first bound 72 vs 113.95.
  • Before / after: after: union-rate charging for built-once readers, latency per node key (ChoiceCoefficients::query_work), held-memory shares, ingestion prices, reach flows, strict cutoff; then per round term_cuts_of (SourceScans), held_cuts_of (HeldPerReader, HeldLongest, SharedBuildHeld), ingestion_copy_cuts_of: charge(term) >= bound * (sum of conditions - (n-1)).
  • Correctness: each cut's bound is a lower bound of the term in every plan with those choices; every lower-bound fix comes with a test that the model cost is <= Stage 3 cost of a known optimal plan (lower_bound tests), plus term_cuts_certify_the_brute_force_price_on_839.
  • Complexity: w840_4 / i840 rounds 176 -> 28, 280 s -> 44 s.
  • Assumptions and limits: per-consumer multiplicity of scans/builds is not cut yet (t11-5m gap ~0.15).

Rollup and pane-chain rules per coarse build

  • Problem: rollup_rules rebuilt every subset's rollup candidates (RollupHeavy coefficient derivation 662 s).
  • Before / after: after: rollup_candidate_for per coarse build, cached per plan, skipping candidates may_change_rollup_candidate says cannot change it; the pane rule builds only the pair's shared-chain candidates (window_composition_shared_chain_candidates).
  • Correctness: same rules (checked against the old function during development; test 72a873ae compares shared-chain roots).
  • Complexity: RollupHeavy coefficient derivation 662 s -> 15 s.
  • Assumptions and limits: none beyond the old rules'.

Before this PR

main (--search memo-milp --milp-time-limit-s 1800, shared host):

row wall s rounds gap / certified cost reference
q24h60 (fargate, raw, enforce) 689 129 0 / yes 8.73884e-05 8.739e-05
w840 276 73 0 / yes 0.14776156 0.1477616
i840 1800 172 0.0425 / no 0.15078779 0.150788
rh8-q1h60 (fargate, raw, enforce) 772 97 0 / yes 3.832840e-06 3.832852e-06
t11-5m (RollupHeavy 4 SQL) 1800 142 no plan — 1.703597e-06
t10 (RollupHeavy 6 SQL) 1801 503 no plan — none

Whole workloads (DRYRUN, 180 s / 30 min): 12 of 12 time out with no plan.

After this PR

Merged head 0f8bf09, --milp-time-limit-s 280, same host (shared, load ~15):

Fixed regression set (component rows):

row reference cost rounds gap wall s main (1800 s limit)
w840 0.1477616 0.1477616 15 1.3e-6, certified 14 276 s, certified
w840_3 0.1477616 0.1477616 15 1.3e-6, certified 14 –
w840_4 0.1507878 0.1507878 28 1.3e-6, certified 40 –
i840 0.150788 0.1507878 28 1.3e-6, certified 42 1800 s, gap 0.0425
j839 raw 113.9514 113.9514 5 2.1e-6, certified 2.6 –
j839 no raw 115.6695 115.6695 2 2.0e-6, certified 0.5 –
t11-5m (RollupHeavy 4 SQL) 1.703597e-06 (legacy) 1.703783e-06 54 0.145, time limit 280 no plan
q24h60 (fargate, raw, enforce) 8.739e-05 3.546e-04 452 0.849, time limit 281 689 s, certified
rh8-q1h60 (fargate, raw, enforce) 3.832852e-06 1.342e-05 162 0.814, time limit 280 772 s, certified

Whole workloads (raw retained, fargate, enforced; each returns a plan and its gap within 300 s, none certifies):

workload clusters cost rounds gap wall s RSS GB main
RollupHeavy 2 4.628e-05 10 0.918 283 1.5 no plan
RollupHeavy 8 users 2 7.112e-05 2 0.994 285 2.2 no plan
RangeHeavy 1 4.708e-04 (warm start) 1 0.968 285 0.8 no plan (OOM at 59-94 GB)
RangeHeavy 8 users 1 5.674e-04 (warm start) 1 0.99997 287 1.5 no plan

decode_failures is 0 in every row. q24h60 and rh8-q1h60 are worse than main at a 280 s limit (main needed 689 s / 772 s to certify them); see Known open problems.

Known open problems

  1. q24h60 ratio-validity gap (regression vs main at a 280 s limit). The model keeps offering choices where a ratio of two approximate children cannot meet its target, so Stage 3 rejects them round after round. Result: 3.546e-04, gap 0.849 at 280 s. The reference is 8.739e-05, which main certified in 689 s. This is a validity gap, not a lower-bound bug: the reported gap is honest. The fix is on the unmerged branch fix/memo-q24h-ingestion, an exact root pair rule that restores 8.738845e-05 certified in 113 s. A review found that it can judge scalar-root queries at the operator group; a WIP fix for that is on the same branch, not gated yet. The per-size form of the same rule is tracked in 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.
  2. rh8-q1h60 misses its reference: 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. Result: 1.342e-05, gap 0.814 at 280 s; the reference is 3.832852e-06. The suspected cause is a decode exactness bug: decoding evaluates one site threshold per choice set, and its cut may remove the same choices at a stricter, valid threshold. If so, a run could certify wrongly. The diagnosis so far says the cut is per threshold version, and the time goes to the pair accuracy rule firing only when every size fails. A WIP fix (fix/memo-rh8) certifies 3.832840e-06 in 17.5 s. 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 stays open until a test settles the decode question.
  3. Whole workloads are far from certified: memo-milp: certify whole evaluation workloads within 300 s #927. Each returns a plan and its gap within 300 s, but the gaps are 0.918–0.99997, and RangeHeavy returns its warm start. Certifying them within 300 s is the follow-up the user decided on.
  4. Sizing once (Option A): memo-milp: size summaries once (final config per reader set and summary count; Stage 3 never resizes) #929. The model prices a choice at a size that Stage 3 may change after selection. The model stays a lower bound, but it is loose. In progress on feat/memo-sizing-once.

The following are not in this PR: fix/memo-q24h-ingestion, fix/memo-rh8, perf/memo-warm-start-cheap, w2/held-once, feat/memo-sizing-once and feat/memo-validity.

Verification

On the merged head (main merged in, 0f8bf09), on the remote build host:

  • cargo fmt --all -- --check: clean.
  • cargo clippy --release -p asap-plan-selection -p asap-integration-tests -p asap-logical-asap-optimization -p asap-physical-asap-optimization --all-targets -- -D warnings: clean.
  • cargo test --release -p asap-plan-selection --lib: 223 passed.
  • cargo test --release -p asap-integration-tests --test joint_search_equivalence three_way_twenty_smallest -- --ignored: passed.
  • cargo test --release -p asap-devtools: all pass, after updating two stale stage_pipeline expectations to PR-B's intended behavior (search_flag_picks_the_search_algorithm: a certified gap is within 1e-4, not 1e-9; milp_time_limit_is_reported_and_needs_the_milp_search: a run stopped before round 1 now returns the warm-start plan with its gap instead of an error).
  • cargo test --release -p asap-integration-tests -p asap-logical-asap-optimization -p asap-physical-asap-optimization (non-ignored): 556 passed.
  • Rows above (fixed regression set + whole workloads), outputs kept on the build host.
  • Each item was reviewed (correctness/exactness) and gated (lower-bound tests, 20-seed three-way, regression rows) as it merged into the integration branch. The 60-seed three-way and the slow ignored equivalence tests were not rerun for this PR.

zzylol added 30 commits October 22, 2026 17:02
…pability, window/rollup/top-k transformations, per-group pricing (behind no flag yet)
…, budget-share variants, topological order

Each Pass 1 candidate's private nodes read their child groups through the
groups' own nodes and output the group's schema. A sketch candidate is
offered once per share of its measure's budget a reader may give it
(budget_share), composed by the new compose_logical_candidate_with_budget_share.
Memo::topological_order orders groups after every group they or their
candidates read.
ScanLabels sites per metric whose scans project different labels, with
WidenedScans twins, label thresholds and widening at a threshold; and
RawHorizon sites per source whose raw windows two or more groups read.
Identical expression tested as group identity.
…nts, sketch rollup over a finer group's state, groups kept in topological order
…(exact-child requirements, sketch rollup) into the memo
price_at_scale's per-node loop body is now price_node, which takes the
node's producers' outputs, its computation rate, a query-time scan's
look-back, a chain's retained panes and an ingestion-time node's window,
and returns the node's output statistics, usage, cost and the charges
known only once the whole plan is (held memory, partial copies, raw
data). price_at_scale calls it per node; prices are unchanged.
…roups, scan materializations

A shared-build site keys its input by structure, so equal windows of two
queries (one group each without a unique key) meet. Candidates list the
materializations Stage 2 may give them; a scan group read through one
window is only built once, one read through different windows may also
be read per reader. Equal SQL table scans are one group.
… grouping's pane chain; sketch rollup state resized to a threshold; required sites; one topological order
…eep their own columns

A lib test composes every whole-workload Pass 1 choice of the random
workloads of joint_search_equivalence and checks each query's plan is a
combination of the memo's per-group candidates. It found the fused top-k
heap candidates of an ungrouped top-k root dropped because their output
columns differ from the logical node's; a candidate keeps its own schema
when its columns differ, and every candidate of a group a parent reads
outputs the group's schema. Equal SQL table scans are one group.
…r context

price_at_scale's node loop is split into price_each_node, and lib.rs gains
price_private_nodes: Stage 3's per-node pricing of a candidate's private
nodes for given consumers (queries, or materialized ancestors with their
rate), reading its children's given outputs, with computations()'
counting restricted to them. Memory held for an evaluation and raw data
on disk stay plan-level terms (HeldMemory, raw scans), priced by
plan_cost once each query's latency is known.

subexpr/pricing.rs: private_usage, output_stats, plan_cost and
frozen_calibration, cached by (candidate key, consumers, materialization,
child outputs); frontiers dropped (cost model 1 only). Tests: the groups'
local prices add up to price_at_scale of the composed plan for several
one-query plans, a shared group not materialized and materialized at
query time (equal and different intervals), and a pane chain at
ingestion.
…threshold

resized_at_threshold resizes a shared-build reader's build to a threshold
(with_resized_build, guarantees composed again, child groups' nodes kept).
The brute-force reference rewrites SharedBuildSize sites with it and makes
resized builds over equal windows one build.
… 1 parity test checks every target's candidates

A memo plan built from per-group candidates kept the frontend's empty
guarantee on a group's own node (an exact top-k over the per-series
sums' exact accumulator), so Stage 3 rejected it for having no
guarantee. composed_roots now recomposes every guarantee, as Pass 1
composition does (recompose_guarantees made public). The parity test
also checks that each target's group, absorbed or not, has the target's
own and fused candidates.
… and lists the identical-expression and scan-widening variants as the exhaustive search does
zzylol added 26 commits October 23, 2026 04:41
…custom accuracy models

A group a plan may read through a node exact whatever its input (a count of
rows) gets no failure-probability target, so an uncertified input of such a
node is no longer ruled out. Both accuracy rules apply only under the
built-in accuracy model's semantics: a model that may accept an output
whatever its guarantee (also when guarantees are ignored) turns them off.
… the largest size Stage 3 can give

Stage 3 enlarges summaries for their readers' failure probabilities before
it checks guarantees (classic HLL precision, Count-Min/CountSketch/KLL
depth, UnivMon heap). The rule now evaluates each composition with every
summary at the most that enlargement can reach (classic HLL at precision
18, others for a vanishing failure probability), so it rules out only what
fails at every size Stage 3 can give.
…y size rules nothing out

A division by a UnivMon distinct count loses its guarantee only when the
UnivMon is sized for a vanishing failure probability (its heap cap lets the
relative error grow). The memo MILP's accuracy rule must treat that as
unknown and keep the UnivMon choices.
…alone, cache it per plan, and skip candidates that cannot change it (same rules, RollupHeavy 662 s -> 15 s)
Each round decodes the MILP solution to the plan it describes and runs
Stage 3 on it (plus its widened-scans variant and the materializations of
nodes no variable sets, at most 16), instead of enumerating the group
choice's thresholds, identical-expression variants and materializations.
The cut excludes exactly the decoded choice set (choices, groups built
once or not).

Model changes that make every solution describe one plan:
- one threshold per coupling site (site threshold variables);
- a group is needed only as a root, a chosen choice's child or a fused
  choice's implemented group;
- a scan shared at a horizon has two or more readers (one reader reads
  it apart, the same plan);
- a build built once only because another group's equal build reads it
  needs such a choice.

Decoding: scans read apart are the reader's own copy; identical non-scan
nodes are one node; widened scans are evaluated as a variant (a decoding
gap until scan labels are a model variable).
…elity, decoder failures reported

- A choice set cut with combinations of free nodes' options not
  evaluated (more than the cap) or that failed to decode keeps its
  solution's objective as the lower bound, so the loop cannot certify
  past it; the last round leaves such a set uncut for the relaxation.
- A group built once whose own node is a build is built once in the
  decoded plan (built once overrides the slot), and the model builds a
  group once only through its own choice, never a fused one.
- Decoding failures are counted in the MILP report (debug_assert).
- Threshold variables keyed by (site, threshold); lower_bound_check
  clears excluded_once; Decoded::deviations removed.
- Tests: site threshold, need <= readers, a shared scan needs two
  readers, cuts on built once, fused groups not built once, the bound
  with unevaluated plans, built once over a build node, decode.
…at their ingestion price

A consumer that is an ingestion-time slot reaches its child groups at
rate 1, and the model charged each reached choice's direct part at its
query-time price per evaluation (scans of the whole look-back, absorbed
scans, class and cross-group charges). Stage 3 computes that part at
ingestion time, once per second over one second of rows
(LazyTerms::direct_at_ingestion), so the model overestimated such plans
and was no lower bound: an exact top-k of 5-minute sums without raw data
was modeled at 0.150 against an exact price of 0.0145.

The model now charges a choice reached through an ingestion-time slot
its direct part's ingestion-time price (0 when it cannot run there).

Test: the model is a lower bound for that top-k below an ingestion-time
slot (failed before: model 0.150 over exact 0.0145).
…ed slot node

Slots that may be one shared node (one shared site, threshold and
materialization) are charged once, at the dearest; their ingestion-time
inputs were still charged once per slot consumer, so the model was above
Stage 3's price when two ingestion-time slots shared one build (two
quantiles within 1%: 26.094 vs 26.042; max by job of a 5-minute sum and
the sum: 0.2701 vs 0.2181). Each such node's inputs are now charged at
the dearest of its slot consumers' charges.

A part that cannot run at ingestion time (no ingestion-time price) is no
longer reached by an ingestion-time slot for free: that reach is fixed
at 0.

Test: the model is a lower bound for children of two ingestion-time
slots, at 300 s/300 s and 60 s/300 s (both cases failed before).
…every group

Held memory of a part is charged per reader at union / sum of its
readers' rates. The model took the readers from the choice's own group
only, but a node several groups compute is one node read by all their
queries: two quantiles every 10 s within 1% over one shared build were
modeled at 33.886070 against an exact 33.886035 (about 1e-6 relative,
one reader's held memory too many). The share now ranges over the
queries of every group with a choice computing one of the part's nodes.

Only a part that Stage 3 cannot place at ingestion time (a grouping with
no window below it) is kept from an ingestion-time slot; any other
pricing error of its ingestion-time copy charges it 0.

Test: the two-ingestion-slot lower-bound test also runs at 10 s/10 s
(failed before).
Before round 1, each cluster loop plans every query alone with the
legacy search (no sharing between queries), composes the plans side by
side with each query's own materializations, and offers the result to
the loop's PlanSelector as the starting incumbent. A time-limited run
then reports that plan and its gap instead of "no plan". Certification
is unchanged: the loop still stops when the MILP lower bound reaches
the incumbent. No HiGHS MIP start: mapping a legacy plan onto the
model's choice and slot variables is not cheap.

Test: a memo-milp run with a 0 ms time limit returns the warm-start
plan, priced at the sum of the queries' own plans.
…e stopped at the time limit, so a stopped search reports its gap without an unlimited relaxation solve
… group joins

A node several groups compute may be read by queries of classes that no
single group has together, so no union_rate key covers their class set
and the share fell back to a single class's ratio (1 for one reader per
class): every 10 s and every 20 s, the model charged 0.15 against Stage
3's 0.10. The share now also takes, for each possible fastest reader,
its rate over the sum of the readers no faster than it: their union
rate is at least the fastest's, so this bounds the share from below.

Test: a node read from two classes keyed only one by one (model 0.15
over 0.10 before); the two-ingestion-slot lower-bound test also runs
at 10 s/20 s.
…lows into chosen choices; requirement-coupled consumer flows
…ances in the lower bound when no plan is left below the incumbent; certified means gap within GAP_TOLERANCE
… shared builds possibly apart; test each cut kind
…4 tolerance; a time-limited run returns the warm-start plan with its gap
@zzylol zzylol added enhancement New feature or request optimizer Workload optimization and plan selection search Candidate-plan search space and algorithms labels Oct 23, 2026
@zzylol
zzylol merged commit bd0882c into main Oct 23, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request optimizer Workload optimization and plan selection search Candidate-plan search space and algorithms

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant