From e6336cdfb32bec148918d5e1ab2079c10fa71802 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:29:45 +0000 Subject: [PATCH 1/4] refactor(planner): move legacy candidate selection out of replacement Move the cost- and recurrence-dependent selection over `CandidateLogicalASAPDAGs` (cost ranking, recurrence profiles, global selection, selected-DAG assembly, runtime support evidence) and its tests from `replacement` into `plan_selection::candidate_selection`. The code is unchanged; only visibility and imports were adjusted. `plan_selection.rs` becomes `plan_selection/mod.rs`. `recurrence_profiles_from_workload` is deleted rather than moved: it has no caller anywhere in the workspace. Co-Authored-By: Claude Opus 5.5 --- crates/asap-aware-mapping/src/lib.rs | 12 +- .../src/plan_selection/candidate_selection.rs | 2869 +++++++ .../mod.rs} | 2 + crates/asap-aware-mapping/src/recurrence.rs | 2 +- crates/asap-aware-mapping/src/replacement.rs | 6998 +++++------------ 5 files changed, 4932 insertions(+), 4951 deletions(-) create mode 100644 crates/asap-aware-mapping/src/plan_selection/candidate_selection.rs rename crates/asap-aware-mapping/src/{plan_selection.rs => plan_selection/mod.rs} (99%) diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 6ef07647..46cb1103 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -195,6 +195,10 @@ pub use pass::{ optimize, OptimizationInput, OptimizationInputError, OptimizationPass, OptimizeError, PassNameConflict, PassRegistry, PlanOutput, PlanningModels, QueryPlan, StagePipeline, }; +pub use plan_selection::candidate_selection::{ + CompositionDecision, GlobalSelection, RankedTargetSubDAGCandidates, RecurrenceProfileMap, + TargetSubDAGSelection, +}; pub use recurrence::{ evaluation_rate_of, total_cost, update_rate_from_data_workload, CostRate, EvaluationRate, Horizon, RecurrenceCostExplanation, RecurrenceError, RecurrenceProfile, RootRecurrence, @@ -203,11 +207,9 @@ pub use recurrence::{ pub use replacement::{ default_strategies, default_strategies_with, is_logical_rewrite, search_workload, search_workload_with, search_workload_with_targets, summary_candidates, ASAPStrategies, - CandidateLogicalASAPDAGs, CompositionDecision, GlobalSelection, Matcher, Proposals, - RankedTargetSubDAGCandidates, Realization, RealizationError, RecurrenceProfileMap, - RejectedCandidate, Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, - SharedSubDAGStrategy, TargetSubDAG, TargetSubDAGCandidates, TargetSubDAGSelection, - MAX_SEARCH_ITERATIONS, + CandidateLogicalASAPDAGs, Matcher, Proposals, Realization, RealizationError, RejectedCandidate, + Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, + SharedSubDAGStrategy, TargetSubDAG, TargetSubDAGCandidates, MAX_SEARCH_ITERATIONS, }; pub use rewrite::{AvgToSumOverCountStrategy, SemanticEquivalentRewriteStrategy}; pub use topk_reuse::TopKLimitReuseStrategy; diff --git a/crates/asap-aware-mapping/src/plan_selection/candidate_selection.rs b/crates/asap-aware-mapping/src/plan_selection/candidate_selection.rs new file mode 100644 index 00000000..a332a900 --- /dev/null +++ b/crates/asap-aware-mapping/src/plan_selection/candidate_selection.rs @@ -0,0 +1,2869 @@ +//! Whole-workload selection over the legacy Stage 1 search space +//! ([`CandidateLogicalASAPDAGs`]): per-target cost ranking, recurrence +//! profiles, global selection and assembly of the selected DAG. It lives here, +//! not in `replacement`, so that Stage 1 does not depend on the cost model. +//! The stage pipeline does not call it; its deletion is tracked in #580. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::rc::Rc; + +use asap_types::ir::operator::agg_intent::AggIntent; +use asap_types::ir::operator::non_asap::any_measure_filtered; +use asap_types::ir::operator::operator_properties::JoinKind; +use asap_types::ir::properties::timing::validate_maintained; +use asap_types::ir::properties::{ExecutionTiming, ResultGuarantee}; +use asap_types::ir::schema::{ColumnId, FieldDataType, GroupingStrategy, SketchAlgorithm}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, Predicate, ScalarExpr}; + +use crate::cost_model::{ + raw_recompute_cost_rate, CostModel, CseCandidate, ExactCompositionCostInputs, + ExactCompositionCostRequest, ShareDecision, +}; +use crate::exact_composition::OperationPlacement; +use crate::recurrence::{ + CostRate, Horizon, RecurrenceError, RecurrenceProfile, RootRecurrence, UpdateRate, +}; +use crate::replacement::{ + bindable_intent, cse_candidate_pair, direct_child_counts, finalize_query_candidate, + is_logical_rewrite, realize_child, retain_exact, CandidateLogicalASAPDAGs, PreparedComposition, + RealizationError, Replacement, ReplacementProvenance, ReplacementSubDAG, TargetSubDAG, + TargetSubDAGCandidates, +}; + +impl ReplacementSubDAG { + /// Physical feasibility evidence for this candidate. A pure logical + /// rewrite needs no new operator. Unknown support is checked during + /// physical/deployment compilation; explicit rejection prevents selection. + pub fn runtime_support_evidence(&self, cost_model: &dyn CostModel) -> Option { + match &self.replacement { + Replacement::ExactComposition(composition) => { + cost_model.value_operation_support_evidence(&composition.op, composition.placement) + } + // Any summary decision, including one rooted in a relational + // operator above its evaluations, asks the deployment for support. + Replacement::SubDAG(node) if !is_logical_rewrite(node) => { + cost_model.summary_support_evidence(node) + } + Replacement::SubDAG(_) => Some(true), + } + } +} + +impl CandidateLogicalASAPDAGs { + /// The `sorted_by(cost_model)` step: every group, each with its own + /// candidates ranked best-first under `cost_model` where this module + /// knows how (see the module docs' "Cost-based final selection" + /// section) — groups themselves stay in discovery order, since targets + /// are independent decision points, not alternatives competing with + /// each other. + /// + /// Ranking itself is decided entirely by [`rank_group`] before + /// [`RankedTargetSubDAGCandidates::costs`] is ever computed — pairing each candidate with + /// [`CostModel::grouping_state_cost`] for grouping alternatives, or + /// [`CostModel::estimate_cost`] otherwise, is an additive annotation + /// for a caller that wants to *display* a cost (e.g. a + /// DAG-visualization view), not a second ranking signal, so plugging in + /// a `CostModel` whose `estimate_cost` disagrees with its own + /// `rank_candidates`/`cse_share_decision` (a deployment bug, not + /// something this method tries to protect against) would show a + /// `RankedTargetSubDAGCandidates` whose `costs` aren't monotonically non-decreasing — + /// `cost_sorted`'s own ordering guarantee is unaffected either way. + pub fn cost_sorted(&self, cost_model: &dyn CostModel) -> Vec> { + self.order + .iter() + .map(|ptr| { + let group = &self.groups[ptr]; + let target = TargetSubDAG::with_consumer_count(&group.target, group.consumer_count); + let mut candidates = rank_group(group, cost_model); + // Availability is candidate-specific and cannot be expressed + // by `rank_candidates`' exhaustive permutation contract. + // Keep unavailable alternatives for explanation, but place + // them after every selectable candidate. + candidates.sort_by_key(|candidate| { + cost_model.candidate_cost(candidate, &target).is_none() + }); + let costs = candidates + .iter() + .map(|c| { + cost_model + .grouping_state_cost(c, &target) + .map_or_else(|| cost_model.estimate_cost(c, &target), |cost| cost.0) + }) + .collect(); + RankedTargetSubDAGCandidates { + target: &group.target, + consumer_count: group.consumer_count, + candidates, + costs, + } + }) + .collect() + } + + /// Recurrence-aware counterpart to [`Self::cost_sorted`]. CSE + /// share/recompute pairs are ordered with the target's recurrence + /// profile; all other candidate shapes retain their existing ranking. + pub fn cost_sorted_with_recurrence( + &self, + cost_model: &dyn CostModel, + profiles: &RecurrenceProfileMap, + horizon: Option, + ) -> Result>, RecurrenceError> { + self.order + .iter() + .map(|ptr| { + let group = &self.groups[ptr]; + let mut candidates = rank_group(group, cost_model); + if cse_candidate_pair(group).is_some() { + if let Some(decision) = decide_group_with_recurrence( + group, + group.consumer_count, + profiles.for_target(&group.target), + horizon, + cost_model, + )? { + candidates.sort_by_key(|candidate| match candidate.provenance { + ReplacementProvenance::CseShare if decision == ShareDecision::Share => { + 0 + } + ReplacementProvenance::CseRecompute + if decision == ShareDecision::RecomputeIndependently => + { + 0 + } + ReplacementProvenance::CseShare + | ReplacementProvenance::CseRecompute => 2, + _ => 1, + }); + } + } + let target = TargetSubDAG::with_consumer_count(&group.target, group.consumer_count); + let costs = candidates + .iter() + .map(|candidate| { + cost_model + .grouping_state_cost(candidate, &target) + .map_or_else( + || cost_model.estimate_cost(candidate, &target), + |cost| cost.0, + ) + }) + .collect(); + Ok(RankedTargetSubDAGCandidates { + target: &group.target, + consumer_count: group.consumer_count, + candidates, + costs, + }) + }) + .collect() + } +} + +// ── Recurrence-aware cost context (issue #287) ────────────────────────── + +/// One [`RecurrenceProfile`] per discovered [`TargetSubDAGCandidates`] target, built by +/// [`CandidateLogicalASAPDAGs::recurrence_profiles`] — the "carry `RepeatingEntry.demand` +/// and relevant `DataWorkload` into ASAP-aware search/cost context" +/// half of issue #287. Looked up by `Rc` pointer identity, the same +/// currency [`CandidateLogicalASAPDAGs::candidates_for_target`]/[`GlobalSelection::for_target`] already +/// use. +/// Holds an owned `Rc` clone alongside each profile (not just +/// its raw pointer) so this map keeps every node it describes alive for as +/// long as the map itself lives — a `RecurrenceProfileMap` is safe to outlive +/// the `CandidateLogicalASAPDAGs` it was built from. Without this, a raw `*const OperatorNode` key +/// could, after the originating `CandidateLogicalASAPDAGs` (the only other owner of those +/// `Rc`s) is dropped, collide with an unrelated, later allocation that +/// happens to reuse the same freed address — silently returning a stale +/// profile for the wrong node (issue #287 review, bug 4). +#[derive(Debug, Clone)] +pub struct RecurrenceProfileMap { + profiles: HashMap<*const OperatorNode, (Rc, RecurrenceProfile)>, +} + +impl RecurrenceProfileMap { + /// The [`RecurrenceProfile`] for `target`, or + /// [`RecurrenceProfile::EMPTY`] when `target` wasn't a discovered site + /// in the [`CandidateLogicalASAPDAGs`] this map was built from (or carried no + /// recurring/one-shot/update-rate metadata at all) — always a valid, + /// "no metadata" answer, never a panic. + pub fn for_target(&self, target: &Rc) -> RecurrenceProfile { + self.profiles + .get(&Rc::as_ptr(target)) + .map(|(_, profile)| *profile) + .unwrap_or(RecurrenceProfile::EMPTY) + } +} + +impl CandidateLogicalASAPDAGs { + /// Build one [`RecurrenceProfile`] per discovered site, by walking every + /// root's whole reachable sub-DAG (the same relational-skeleton + /// traversal [`discover_targets`] itself used to discover those sites) + /// and folding each root's own recurrence tag + /// (a normalized repeating rate or a one-time invocation count) into every + /// site reachable from it. + /// + /// `root_recurrence` is positional: `root_recurrence[i]` describes + /// `self.roots[i]` — the same order [`search_workload`]/ + /// [`search_workload_with`] were originally called with (post-CSE + /// dedup preserves both root count and order — see + /// `asap_types::ir::cse::share_common_sub_dags`'s own + /// `.map(...).collect()` body). This keeps `Id` fully opaque (no `Eq`/ + /// `Hash`/`Clone` bound needed on it at all — issue #287's "keep + /// caller/query identifiers opaque" requirement) at the cost of the + /// caller keeping the two slices in step; `root_recurrence.len()` must + /// equal `self.roots.len()`. + /// + /// A shared sub-DAG reachable from more than one root aggregates every + /// reaching root's contribution — repeating roots' rates are summed and + /// one-shot roots + /// increment [`RecurrenceProfile::one_shot_consumers`] — so a summary + /// consumed by queries with different intervals gets one profile + /// reflecting all of them, per issue #287's "support a shared sub-DAG + /// consumed by queries with different intervals". + /// + /// `update_rate` is applied uniformly to every discovered site *that + /// this walk actually reached from some root* (see the "unreachable + /// sites" note below): today's + /// [`asap_types::workload::DataWorkload`] is a single + /// workload-level value (applies to every query in a `QueryWorkload`), + /// not per-target, so there is no finer-grained source to attach + /// instead. `None` when no `DataWorkload` evidence was available — + /// preserves "missing metadata" behavior for the update-rate term alone + /// even when repeating/one-shot consumer information is present. + /// + /// A parent that structurally references the same child more than once + /// (e.g. `BinaryOp{lhs: X, rhs: X}`) credits that child with one + /// contribution per reference, not one contribution per distinct node — + /// matching how [`TargetSubDAGCandidates::consumer_count`] counts that occurrence. + /// Multiplicity is propagated through the full descendant path: if the + /// repeated parent is independently evaluated twice, its child is also + /// evaluated twice. This supplies recurrence-aware selection with the + /// effective structural execution rate rather than mere reachability. + /// + /// **Unreachable sites**: [`CandidateLogicalASAPDAGs`] can contain a site no root's own + /// structural DAG actually reaches — e.g. one only ever produced by a + /// [`Replacement::Rewrite`] candidate a [`ReplacementStrategy`] invented + /// (this walk only follows [`TargetSubDAGCandidates::target`]'s own structural + /// children, the same scope [`discover_targets`] uses for the original + /// roots, never a candidate's rewritten value). Such a site gets + /// [`RecurrenceProfile::EMPTY`] — in particular, `update_rate` is + /// **not** stamped onto it — so it falls back to the ordinary + /// structural decision instead of being charged an ingest-driven + /// maintenance cost against a real evaluation/one-shot signal of + /// exactly zero, which previously made `RecomputeIndependently` win + /// there unconditionally, regardless of the site's actual + /// `consumer_count` (issue #287 review, bug 2). + /// + /// Returns [`RecurrenceError::InvalidEvaluationRate`] if any repeating + /// rate is non-finite or negative, + /// [`RecurrenceError::InvalidUpdateRate`] if `update_rate` is non-finite + /// or negative, or [`RecurrenceError::RootCountMismatch`] if + /// `root_recurrence.len() != self.roots.len()`. + pub fn recurrence_profiles( + &self, + root_recurrence: &[RootRecurrence], + update_rate: Option, + ) -> Result { + if root_recurrence.len() != self.roots.len() { + return Err(crate::recurrence::RecurrenceError::RootCountMismatch { + expected: self.roots.len(), + got: root_recurrence.len(), + }); + } + if let Some(rate) = update_rate { + crate::recurrence::validate_update_rate(rate)?; + } + for recurrence in root_recurrence { + if let RootRecurrence::Repeating(rate) = recurrence { + if !rate.0.is_finite() || rate.0 < 0.0 { + return Err(crate::recurrence::RecurrenceError::InvalidEvaluationRate( + *rate, + )); + } + } + } + + let mut rates: HashMap<*const OperatorNode, f64> = HashMap::new(); + let mut one_shot_counts: HashMap<*const OperatorNode, usize> = HashMap::new(); + // Sites actually reached by at least one root's own recurrence tag + // during the walk below — see this method's own "Unreachable + // sites" doc. + let mut reached: HashSet<*const OperatorNode> = HashSet::new(); + + for ((_, root), recurrence) in self.roots.iter().zip(root_recurrence) { + let recurrence = *recurrence; + let root_ptr = Rc::as_ptr(root); + // Carry path multiplicity transitively. If a shared ancestor is + // referenced twice, every descendant below an independently + // recomputed occurrence is evaluated twice as well; stopping + // expansion after the first pointer visit undercounts exactly + // the effective-consumer rate recurrence-aware costing needs. + let mut queue: VecDeque<(*const OperatorNode, usize)> = VecDeque::new(); + queue.push_back((root_ptr, 1)); + + while let Some((ptr, path_count)) = queue.pop_front() { + contribute( + ptr, + path_count, + recurrence, + &mut rates, + &mut one_shot_counts, + &mut reached, + ); + // Every reachable node was itself discovered as its own + // `TargetSubDAGCandidates` (`discover_targets` walks the identical + // relational-skeleton scope) — its own `target` is the + // canonical `Rc` to read children off. + if let Some(group) = self.groups.get(&ptr) { + for (child, edge_count) in direct_child_counts(&group.target) { + queue.push_back(( + child, + path_count + .checked_mul(edge_count) + .expect("query DAG path multiplicity overflowed usize"), + )); + } + } + } + } + + let mut profiles = HashMap::with_capacity(self.order.len()); + for ptr in &self.order { + let rate = rates.get(ptr).copied().unwrap_or(0.0); + let evaluation_rate = (rate > 0.0).then_some(crate::recurrence::EvaluationRate(rate)); + let one_shot_consumers = one_shot_counts.get(ptr).copied().unwrap_or(0); + // Bug 2 fix (see "Unreachable sites" above): only a reached + // site carries the caller-supplied `update_rate`. + let site_update_rate = if reached.contains(ptr) { + update_rate + } else { + None + }; + let node = Rc::clone(&self.groups[ptr].target); + profiles.insert( + *ptr, + ( + node, + RecurrenceProfile { + evaluation_rate, + one_shot_consumers, + update_rate: site_update_rate, + }, + ), + ); + } + + Ok(RecurrenceProfileMap { profiles }) + } +} + +/// Record `times` occurrences of `recurrence` against `ptr` — `times > 1` +/// when a single parent structurally references `ptr` more than once (see +/// [`CandidateLogicalASAPDAGs::recurrence_profiles`]'s own doc on edge multiplicity). +/// A no-op for `times == 0` (an `Rc` returned as a `direct_child_counts` +/// child always has `edge_count >= 1` in practice, but this keeps the +/// helper correct regardless). +fn contribute( + ptr: *const OperatorNode, + times: usize, + recurrence: RootRecurrence, + rates: &mut HashMap<*const OperatorNode, f64>, + one_shot_counts: &mut HashMap<*const OperatorNode, usize>, + reached: &mut HashSet<*const OperatorNode>, +) { + if times == 0 { + return; + } + reached.insert(ptr); + match recurrence { + RootRecurrence::Repeating(rate) => { + *rates.entry(ptr).or_insert(0.0) += rate.0 * times as f64; + } + RootRecurrence::OneShotCount(count) => { + *one_shot_counts.entry(ptr).or_insert(0) += count.saturating_mul(times); + } + RootRecurrence::Unknown => {} + } +} + +/// One [`TargetSubDAGCandidates`]'s candidates, ranked best-first by +/// [`CandidateLogicalASAPDAGs::cost_sorted`]. +#[derive(Debug)] +pub struct RankedTargetSubDAGCandidates<'a> { + pub target: &'a Rc, + pub consumer_count: usize, + pub candidates: Vec<&'a ReplacementSubDAG>, + /// `costs[i]` is `candidates[i]`'s own grouping-state cost when available, + /// and its [`CostModel::estimate_cost`] otherwise + /// estimate — aligned index-for-index with `candidates`, one number per + /// candidate, for a caller that wants an actual `f64` next to each + /// candidate (e.g. "candidate A costs ≈ X, candidate B costs ≈ Y") and + /// not just `candidates`' own relative order. `f64::NAN` throughout + /// unless `cost_model` overrides `estimate_cost` — see that method's own + /// doc. + pub costs: Vec, +} + +/// Rank `group`'s candidates best-first under `cost_model`, per the module +/// docs' "Cost-based final selection" section. Falls back to discovery +/// order whenever there's nothing to rank (0 or 1 candidates) or this +/// module doesn't have a defined `CostModel` comparison for the shape it +/// sees — it never invents one. +fn rank_group<'a>( + group: &'a TargetSubDAGCandidates, + cost_model: &dyn CostModel, +) -> Vec<&'a ReplacementSubDAG> { + let mut ranked: Vec<&ReplacementSubDAG> = group.candidates.iter().collect(); + if ranked.len() <= 1 { + return ranked; + } + + // Shape 1: the exact `SharedSubDAGStrategy` share-vs-recompute pair — + // rank via `CostModel::cse_share_decision`, the same comparison + // the local CSE ranking path already uses. + if cse_candidate_pair(group).is_some() { + if let Some(prefer_target) = cse_preference(group, cost_model) { + ranked.sort_by_key(|c| match c.provenance { + ReplacementProvenance::CseShare if prefer_target => 0, + ReplacementProvenance::CseRecompute if !prefer_target => 0, + ReplacementProvenance::CseShare | ReplacementProvenance::CseRecompute => 2, + _ => 1, + }); + } + return ranked; + } + + // Shape 2: independent and Hydra grouping alternatives for the same + // sketch algorithms. When deployment statistics provide a subpopulation + // estimate, compare N independent states with the shared grid directly. + let target = TargetSubDAG::with_consumer_count(&group.target, group.consumer_count); + let has_hydra = ranked.iter().any(|candidate| { + let Replacement::SubDAG(node) = &candidate.replacement else { + return false; + }; + summary_grouping(node).is_some_and(|grouping| { + matches!(grouping, GroupingStrategy::SharedMultiSubpopulation { .. }) + }) + }); + let grouping_costs: Option> = if has_hydra { + ranked + .iter() + .map(|candidate| { + cost_model + .grouping_state_cost(candidate, &target) + .map(|cost| cost.0) + }) + .collect() + } else { + None + }; + if let Some(costs) = grouping_costs { + let by_ptr: HashMap<*const ReplacementSubDAG, f64> = ranked + .iter() + .zip(costs) + .map(|(candidate, cost)| (*candidate as *const ReplacementSubDAG, cost)) + .collect(); + ranked.sort_by(|a, b| { + by_ptr[&(*a as *const ReplacementSubDAG)] + .total_cmp(&by_ptr[&(*b as *const ReplacementSubDAG)]) + }); + return ranked; + } + + // Shape 3: `ASAPStrategies`'s sketch-family candidates (every + // candidate is a `Summary` that realizes a `SketchAlgorithm`) — rank via + // `CostModel::rank_candidates`, the same hook `realizations_for_intent` + // itself consults. + if let Some(intent) = bindable_intent(&group.target) { + let kinds: Option> = ranked + .iter() + .map(|c| match &c.replacement { + Replacement::SubDAG(node) => sketch_kind_of(node), + Replacement::ExactComposition(_) => None, + }) + .collect(); + if let Some(kinds) = kinds { + let order = crate::cost_model::validated_candidate_ranking(cost_model, intent, &kinds); + ranked.sort_by_key(|c| { + let kind = match &c.replacement { + Replacement::SubDAG(node) => sketch_kind_of(node), + Replacement::ExactComposition(_) => None, + }; + kind.and_then(|k| order.iter().position(|o| *o == k)) + .unwrap_or(usize::MAX) + }); + return ranked; + } + } + + // A target may be handled by more than one strategy (for example, a + // shared aggregate has both bound-summary and share/recompute rewrite + // candidates). No shape-specific hook spans those different candidate + // types, so compare the numeric estimates the CostModel exposes for that + // purpose. `total_cmp` gives deterministic placement to a model's NaN + // placeholders without dropping any candidate. + ranked.sort_by(|a, b| { + match ( + cost_model.candidate_cost(a, &target), + cost_model.candidate_cost(b, &target), + ) { + (Some(a), Some(b)) => a.0.total_cmp(&b.0), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => cost_model + .estimate_cost(a, &target) + .total_cmp(&cost_model.estimate_cost(b, &target)), + } + }); + ranked +} + +/// For a group whose candidates are all [`Replacement::Rewrite`] (the +/// [`SharedSubDAGStrategy`] shape): does [`CostModel::cse_share_decision`] +/// prefer the candidate that shares `group.target`'s own `Rc` (`true`), or +/// the one that recomputes independently (`false`)? `None` when there's no +/// real comparison to make — fewer than 2 consumers (mirrors +/// [`SharedSubDAGStrategy::matches`]'s own gate), or `group.target` can't +/// actually be bound at all (no candidate and no logical fallback — never +/// expected in practice for a target that's already part of a legitimate +/// workload DAG, but this degrades to "keep discovery order" rather than +/// panicking). +fn cse_preference(group: &TargetSubDAGCandidates, cost_model: &dyn CostModel) -> Option { + if group.consumer_count < 2 { + return None; + } + let bound = realize_one(&group.target, cost_model)?; + let candidate = CseCandidate { + sub_dag: &group.target, + bound_summary: &bound, + consumer_count: group.consumer_count, + }; + Some(match cost_model.cse_share_decision(&candidate) { + ShareDecision::Share => true, + ShareDecision::RecomputeIndependently => false, + }) +} + +/// [`cse_preference`] only needs one representative bound [`OperatorNode`] +/// for `target` (to build a [`CseCandidate`] for +/// [`CostModel::cse_share_decision`]), not the full ranked candidate list +/// [`ASAPStrategies::replacements`] returns — so this just reuses +/// [`realize_child`], the same rank-and-take-first helper +/// `construct_summary_agg`'s own recursion and +/// [`crate::cost_model::DefaultCostModel::estimate_cost`] already use, +/// wrapped to swallow the (here, uninteresting) error into `None`. +fn realize_one(target: &Rc, cost_model: &dyn CostModel) -> Option> { + realize_child(target, cost_model).ok() +} + +/// The `SketchAlgorithm` a bound [`Replacement::SubDAG`] candidate ultimately +/// realizes, if any (`None` for an `ExactAggregate`/pass-through +/// sub-DAG — nothing to rank against another `SketchAlgorithm`). +/// +/// Mirrors this module's own `#[cfg(test)]`-only `summary_family_algorithm` +/// helper (in the test module below), which does the identical +/// `SummaryEstimate`-unwrap-then-match for that module's own tests; that +/// copy is test-only, so this needs its own for real (non-test) ranking +/// code — the same "duplicate a small, self-contained traversal rather than +/// restructure a test helper" call this file's own top doc already makes +/// for [`discover_targets`]. +pub(crate) fn sketch_kind_of(node: &OperatorNode) -> Option { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + sketch_kind_of(summary_input) + } + Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + }) => Some(kind.algorithm().clone()), + _ => None, + } +} + +/// The grouping strategy used by a bound summary candidate, unwrapping its +/// evaluation node when necessary. +fn summary_grouping(node: &OperatorNode) -> Option<&GroupingStrategy> { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + summary_grouping(summary_input) + } + Operator::ASAP(ASAPOp::SummaryAgg { grouping, .. }) => Some(grouping), + _ => None, + } +} + +// ── global_selection ───────────────────────────────────────────────────── + +/// One target sub-DAG's selected choice and usage information — the answer +/// [`CandidateLogicalASAPDAGs::global_selection`] commits to for one site, after folding in +/// every ancestor [`SharedSubDAGStrategy`] decision on the path from a +/// workload root to this site. See the module docs' "Whole-plan +/// (cross-group) selection" section for the full recurrence. +/// +/// Contrast with [`RankedTargetSubDAGCandidates`] ([`CandidateLogicalASAPDAGs::cost_sorted`]'s output): +/// that ranks every candidate for one target in isolation and never commits +/// to just one; this commits to exactly one (or none), and the count it +/// ranks against — [`Self::effective_consumer_count`] — can differ from the +/// target's own raw structural [`TargetSubDAGCandidates::consumer_count`] whenever an +/// ancestor's choice changes how many times this site truly runs. Use +/// `cost_sorted` to inspect every alternative for a site; use +/// `global_selection` when you need this module's best single answer, +/// accounting for cross-target interaction where it knows how to. +#[derive(Debug)] +pub struct TargetSubDAGSelection<'a> { + /// The target sub-DAG this selection is for. + pub target: &'a Rc, + /// [`TargetSubDAGCandidates::consumer_count`] — how many operator-child positions + /// directly reference `target`, ignoring every ancestor's own choice. + pub consumer_count: usize, + /// How many times `target`'s computation actually runs once every + /// ancestor's own selected candidate is accounted for — see + /// [`multiplier`]'s doc for the exact recurrence. Equal to + /// `consumer_count` unless some ancestor on a path from a root to this + /// site has a [`SharedSubDAGStrategy`] alternative that chose + /// [`ShareDecision::RecomputeIndependently`]. + pub effective_consumer_count: usize, + /// The candidate chosen for this target, or `None` when no replacement + /// is selected. The candidate set need not be empty: an unproven DDSketch + /// ratio can remain available for backend inspection but be excluded from + /// automatic selection, or costing can prefer raw recomputation. + /// DAG assembly then preserves exact computation at this target where + /// supported, while independently selected children may remain visible. + pub chosen: Option<&'a ReplacementSubDAG>, + /// When `chosen` is a [`Replacement::ExactComposition`]: the child + /// decision it was committed together with, and the cost comparison + /// that justified it — the explicit target-to-decision provenance + /// chain (issue #171). + pub composition: Option>, +} + +/// Why [`CandidateLogicalASAPDAGs::global_selection`] committed an exact composition at a +/// site: which child candidate it composes with, and the +/// cost-units-per-second comparison against the raw fallback that it won. +#[derive(Debug)] +pub struct CompositionDecision<'a> { + /// The exact child/operation pair validated by the search accuracy model. + pub plan: Rc, + /// The child target the composed operator consumes. + pub child_target: &'a Rc, + /// For a read-time operation: the child's own candidate committed alongside + /// (the summary evaluation the operator folds). `None` for an update-path + /// transform, whose input is raw update data — its cost is charged to + /// the maintained summary *above* it instead. + pub child_candidate: Option<&'a ReplacementSubDAG>, + /// The composed plan's recurring rate — `read_operation_plan_cost_rate` + /// or `maintenance_operation_plan_cost_rate`. + pub cost_rate: CostRate, + /// `raw_recompute_cost_rate` — the kept-sub-DAG baseline it beat. + pub baseline_rate: CostRate, + /// The statistics (and their provenance) both rates were computed from. + pub inputs: ExactCompositionCostInputs, +} + +/// [`CandidateLogicalASAPDAGs::global_selection`]'s result: one [`TargetSubDAGSelection`] per +/// discovered site, in the same discovery order [`CandidateLogicalASAPDAGs::target_subdag_candidates`]/ +/// [`CandidateLogicalASAPDAGs::cost_sorted`] use. +#[derive(Debug)] +pub struct GlobalSelection<'a> { + pub(crate) order: Vec<*const OperatorNode>, + pub(crate) groups: HashMap<*const OperatorNode, TargetSubDAGSelection<'a>>, + /// [`Self::assemble_selected_dag`]'s memo — one bound node per target for the + /// life of this selection, so two parents composing over one shared + /// child get the *same* `Rc` (a kept pre-ASAP sub-DAG + /// shared by two parents stays one `Rc` the same way). + pub(crate) assembled_nodes: RefCell>>, +} + +fn normalize_cross_input_equi_predicate( + pred: &Predicate, + left_width: usize, + total_width: usize, +) -> Option { + let ScalarExpr::Compare { + left, + op: asap_types::ir::scalar::CompareOpKind::Eq, + right, + semantics, + } = &pred.0 + else { + return None; + }; + let (ScalarExpr::Column(left_id), ScalarExpr::Column(right_id)) = + (left.as_ref(), right.as_ref()) + else { + return None; + }; + let is_left = |id: ColumnId| id < left_width; + let is_right = |id: ColumnId| left_width <= id && id < total_width; + let (left_id, right_id) = if is_left(*left_id) && is_right(*right_id) { + (*left_id, *right_id) + } else if is_right(*left_id) && is_left(*right_id) { + (*right_id, *left_id) + } else { + return None; + }; + Some(Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(left_id)), + op: asap_types::ir::scalar::CompareOpKind::Eq, + right: Box::new(ScalarExpr::Column(right_id)), + semantics: *semantics, + })) +} + +impl<'a> GlobalSelection<'a> { + /// One selection per discovered target sub-DAG, in discovery order. + pub fn target_selections(&self) -> impl Iterator> { + self.order.iter().map(move |ptr| &self.groups[ptr]) + } + + /// The selection for `target`, if `target`'s own `Rc` is a discovered + /// site (i.e. `Rc::ptr_eq` to some node reachable from the workload's + /// roots). + pub fn for_target(&self, target: &Rc) -> Option<&TargetSubDAGSelection<'a>> { + self.groups.get(&Rc::as_ptr(target)) + } + + /// Link this selection's per-site decisions into one data_state-validated + /// post-ASAP DAG rooted at `target` — the one place a committed + /// composition's child *reference* becomes an actual `Rc` + /// edge (issue #171). `None` if `target` is not a discovered site. + /// + /// Per site: a [`Replacement::ExactComposition`] uses its validated + /// operation/child plan, retaining the search model's guarantee; + /// a bound-summary [`Replacement::SubDAG`] is + /// re-linked so its `SummaryAgg` child is the child target's own + /// DAG assembly whenever that is phase-legal beneath maintenance + /// (so a child that chose an `ValueOperationAtIngestionTime` actually ends up under + /// the summary); a logical-rewrite [`Replacement::SubDAG`] is kept + /// as it is (exact); an unmatched site keeps its own operator with each + /// child assembled independently ([`Self::assemble_residual`]). + /// Memoized by target identity, so a shared inner summary is one `Rc` + /// no matter how many roots reach it. + pub fn assemble_selected_dag( + &self, + target: &Rc, + ) -> Result>, RealizationError> { + if !self.groups.contains_key(&Rc::as_ptr(target)) { + return Ok(None); + } + self.assemble_target(target).map(Some) + } + + /// Assemble a complete query result, including an exact-state evaluation when + /// needed. `assemble_selected_dag` also serves internal state frontiers; + /// callers exposing query results must use this boundary instead. + pub fn assemble_selected_query( + &self, + target: &Rc, + ) -> Result>, RealizationError> { + self.assemble_selected_dag(target)? + .map(|node| finalize_query_candidate(node, target)) + .transpose() + } + + pub(crate) fn assemble_target( + &self, + target: &Rc, + ) -> Result, RealizationError> { + let ptr = Rc::as_ptr(target); + if let Some(node) = self.assembled_nodes.borrow().get(&ptr) { + return Ok(Rc::clone(node)); + } + // A selected summary that realizes its inner aggregate, instead of + // hiding it in `KeepPreAsap`, is kept; materialization assignment decides + // whether it runs in precompute or at query time. + let selected_composed_summary = self + .groups + .get(&ptr) + .and_then(|sel| sel.chosen) + .is_some_and(|candidate| { + matches!(&candidate.replacement, + Replacement::SubDAG(node) if matches!(&node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) + if child.contains_asap() || !contains_aggregate(child))) + }); + let node = if query_time_nested_sum(target) && !selected_composed_summary { + self.assemble_residual(target)? + } else { + match self + .groups + .get(&ptr) + .and_then(|sel| sel.chosen) + .map(|c| &c.replacement) + { + None => self.assemble_residual(target)?, + Some(Replacement::SubDAG(node)) if node.contains_asap() => { + self.relink_summary(node, target)? + } + Some(Replacement::SubDAG(kept)) => retain_exact(kept)?, + Some(Replacement::ExactComposition(_)) => Rc::clone( + &self.groups[&ptr] + .composition + .as_ref() + .expect("selected compositions have a validated decision") + .plan, + ), + } + }; + self.assembled_nodes + .borrow_mut() + .insert(ptr, Rc::clone(&node)); + Ok(node) + } + + /// Keep `target`'s own operator and assemble each child independently, + /// so a selected summary remains visible beneath a relational operator + /// that has no summary realization of its own instead of being + /// swallowed by one opaque kept sub-DAG. Every child that is a + /// discovered target is assembled (and finalized to query-time values); + /// any other child is kept as it is. The guarantee is composed from the + /// assembled children: all exact → exact; exactly one child → that + /// child's guarantee; otherwise unknown. An inner `Join` first has its + /// cross-input equi-predicate normalized; any other join is kept whole. + fn assemble_residual( + &self, + target: &Rc, + ) -> Result, RealizationError> { + if target.children().is_empty() { + // A leaf has nothing to assemble beneath it: keep it as it is. + return retain_exact(target); + } + let mut operator = target.operator.clone(); + if let Operator::NonASAP(NonASAPOp::Join { + left, + right, + kind, + pred, + }) = &mut operator + { + let left_width = left.schema.fields.len(); + let total_width = left_width + right.schema.fields.len(); + let normalized_pred = matches!(kind, JoinKind::Inner) + .then(|| normalize_cross_input_equi_predicate(pred, left_width, total_width)) + .flatten(); + let Some(normalized) = normalized_pred else { + return retain_exact(target); + }; + *pred = normalized; + } + let mut failure = None; + let mut children = Vec::new(); + let operator = operator.map_children(|child| { + if failure.is_some() { + return Rc::clone(child); + } + let assembled = if self.groups.contains_key(&Rc::as_ptr(child)) { + self.assemble_target(child) + .and_then(|node| finalize_query_candidate(node, child)) + } else { + Ok(Rc::clone(child)) + }; + match assembled { + Ok(node) => { + children.push(Rc::clone(&node)); + node + } + Err(error) => { + failure = Some(error); + Rc::clone(child) + } + } + }); + if let Some(error) = failure { + return Err(error); + } + // An operator that computes new values from its input rows has no + // sound accuracy composition over an approximate input (e.g. `max` + // over a quantile evaluation's rank error). Without a selected + // composition such a node stays an exact pre-ASAP sub-DAG; only the + // read-time nested SUM keeps its assembled children. + let computes_values = matches!( + target.non_asap(), + Some( + NonASAPOp::Aggregate { .. } + | NonASAPOp::BinaryOp { .. } + | NonASAPOp::SQLWindowFunc { .. } + ) + ) && !query_time_nested_sum(target); + let approximate_input = children.iter().any(|child| { + !child + .guarantee + .as_ref() + .is_some_and(ResultGuarantee::is_exact) + }); + if computes_values && approximate_input { + return retain_exact(target); + } + let guarantee = match children.as_slice() { + [child] => child.guarantee.clone(), + children + if children.iter().all(|child| { + child + .guarantee + .as_ref() + .is_some_and(ResultGuarantee::is_exact) + }) => + { + Some(ResultGuarantee::exact(format!( + "{} over exact inputs", + target.operator.kind_name() + ))) + } + _ => None, + }; + let node = Rc::new( + OperatorNode::with_schema(operator, target.schema.clone()).with_guarantee(guarantee), + ); + validate_maintained(&node, ExecutionTiming::QueryTime)?; + Ok(node) + } + + /// Re-link a bound summary candidate's `SummaryAgg` child to the + /// child target's own DAG assembly when that is legal beneath + /// maintenance; otherwise keep the candidate exactly as constructed. + fn relink_summary( + &self, + node: &Rc, + target: &Rc, + ) -> Result, RealizationError> { + let Some(NonASAPOp::Aggregate { + child: pre_child, .. + }) = target.non_asap() + else { + return Ok(Rc::clone(node)); + }; + let has_maintenance_operation = self + .groups + .get(&Rc::as_ptr(pre_child)) + .and_then(|selection| selection.chosen) + .is_some_and(|candidate| { + matches!( + &candidate.replacement, + Replacement::ExactComposition(composition) + if composition.placement == OperationPlacement::Maintenance + ) + }); + if !has_maintenance_operation { + return Ok(Rc::clone(node)); + } + let new_child = self.assemble_target(pre_child)?; + Ok(relink_agg_child(node, &new_child)) + } +} + +/// A mergeable outer SUM over a relationally wrapped aggregate is a read-time +/// reduction of the inner summary values. Maintaining the outer SUM directly +/// would hide that inner temporal aggregate inside one kept sub-DAG and lose +/// its independently selected summary. +fn query_time_nested_sum(target: &OperatorNode) -> bool { + let Some(NonASAPOp::Aggregate { + measures, + filters, + having: None, + child, + .. + }) = target.non_asap() + else { + return false; + }; + !any_measure_filtered(filters) + && matches!(measures.as_slice(), [AggIntent::Sum { .. }]) + && contains_aggregate(child) +} + +fn contains_aggregate(expr: &OperatorNode) -> bool { + match expr.non_asap() { + Some(NonASAPOp::Aggregate { .. }) => true, + Some( + NonASAPOp::Project { child, .. } + | NonASAPOp::Filter { child, .. } + | NonASAPOp::Sort { child, .. } + | NonASAPOp::Limit { child, .. }, + ) => contains_aggregate(child), + _ => false, + } +} + +/// Rebuild `node` (a `SummaryAgg`, possibly under a `SummaryEstimate`) with +/// `new_child` as the `SummaryAgg`'s child, if the result still validates +/// as maintained state; otherwise return `node` unchanged. +fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input, + query, + }) => { + let inner = relink_agg_child(summary_input, new_child); + if Rc::ptr_eq(&inner, summary_input) { + return Rc::clone(node); + } + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: inner, + query: query.clone(), + }), + node.schema.clone(), + ) + .with_guarantee(node.guarantee.clone()), + ) + } + Operator::ASAP(ASAPOp::SummaryAgg { + child, + family, + input, + reduction, + grouping, + filter, + }) => { + if Rc::ptr_eq(child, new_child) { + return Rc::clone(node); + } + // The same summary over a re-placed input keeps its coverage. + let rebuilt = std::rc::Rc::new(OperatorNode { + coverage: node.coverage.clone(), + ..OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child: Rc::clone(new_child), + family: family.clone(), + input: input.clone(), + reduction: reduction.clone(), + grouping: grouping.clone(), + filter: filter.clone(), + }), + node.schema.clone(), + ) + .with_guarantee(node.guarantee.clone()) + }); + match validate_maintained(&rebuilt, ExecutionTiming::IngestionTime) { + Ok(_) => rebuilt, + Err(_) => Rc::clone(node), + } + } + _ => Rc::clone(node), + } +} + +/// The maintained `SummaryAgg` a bound summary candidate builds (under +/// its `SummaryEstimate` evaluation, if any) — the summary an `ValueOperationAtIngestionTime` +/// beneath it feeds, for `maintenance_operation_plan_cost_rate`. +fn maintained_summary(node: &Rc) -> Option<&Rc> { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + maintained_summary(summary_input) + } + Operator::ASAP(ASAPOp::SummaryAgg { .. }) => Some(node), + _ => None, + } +} + +fn is_composition_candidate(candidate: &ReplacementSubDAG) -> bool { + matches!(candidate.replacement, Replacement::ExactComposition(_)) +} + +/// Everything [`CandidateLogicalASAPDAGs::global_selection`] threads between sites for +/// exact compositions (issue #171): child candidates already committed by +/// an earlier parent, and the maintained summary above each site. +#[derive(Default)] +struct CompositionContext { + /// child target ptr → the child's candidate an ancestor's composition + /// already committed to (a later parent must compose with the *same* + /// one, and the child's own selection is forced to it). + committed_child: HashMap<*const OperatorNode, *const ReplacementSubDAG>, + /// site ptr → the maintained `SummaryAgg` directly above it, when its + /// parent chose a bound summary — what an `ValueOperationAtIngestionTime` here feeds. + maintaining_parent: HashMap<*const OperatorNode, Rc>, +} + +/// One eligible composed alternative at a site, before the cheapest wins. +struct CompositionOption<'a> { + candidate: &'a ReplacementSubDAG, + decision: CompositionDecision<'a>, +} + +/// Every [`Replacement::ExactComposition`] candidate of `group` whose +/// composed-plan rate is *known* and beats the raw-recompute baseline — +/// costed against each compatible child candidate already in `CandidateLogicalASAPDAGs` +/// (or the one an earlier parent committed). Unknown statistics yield no +/// option at all: the conservative kept-sub-DAG path stays. +fn composition_options<'a>( + group: &'a TargetSubDAGCandidates, + groups: &'a HashMap<*const OperatorNode, TargetSubDAGCandidates>, + effective: usize, + cost_model: &dyn CostModel, + context: &CompositionContext, + plans: &[PreparedComposition], +) -> Vec> { + let mut options = Vec::new(); + for candidate in &group.candidates { + let Replacement::ExactComposition(composition) = &candidate.replacement else { + continue; + }; + if candidate.runtime_support_evidence(cost_model) != Some(true) { + continue; + } + let child_ptr = Rc::as_ptr(&composition.child_target); + let Some(child_group) = groups.get(&child_ptr) else { + continue; + }; + let already_committed = context.committed_child.get(&child_ptr).copied(); + let cost = |summary: &OperatorNode, shared: bool| { + let request = ExactCompositionCostRequest { + target: &group.target, + composition, + summary, + effective_consumer_count: effective, + }; + let mut inputs = cost_model.exact_composition_cost_inputs(&request); + if shared { + // Shared state is counted once: an earlier parent already + // pays this child's maintenance, so the marginal cost here + // is zero — a *known* zero, unlike an unknown input. + if let Some(maintenance) = inputs.summary_maintenance_cost_per_update.as_mut() { + *maintenance = 0.0; + } + } + let rate = inputs.composed_plan_cost_rate(composition.placement)?; + let baseline = raw_recompute_cost_rate(&inputs)?; + (rate < baseline).then_some((rate, baseline, inputs)) + }; + match composition.placement { + OperationPlacement::Read => { + let child_candidates: Vec<&'a ReplacementSubDAG> = match already_committed { + // SAFETY-free: the pointer was taken from `groups`'s own + // candidate storage, which outlives this borrow. + Some(ptr) => child_group + .candidates + .iter() + .filter(|c| std::ptr::eq(*c, ptr)) + .collect(), + None => child_group.candidates.iter().collect(), + }; + for child_candidate in child_candidates { + if !is_automatically_selectable(child_candidate, cost_model) { + continue; + } + let Replacement::SubDAG(summary) = &child_candidate.replacement else { + continue; + }; + if is_logical_rewrite(summary) || !composition.accepts_child(summary) { + continue; + } + let Some(prepared) = plans.iter().find(|p| { + p.target == Rc::as_ptr(&group.target) + && p.operation.same_as(composition) + && Rc::ptr_eq(&p.child, summary) + }) else { + continue; + }; + let Some((rate, baseline, inputs)) = cost(summary, already_committed.is_some()) + else { + continue; + }; + options.push(CompositionOption { + candidate, + decision: CompositionDecision { + plan: Rc::clone(&prepared.plan), + child_target: &composition.child_target, + child_candidate: Some(child_candidate), + cost_rate: rate, + baseline_rate: baseline, + inputs, + }, + }); + } + } + OperationPlacement::Maintenance => { + let Some(prepared) = plans.iter().find(|p| { + p.target == Rc::as_ptr(&group.target) && p.operation.same_as(composition) + }) else { + continue; + }; + // An maintenance-time operation only pays off beneath a + // maintained summary; with nothing above it, its output is + // never read and the raw fallback is the same computation. + let Some(parent) = context.maintaining_parent.get(&Rc::as_ptr(&group.target)) + else { + continue; + }; + let Some((rate, baseline, inputs)) = cost(parent, false) else { + continue; + }; + options.push(CompositionOption { + candidate, + decision: CompositionDecision { + plan: Rc::clone(&prepared.plan), + child_target: &composition.child_target, + child_candidate: None, + cost_rate: rate, + baseline_rate: baseline, + inputs, + }, + }); + } + } + } + options +} + +impl CandidateLogicalASAPDAGs { + /// The whole-plan (cross-group) selection step the module docs' + /// "Whole-plan (cross-group) selection" section describes: one + /// [`TargetSubDAGSelection`] per discovered site, each ranked against an + /// `effective_consumer_count` that accounts for every ancestor + /// [`SharedSubDAGStrategy`] decision on the path to it — unlike + /// [`Self::cost_sorted`], whose per-group ranking only ever sees a + /// group's own raw [`TargetSubDAGCandidates::consumer_count`]. + /// Uncertified DDSketch ratios remain in [`CandidateLogicalASAPDAGs`] for downstream + /// inspection but are not chosen automatically by this selector. + pub fn global_selection(&self, cost_model: &dyn CostModel) -> GlobalSelection<'_> { + self.global_selection_impl(cost_model, None, None) + .expect("structural global selection cannot produce a recurrence error") + } + + /// Recurrence-aware counterpart to [`Self::global_selection`]. The same + /// whole-plan traversal and effective structural consumer counts are + /// retained, while every CSE share/recompute choice is made from the + /// corresponding recurrence profile. + pub fn global_selection_with_recurrence( + &self, + cost_model: &dyn CostModel, + profiles: &RecurrenceProfileMap, + horizon: Option, + ) -> Result, RecurrenceError> { + self.global_selection_impl(cost_model, Some(profiles), horizon) + } + + fn global_selection_impl( + &self, + cost_model: &dyn CostModel, + profiles: Option<&RecurrenceProfileMap>, + horizon: Option, + ) -> Result, RecurrenceError> { + let dag = reference_dag(self); + let topo = topological_order(&self.order, &dag); + + let mut effective_uses = dag.external_root_uses.clone(); + let mut chosen_share: HashMap<*const OperatorNode, ShareDecision> = HashMap::new(); + let mut groups: HashMap<*const OperatorNode, TargetSubDAGSelection<'_>> = HashMap::new(); + let mut context = CompositionContext::default(); + + for ptr in &topo { + let group = &self.groups[ptr]; + + let effective = effective_uses.get(ptr).copied().unwrap_or(0); + effective_uses.insert(*ptr, effective); + + // ── Exact compositions (issue #171) ───────────────────────── + // A child an earlier parent's composition committed to is + // forced to exactly that candidate — the parent/child pair is + // one decision. Otherwise, a composition here wins only when + // its cost-units-per-second rate is *known* and beats the raw + // recompute baseline; missing statistics keep the conservative + // path below. + let mut composition_decision = None; + let forced = context + .committed_child + .get(ptr) + .and_then(|&cptr| group.candidates.iter().find(|c| std::ptr::eq(*c, cptr))); + let composed = if forced.is_some() { + None + } else { + composition_options( + group, + &self.groups, + effective, + cost_model, + &context, + &self.composition_plans, + ) + .into_iter() + .min_by(|a, b| a.decision.cost_rate.0.total_cmp(&b.decision.cost_rate.0)) + }; + if let Some(option) = &composed { + if let Some(child_candidate) = option.decision.child_candidate { + context.committed_child.insert( + Rc::as_ptr(option.decision.child_target), + child_candidate as *const ReplacementSubDAG, + ); + } + if let Replacement::ExactComposition(composition) = &option.candidate.replacement { + if composition.placement == OperationPlacement::Maintenance { + // A chain of functions feeds the same summary. + if let Some(parent) = context.maintaining_parent.get(ptr).cloned() { + context + .maintaining_parent + .insert(Rc::as_ptr(&composition.child_target), parent); + } + } + } + } + + let complete_plan_choice = (!forced.is_some() + && composed.is_none() + && cost_model.candidate_cost_covers_complete_plan()) + .then(|| { + let effective_target = TargetSubDAG::with_consumer_count(&group.target, effective); + let bound = group + .candidates + .iter() + .filter(|candidate| { + !is_cse_candidate(candidate) + && !is_composition_candidate(candidate) + && is_automatically_selectable(candidate, cost_model) + }) + .filter_map(|candidate| { + cost_model + .candidate_cost(candidate, &effective_target) + .map(|cost| (candidate, cost)) + }) + .min_by(|(_, left), (_, right)| left.0.total_cmp(&right.0)) + .map(|(candidate, _)| candidate); + bound.or_else(|| { + (cost_model.allow_uncosted_legacy_selection() && effective >= 2) + .then(|| { + decide_with_effective_count(group, effective, cost_model).and_then( + |decision| { + let candidate = pick_shared_sub_dag_candidate(group, decision)?; + chosen_share.insert(*ptr, decision); + Some(candidate) + }, + ) + }) + .flatten() + }) + }) + .flatten(); + + let chosen = if let Some(forced) = forced { + Some(forced) + } else if let Some(option) = composed { + composition_decision = Some(option.decision); + Some(option.candidate) + } else if cost_model.candidate_cost_covers_complete_plan() { + complete_plan_choice + } else if effective >= 2 && cse_candidate_pair(group).is_some() { + let decision = if let Some(profiles) = profiles { + decide_group_with_recurrence( + group, + effective, + profiles.for_target(&group.target), + horizon, + cost_model, + )? + } else { + decide_with_effective_count(group, effective, cost_model) + }; + match decision { + Some(decision) => { + let cse = pick_shared_sub_dag_candidate(group, decision); + let effective_target = + TargetSubDAG::with_consumer_count(&group.target, effective); + let logical = group + .candidates + .iter() + .filter(|candidate| { + !is_cse_candidate(candidate) + && !is_composition_candidate(candidate) + && is_automatically_selectable(candidate, cost_model) + }) + .filter_map(|candidate| { + cost_model + .candidate_cost(candidate, &effective_target) + .map(|cost| (candidate, cost)) + }) + .min_by(|(_, a), (_, b)| a.0.total_cmp(&b.0)) + .map(|(candidate, _)| candidate); + let cse = cse.filter(|candidate| { + cost_model + .candidate_cost(candidate, &effective_target) + .is_some() + || cost_model.allow_uncosted_legacy_selection() + }); + match (cse, logical) { + (Some(cse), Some(logical)) + if cost_model + .candidate_cost(cse, &effective_target) + .is_none_or(|cse_cost| { + cost_model + .candidate_cost(logical, &effective_target) + .is_some_and(|logical_cost| logical_cost.0 < cse_cost.0) + }) => + { + Some(logical) + } + (cse, _) => { + if cse.is_some() { + chosen_share.insert(*ptr, decision); + } + cse + } + } + } + // `realize_child` couldn't produce even a logical fallback — + // not expected in practice for a target that's already + // part of a legitimate workload DAG (mirrors + // `cse_preference`'s own doc on this same degrade). + // Falling back to ordinary local ranking is still a + // valid answer, just not a cross-group-aware one; this + // group also contributes no Share collapse to its own + // children (see `multiplier`'s `_ => effective` arm). + None => rank_group(group, cost_model).into_iter().find(|candidate| { + !is_composition_candidate(candidate) + && is_automatically_selectable(candidate, cost_model) + && (cost_model + .candidate_cost( + candidate, + &TargetSubDAG::with_consumer_count(&group.target, effective), + ) + .is_some() + || cost_model.allow_uncosted_legacy_selection()) + }), + } + } else { + let effective_target = TargetSubDAG::with_consumer_count(&group.target, effective); + rank_group(group, cost_model) + .into_iter() + .find(|candidate| { + !is_cse_candidate(candidate) + && !is_composition_candidate(candidate) + && is_automatically_selectable(candidate, cost_model) + && (cost_model + .candidate_cost(candidate, &effective_target) + .is_some() + || cost_model.allow_uncosted_legacy_selection()) + }) + .or_else(|| { + cse_candidate_pair(group) + .map(|(share, _)| share) + .filter(|candidate| { + cost_model + .candidate_cost(candidate, &effective_target) + .is_some() + || cost_model.allow_uncosted_legacy_selection() + }) + }) + }; + + // Record the maintained summary this site's bound candidate + // builds, for a child that may compose an `ValueOperationAtIngestionTime` + // beneath it. + if let (Some(Replacement::SubDAG(node)), Some(NonASAPOp::Aggregate { child, .. })) = + (chosen.map(|c| &c.replacement), group.target.non_asap()) + { + if let Some(summary) = maintained_summary(node) { + context + .maintaining_parent + .insert(Rc::as_ptr(child), Rc::clone(summary)); + } + } + + let outgoing_multiplier = multiplier(*ptr, &effective_uses, &chosen_share); + match chosen { + Some(ReplacementSubDAG { + replacement: Replacement::SubDAG(source), + provenance: ReplacementProvenance::AccuracyReconciliation, + .. + }) => { + // Accuracy reconciliation reads another discovered memo + // group, rather than inlining that group's children. Let + // the source group receive the uses and propagate them + // through its own selected realization when its turn + // arrives in topological order. + *effective_uses.entry(Rc::as_ptr(source)).or_insert(0) += outgoing_multiplier; + } + _ => { + let selected_rewrite = match chosen.map(|candidate| &candidate.replacement) { + Some(Replacement::SubDAG(rewrite)) if is_logical_rewrite(rewrite) => { + rewrite + } + Some(Replacement::SubDAG(_) | Replacement::ExactComposition(_)) | None => { + &group.target + } + }; + for (child, edge_count) in direct_child_counts(selected_rewrite) { + *effective_uses.entry(child).or_insert(0) += + edge_count * outgoing_multiplier; + } + } + } + + groups.insert( + *ptr, + TargetSubDAGSelection { + target: &group.target, + consumer_count: group.consumer_count, + effective_consumer_count: effective, + chosen, + composition: composition_decision, + }, + ); + } + + Ok(GlobalSelection { + order: self.order.clone(), + groups, + assembled_nodes: RefCell::new(HashMap::new()), + }) + } +} + +fn is_cse_candidate(candidate: &ReplacementSubDAG) -> bool { + matches!( + candidate.provenance, + ReplacementProvenance::CseShare | ReplacementProvenance::CseRecompute + ) +} + +fn is_automatically_selectable(candidate: &ReplacementSubDAG, cost_model: &dyn CostModel) -> bool { + candidate.provenance != ReplacementProvenance::RootPhysicalRealization + && !candidate.has_missing_accuracy_evidence() + && candidate.runtime_support_evidence(cost_model) != Some(false) +} + +/// How much one direct reference to `parent_ptr` actually costs, once +/// `parent_ptr`'s own chosen candidate (if it has a Share/Recompute pair at +/// all) is taken into account: +/// +/// - `1`, if `parent_ptr` chose [`ShareDecision::Share`] — one shared +/// execution backs every reference to it, so referencing it costs no more +/// than referencing it once. +/// - `parent_ptr`'s own `effective_consumer_count` otherwise — either it +/// chose [`ShareDecision::RecomputeIndependently`] (each of its own uses +/// gets its own independent execution, so referencing it costs as much as +/// its *own* full multiplicity), or it has no Share/Recompute decision at +/// all (not a [`SharedSubDAGStrategy`] shape — nothing here collapses +/// its multiplicity to one, so whatever multiplicity *its* ancestors +/// established simply passes through). +/// +/// Composing this recurrence transitively up the whole ancestor chain (not +/// just the immediate parent) is exactly what makes +/// [`CandidateLogicalASAPDAGs::global_selection`]'s `effective_consumer_count` differ from +/// [`TargetSubDAGCandidates::consumer_count`] whenever a `RecomputeIndependently` +/// ancestor sits anywhere on the path from a root to a site — see the +/// module docs' "Whole-plan (cross-group) selection" section. +fn multiplier( + parent_ptr: *const OperatorNode, + effective_uses: &HashMap<*const OperatorNode, usize>, + chosen_share: &HashMap<*const OperatorNode, ShareDecision>, +) -> usize { + let effective = *effective_uses.get(&parent_ptr).expect( + "topological_order guarantees a parent is processed (and its effective_consumer_count \ + recorded) before any of its children", + ); + match chosen_share.get(&parent_ptr) { + Some(ShareDecision::Share) => 1, + _ => effective, + } +} + +/// [`CostModel::cse_share_decision`] for `group`, against an explicit +/// `effective_consumer_count` instead of `group.consumer_count` — the +/// cross-group-aware counterpart to [`cse_preference`], which uses the raw +/// structural count. `None` only when [`realize_child`] can't produce even a +/// logical fallback for `group.target` (see that function's own doc). +fn decide_with_effective_count( + group: &TargetSubDAGCandidates, + effective_consumer_count: usize, + cost_model: &dyn CostModel, +) -> Option { + let bound = realize_child(&group.target, cost_model).ok()?; + let candidate = CseCandidate { + sub_dag: &group.target, + bound_summary: &bound, + consumer_count: effective_consumer_count, + }; + Some(cost_model.cse_share_decision(&candidate)) +} + +fn decide_group_with_recurrence( + group: &TargetSubDAGCandidates, + effective_consumer_count: usize, + recurrence: RecurrenceProfile, + horizon: Option, + cost_model: &dyn CostModel, +) -> Result, RecurrenceError> { + let Some(bound) = realize_child(&group.target, cost_model).ok() else { + return Ok(None); + }; + let candidate = CseCandidate { + sub_dag: &group.target, + bound_summary: &bound, + consumer_count: effective_consumer_count, + }; + Ok(Some( + cost_model + .cse_share_decision_with_recurrence(&candidate, &recurrence, horizon)? + .decision, + )) +} + +/// The [`SharedSubDAGStrategy`] candidate matching `decision`: the one +/// that shares `group.target`'s own `Rc` for [`ShareDecision::Share`], the +/// freshly-allocated one for [`ShareDecision::RecomputeIndependently`] — +/// the same `Rc`-identity distinction [`is_duplicate_rewrite`]'s own doc +/// explains is the *only* signal this IR carries for that choice. +fn pick_shared_sub_dag_candidate( + group: &TargetSubDAGCandidates, + decision: ShareDecision, +) -> Option<&ReplacementSubDAG> { + let (share, recompute) = cse_candidate_pair(group)?; + Some(match decision { + ShareDecision::Share => share, + ShareDecision::RecomputeIndependently => recompute, + }) +} + +// ── reference DAG + topological order ───────────────────────────────── + +/// The parent/child structure [`CandidateLogicalASAPDAGs::global_selection`]'s DP walks — +/// built separately from [`discover_targets`]'s own `order`/`nodes`/`counts` +/// maps (which only track *aggregate* reference counts, not per-parent +/// breakdown or direction). Selection needs per-parent edge counts to +/// distinguish shared producers from repeated uses within one consumer. +struct ReferenceDAG { + /// child ptr -> `(parent ptr, edge count from that one parent)`, for + /// every direct operator-child edge in the relational-skeleton scope + /// [`walk_children`] itself uses (an edge count above 1 happens when + /// one parent references the same child from two different fields, + /// e.g. a `Join`'s `left`/`right` both being the same `Rc`). + parents_of: HashMap<*const OperatorNode, Vec<(*const OperatorNode, usize)>>, + /// parent ptr -> every distinct child ptr it directly references — the + /// reverse of `parents_of`, for [`topological_order`]'s Kahn's-algorithm + /// traversal. + children_of: HashMap<*const OperatorNode, Vec<*const OperatorNode>>, + /// How many of the workload's own `roots` point directly at each node — + /// a node's "external" use. Nothing inside the DAG decides this (it + /// isn't a reference from another discovered site), so it's never + /// subject to any ancestor's Share/Recompute choice — it's the base + /// case [`CandidateLogicalASAPDAGs::global_selection`]'s recurrence starts from. + external_root_uses: HashMap<*const OperatorNode, usize>, +} + +/// Build an ordering DAG containing every edge that could be selected: +/// the original target's edges plus every rewrite candidate's edges. An +/// accuracy-reconciliation rewrite points at another discovered memo group, +/// so it contributes an edge to that group itself; other rewrites contribute +/// their relational children as before. The +/// DAG is deliberately only used for topological ordering; effective-use +/// counts are propagated through the one candidate actually selected. +fn reference_dag(space: &CandidateLogicalASAPDAGs) -> ReferenceDAG { + let mut dag = ReferenceDAG { + parents_of: HashMap::new(), + children_of: HashMap::new(), + external_root_uses: HashMap::new(), + }; + for (_, root) in &space.roots { + *dag.external_root_uses.entry(Rc::as_ptr(root)).or_insert(0) += 1; + } + for ptr in &space.order { + let group = &space.groups[ptr]; + record_possible_edges(*ptr, &group.target, &mut dag); + for candidate in &group.candidates { + if let Replacement::SubDAG(rewrite) = &candidate.replacement { + if !is_logical_rewrite(rewrite) { + continue; + } + if candidate.provenance == ReplacementProvenance::AccuracyReconciliation { + add_edge(*ptr, Rc::as_ptr(rewrite), 1, &mut dag); + } else { + record_possible_edges(*ptr, rewrite, &mut dag); + } + } + } + } + dag +} + +/// Record one `parent_ptr -> child` edge (both directions — see +/// [`ReferenceDAG`]'s fields), retaining the greatest multiplicity seen +/// when the target and alternative rewrites expose the same edge. +fn add_edge( + parent_ptr: *const OperatorNode, + child_ptr: *const OperatorNode, + edge_count: usize, + dag: &mut ReferenceDAG, +) { + let siblings = dag.parents_of.entry(child_ptr).or_default(); + match siblings.iter_mut().find(|(p, _)| *p == parent_ptr) { + Some((_, count)) => *count = (*count).max(edge_count), + None => siblings.push((parent_ptr, edge_count)), + } + let kids = dag.children_of.entry(parent_ptr).or_default(); + if !kids.contains(&child_ptr) { + kids.push(child_ptr); + } +} + +fn record_possible_edges( + parent_ptr: *const OperatorNode, + node: &OperatorNode, + dag: &mut ReferenceDAG, +) { + for (child_ptr, edge_count) in direct_child_counts(node) { + add_edge(parent_ptr, child_ptr, edge_count, dag); + } +} + +/// A topological order over `order` (parent before every child) via Kahn's +/// algorithm on `dag`'s reverse adjacency — needed because +/// [`discover_targets`]'s own `order` is only a valid *discovery* order +/// (first-seen-first), not a valid topological one: a node reached via two +/// different root paths can have a parent that's discovered *after* it (see +/// this function's own test for a worked diamond example), which is exactly +/// backwards for [`CandidateLogicalASAPDAGs::global_selection`]'s recurrence. +fn topological_order( + order: &[*const OperatorNode], + dag: &ReferenceDAG, +) -> Vec<*const OperatorNode> { + let mut in_degree: HashMap<*const OperatorNode, usize> = HashMap::new(); + for ptr in order { + let degree = dag.parents_of.get(ptr).map(Vec::len).unwrap_or(0); + in_degree.insert(*ptr, degree); + } + + let mut queue: VecDeque<*const OperatorNode> = order + .iter() + .copied() + .filter(|ptr| in_degree[ptr] == 0) + .collect(); + + let mut topo = Vec::with_capacity(order.len()); + while let Some(ptr) = queue.pop_front() { + topo.push(ptr); + if let Some(children) = dag.children_of.get(&ptr) { + for child in children { + if let Some(degree) = in_degree.get_mut(child) { + *degree -= 1; + if *degree == 0 { + queue.push_back(*child); + } + } + } + } + } + + assert_eq!( + topo.len(), + order.len(), + "topological_order: the discovered-site reference dag has a cycle — every \ + OperatorNode is built from Rc children, which can't form one, so this indicates a bug \ + in reference_dag rather than a real cyclic workload", + ); + topo +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::accuracy::DefaultAccuracyModel; + use crate::cost_model::{Cost, DefaultCostModel}; + use crate::replacement::{ + default_strategies, default_strategies_with, discover_targets, search_workload, + search_workload_with, search_workload_with_targets, ASAPStrategies, ReplacementStrategy, + }; + use crate::test_support::{agg, lower_promql, metric_scan}; + use asap_types::ir::operator::agg_intent::default_quantile; + use asap_types::ir::operator::operator_properties::Reduction; + use asap_types::ir::ProjectItem; + use asap_types::types::AccuracyTarget; + + fn equi_pred(left: ColumnId, right: ColumnId) -> Predicate { + Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(left)), + op: asap_types::ir::scalar::CompareOpKind::Eq, + right: Box::new(ScalarExpr::Column(right)), + semantics: asap_types::ir::ExprSemantics::Sql, + }) + } + + fn quantile_eps_intent(q: f64, e: f64) -> AggIntent { + AggIntent::Quantile { + col: None, + q, + accuracy: AccuracyTarget::Epsilon(e), + } + } + + fn realize(expr: &OperatorNode) -> Result, RealizationError> { + realize_child(&Rc::new(expr.clone()), &DefaultCostModel) + } + + #[test] + fn relational_join_predicate_requires_and_normalizes_cross_input_columns() { + let forward = normalize_cross_input_equi_predicate(&equi_pred(1, 3), 2, 4) + .expect("left-to-right equality"); + let reverse = normalize_cross_input_equi_predicate(&equi_pred(3, 1), 2, 4) + .expect("right-to-left equality"); + assert_eq!(forward, reverse, "reverse equality must be canonicalized"); + assert!(normalize_cross_input_equi_predicate(&equi_pred(0, 1), 2, 4).is_none()); + assert!(normalize_cross_input_equi_predicate(&equi_pred(0, 4), 2, 4).is_none()); + } + + #[test] + fn relational_join_is_exact_only_when_both_inputs_are_exact() { + // `relational_join_guarantee` folded into assembly's generic + // "keep the operator, assemble its children" branch: an assembled + // inner equi-`Join` is exact exactly when both assembled inputs are. + let join = |left_intent: AggIntent, right_intent: AggIntent| { + let left = agg(vec![2], left_intent, metric_scan(&["job"])); + let right = agg( + vec![2], + right_intent, + crate::test_support::scan("n", metric_scan(&["job"]).schema.clone()), + ); + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { + kind: asap_types::ir::operator::operator_properties::JoinKind::Inner, + pred: equi_pred(0, 2), + left, + right, + })) + .unwrap() + }; + let is_exact = |node: &OperatorNode| { + node.guarantee + .as_ref() + .is_some_and(ResultGuarantee::is_exact) + }; + for (root, both_exact_expected) in [ + ( + join(AggIntent::Sum { col: None }, AggIntent::Sum { col: None }), + true, + ), + ( + join(AggIntent::Sum { col: None }, quantile_eps_intent(0.5, 0.05)), + false, + ), + ] { + let space = search_workload(vec![(0usize, Rc::clone(&root))]); + let assembled = space + .global_selection(&DefaultCostModel) + .assemble_selected_query(&space.roots[0].1) + .unwrap() + .unwrap(); + let Some(NonASAPOp::Join { left, right, .. }) = assembled.non_asap() else { + panic!("the join is kept and its inputs assembled: {assembled:?}"); + }; + assert_eq!( + is_exact(&assembled), + is_exact(left) && is_exact(right), + "join guarantee must be exact iff both inputs are exact" + ); + if both_exact_expected { + assert!(is_exact(&assembled), "exact inputs give an exact join"); + } + } + } + + // ── cost-based ranking ─────────────────────────────────────────────── + + #[test] + fn cost_sorted_orders_shared_sub_dag_candidates_by_cse_share_decision() { + // Many consumers of a cheap-to-recompute, cheap-to-maintain exact + // accumulator: cse_share_decision should prefer Share (see + // cost_model.rs's own `cse_share_decision_shares_when_recompute_dominates_maintenance`). + let mut roots = Vec::new(); + let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + for i in 0..20 { + roots.push((i, Rc::new((*shared).clone()))); + } + let space = search_workload(roots); + let group = space.candidates_for_target(&space.roots[0].1).unwrap(); + assert_eq!(group.consumer_count, 20); + + let ranked = space.cost_sorted(&DefaultCostModel); + let ranked_group = ranked + .iter() + .find(|g| Rc::ptr_eq(g.target, &space.roots[0].1)) + .unwrap(); + assert!(matches!( + &ranked_group.candidates[0].replacement, + Replacement::SubDAG(rc) if Rc::ptr_eq(rc, &group.target) + )); + let rewrites: Vec<&ReplacementSubDAG> = ranked_group + .candidates + .iter() + .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if !n.contains_asap())) + .copied() + .collect(); + assert_eq!(rewrites.len(), 2); + let first_shares_target = match &rewrites[0].replacement { + Replacement::SubDAG(rc) => Rc::ptr_eq(rc, &group.target), + Replacement::ExactComposition(_) => false, + }; + assert!( + first_shares_target, + "with 20 cheap consumers, Share should rank first: {rewrites:?}" + ); + } + + #[test] + fn cost_sorted_orders_sketch_candidates_by_rank_candidates() { + struct PreferDDSketch; + impl CostModel for PreferDDSketch { + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + let mut v = candidates.to_vec(); + if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { + let dd = v.remove(pos); + v.insert(0, dd); + } + v + } + } + + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + let space = search_workload(vec![("q", root)]); + let ranked = space.cost_sorted(&PreferDDSketch); + let agg_group = ranked + .iter() + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) + .unwrap(); + assert_eq!(agg_group.candidates.len(), 2); + let first_kind = match &agg_group.candidates[0].replacement { + Replacement::SubDAG(node) => sketch_kind_of(node), + Replacement::ExactComposition(_) => None, + }; + assert_eq!(first_kind, Some(SketchAlgorithm::DDSketch)); + } + + #[test] + fn grouping_cost_cannot_resurrect_unprovable_hydra_candidates() { + struct EstimatedSubpopulations(usize); + + impl CostModel for EstimatedSubpopulations { + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + + fn estimated_subpopulation_count(&self, _target: &OperatorNode) -> Option { + Some(self.0) + } + } + + fn first_grouping(estimated_count: usize) -> GroupingStrategy { + let model = EstimatedSubpopulations(estimated_count); + let intent = AggIntent::Count { + accuracy: AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }, + }; + let root = agg(vec![2, 3], intent, metric_scan(&["tenant_id", "endpoint"])); + let strategies = default_strategies_with(&model); + let space = search_workload_with(vec![("tenant_endpoint_count", root)], &strategies); + let ranked = space.cost_sorted(&model); + let aggregate = ranked + .iter() + .find(|group| matches!(group.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) + .expect("aggregate group"); + let Replacement::SubDAG(node) = &aggregate.candidates[0].replacement else { + panic!("grouping candidate must be a summary") + }; + summary_grouping(node) + .expect("bound summary grouping") + .clone() + } + + assert_eq!( + first_grouping(10_000), + GroupingStrategy::PerSubpopulationInstance + ); + assert_eq!( + first_grouping(10), + GroupingStrategy::PerSubpopulationInstance + ); + } + + /// [`RankedTargetSubDAGCandidates::costs`] is a per-candidate annotation, aligned + /// index-for-index with `candidates` — each entry must equal what + /// calling [`CostModel::estimate_cost`] directly on that same candidate + /// and target produces, not some other (or stale) number. + #[test] + fn cost_sorted_pairs_each_candidate_with_its_own_estimate_cost() { + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + let space = search_workload(vec![("q", root)]); + let ranked = space.cost_sorted(&DefaultCostModel); + let agg_group = ranked + .iter() + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) + .unwrap(); + assert_eq!( + agg_group.costs.len(), + agg_group.candidates.len(), + "costs must be aligned 1:1 with candidates" + ); + assert!(!agg_group.costs.is_empty()); + + let target = TargetSubDAG::with_consumer_count(agg_group.target, agg_group.consumer_count); + for (candidate, &cost) in agg_group.candidates.iter().zip(&agg_group.costs) { + assert_eq!( + cost, + DefaultCostModel.estimate_cost(candidate, &target), + "RankedTargetSubDAGCandidates::costs must match calling CostModel::estimate_cost directly \ + for the same candidate/target" + ); + } + } + + // ── global_selection (issue #271) ─────────────────────────────────── + + /// A `CostModel` with a constant, `sub-DAG`-independent recompute cost + /// and shared-maintenance cost, chosen (40 recompute-per-use, 100 + /// maintenance) so that a `SharedSubDAGStrategy` group's + /// `cse_share_decision` flips exactly between a consumer count of 2 + /// (recompute total 80, below maintenance: `RecomputeIndependently`) + /// and a consumer count of 3 (recompute total 120, above + /// maintenance: `Share`) — the precise threshold + /// `effective_consumer_count_corrects_a_nested_groups_share_decision` + /// needs to cross. + struct ConstantCseCost; + impl CostModel for ConstantCseCost { + fn allow_uncosted_legacy_selection(&self) -> bool { + true + } + + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + fn cse_recompute_cost(&self, _candidate: &CseCandidate) -> Cost { + Cost(40.0) + } + fn cse_shared_maintenance_cost(&self, _candidate: &CseCandidate) -> Cost { + Cost(100.0) + } + } + + /// A costed logical choice must not panic when an explicitly allowed CSE + /// choice has no numeric cost. + #[test] + fn costed_logical_candidate_beats_uncosted_legacy_cse_choice() { + struct MixedCost; + impl CostModel for MixedCost { + fn allow_uncosted_legacy_selection(&self) -> bool { + true + } + + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + + fn candidate_cost( + &self, + candidate: &ReplacementSubDAG, + _target: &TargetSubDAG<'_>, + ) -> Option { + (!is_cse_candidate(candidate)).then_some(Cost(1.0)) + } + } + + let aggregate = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + let space = search_workload(vec![("left", Rc::clone(&aggregate)), ("right", aggregate)]); + let root = &space.roots[0].1; + assert!(cse_candidate_pair(space.candidates_for_target(root).unwrap()).is_some()); + let selected = space.global_selection(&MixedCost); + let chosen = selected.for_target(root).unwrap().chosen.unwrap(); + assert!(!is_cse_candidate(chosen)); + } + + #[test] + fn global_selection_matches_cost_sorted_for_a_non_interacting_workload() { + // No nested sharing at all — global_selection's effective_consumer_count + // must equal the group's own raw consumer_count, and its `chosen` + // candidate must be cost_sorted's top pick, for both the sketch + // group and its child Scan. + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + let space = search_workload(vec![("q", root)]); + + let ranked = space.cost_sorted(&DefaultCostModel); + let selected = space.global_selection(&DefaultCostModel); + assert_eq!(ranked.len(), selected.target_selections().count()); + + for ranked_group in &ranked { + let selected_group = selected.for_target(ranked_group.target).unwrap(); + assert_eq!( + selected_group.effective_consumer_count, ranked_group.consumer_count, + "no ancestor is ever RecomputeIndependently here, so effective must equal raw" + ); + assert_eq!( + selected_group.chosen.map(|c| &c.rationale), + ranked_group.candidates.first().map(|c| &c.rationale), + "with no cross-group interaction, global_selection's pick must match \ + cost_sorted's top-ranked candidate" + ); + } + } + + #[test] + fn global_selection_leaves_an_unmatched_group_as_none() { + // A bare Scan: no registered strategy has an opinion on it, so it + // gets a group with an empty candidate list (see TargetSubDAGCandidates's own + // doc) — global_selection must not invent a candidate for it. + let root = metric_scan(&["job"]); + let space = search_workload(vec![("q", root)]); + let selected = space.global_selection(&DefaultCostModel); + let scan_group = selected + .target_selections() + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Scan { .. }))) + .unwrap(); + assert!(scan_group.chosen.is_none()); + assert_eq!(scan_group.effective_consumer_count, 1); + } + + #[test] + fn global_selection_falls_back_to_local_ranking_for_sketch_family_groups() { + // ASAPStrategies groups have no cross-group-aware cost hook + // (rank_candidates takes no consumer_count) — global_selection must + // still return cost_sorted's own top pick for them (documented in + // the module docs' "Whole-plan (cross-group) selection" section), + // not silently drop the candidate or fall back to discovery order. + struct PreferDDSketch; + impl CostModel for PreferDDSketch { + fn allow_uncosted_legacy_selection(&self) -> bool { + true + } + + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + let mut v = candidates.to_vec(); + if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { + let dd = v.remove(pos); + v.insert(0, dd); + } + v + } + } + + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + let space = search_workload(vec![("q", root)]); + let selected = space.global_selection(&PreferDDSketch); + let agg_group = selected + .target_selections() + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) + .unwrap(); + let kind = match &agg_group.chosen.unwrap().replacement { + Replacement::SubDAG(node) => sketch_kind_of(node), + Replacement::ExactComposition(_) => None, + }; + assert_eq!(kind, Some(SketchAlgorithm::DDSketch)); + + struct Uncosted; + impl CostModel for Uncosted { + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + } + assert!(space + .global_selection(&Uncosted) + .for_target(&space.roots[0].1) + .unwrap() + .chosen + .is_none()); + } + + #[test] + fn mixed_rewrite_group_keeps_and_selects_its_explicit_cse_pair() { + let target = metric_scan(&["job"]); + let mut group = TargetSubDAGCandidates::new(Rc::clone(&target), 2); + group.candidates = vec![ + ReplacementSubDAG { + strategy: "TestStrategy", + replacement: Replacement::SubDAG(Rc::clone(&target)), + provenance: ReplacementProvenance::CseShare, + rationale: "share".into(), + }, + ReplacementSubDAG { + strategy: "TestStrategy", + replacement: Replacement::SubDAG(Rc::new(target.as_ref().clone())), + provenance: ReplacementProvenance::CseRecompute, + rationale: "recompute".into(), + }, + ReplacementSubDAG { + strategy: "TestStrategy", + replacement: Replacement::SubDAG( + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::PromqlVectorFromScalar(ScalarExpr::EvalTimestamp), + )) + .unwrap(), + ), + provenance: ReplacementProvenance::LogicalRewrite, + rationale: "different rewrite strategy".into(), + }, + ]; + + assert!(cse_candidate_pair(&group).is_some()); + let ranked = rank_group(&group, &ConstantCseCost); + assert_eq!( + ranked + .iter() + .map(|c| c.rationale.as_str()) + .collect::>(), + vec!["recompute", "different rewrite strategy", "share"], + "the preferred CSE choice must be ranked without losing the unrelated rewrite" + ); + let chosen = pick_shared_sub_dag_candidate( + &group, + decide_with_effective_count(&group, 2, &ConstantCseCost).unwrap(), + ) + .unwrap(); + assert_eq!(chosen.provenance, ReplacementProvenance::CseRecompute); + } + + #[test] + fn effective_consumer_count_corrects_a_nested_groups_share_decision() { + // The interaction issue #271 describes: an outer shared sub-DAG `a` + // (referenced by 2 roots, so consumer_count == 2) wraps an inner + // shared sub-DAG `c` (referenced once through `a`'s own child edge, + // plus once more directly by a third, separate root — so `c`'s own + // *raw* structural consumer_count is also 2, independent of `a`). + // + // root1 ─┐ + // ├─▶ a = Filter(child = c) ─▶ c = Dedup(job) + // root2 ─┘ + // root3 ───────────────────────────▶ c (same shared Rc) + // + // `a` and `c` are both non-`Aggregate` nodes (`Filter`/`Dedup`) so + // neither is bindable — each group is a *clean* two-candidate + // SharedSubDAGStrategy share-vs-recompute pair, with no + // ASAPStrategies `Summary` candidate mixed in to complicate + // ranking (see `shared_aggregate_across_two_roots_gets_both_strategies_candidates` + // for what a *mixed*-shape group looks like — deliberately avoided + // here to isolate the SharedSubDAGStrategy-only interaction). + // + // Under ConstantCseCost, consumer_count == 2 loses to maintenance + // (2 * 40 = 80 < 100 ⇒ RecomputeIndependently); consumer_count == 3 wins + // (3 * 40 = 120 > 100 ⇒ Share). `cost_sorted` only ever sees `c`'s raw + // count (2) and picks RecomputeIndependently for it — the WRONG + // answer once `a` itself is accounted for: `a`'s own decision is + // also RecomputeIndependently (same 80-vs-100 threshold), so `a` + // actually runs twice, and each run recomputes `c` once more — + // `c`'s *true* effective count is 2 (via `a`) + 1 (via root3) = 3, + // which flips its own decision to Share. Only global_selection, + // which folds `a`'s decision into `c`'s effective_consumer_count + // before deciding `c`, gets this right. + use asap_types::ir::scalar::ScalarValue; + use asap_types::ir::Predicate; + + let c = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["job"]), + })) + .unwrap() + }; + let a = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), + child: c(), + })) + .unwrap() + }; + + let space = search_workload(vec![("root1", a()), ("root2", a()), ("root3", c())]); + + // Fixture sanity: root1/root2 merged onto one shared `a`, and `c` + // (root1/root2's shared child, and root3 itself) merged onto one + // shared `c` with raw consumer_count 2, and both groups are clean + // (non-mixed) two-candidate SharedSubDAGStrategy pairs. + assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); + let a_rc = &space.roots[0].1; + let Some(NonASAPOp::Filter { child: c_via_a, .. }) = a_rc.non_asap() else { + panic!("expected root1/root2 to still be a Filter"); + }; + assert!(Rc::ptr_eq(c_via_a, &space.roots[2].1)); + let a_group = space.candidates_for_target(a_rc).unwrap(); + let c_group = space.candidates_for_target(c_via_a).unwrap(); + assert_eq!( + a_group.consumer_count, 2, + "fixture sanity: a has 2 consumers" + ); + assert_eq!( + c_group.consumer_count, 2, + "fixture sanity: c has 2 raw consumers (via a's child edge, and via root3)" + ); + assert_eq!( + a_group.candidates.len(), + 2, + "fixture sanity: a is a clean Rewrite pair" + ); + assert_eq!( + c_group.candidates.len(), + 2, + "fixture sanity: c is a clean Rewrite pair" + ); + + // The naive/local answer: cost_sorted ranks c using its raw count + // (2) alone and prefers RecomputeIndependently. + let ranked = space.cost_sorted(&ConstantCseCost); + let c_ranked = ranked + .iter() + .find(|g| Rc::ptr_eq(g.target, c_via_a)) + .unwrap(); + let c_top_shares = matches!( + &c_ranked.candidates[0].replacement, + Replacement::SubDAG(rc) if Rc::ptr_eq(rc, c_via_a) + ); + assert!( + !c_top_shares, + "cost_sorted, blind to a's own decision, must (wrongly) prefer \ + RecomputeIndependently for c using its raw consumer_count of 2" + ); + + // The corrected, cross-group-aware answer: global_selection folds + // a's own RecomputeIndependently choice into c's effective count + // (2 from a + 1 from root3 = 3) and flips to Share. + let selected = space.global_selection(&ConstantCseCost); + let a_selected = selected.for_target(a_rc).unwrap(); + let c_selected = selected.for_target(c_via_a).unwrap(); + + assert_eq!( + a_selected.effective_consumer_count, 2, + "a has no interacting ancestor" + ); + let a_shares = matches!( + &a_selected.chosen.unwrap().replacement, + Replacement::SubDAG(rc) if Rc::ptr_eq(rc, a_rc) + ); + assert!( + !a_shares, + "fixture sanity: a itself must also choose RecomputeIndependently" + ); + + assert_eq!( + c_selected.effective_consumer_count, 3, + "c's effective count must be 2 (a, itself recomputed twice) + 1 (root3)" + ); + let c_shares = matches!( + &c_selected.chosen.unwrap().replacement, + Replacement::SubDAG(rc) if Rc::ptr_eq(rc, c_via_a) + ); + assert!( + c_shares, + "global_selection must flip c to Share once a's own recomputation is accounted for" + ); + } + + #[test] + fn complete_plan_costs_reject_unbound_cse_arms() { + struct CompletePlanCost; + impl CostModel for CompletePlanCost { + fn candidate_cost_covers_complete_plan(&self) -> bool { + true + } + + fn candidate_cost( + &self, + candidate: &ReplacementSubDAG, + _target: &TargetSubDAG<'_>, + ) -> Option { + assert!(!is_cse_candidate(candidate)); + None + } + + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + + fn cse_share_decision(&self, _candidate: &CseCandidate) -> ShareDecision { + ShareDecision::RecomputeIndependently + } + } + + let shared = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["job"]), + })) + .unwrap(); + let space = search_workload(vec![ + ("left", Rc::clone(&shared)), + ("right", Rc::clone(&shared)), + ]); + let planned = &space.roots[0].1; + + let selected = space.global_selection(&CompletePlanCost); + assert!(selected.for_target(planned).unwrap().chosen.is_none()); + } + + #[test] + fn effective_repetition_materializes_a_cse_choice_for_a_single_edge_child() { + use asap_types::ir::scalar::ScalarValue; + use asap_types::ir::Predicate; + + let c = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["job"]), + })) + .unwrap() + }; + let a = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), + child: c(), + })) + .unwrap() + }; + let space = search_workload(vec![("root1", a()), ("root2", a())]); + let a_rc = &space.roots[0].1; + let Some(NonASAPOp::Filter { child: c_rc, .. }) = a_rc.non_asap() else { + panic!("expected Filter root"); + }; + + assert_eq!(space.candidates_for_target(c_rc).unwrap().consumer_count, 1); + assert!(cse_candidate_pair(space.candidates_for_target(c_rc).unwrap()).is_some()); + + let selected = space.global_selection(&ConstantCseCost); + let child = selected.for_target(c_rc).unwrap(); + assert_eq!(child.effective_consumer_count, 2); + assert!(child.chosen.is_some()); + } + + #[test] + fn shared_ancestor_keeps_a_single_use_cse_descendant_selected() { + use asap_types::ir::scalar::ScalarValue; + use asap_types::ir::Predicate; + + struct AlwaysShare; + impl CostModel for AlwaysShare { + fn allow_uncosted_legacy_selection(&self) -> bool { + true + } + + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + + fn cse_share_decision(&self, _candidate: &CseCandidate) -> ShareDecision { + ShareDecision::Share + } + } + + let child = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["job"]), + })) + .unwrap() + }; + let parent = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), + child: child(), + })) + .unwrap() + }; + let space = search_workload(vec![("root1", parent()), ("root2", parent())]); + let parent_rc = &space.roots[0].1; + let Some(NonASAPOp::Filter { + child: child_rc, .. + }) = parent_rc.non_asap() + else { + panic!("expected Filter root"); + }; + + let selected = space.global_selection(&AlwaysShare); + assert_eq!( + selected + .for_target(parent_rc) + .unwrap() + .effective_consumer_count, + 2 + ); + let child_selection = selected.for_target(child_rc).unwrap(); + assert_eq!(child_selection.effective_consumer_count, 1); + assert_eq!( + child_selection.chosen.map(|candidate| candidate.provenance), + Some(ReplacementProvenance::CseShare), + "a descendant collapsed to one execution still needs a selected plan" + ); + } + + #[test] + fn global_selection_propagates_uses_through_the_selected_rewrite() { + use asap_types::ir::scalar::ScalarValue; + use asap_types::ir::Predicate; + + struct ReplaceFilterChild; + impl ReplacementStrategy for ReplaceFilterChild { + fn matches(&self, target: &TargetSubDAG<'_>) -> bool { + matches!(target.root.non_asap(), Some(NonASAPOp::Filter { .. })) + } + + fn replacements(&self, _target: &TargetSubDAG<'_>) -> Vec { + vec![ReplacementSubDAG { + strategy: "ReplaceFilterChild", + replacement: Replacement::SubDAG( + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["replacement"]), + }, + )) + .unwrap(), + ), + provenance: ReplacementProvenance::LogicalRewrite, + rationale: "replace the Filter and its input".into(), + }] + } + } + + let original_child = metric_scan(&["original"]); + let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), + child: Rc::clone(&original_child), + })) + .unwrap(); + let strategies: Vec> = vec![Box::new(ReplaceFilterChild)]; + let space = search_workload_with(vec![("q", root)], &strategies); + let root = &space.roots[0].1; + let selected = space.global_selection(&DefaultCostModel); + let Replacement::SubDAG(rewrite) = &selected + .for_target(root) + .unwrap() + .chosen + .unwrap() + .replacement + else { + panic!("expected logical rewrite"); + }; + let Some(NonASAPOp::Dedup { + child: replacement_child, + .. + }) = rewrite.non_asap() + else { + panic!("expected Dedup rewrite"); + }; + let Some(NonASAPOp::Filter { + child: original_child, + .. + }) = root.non_asap() + else { + panic!("expected Filter root"); + }; + + assert_eq!( + selected + .for_target(original_child) + .unwrap() + .effective_consumer_count, + 0 + ); + assert_eq!( + selected + .for_target(replacement_child) + .unwrap() + .effective_consumer_count, + 1 + ); + } + + // A cheap but physically infeasible candidate must not be selected. + #[test] + fn explicit_summary_infeasibility_prevents_selection() { + struct Unsupported; + impl CostModel for Unsupported { + fn rank_candidates( + &self, + _: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + fn candidate_cost(&self, _: &ReplacementSubDAG, _: &TargetSubDAG<'_>) -> Option { + Some(Cost(1.0)) + } + fn summary_support_evidence(&self, _: &OperatorNode) -> Option { + Some(false) + } + } + let root = lower_promql("sum_over_time(a[1m])", AccuracyTarget::Exact); + let space = search_workload(vec![("q", root)]); + let selected = space.global_selection(&Unsupported); + assert!(selected + .for_target(&space.roots[0].1) + .unwrap() + .chosen + .is_none()); + } + + // Composable temporal/grouped Sum must be executable as one producer. + #[test] + fn grouped_temporal_sum_has_one_summary_producer_candidate() { + let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); + let candidates = + ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + assert!(candidates + .iter() + .any(|candidate| matches!(&candidate.replacement, + Replacement::SubDAG(node) if matches!(&node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) + if !child.contains_asap())))); + struct PreferComposed; + impl CostModel for PreferComposed { + fn rank_candidates( + &self, + _: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + fn candidate_cost( + &self, + candidate: &ReplacementSubDAG, + _: &TargetSubDAG<'_>, + ) -> Option { + Some(Cost( + if matches!(&candidate.replacement, + Replacement::SubDAG(node) if matches!(&node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) + if !child.contains_asap())) + { + 1.0 + } else { + 100.0 + }, + )) + } + } + let space = search_workload(vec![("q", root.clone())]); + let selected = space.global_selection(&PreferComposed); + let node = selected.assemble_target(&space.roots[0].1).unwrap(); + assert!(matches!(&node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) + if !child.contains_asap())); + } + + // Mixed candidate ranking must honor explicit costs, not legacy estimates. + #[test] + fn mixed_candidate_ranking_uses_explicit_candidate_costs() { + struct ExplicitCosts; + impl CostModel for ExplicitCosts { + fn rank_candidates( + &self, + _: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + fn candidate_cost( + &self, + candidate: &ReplacementSubDAG, + _: &TargetSubDAG<'_>, + ) -> Option { + Some(Cost( + if candidate.provenance == ReplacementProvenance::LogicalRewrite { + 1.0 + } else { + 100.0 + }, + )) + } + } + let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); + let space = search_workload(vec![("q", root)]); + let selection = space.global_selection(&ExplicitCosts); + let selected = selection + .for_target(&space.roots[0].1) + .unwrap() + .chosen + .unwrap(); + assert_eq!(selected.provenance, ReplacementProvenance::LogicalRewrite); + } + + #[test] + fn global_selection_compares_a_logical_rewrite_with_the_cse_choice() { + struct PreferLogicalRewrite; + + impl CostModel for PreferLogicalRewrite { + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + + fn estimate_cost( + &self, + candidate: &ReplacementSubDAG, + _target: &TargetSubDAG<'_>, + ) -> f64 { + match candidate.provenance { + ReplacementProvenance::LogicalRewrite => 0.0, + _ => 100.0, + } + } + } + + let a = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); + let b = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); + let space = search_workload(vec![("a", a), ("b", b)]); + let root = &space.roots[0].1; + let selected = space.global_selection(&PreferLogicalRewrite); + + assert_eq!( + selected + .for_target(root) + .and_then(|group| group.chosen) + .map(|candidate| candidate.provenance), + Some(ReplacementProvenance::LogicalRewrite) + ); + } + + #[test] + fn topological_order_puts_a_later_discovered_parent_before_its_child() { + // Mirrors nested_shared_sub-DAG_below_an_unshared_parent_is_still_discovered's + // diamond fixture: discover_targets's own `order` visits root_b (a + // parent of `shared`) *after* `shared` itself, because `shared` was + // already fully walked via root_a first. A naive "process + // discover_targets's own order" DP would see root_b's child edge + // after already processing `shared` — topological_order must not + // make that mistake. + use asap_types::ir::scalar::ScalarValue; + use asap_types::ir::Predicate; + + let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let root_a = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(1))), + child: shared.clone(), + })) + .unwrap(); + let root_b = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(2))), + child: shared, + })) + .unwrap(); + let roots = vec![("a", root_a), ("b", root_b)]; + + let mut order = Vec::new(); + let mut nodes = HashMap::new(); + let mut counts = HashMap::new(); + discover_targets(&roots, &mut order, &mut nodes, &mut counts); + let groups = order + .iter() + .map(|ptr| { + ( + *ptr, + TargetSubDAGCandidates::new(Rc::clone(&nodes[ptr]), counts[ptr]), + ) + }) + .collect(); + let space = CandidateLogicalASAPDAGs { + roots, + groups, + order: order.clone(), + composition_plans: Vec::new(), + }; + let dag = reference_dag(&space); + + // Discovery-order sanity: root_b comes after the shared child in + // discover_targets's own order (the exact non-topological case this + // test exists to cover). + let Some(NonASAPOp::Filter { + child: shared_via_a, + .. + }) = space.roots[0].1.non_asap() + else { + panic!("expected a Filter root"); + }; + let shared_ptr = Rc::as_ptr(shared_via_a); + let root_b_ptr = Rc::as_ptr(&space.roots[1].1); + let shared_discovery_pos = order.iter().position(|p| *p == shared_ptr).unwrap(); + let root_b_discovery_pos = order.iter().position(|p| *p == root_b_ptr).unwrap(); + assert!( + root_b_discovery_pos > shared_discovery_pos, + "fixture sanity: discover_targets's own order must NOT already be topological here" + ); + + let topo = topological_order(&order, &dag); + let shared_topo_pos = topo.iter().position(|p| *p == shared_ptr).unwrap(); + let root_b_topo_pos = topo.iter().position(|p| *p == root_b_ptr).unwrap(); + assert!( + root_b_topo_pos < shared_topo_pos, + "topological_order must place root_b (a parent of the shared node) before it, \ + unlike discover_targets's own discovery order" + ); + } + + // A value projection cannot consume an opaque exact accumulator edge. + #[test] + fn residual_projection_finalizes_selected_exact_state() { + let inner = agg(vec![], AggIntent::Sum { col: None }, metric_scan(&[])); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { + cols: vec![ProjectItem { + expr: ScalarExpr::Column(0), + alias: Some("result".into()), + }], + qualifier: None, + child: inner.clone(), + })) + .unwrap(); + let space = search_workload_with_targets( + vec![("q", root.clone(), Some(AccuracyTarget::Exact))], + &default_strategies(), + &DefaultAccuracyModel, + ); + let selected = space.global_selection(&DefaultCostModel); + // CSE re-interns the workload, so the space's root/child `Rc`s are not + // the fixture's. Assembly only assembles children that are discovered + // targets, so seed the memo under the space's own child pointer. + let root = Rc::clone(&space.roots[0].1); + let Some(NonASAPOp::Project { child: inner, .. }) = root.non_asap() else { + unreachable!() + }; + assert!(space.candidates_for_target(inner).is_some()); + selected + .assembled_nodes + .borrow_mut() + .insert(Rc::as_ptr(inner), realize(inner.as_ref()).unwrap()); + let node = selected.assemble_target(&root).unwrap(); + let Operator::NonASAP(NonASAPOp::Project { child, .. }) = &node.operator else { + panic!("expected Project"); + }; + assert!(matches!( + child.operator, + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) + )); + assert!(child + .schema + .fields + .iter() + .all(|field| matches!(field.dtype, FieldDataType::Plain(_)))); + } +} diff --git a/crates/asap-aware-mapping/src/plan_selection.rs b/crates/asap-aware-mapping/src/plan_selection/mod.rs similarity index 99% rename from crates/asap-aware-mapping/src/plan_selection.rs rename to crates/asap-aware-mapping/src/plan_selection/mod.rs index bb3d5f86..090151cf 100644 --- a/crates/asap-aware-mapping/src/plan_selection.rs +++ b/crates/asap-aware-mapping/src/plan_selection/mod.rs @@ -19,6 +19,8 @@ //! every combination: a dynamic program over target nesting (see there). //! [`select_exhaustive`] builds and prices every combination, for display and //! for checking the program. +pub mod candidate_selection; + use std::collections::{BTreeMap, HashMap}; use std::rc::Rc; diff --git a/crates/asap-aware-mapping/src/recurrence.rs b/crates/asap-aware-mapping/src/recurrence.rs index f6b4501d..9a648f6d 100644 --- a/crates/asap-aware-mapping/src/recurrence.rs +++ b/crates/asap-aware-mapping/src/recurrence.rs @@ -1290,7 +1290,7 @@ mod tests { .cost_sorted_with_recurrence(&DeterministicUnitCostModel, &infrequent, None) .unwrap(); let first_provenance = - |ranked: &[crate::replacement::RankedTargetSubDAGCandidates<'_>]| { + |ranked: &[crate::plan_selection::candidate_selection::RankedTargetSubDAGCandidates<'_>]| { ranked .iter() .find(|group| Rc::ptr_eq(group.target, &shared.target)) diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index e4391156..095be55e 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -350,7 +350,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use asap_types::ir::cse::{share_common_sub_dags, structural_hash, HashCache}; use asap_types::ir::operator::agg_intent::{agg_is_mergeable, AggIntent}; -use asap_types::ir::operator::operator_properties::{BinaryOpKind, JoinKind, Reduction}; +use asap_types::ir::operator::operator_properties::{BinaryOpKind, Reduction}; use asap_types::ir::properties::summary_coverage::{CoverageRegion, SummaryCoverage}; use asap_types::ir::properties::timing::validate_maintained; use asap_types::ir::properties::{ @@ -358,7 +358,6 @@ use asap_types::ir::properties::{ }; use asap_types::ir::properties::{ExecutionDataStateError, ExecutionTiming}; use asap_types::ir::scalar::{ArithmeticOpKind, ColumnRef}; -use asap_types::ir::schema::ColumnId; use asap_types::ir::schema::{ EntityIdentity, ExactKind, ExactParams, Field, FieldDataType, GroupingStrategy, NonNegativeWeightProof, SamplingKind, SamplingParams, Schema, SketchAlgorithm, SketchKind, @@ -367,12 +366,10 @@ use asap_types::ir::schema::{ }; use asap_types::ir::SchemaDerivationError; use asap_types::ir::{ - ASAPOp, BinaryOperator, NonASAPOp, Operator, OperatorNode, Predicate, ProjectItem, ScalarExpr, - SortKey, + ASAPOp, BinaryOperator, NonASAPOp, Operator, OperatorNode, ProjectItem, ScalarExpr, SortKey, }; use asap_types::physical::ExactOperationSchemaError; use asap_types::types::AccuracyTarget; -use asap_types::workload::{DataWorkload, QueryRecurrence, QueryWorkload, RepeatedDemand}; use std::rc::{Rc, Weak}; use thiserror::Error; @@ -381,16 +378,10 @@ use crate::accuracy::{ AccuracyBudgetAllocator, AccuracyEvidenceProvider, AccuracyModel, CompositionShape, DefaultAccuracyModel, EqualSplitAllocator, NoAccuracyEvidence, }; -use crate::cost_model::{ - raw_recompute_cost_rate, CostModel, CseCandidate, DefaultCostModel, ExactCompositionCostInputs, - ExactCompositionCostRequest, ShareDecision, -}; +use crate::cost_model::{CostModel, DefaultCostModel}; use crate::exact_composition::{ExactComposition, ExactCompositionStrategy, OperationPlacement}; use crate::grouping::HydraGroupingStrategy; -use crate::recurrence::CostRate; -use crate::recurrence::{ - evaluation_rate_of, Horizon, RecurrenceError, RecurrenceProfile, RootRecurrence, UpdateRate, -}; +use crate::plan_selection::candidate_selection::{GlobalSelection, TargetSubDAGSelection}; use crate::rollup::RollupStrategy; use crate::topk_reuse::TopKLimitReuseStrategy; @@ -545,23 +536,6 @@ impl ReplacementSubDAG { Replacement::SubDAG(node) if !is_logical_rewrite(node) && has_missing_accuracy_evidence(node) ) } - - /// Physical feasibility evidence for this candidate. A pure logical - /// rewrite needs no new operator. Unknown support is checked during - /// physical/deployment compilation; explicit rejection prevents selection. - pub fn runtime_support_evidence(&self, cost_model: &dyn CostModel) -> Option { - match &self.replacement { - Replacement::ExactComposition(composition) => { - cost_model.value_operation_support_evidence(&composition.op, composition.placement) - } - // Any summary decision, including one rooted in a relational - // operator above its evaluations, asks the deployment for support. - Replacement::SubDAG(node) if !is_logical_rewrite(node) => { - cost_model.summary_support_evidence(node) - } - Replacement::SubDAG(_) => Some(true), - } - } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -4016,7 +3990,7 @@ pub struct TargetSubDAGCandidates { } impl TargetSubDAGCandidates { - fn new(target: Rc, consumer_count: usize) -> Self { + pub(crate) fn new(target: Rc, consumer_count: usize) -> Self { Self { target, consumer_count, @@ -4138,20 +4112,20 @@ pub struct CandidateLogicalASAPDAGs { /// [`search_workload_with`] runs up front — the same post-CSE roots /// every `TargetSubDAG` in `groups` was discovered from. pub roots: Vec<(Id, Rc)>, - groups: HashMap<*const OperatorNode, TargetSubDAGCandidates>, + pub(crate) groups: HashMap<*const OperatorNode, TargetSubDAGCandidates>, /// Discovery order — stable iteration for [`CandidateLogicalASAPDAGs::target_subdag_candidates`]/ /// [`CandidateLogicalASAPDAGs::cost_sorted`], since `HashMap` iteration order isn't. - order: Vec<*const OperatorNode>, + pub(crate) order: Vec<*const OperatorNode>, /// Composition proofs are computed with the search model, then retained /// through costing and DAG assembly so no later default can replace it. - composition_plans: Vec, + pub(crate) composition_plans: Vec, } -struct PreparedComposition { - target: *const OperatorNode, - operation: ExactComposition, - child: Rc, - plan: Rc, +pub(crate) struct PreparedComposition { + pub(crate) target: *const OperatorNode, + pub(crate) operation: ExactComposition, + pub(crate) child: Rc, + pub(crate) plan: Rc, } impl CandidateLogicalASAPDAGs { @@ -4427,5220 +4401,2400 @@ impl CandidateLogicalASAPDAGs { ) -> Option<&TargetSubDAGCandidates> { self.groups.get(&Rc::as_ptr(target)) } - - /// The `sorted_by(cost_model)` step: every group, each with its own - /// candidates ranked best-first under `cost_model` where this module - /// knows how (see the module docs' "Cost-based final selection" - /// section) — groups themselves stay in discovery order, since targets - /// are independent decision points, not alternatives competing with - /// each other. - /// - /// Ranking itself is decided entirely by [`rank_group`] before - /// [`RankedTargetSubDAGCandidates::costs`] is ever computed — pairing each candidate with - /// [`CostModel::grouping_state_cost`] for grouping alternatives, or - /// [`CostModel::estimate_cost`] otherwise, is an additive annotation - /// for a caller that wants to *display* a cost (e.g. a - /// DAG-visualization view), not a second ranking signal, so plugging in - /// a `CostModel` whose `estimate_cost` disagrees with its own - /// `rank_candidates`/`cse_share_decision` (a deployment bug, not - /// something this method tries to protect against) would show a - /// `RankedTargetSubDAGCandidates` whose `costs` aren't monotonically non-decreasing — - /// `cost_sorted`'s own ordering guarantee is unaffected either way. - pub fn cost_sorted(&self, cost_model: &dyn CostModel) -> Vec> { - self.order - .iter() - .map(|ptr| { - let group = &self.groups[ptr]; - let target = TargetSubDAG::with_consumer_count(&group.target, group.consumer_count); - let mut candidates = rank_group(group, cost_model); - // Availability is candidate-specific and cannot be expressed - // by `rank_candidates`' exhaustive permutation contract. - // Keep unavailable alternatives for explanation, but place - // them after every selectable candidate. - candidates.sort_by_key(|candidate| { - cost_model.candidate_cost(candidate, &target).is_none() - }); - let costs = candidates - .iter() - .map(|c| { - cost_model - .grouping_state_cost(c, &target) - .map_or_else(|| cost_model.estimate_cost(c, &target), |cost| cost.0) - }) - .collect(); - RankedTargetSubDAGCandidates { - target: &group.target, - consumer_count: group.consumer_count, - candidates, - costs, - } - }) - .collect() - } - - /// Recurrence-aware counterpart to [`Self::cost_sorted`]. CSE - /// share/recompute pairs are ordered with the target's recurrence - /// profile; all other candidate shapes retain their existing ranking. - pub fn cost_sorted_with_recurrence( - &self, - cost_model: &dyn CostModel, - profiles: &RecurrenceProfileMap, - horizon: Option, - ) -> Result>, RecurrenceError> { - self.order - .iter() - .map(|ptr| { - let group = &self.groups[ptr]; - let mut candidates = rank_group(group, cost_model); - if cse_candidate_pair(group).is_some() { - if let Some(decision) = decide_group_with_recurrence( - group, - group.consumer_count, - profiles.for_target(&group.target), - horizon, - cost_model, - )? { - candidates.sort_by_key(|candidate| match candidate.provenance { - ReplacementProvenance::CseShare if decision == ShareDecision::Share => { - 0 - } - ReplacementProvenance::CseRecompute - if decision == ShareDecision::RecomputeIndependently => - { - 0 - } - ReplacementProvenance::CseShare - | ReplacementProvenance::CseRecompute => 2, - _ => 1, - }); - } - } - let target = TargetSubDAG::with_consumer_count(&group.target, group.consumer_count); - let costs = candidates - .iter() - .map(|candidate| { - cost_model - .grouping_state_cost(candidate, &target) - .map_or_else( - || cost_model.estimate_cost(candidate, &target), - |cost| cost.0, - ) - }) - .collect(); - Ok(RankedTargetSubDAGCandidates { - target: &group.target, - consumer_count: group.consumer_count, - candidates, - costs, - }) - }) - .collect() - } -} - -// ── Recurrence-aware cost context (issue #287) ────────────────────────── - -/// One [`RecurrenceProfile`] per discovered [`TargetSubDAGCandidates`] target, built by -/// [`CandidateLogicalASAPDAGs::recurrence_profiles`] — the "carry `RepeatingEntry.demand` -/// and relevant `DataWorkload` into ASAP-aware search/cost context" -/// half of issue #287. Looked up by `Rc` pointer identity, the same -/// currency [`CandidateLogicalASAPDAGs::candidates_for_target`]/[`GlobalSelection::for_target`] already -/// use. -/// Holds an owned `Rc` clone alongside each profile (not just -/// its raw pointer) so this map keeps every node it describes alive for as -/// long as the map itself lives — a `RecurrenceProfileMap` is safe to outlive -/// the `CandidateLogicalASAPDAGs` it was built from. Without this, a raw `*const OperatorNode` key -/// could, after the originating `CandidateLogicalASAPDAGs` (the only other owner of those -/// `Rc`s) is dropped, collide with an unrelated, later allocation that -/// happens to reuse the same freed address — silently returning a stale -/// profile for the wrong node (issue #287 review, bug 4). -#[derive(Debug, Clone)] -pub struct RecurrenceProfileMap { - profiles: HashMap<*const OperatorNode, (Rc, RecurrenceProfile)>, -} - -impl RecurrenceProfileMap { - /// The [`RecurrenceProfile`] for `target`, or - /// [`RecurrenceProfile::EMPTY`] when `target` wasn't a discovered site - /// in the [`CandidateLogicalASAPDAGs`] this map was built from (or carried no - /// recurring/one-shot/update-rate metadata at all) — always a valid, - /// "no metadata" answer, never a panic. - pub fn for_target(&self, target: &Rc) -> RecurrenceProfile { - self.profiles - .get(&Rc::as_ptr(target)) - .map(|(_, profile)| *profile) - .unwrap_or(RecurrenceProfile::EMPTY) - } } -impl CandidateLogicalASAPDAGs { - /// Build one [`RecurrenceProfile`] per discovered site, by walking every - /// root's whole reachable sub-DAG (the same relational-skeleton - /// traversal [`discover_targets`] itself used to discover those sites) - /// and folding each root's own recurrence tag - /// (a normalized repeating rate or a one-time invocation count) into every - /// site reachable from it. - /// - /// `root_recurrence` is positional: `root_recurrence[i]` describes - /// `self.roots[i]` — the same order [`search_workload`]/ - /// [`search_workload_with`] were originally called with (post-CSE - /// dedup preserves both root count and order — see - /// `asap_types::ir::cse::share_common_sub_dags`'s own - /// `.map(...).collect()` body). This keeps `Id` fully opaque (no `Eq`/ - /// `Hash`/`Clone` bound needed on it at all — issue #287's "keep - /// caller/query identifiers opaque" requirement) at the cost of the - /// caller keeping the two slices in step; `root_recurrence.len()` must - /// equal `self.roots.len()`. - /// - /// A shared sub-DAG reachable from more than one root aggregates every - /// reaching root's contribution — repeating roots' rates are summed and - /// one-shot roots - /// increment [`RecurrenceProfile::one_shot_consumers`] — so a summary - /// consumed by queries with different intervals gets one profile - /// reflecting all of them, per issue #287's "support a shared sub-DAG - /// consumed by queries with different intervals". - /// - /// `update_rate` is applied uniformly to every discovered site *that - /// this walk actually reached from some root* (see the "unreachable - /// sites" note below): today's - /// [`asap_types::workload::DataWorkload`] is a single - /// workload-level value (applies to every query in a `QueryWorkload`), - /// not per-target, so there is no finer-grained source to attach - /// instead. `None` when no `DataWorkload` evidence was available — - /// preserves "missing metadata" behavior for the update-rate term alone - /// even when repeating/one-shot consumer information is present. - /// - /// A parent that structurally references the same child more than once - /// (e.g. `BinaryOp{lhs: X, rhs: X}`) credits that child with one - /// contribution per reference, not one contribution per distinct node — - /// matching how [`TargetSubDAGCandidates::consumer_count`] counts that occurrence. - /// Multiplicity is propagated through the full descendant path: if the - /// repeated parent is independently evaluated twice, its child is also - /// evaluated twice. This supplies recurrence-aware selection with the - /// effective structural execution rate rather than mere reachability. - /// - /// **Unreachable sites**: [`CandidateLogicalASAPDAGs`] can contain a site no root's own - /// structural DAG actually reaches — e.g. one only ever produced by a - /// [`Replacement::Rewrite`] candidate a [`ReplacementStrategy`] invented - /// (this walk only follows [`TargetSubDAGCandidates::target`]'s own structural - /// children, the same scope [`discover_targets`] uses for the original - /// roots, never a candidate's rewritten value). Such a site gets - /// [`RecurrenceProfile::EMPTY`] — in particular, `update_rate` is - /// **not** stamped onto it — so it falls back to the ordinary - /// structural decision instead of being charged an ingest-driven - /// maintenance cost against a real evaluation/one-shot signal of - /// exactly zero, which previously made `RecomputeIndependently` win - /// there unconditionally, regardless of the site's actual - /// `consumer_count` (issue #287 review, bug 2). - /// - /// Returns [`RecurrenceError::InvalidEvaluationRate`] if any repeating - /// rate is non-finite or negative, - /// [`RecurrenceError::InvalidUpdateRate`] if `update_rate` is non-finite - /// or negative, or [`RecurrenceError::RootCountMismatch`] if - /// `root_recurrence.len() != self.roots.len()`. - pub fn recurrence_profiles( - &self, - root_recurrence: &[RootRecurrence], - update_rate: Option, - ) -> Result { - if root_recurrence.len() != self.roots.len() { - return Err(crate::recurrence::RecurrenceError::RootCountMismatch { - expected: self.roots.len(), - got: root_recurrence.len(), - }); - } - if let Some(rate) = update_rate { - crate::recurrence::validate_update_rate(rate)?; - } - for recurrence in root_recurrence { - if let RootRecurrence::Repeating(rate) = recurrence { - if !rate.0.is_finite() || rate.0 < 0.0 { - return Err(crate::recurrence::RecurrenceError::InvalidEvaluationRate( - *rate, - )); +/// Find the explicitly-tagged CSE share/recompute pair inside `group`, even +/// when other strategies contributed additional alternatives to the same +/// memo group. Provenance makes these two orthogonal choices identifiable +/// without inferring semantics from pointer or expression shape. +pub(crate) fn cse_candidate_pair( + group: &TargetSubDAGCandidates, +) -> Option<(&ReplacementSubDAG, &ReplacementSubDAG)> { + let mut share = None; + let mut recompute = None; + for candidate in &group.candidates { + match candidate.provenance { + ReplacementProvenance::CseShare => { + let Replacement::SubDAG(rc) = &candidate.replacement else { + return None; + }; + if !Rc::ptr_eq(rc, &group.target) || share.replace(candidate).is_some() { + return None; } } - } - - let mut rates: HashMap<*const OperatorNode, f64> = HashMap::new(); - let mut one_shot_counts: HashMap<*const OperatorNode, usize> = HashMap::new(); - // Sites actually reached by at least one root's own recurrence tag - // during the walk below — see this method's own "Unreachable - // sites" doc. - let mut reached: HashSet<*const OperatorNode> = HashSet::new(); - - for ((_, root), recurrence) in self.roots.iter().zip(root_recurrence) { - let recurrence = *recurrence; - let root_ptr = Rc::as_ptr(root); - // Carry path multiplicity transitively. If a shared ancestor is - // referenced twice, every descendant below an independently - // recomputed occurrence is evaluated twice as well; stopping - // expansion after the first pointer visit undercounts exactly - // the effective-consumer rate recurrence-aware costing needs. - let mut queue: VecDeque<(*const OperatorNode, usize)> = VecDeque::new(); - queue.push_back((root_ptr, 1)); - - while let Some((ptr, path_count)) = queue.pop_front() { - contribute( - ptr, - path_count, - recurrence, - &mut rates, - &mut one_shot_counts, - &mut reached, - ); - // Every reachable node was itself discovered as its own - // `TargetSubDAGCandidates` (`discover_targets` walks the identical - // relational-skeleton scope) — its own `target` is the - // canonical `Rc` to read children off. - if let Some(group) = self.groups.get(&ptr) { - for (child, edge_count) in direct_child_counts(&group.target) { - queue.push_back(( - child, - path_count - .checked_mul(edge_count) - .expect("query DAG path multiplicity overflowed usize"), - )); - } + ReplacementProvenance::CseRecompute => { + let Replacement::SubDAG(rc) = &candidate.replacement else { + return None; + }; + if Rc::ptr_eq(rc, &group.target) + || rc.as_ref() != group.target.as_ref() + || recompute.replace(candidate).is_some() + { + return None; } } + _ => {} } - - let mut profiles = HashMap::with_capacity(self.order.len()); - for ptr in &self.order { - let rate = rates.get(ptr).copied().unwrap_or(0.0); - let evaluation_rate = (rate > 0.0).then_some(crate::recurrence::EvaluationRate(rate)); - let one_shot_consumers = one_shot_counts.get(ptr).copied().unwrap_or(0); - // Bug 2 fix (see "Unreachable sites" above): only a reached - // site carries the caller-supplied `update_rate`. - let site_update_rate = if reached.contains(ptr) { - update_rate - } else { - None - }; - let node = Rc::clone(&self.groups[ptr].target); - profiles.insert( - *ptr, - ( - node, - RecurrenceProfile { - evaluation_rate, - one_shot_consumers, - update_rate: site_update_rate, - }, - ), - ); + } + Some((share?, recompute?)) +} +/// Direct relational-skeleton children and their edge multiplicities. +/// `Concat` is transparent, matching [`walk_children`]'s site scope. +pub(crate) fn direct_child_counts(node: &OperatorNode) -> Vec<(*const OperatorNode, usize)> { + fn push(children: &mut Vec<(*const OperatorNode, usize)>, child: &Rc) { + let ptr = Rc::as_ptr(child); + match children.iter_mut().find(|(existing, _)| *existing == ptr) { + Some((_, count)) => *count += 1, + None => children.push((ptr, 1)), } - - Ok(RecurrenceProfileMap { profiles }) } - /// Derive per-target recurrence profiles directly from the normalized - /// query and data workloads. This is the authoritative bridge from the - /// public workload model into recurrence-aware candidate costing. - /// `root_workload_entries[i]` explicitly identifies the normalized - /// workload entry for `self.roots[i]`; callers need not arrange roots in - /// the batch-then-repeating storage order. - pub fn recurrence_profiles_from_workload( - &self, - workload: &QueryWorkload, - data_workload: Option<&DataWorkload>, - // For each `CandidateLogicalASAPDAGs::roots[i]`, the explicit index of its - // corresponding normalized workload entry. - root_workload_entries: &[usize], - now_ms: u64, - horizon: Option, - ) -> Result { - workload.validate()?; - if let Some(data) = data_workload { - data.validate()?; - } - if let Some(horizon) = horizon { - if !horizon.0.is_finite() || horizon.0 <= 0.0 { - return Err(crate::recurrence::RecurrenceError::InvalidHorizon(horizon)); + fn collect(node: &OperatorNode, children: &mut Vec<(*const OperatorNode, usize)>) { + if let Some(NonASAPOp::Concat { + children: concat_children, + .. + }) = node.non_asap() + { + for c in concat_children { + collect(c, children); } + return; } - if root_workload_entries.len() != self.roots.len() { - return Err(crate::recurrence::RecurrenceError::RootCountMismatch { - expected: self.roots.len(), - got: root_workload_entries.len(), - }); - } - let entries: Vec<_> = workload.entries().collect(); - let mut recurrences = Vec::with_capacity(root_workload_entries.len()); - for &index in root_workload_entries { - let entry = entries.get(index).ok_or( - crate::recurrence::RecurrenceError::InvalidWorkloadEntry { - index, - entry_count: entries.len(), - }, - )?; - let recurrence = match &entry.recurrence { - QueryRecurrence::OneTime { invocations, .. } => RootRecurrence::OneShotCount( - usize::try_from(*invocations).unwrap_or(usize::MAX), - ), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(interval)) - | QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { interval, .. }) => { - RootRecurrence::Repeating(evaluation_rate_of([*interval])?.unwrap()) - } - QueryRecurrence::Repeated(RepeatedDemand::Scheduled(schedule)) => { - let Some(horizon) = horizon else { - return Err(crate::recurrence::RecurrenceError::MissingHorizon); - }; - let end_ms = now_ms.saturating_add((horizon.0 * 1000.0) as u64); - let count = schedule - .iter() - .filter(|at| at.0 >= now_ms && at.0 <= end_ms) - .count(); - RootRecurrence::Repeating(crate::recurrence::EvaluationRate( - count as f64 / horizon.0, - )) - } - QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(estimate)) => { - if !estimate.is_fresh_at(now_ms) { - RootRecurrence::Unknown - } else { - RootRecurrence::Repeating(crate::recurrence::EvaluationRate( - estimate.expected_rate.0, - )) - } - } - QueryRecurrence::Unknown => RootRecurrence::Unknown, - }; - recurrences.push(recurrence); + for child in node.children() { + push(children, child); } - let update_rate = data_workload - .and_then(|data| data.ingestion_rate.value_at(now_ms)) - .map(|rate| UpdateRate(rate.0)); - self.recurrence_profiles(&recurrences, update_rate) } -} -/// Record `times` occurrences of `recurrence` against `ptr` — `times > 1` -/// when a single parent structurally references `ptr` more than once (see -/// [`CandidateLogicalASAPDAGs::recurrence_profiles`]'s own doc on edge multiplicity). -/// A no-op for `times == 0` (an `Rc` returned as a `direct_child_counts` -/// child always has `edge_count >= 1` in practice, but this keeps the -/// helper correct regardless). -fn contribute( - ptr: *const OperatorNode, - times: usize, - recurrence: RootRecurrence, - rates: &mut HashMap<*const OperatorNode, f64>, - one_shot_counts: &mut HashMap<*const OperatorNode, usize>, - reached: &mut HashSet<*const OperatorNode>, -) { - if times == 0 { - return; - } - reached.insert(ptr); - match recurrence { - RootRecurrence::Repeating(rate) => { - *rates.entry(ptr).or_insert(0.0) += rate.0 * times as f64; - } - RootRecurrence::OneShotCount(count) => { - *one_shot_counts.entry(ptr).or_insert(0) += count.saturating_mul(times); - } - RootRecurrence::Unknown => {} - } + let mut children = Vec::new(); + collect(node, &mut children); + children } +// ── default_strategies ────────────────────────────────────────────────── -/// One [`TargetSubDAGCandidates`]'s candidates, ranked best-first by -/// [`CandidateLogicalASAPDAGs::cost_sorted`]. -#[derive(Debug)] -pub struct RankedTargetSubDAGCandidates<'a> { - pub target: &'a Rc, - pub consumer_count: usize, - pub candidates: Vec<&'a ReplacementSubDAG>, - /// `costs[i]` is `candidates[i]`'s own grouping-state cost when available, - /// and its [`CostModel::estimate_cost`] otherwise - /// estimate — aligned index-for-index with `candidates`, one number per - /// candidate, for a caller that wants an actual `f64` next to each - /// candidate (e.g. "candidate A costs ≈ X, candidate B costs ≈ Y") and - /// not just `candidates`' own relative order. `f64::NAN` throughout - /// unless `cost_model` overrides `estimate_cost` — see that method's own - /// doc. - pub costs: Vec, +/// The context-free strategies [`search_workload`] runs in the built-in +/// [`DefaultCostModel`] configuration. Workload-dependent strategies such as +/// [`RollupStrategy`] and [`AccuracyReconciliationStrategy`] (issue #273, +/// cross-consumer accuracy reconciliation for CSE sharing — see that +/// module's own docs) are added by [`search_workload`] after CSE and target +/// discovery, when their sibling context exists. +/// [`crate::explanation::explain_replacements`] (issue #257) uses +/// this same set (via [`search_workload`]) rather than keeping a second, +/// explanation-specific list to stay in sync with. Use +/// [`default_strategies_with`] to plug in a deployment-specific +/// [`CostModel`] instead. +/// +/// [`AvgToSumOverCountStrategy`](crate::rewrite::AvgToSumOverCountStrategy) is +/// included here (issue #253) even though it's a +/// [`Replacement::Rewrite`]-only strategy with no [`CostModel`] of its own to +/// plug in — it's context-free (`matches`/`replacements` need nothing beyond +/// the target itself) exactly like [`SharedSubDAGStrategy`], so it belongs +/// in this list rather than being derived per-workload the way +/// [`RollupStrategy`] is. Rewriting `avg` into `sum`/`count` upfront is what +/// lets [`ASAPStrategies`] and [`SharedSubDAGStrategy`] see a +/// mergeable accumulator to sketch or share at all — see that module's own +/// doc comment for why a bare `avg` node otherwise never becomes a +/// [`ReplacementStrategy`] target for anything. +pub fn default_strategies() -> Vec> { + vec![ + Box::new(ASAPStrategies::default_cost_model()), + Box::new(HydraGroupingStrategy::default_cost_model()), + Box::new(SharedSubDAGStrategy), + Box::new(crate::rewrite::AvgToSumOverCountStrategy), + Box::new(ExactCompositionStrategy::default_cost_model()), + ] } -/// Rank `group`'s candidates best-first under `cost_model`, per the module -/// docs' "Cost-based final selection" section. Falls back to discovery -/// order whenever there's nothing to rank (0 or 1 candidates) or this -/// module doesn't have a defined `CostModel` comparison for the shape it -/// sees — it never invents one. -fn rank_group<'a>( - group: &'a TargetSubDAGCandidates, - cost_model: &dyn CostModel, -) -> Vec<&'a ReplacementSubDAG> { - let mut ranked: Vec<&ReplacementSubDAG> = group.candidates.iter().collect(); - if ranked.len() <= 1 { - return ranked; - } - - // Shape 1: the exact `SharedSubDAGStrategy` share-vs-recompute pair — - // rank via `CostModel::cse_share_decision`, the same comparison - // the local CSE ranking path already uses. - if cse_candidate_pair(group).is_some() { - if let Some(prefer_target) = cse_preference(group, cost_model) { - ranked.sort_by_key(|c| match c.provenance { - ReplacementProvenance::CseShare if prefer_target => 0, - ReplacementProvenance::CseRecompute if !prefer_target => 0, - ReplacementProvenance::CseShare | ReplacementProvenance::CseRecompute => 2, - _ => 1, - }); - } - return ranked; - } - - // Shape 2: independent and Hydra grouping alternatives for the same - // sketch algorithms. When deployment statistics provide a subpopulation - // estimate, compare N independent states with the shared grid directly. - let target = TargetSubDAG::with_consumer_count(&group.target, group.consumer_count); - let has_hydra = ranked.iter().any(|candidate| { - let Replacement::SubDAG(node) = &candidate.replacement else { - return false; - }; - summary_grouping(node).is_some_and(|grouping| { - matches!(grouping, GroupingStrategy::SharedMultiSubpopulation { .. }) - }) - }); - let grouping_costs: Option> = if has_hydra { - ranked - .iter() - .map(|candidate| { - cost_model - .grouping_state_cost(candidate, &target) - .map(|cost| cost.0) - }) - .collect() - } else { - None - }; - if let Some(costs) = grouping_costs { - let by_ptr: HashMap<*const ReplacementSubDAG, f64> = ranked - .iter() - .zip(costs) - .map(|(candidate, cost)| (*candidate as *const ReplacementSubDAG, cost)) - .collect(); - ranked.sort_by(|a, b| { - by_ptr[&(*a as *const ReplacementSubDAG)] - .total_cmp(&by_ptr[&(*b as *const ReplacementSubDAG)]) - }); - return ranked; - } - - // Shape 3: `ASAPStrategies`'s sketch-family candidates (every - // candidate is a `Summary` that realizes a `SketchAlgorithm`) — rank via - // `CostModel::rank_candidates`, the same hook `realizations_for_intent` - // itself consults. - if let Some(intent) = bindable_intent(&group.target) { - let kinds: Option> = ranked - .iter() - .map(|c| match &c.replacement { - Replacement::SubDAG(node) => sketch_kind_of(node), - Replacement::ExactComposition(_) => None, - }) - .collect(); - if let Some(kinds) = kinds { - let order = crate::cost_model::validated_candidate_ranking(cost_model, intent, &kinds); - ranked.sort_by_key(|c| { - let kind = match &c.replacement { - Replacement::SubDAG(node) => sketch_kind_of(node), - Replacement::ExactComposition(_) => None, - }; - kind.and_then(|k| order.iter().position(|o| *o == k)) - .unwrap_or(usize::MAX) - }); - return ranked; - } - } - - // A target may be handled by more than one strategy (for example, a - // shared aggregate has both bound-summary and share/recompute rewrite - // candidates). No shape-specific hook spans those different candidate - // types, so compare the numeric estimates the CostModel exposes for that - // purpose. `total_cmp` gives deterministic placement to a model's NaN - // placeholders without dropping any candidate. - ranked.sort_by(|a, b| { - match ( - cost_model.candidate_cost(a, &target), - cost_model.candidate_cost(b, &target), - ) { - (Some(a), Some(b)) => a.0.total_cmp(&b.0), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => cost_model - .estimate_cost(a, &target) - .total_cmp(&cost_model.estimate_cost(b, &target)), - } - }); - ranked +/// Like [`default_strategies`], but [`ASAPStrategies`] ranks/binds via +/// `cost_model` instead of the built-in [`DefaultCostModel`] — the same +/// customization point [`ASAPStrategies::new`] itself offers. +pub fn default_strategies_with<'a>( + cost_model: &'a dyn CostModel, +) -> Vec> { + vec![ + Box::new(ASAPStrategies::new(cost_model)), + Box::new(HydraGroupingStrategy::new(cost_model)), + Box::new(SharedSubDAGStrategy), + Box::new(crate::rewrite::SemanticEquivalentRewriteStrategy), + Box::new(ExactCompositionStrategy::new(cost_model)), + ] } -/// For a group whose candidates are all [`Replacement::Rewrite`] (the -/// [`SharedSubDAGStrategy`] shape): does [`CostModel::cse_share_decision`] -/// prefer the candidate that shares `group.target`'s own `Rc` (`true`), or -/// the one that recomputes independently (`false`)? `None` when there's no -/// real comparison to make — fewer than 2 consumers (mirrors -/// [`SharedSubDAGStrategy::matches`]'s own gate), or `group.target` can't -/// actually be bound at all (no candidate and no logical fallback — never -/// expected in practice for a target that's already part of a legitimate -/// workload DAG, but this degrades to "keep discovery order" rather than -/// panicking). -fn cse_preference(group: &TargetSubDAGCandidates, cost_model: &dyn CostModel) -> Option { - if group.consumer_count < 2 { - return None; - } - let bound = realize_one(&group.target, cost_model)?; - let candidate = CseCandidate { - sub_dag: &group.target, - bound_summary: &bound, - consumer_count: group.consumer_count, - }; - Some(match cost_model.cse_share_decision(&candidate) { - ShareDecision::Share => true, - ShareDecision::RecomputeIndependently => false, - }) +/// Default context-free strategies with both deployment costing and typed +/// planning-time accuracy evidence. This is the production counterpart of +/// constructing [`ASAPStrategies::new_with_planning_inputs_and_evidence`] and +/// [`HydraGroupingStrategy::new_with_planning_inputs_and_evidence`] separately. +pub fn default_strategies_with_evidence<'a>( + cost_model: &'a dyn CostModel, + evidence: &'a dyn AccuracyEvidenceProvider, +) -> Vec> { + vec![ + Box::new(ASAPStrategies::new_with_planning_inputs_and_evidence( + cost_model, + &DEFAULT_ACCURACY_MODEL, + &DEFAULT_ALLOCATOR, + evidence, + )), + Box::new( + HydraGroupingStrategy::new_with_planning_inputs_and_evidence( + cost_model, + &DEFAULT_ACCURACY_MODEL, + &DEFAULT_ALLOCATOR, + evidence, + ), + ), + Box::new(SharedSubDAGStrategy), + Box::new(crate::rewrite::AvgToSumOverCountStrategy), + Box::new(ExactCompositionStrategy::new(cost_model)), + ] } -/// [`cse_preference`] only needs one representative bound [`OperatorNode`] -/// for `target` (to build a [`CseCandidate`] for -/// [`CostModel::cse_share_decision`]), not the full ranked candidate list -/// [`ASAPStrategies::replacements`] returns — so this just reuses -/// [`realize_child`], the same rank-and-take-first helper -/// `construct_summary_agg`'s own recursion and -/// [`crate::cost_model::DefaultCostModel::estimate_cost`] already use, -/// wrapped to swallow the (here, uninteresting) error into `None`. -fn realize_one(target: &Rc, cost_model: &dyn CostModel) -> Option> { - realize_child(target, cost_model).ok() -} +// ── search_workload ────────────────────────────────────────────────────── -/// The `SketchAlgorithm` a bound [`Replacement::SubDAG`] candidate ultimately -/// realizes, if any (`None` for an `ExactAggregate`/pass-through -/// sub-DAG — nothing to rank against another `SketchAlgorithm`). -/// -/// Mirrors this module's own `#[cfg(test)]`-only `summary_family_algorithm` -/// helper (in the test module below), which does the identical -/// `SummaryEstimate`-unwrap-then-match for that module's own tests; that -/// copy is test-only, so this needs its own for real (non-test) ranking -/// code — the same "duplicate a small, self-contained traversal rather than -/// restructure a test helper" call this file's own top doc already makes -/// for [`discover_targets`]. -fn sketch_kind_of(node: &OperatorNode) -> Option { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - sketch_kind_of(summary_input) - } - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) => Some(kind.algorithm().clone()), - _ => None, - } -} - -/// The grouping strategy used by a bound summary candidate, unwrapping its -/// evaluation node when necessary. -fn summary_grouping(node: &OperatorNode) -> Option<&GroupingStrategy> { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - summary_grouping(summary_input) - } - Operator::ASAP(ASAPOp::SummaryAgg { grouping, .. }) => Some(grouping), - _ => None, - } +/// Search a whole workload's pre-ASAP roots for every candidate replacement +/// [`default_strategies`] can find, deduped into a [`CandidateLogicalASAPDAGs`]. Candidate +/// *generation* uses the built-in [`DefaultCostModel`] (via +/// [`default_strategies`], the same way [`ASAPStrategies::default_cost_model`] +/// does); call [`CandidateLogicalASAPDAGs::cost_sorted`] on the result for the final +/// `sorted_by(cost_model)` step. Use [`search_workload_with`] to plug in a +/// custom strategy set (e.g. built via [`default_strategies_with`] for a +/// deployment-specific [`CostModel`]). +pub fn search_workload(roots: Vec<(Id, Rc)>) -> CandidateLogicalASAPDAGs { + search_workload_with(roots, &default_strategies()) } -// ── global_selection ───────────────────────────────────────────────────── - -/// One target sub-DAG's selected choice and usage information — the answer -/// [`CandidateLogicalASAPDAGs::global_selection`] commits to for one site, after folding in -/// every ancestor [`SharedSubDAGStrategy`] decision on the path from a -/// workload root to this site. See the module docs' "Whole-plan -/// (cross-group) selection" section for the full recurrence. +/// Like [`search_workload`], but with an explicit set of context-free +/// `strategies` (see [`default_strategies_with`] to plug in a +/// deployment-specific [`CostModel`]). The workload-dependent +/// [`RollupStrategy`] is derived and added automatically after CSE for both +/// entry points, because only this function owns the post-CSE sibling set. /// -/// Contrast with [`RankedTargetSubDAGCandidates`] ([`CandidateLogicalASAPDAGs::cost_sorted`]'s output): -/// that ranks every candidate for one target in isolation and never commits -/// to just one; this commits to exactly one (or none), and the count it -/// ranks against — [`Self::effective_consumer_count`] — can differ from the -/// target's own raw structural [`TargetSubDAGCandidates::consumer_count`] whenever an -/// ancestor's choice changes how many times this site truly runs. Use -/// `cost_sorted` to inspect every alternative for a site; use -/// `global_selection` when you need this module's best single answer, -/// accounting for cross-target interaction where it knows how to. -#[derive(Debug)] -pub struct TargetSubDAGSelection<'a> { - /// The target sub-DAG this selection is for. - pub target: &'a Rc, - /// [`TargetSubDAGCandidates::consumer_count`] — how many operator-child positions - /// directly reference `target`, ignoring every ancestor's own choice. - pub consumer_count: usize, - /// How many times `target`'s computation actually runs once every - /// ancestor's own selected candidate is accounted for — see - /// [`multiplier`]'s doc for the exact recurrence. Equal to - /// `consumer_count` unless some ancestor on a path from a root to this - /// site has a [`SharedSubDAGStrategy`] alternative that chose - /// [`ShareDecision::RecomputeIndependently`]. - pub effective_consumer_count: usize, - /// The candidate chosen for this target, or `None` when no replacement - /// is selected. The candidate set need not be empty: an unproven DDSketch - /// ratio can remain available for backend inspection but be excluded from - /// automatic selection, or costing can prefer raw recomputation. - /// DAG assembly then preserves exact computation at this target where - /// supported, while independently selected children may remain visible. - pub chosen: Option<&'a ReplacementSubDAG>, - /// When `chosen` is a [`Replacement::ExactComposition`]: the child - /// decision it was committed together with, and the cost comparison - /// that justified it — the explicit target-to-decision provenance - /// chain (issue #171). - pub composition: Option>, -} - -/// Why [`CandidateLogicalASAPDAGs::global_selection`] committed an exact composition at a -/// site: which child candidate it composes with, and the -/// cost-units-per-second comparison against the raw fallback that it won. -#[derive(Debug)] -pub struct CompositionDecision<'a> { - /// The exact child/operation pair validated by the search accuracy model. - pub plan: Rc, - /// The child target the composed operator consumes. - pub child_target: &'a Rc, - /// For a read-time operation: the child's own candidate committed alongside - /// (the summary evaluation the operator folds). `None` for an update-path - /// transform, whose input is raw update data — its cost is charged to - /// the maintained summary *above* it instead. - pub child_candidate: Option<&'a ReplacementSubDAG>, - /// The composed plan's recurring rate — `read_operation_plan_cost_rate` - /// or `maintenance_operation_plan_cost_rate`. - pub cost_rate: CostRate, - /// `raw_recompute_cost_rate` — the kept-sub-DAG baseline it beat. - pub baseline_rate: CostRate, - /// The statistics (and their provenance) both rates were computed from. - pub inputs: ExactCompositionCostInputs, -} - -/// [`CandidateLogicalASAPDAGs::global_selection`]'s result: one [`TargetSubDAGSelection`] per -/// discovered site, in the same discovery order [`CandidateLogicalASAPDAGs::target_subdag_candidates`]/ -/// [`CandidateLogicalASAPDAGs::cost_sorted`] use. -#[derive(Debug)] -pub struct GlobalSelection<'a> { - order: Vec<*const OperatorNode>, - groups: HashMap<*const OperatorNode, TargetSubDAGSelection<'a>>, - /// [`Self::assemble_selected_dag`]'s memo — one bound node per target for the - /// life of this selection, so two parents composing over one shared - /// child get the *same* `Rc` (a kept pre-ASAP sub-DAG - /// shared by two parents stays one `Rc` the same way). - assembled_nodes: RefCell>>, -} - -fn normalize_cross_input_equi_predicate( - pred: &Predicate, - left_width: usize, - total_width: usize, -) -> Option { - let ScalarExpr::Compare { - left, - op: asap_types::ir::scalar::CompareOpKind::Eq, - right, - semantics, - } = &pred.0 - else { - return None; - }; - let (ScalarExpr::Column(left_id), ScalarExpr::Column(right_id)) = - (left.as_ref(), right.as_ref()) - else { - return None; - }; - let is_left = |id: ColumnId| id < left_width; - let is_right = |id: ColumnId| left_width <= id && id < total_width; - let (left_id, right_id) = if is_left(*left_id) && is_right(*right_id) { - (*left_id, *right_id) - } else if is_right(*left_id) && is_left(*right_id) { - (*right_id, *left_id) - } else { - return None; - }; - Some(Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(left_id)), - op: asap_types::ir::scalar::CompareOpKind::Eq, - right: Box::new(ScalarExpr::Column(right_id)), - semantics: *semantics, - })) +/// Runs [`share_common_sub_dags`] once over `roots` first — so every +/// strategy (and, transitively, every +/// [`crate::explanation::ReplacementExplanation`] a caller reads off the +/// result) sees the same already-deduplicated DAG — then discovers every +/// `TargetSubDAG` (see [`discover_targets`]) and runs the +/// fixpoint loop the module docs describe, capped at +/// [`MAX_SEARCH_ITERATIONS`] passes (see the module docs' "Termination" +/// section). Deduping candidate plans this way needs no +/// [`CostModel`] at all — that only enters at two well-defined points: each +/// [`ReplacementStrategy`] in `strategies` may already carry its own (e.g. +/// [`ASAPStrategies::new`]'s), and [`CandidateLogicalASAPDAGs::cost_sorted`]'s final +/// ranking step takes one explicitly. +pub fn search_workload_with<'s, Id>( + roots: Vec<(Id, Rc)>, + strategies: &[Box], +) -> CandidateLogicalASAPDAGs { + let mut space = search_cse_workload_with(cse_workload(roots), strategies); + space.prepare_compositions(&DefaultAccuracyModel, &HashMap::new()); + space } -impl<'a> GlobalSelection<'a> { - /// One selection per discovered target sub-DAG, in discovery order. - pub fn target_selections(&self) -> impl Iterator> { - self.order.iter().map(move |ptr| &self.groups[ptr]) - } - - /// The selection for `target`, if `target`'s own `Rc` is a discovered - /// site (i.e. `Rc::ptr_eq` to some node reachable from the workload's - /// roots). - pub fn for_target(&self, target: &Rc) -> Option<&TargetSubDAGSelection<'a>> { - self.groups.get(&Rc::as_ptr(target)) - } - - /// Link this selection's per-site decisions into one data_state-validated - /// post-ASAP DAG rooted at `target` — the one place a committed - /// composition's child *reference* becomes an actual `Rc` - /// edge (issue #171). `None` if `target` is not a discovered site. - /// - /// Per site: a [`Replacement::ExactComposition`] uses its validated - /// operation/child plan, retaining the search model's guarantee; - /// a bound-summary [`Replacement::SubDAG`] is - /// re-linked so its `SummaryAgg` child is the child target's own - /// DAG assembly whenever that is phase-legal beneath maintenance - /// (so a child that chose an `ValueOperationAtIngestionTime` actually ends up under - /// the summary); a logical-rewrite [`Replacement::SubDAG`] is kept - /// as it is (exact); an unmatched site keeps its own operator with each - /// child assembled independently ([`Self::assemble_residual`]). - /// Memoized by target identity, so a shared inner summary is one `Rc` - /// no matter how many roots reach it. - pub fn assemble_selected_dag( - &self, - target: &Rc, - ) -> Result>, RealizationError> { - if !self.groups.contains_key(&Rc::as_ptr(target)) { - return Ok(None); +/// [`search_workload_with`] plus a per-root end-to-end `AccuracyTarget` +/// (issue #172) — the workload's `QueryRequirements.accuracy`, threaded +/// alongside each root. After the search, every root that carries a target +/// has its group's bound-summary [`Replacement::SubDAG`] candidates checked with +/// `accuracy_model`'s [`AccuracyModel::satisfies`]: a candidate whose +/// guarantee is fully known and misses the target is moved from +/// [`TargetSubDAGCandidates::candidates`] to [`TargetSubDAGCandidates::rejected`] *before* +/// [`CandidateLogicalASAPDAGs::cost_sorted`]/[`CandidateLogicalASAPDAGs::global_selection`] ever rank the +/// group. A constructible candidate with unknown accuracy remains visible for +/// downstream review under an approximate target, but default whole-plan +/// selection does not commit it. An exact target cannot accept an unknown +/// approximate summary. A kept pre-ASAP candidate is +/// exact and always survives — the raw/pre-ASAP alternative is what an +/// unsatisfiable root keeps. Logical-rewrite [`Replacement::SubDAG`] +/// candidates are not bound values and are left alone; the targets *inside* +/// a rewrite are their own groups. +/// +/// Precedence against per-node `AggIntent.accuracy` is documented in +/// [`crate::accuracy`]'s module docs. +pub fn search_workload_with_targets<'s, Id>( + roots: Vec<(Id, Rc, Option)>, + strategies: &[Box], + accuracy_model: &dyn AccuracyModel, +) -> CandidateLogicalASAPDAGs { + let mut targets = Vec::with_capacity(roots.len()); + let roots = roots + .into_iter() + .map(|(id, root, target)| { + targets.push(target); + (id, root) + }) + .collect(); + let mut space = search_cse_workload_with(cse_workload(roots), strategies); + // `cse_workload` preserves root order, so targets zip by position. + let root_ptrs: Vec<(*const OperatorNode, AccuracyTarget)> = space + .roots + .iter() + .zip(targets) + .filter_map(|((_, root), target)| target.map(|t| (Rc::as_ptr(root), t))) + .collect(); + // Whole-root proposals join the root group before its target check. + for (index, (ptr, target)) in root_ptrs.iter().enumerate() { + if root_ptrs[..index].contains(&(*ptr, target.clone())) { + continue; } - self.assemble_target(target).map(Some) - } - - /// Assemble a complete query result, including an exact-state evaluation when - /// needed. `assemble_selected_dag` also serves internal state frontiers; - /// callers exposing query results must use this boundary instead. - pub fn assemble_selected_query( - &self, - target: &Rc, - ) -> Result>, RealizationError> { - self.assemble_selected_dag(target)? - .map(|node| finalize_query_candidate(node, target)) - .transpose() - } - - fn assemble_target( - &self, - target: &Rc, - ) -> Result, RealizationError> { - let ptr = Rc::as_ptr(target); - if let Some(node) = self.assembled_nodes.borrow().get(&ptr) { - return Ok(Rc::clone(node)); - } - // A selected summary that realizes its inner aggregate, instead of - // hiding it in `KeepPreAsap`, is kept; materialization assignment decides - // whether it runs in precompute or at query time. - let selected_composed_summary = self - .groups - .get(&ptr) - .and_then(|sel| sel.chosen) - .is_some_and(|candidate| { - matches!(&candidate.replacement, - Replacement::SubDAG(node) if matches!(&node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) - if child.contains_asap() || !contains_aggregate(child))) - }); - let node = if query_time_nested_sum(target) && !selected_composed_summary { - self.assemble_residual(target)? - } else { - match self - .groups - .get(&ptr) - .and_then(|sel| sel.chosen) - .map(|c| &c.replacement) - { - None => self.assemble_residual(target)?, - Some(Replacement::SubDAG(node)) if node.contains_asap() => { - self.relink_summary(node, target)? - } - Some(Replacement::SubDAG(kept)) => retain_exact(kept)?, - Some(Replacement::ExactComposition(_)) => Rc::clone( - &self.groups[&ptr] - .composition - .as_ref() - .expect("selected compositions have a validated decision") - .plan, - ), + let group = space.groups.get_mut(ptr).expect("every root has a group"); + let root = Rc::clone(&group.target); + for strategy in strategies { + let name = strategy.name(); + let proposals = strategy.propose_for_root(&root, target); + for mut candidate in proposals.candidates { + candidate.strategy = name; + group.add_candidate(candidate); } - }; - self.assembled_nodes - .borrow_mut() - .insert(ptr, Rc::clone(&node)); - Ok(node) - } - - /// Keep `target`'s own operator and assemble each child independently, - /// so a selected summary remains visible beneath a relational operator - /// that has no summary realization of its own instead of being - /// swallowed by one opaque kept sub-DAG. Every child that is a - /// discovered target is assembled (and finalized to query-time values); - /// any other child is kept as it is. The guarantee is composed from the - /// assembled children: all exact → exact; exactly one child → that - /// child's guarantee; otherwise unknown. An inner `Join` first has its - /// cross-input equi-predicate normalized; any other join is kept whole. - fn assemble_residual( - &self, - target: &Rc, - ) -> Result, RealizationError> { - if target.children().is_empty() { - // A leaf has nothing to assemble beneath it: keep it as it is. - return retain_exact(target); - } - let mut operator = target.operator.clone(); - if let Operator::NonASAP(NonASAPOp::Join { - left, - right, - kind, - pred, - }) = &mut operator - { - let left_width = left.schema.fields.len(); - let total_width = left_width + right.schema.fields.len(); - let normalized_pred = matches!(kind, JoinKind::Inner) - .then(|| normalize_cross_input_equi_predicate(pred, left_width, total_width)) - .flatten(); - let Some(normalized) = normalized_pred else { - return retain_exact(target); - }; - *pred = normalized; + group + .rejected + .extend(proposals.rejected.into_iter().map(|mut rejection| { + rejection.strategy = name; + rejection + })); } - let mut failure = None; - let mut children = Vec::new(); - let operator = operator.map_children(|child| { - if failure.is_some() { - return Rc::clone(child); - } - let assembled = if self.groups.contains_key(&Rc::as_ptr(child)) { - self.assemble_target(child) - .and_then(|node| finalize_query_candidate(node, child)) - } else { - Ok(Rc::clone(child)) - }; - match assembled { - Ok(node) => { - children.push(Rc::clone(&node)); - node - } - Err(error) => { - failure = Some(error); - Rc::clone(child) - } - } - }); - if let Some(error) = failure { - return Err(error); - } - // An operator that computes new values from its input rows has no - // sound accuracy composition over an approximate input (e.g. `max` - // over a quantile evaluation's rank error). Without a selected - // composition such a node stays an exact pre-ASAP sub-DAG; only the - // read-time nested SUM keeps its assembled children. - let computes_values = matches!( - target.non_asap(), - Some( - NonASAPOp::Aggregate { .. } - | NonASAPOp::BinaryOp { .. } - | NonASAPOp::SQLWindowFunc { .. } - ) - ) && !query_time_nested_sum(target); - let approximate_input = children.iter().any(|child| { - !child - .guarantee - .as_ref() - .is_some_and(ResultGuarantee::is_exact) - }); - if computes_values && approximate_input { - return retain_exact(target); - } - let guarantee = match children.as_slice() { - [child] => child.guarantee.clone(), - children - if children.iter().all(|child| { - child - .guarantee - .as_ref() - .is_some_and(ResultGuarantee::is_exact) - }) => - { - Some(ResultGuarantee::exact(format!( - "{} over exact inputs", - target.operator.kind_name() - ))) - } - _ => None, - }; - let node = Rc::new( - OperatorNode::with_schema(operator, target.schema.clone()).with_guarantee(guarantee), - ); - validate_maintained(&node, ExecutionTiming::QueryTime)?; - Ok(node) } - - /// Re-link a bound summary candidate's `SummaryAgg` child to the - /// child target's own DAG assembly when that is legal beneath - /// maintenance; otherwise keep the candidate exactly as constructed. - fn relink_summary( - &self, - node: &Rc, - target: &Rc, - ) -> Result, RealizationError> { - let Some(NonASAPOp::Aggregate { - child: pre_child, .. - }) = target.non_asap() - else { - return Ok(Rc::clone(node)); + let mut composition_targets: HashMap<_, Vec<_>> = HashMap::new(); + for (ptr, target) in root_ptrs { + composition_targets + .entry(ptr) + .or_default() + .push(target.clone()); + let Some(group) = space.groups.get_mut(&ptr) else { + continue; }; - let has_maintenance_operation = self - .groups - .get(&Rc::as_ptr(pre_child)) - .and_then(|selection| selection.chosen) - .is_some_and(|candidate| { - matches!( - &candidate.replacement, - Replacement::ExactComposition(composition) - if composition.placement == OperationPlacement::Maintenance - ) - }); - if !has_maintenance_operation { - return Ok(Rc::clone(node)); - } - let new_child = self.assemble_target(pre_child)?; - Ok(relink_agg_child(node, &new_child)) + let (legal, illegal): (Vec<_>, Vec<_>) = + group + .candidates + .drain(..) + .partition(|candidate| match &candidate.replacement { + Replacement::SubDAG(node) if is_logical_rewrite(node) => true, + Replacement::SubDAG(node) => node.guarantee.as_ref().map_or_else( + || !matches!(target, AccuracyTarget::Exact), + |g| accuracy_model.satisfies(&g.optimistic_floor(), &target), + ), + // A composition's guarantee depends on the concrete child; + // prepare_compositions checks those pairs after all roots. + Replacement::ExactComposition(_) => true, + }); + group.candidates = legal; + group.rejected.extend(illegal.into_iter().map(|candidate| { + let (metric, bound, failure_probability) = match &candidate.replacement { + Replacement::SubDAG(node) => node + .guarantee + .as_ref() + .map(|g| { + ( + g.metric, + g.bound.evaluate(), + g.failure_probability.evaluate(), + ) + }) + .unwrap_or(( + asap_types::ir::properties::ErrorMetric::AbsoluteValue, + None, + None, + )), + Replacement::ExactComposition(_) => ( + asap_types::ir::properties::ErrorMetric::AbsoluteValue, + None, + None, + ), + }; + RejectedCandidate { + strategy: candidate.strategy, + description: format!("{} (root end-to-end target check)", candidate.rationale), + error: AccuracyError::TargetNotSatisfied { + metric, + bound, + failure_probability, + target: target.clone(), + }, + } + })); } + space.prepare_compositions(accuracy_model, &composition_targets); + space } -/// A mergeable outer SUM over a relationally wrapped aggregate is a read-time -/// reduction of the inner summary values. Maintaining the outer SUM directly -/// would hide that inner temporal aggregate inside one kept sub-DAG and lose -/// its independently selected summary. -fn query_time_nested_sum(target: &OperatorNode) -> bool { +/// The strictest accuracy among `siblings` that read the same summary input +/// as `root` — same child, grouping and filters, and the same intent apart +/// from its accuracy (and a quantile's rank, a evaluation parameter) — when +/// stricter than `root`'s own. One summary sized for the strictest consumer +/// serves every sibling: #509's summary-capability rule. +fn strictest_sibling_accuracy( + root: &OperatorNode, + siblings: &[Rc], +) -> Option { + fn approximate(intent: &AggIntent) -> Option<&AccuracyTarget> { + accuracy_target(intent).filter(|accuracy| !matches!(accuracy, AccuracyTarget::Exact)) + } let Some(NonASAPOp::Aggregate { - measures, + reduction, filters, - having: None, child, .. - }) = target.non_asap() + }) = root.non_asap() else { - return false; + return None; }; - !any_measure_filtered(filters) - && matches!(measures.as_slice(), [AggIntent::Sum { .. }]) - && contains_aggregate(child) -} - -fn contains_aggregate(expr: &OperatorNode) -> bool { - match expr.non_asap() { - Some(NonASAPOp::Aggregate { .. }) => true, - Some( - NonASAPOp::Project { child, .. } - | NonASAPOp::Filter { child, .. } - | NonASAPOp::Sort { child, .. } - | NonASAPOp::Limit { child, .. }, - ) => contains_aggregate(child), - _ => false, - } -} - -/// Rebuild `node` (a `SummaryAgg`, possibly under a `SummaryEstimate`) with -/// `new_child` as the `SummaryAgg`'s child, if the result still validates -/// as maintained state; otherwise return `node` unchanged. -fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) => { - let inner = relink_agg_child(summary_input, new_child); - if Rc::ptr_eq(&inner, summary_input) { - return Rc::clone(node); - } - std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input: inner, - query: query.clone(), - }), - node.schema.clone(), - ) - .with_guarantee(node.guarantee.clone()), - ) - } - Operator::ASAP(ASAPOp::SummaryAgg { - child, - family, - input, - reduction, - grouping, - filter, - }) => { - if Rc::ptr_eq(child, new_child) { - return Rc::clone(node); - } - // The same summary over a re-placed input keeps its coverage. - let rebuilt = std::rc::Rc::new(OperatorNode { - coverage: node.coverage.clone(), - ..OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child: Rc::clone(new_child), - family: family.clone(), - input: input.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: filter.clone(), - }), - node.schema.clone(), - ) - .with_guarantee(node.guarantee.clone()) - }); - match validate_maintained(&rebuilt, ExecutionTiming::IngestionTime) { - Ok(_) => rebuilt, - Err(_) => Rc::clone(node), + let intent = bindable_intent(root)?; + let own = accuracy_budget(approximate(intent)?); + let (mut eps, mut delta) = own; + for sibling in siblings { + let Some(NonASAPOp::Aggregate { + reduction: sibling_reduction, + filters: sibling_filters, + child: sibling_child, + .. + }) = sibling.non_asap() + else { + continue; + }; + let Some(other) = bindable_intent(sibling) else { + continue; + }; + let Some(accuracy) = approximate(other) else { + continue; + }; + let same_intent = match (intent, other) { + (AggIntent::Quantile { col, .. }, AggIntent::Quantile { col: other_col, .. }) => { + col == other_col } + _ => override_accuracy(intent, accuracy) == *other, + }; + if same_intent + && sibling_reduction == reduction + && sibling_filters == filters + && (Rc::ptr_eq(sibling_child, child) || sibling_child == child) + { + let (sibling_eps, sibling_delta) = accuracy_budget(accuracy); + eps = eps.min(sibling_eps); + delta = delta.min(sibling_delta); } - _ => Rc::clone(node), } -} - -/// The maintained `SummaryAgg` a bound summary candidate builds (under -/// its `SummaryEstimate` evaluation, if any) — the summary an `ValueOperationAtIngestionTime` -/// beneath it feeds, for `maintenance_operation_plan_cost_rate`. -fn maintained_summary(node: &Rc) -> Option<&Rc> { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - maintained_summary(summary_input) - } - Operator::ASAP(ASAPOp::SummaryAgg { .. }) => Some(node), - _ => None, + if (eps, delta) == own { + None + } else if delta == DEFAULT_DELTA { + Some(AccuracyTarget::Epsilon(eps)) + } else { + Some(AccuracyTarget::EpsilonDelta { + epsilon: eps, + delta, + }) } } -fn is_composition_candidate(candidate: &ReplacementSubDAG) -> bool { - matches!(candidate.replacement, Replacement::ExactComposition(_)) +fn cse_workload(roots: Vec<(Id, Rc)>) -> Vec<(Id, Rc)> { + share_common_sub_dags(roots) } -/// Everything [`CandidateLogicalASAPDAGs::global_selection`] threads between sites for -/// exact compositions (issue #171): child candidates already committed by -/// an earlier parent, and the maintained summary above each site. -#[derive(Default)] -struct CompositionContext { - /// child target ptr → the child's candidate an ancestor's composition - /// already committed to (a later parent must compose with the *same* - /// one, and the child's own selection is forced to it). - committed_child: HashMap<*const OperatorNode, *const ReplacementSubDAG>, - /// site ptr → the maintained `SummaryAgg` directly above it, when its - /// parent chose a bound summary — what an `ValueOperationAtIngestionTime` here feeds. - maintaining_parent: HashMap<*const OperatorNode, Rc>, -} +fn search_cse_workload_with<'s, Id>( + cse_roots: Vec<(Id, Rc)>, + strategies: &[Box], +) -> CandidateLogicalASAPDAGs { + for (_, root) in &cse_roots { + assert!( + !root.contains_asap(), + "search_workload: a workload root already contains an ASAP operator \ + ({}); replacement search takes the front end's pre-ASAP DAG only", + root.operator.kind_name() + ); + } + let mut order = Vec::new(); + let mut nodes = HashMap::new(); + let mut counts: HashMap<*const OperatorNode, usize> = HashMap::new(); + discover_targets(&cse_roots, &mut order, &mut nodes, &mut counts); + let siblings: Vec> = order + .iter() + .filter_map(|ptr| { + let node = &nodes[ptr]; + matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. })).then(|| Rc::clone(node)) + }) + .collect(); + let rollup_strategy = RollupStrategy::new(&siblings); + let accuracy_reconciliation_strategy = AccuracyReconciliationStrategy::new(&siblings); + let limits: Vec> = order + .iter() + .filter_map(|ptr| { + let node = &nodes[ptr]; + matches!(node.non_asap(), Some(NonASAPOp::Limit { .. })).then(|| Rc::clone(node)) + }) + .collect(); + let topk_reuse_strategy = TopKLimitReuseStrategy::new(&limits); -/// One eligible composed alternative at a site, before the cheapest wins. -struct CompositionOption<'a> { - candidate: &'a ReplacementSubDAG, - decision: CompositionDecision<'a>, -} + let mut groups: HashMap<*const OperatorNode, TargetSubDAGCandidates> = HashMap::new(); + for ptr in &order { + groups.insert( + *ptr, + TargetSubDAGCandidates::new(Rc::clone(&nodes[ptr]), counts[ptr]), + ); + } -/// Every [`Replacement::ExactComposition`] candidate of `group` whose -/// composed-plan rate is *known* and beats the raw-recompute baseline — -/// costed against each compatible child candidate already in `CandidateLogicalASAPDAGs` -/// (or the one an earlier parent committed). Unknown statistics yield no -/// option at all: the conservative kept-sub-DAG path stays. -fn composition_options<'a>( - group: &'a TargetSubDAGCandidates, - groups: &'a HashMap<*const OperatorNode, TargetSubDAGCandidates>, - effective: usize, - cost_model: &dyn CostModel, - context: &CompositionContext, - plans: &[PreparedComposition], -) -> Vec> { - let mut options = Vec::new(); - for candidate in &group.candidates { - let Replacement::ExactComposition(composition) = &candidate.replacement else { - continue; - }; - if candidate.runtime_support_evidence(cost_model) != Some(true) { - continue; - } - let child_ptr = Rc::as_ptr(&composition.child_target); - let Some(child_group) = groups.get(&child_ptr) else { - continue; - }; - let already_committed = context.committed_child.get(&child_ptr).copied(); - let cost = |summary: &OperatorNode, shared: bool| { - let request = ExactCompositionCostRequest { - target: &group.target, - composition, - summary, - effective_consumer_count: effective, + // Round-based frontier: every target is asked exactly once per strategy + // (never re-asked — see the module docs' "Termination" section on why + // that matters for `Replacement::Summary` dedup specifically). A round + // can grow the *next* round's frontier only by a candidate's own + // reachable children exposing a genuinely new, not-yet-known `Rc` — see + // `discover_new_descendant_targets`. + let mut frontier = order.clone(); + let mut rounds = 0usize; + while !frontier.is_empty() { + rounds += 1; + assert!( + rounds <= MAX_SEARCH_ITERATIONS, + "search_workload: fixpoint search did not converge within {MAX_SEARCH_ITERATIONS} \ + rounds — a registered ReplacementStrategy's Replacement::Rewrite candidates keep \ + exposing new, never-before-seen descendant structure every round. \ + ASAPStrategies/SharedSubDAGStrategy never do this (see replacement.rs's \ + module docs' \"Termination\" section); check any custom strategies passed to \ + search_workload_with.", + ); + + let targets_before = order.len(); + for ptr in &frontier { + let (root, consumer_count) = { + let group = &groups[ptr]; + (Rc::clone(&group.target), group.consumer_count) }; - let mut inputs = cost_model.exact_composition_cost_inputs(&request); - if shared { - // Shared state is counted once: an earlier parent already - // pays this child's maintenance, so the marginal cost here - // is zero — a *known* zero, unlike an unknown input. - if let Some(maintenance) = inputs.summary_maintenance_cost_per_update.as_mut() { - *maintenance = 0.0; + let strictest = strictest_sibling_accuracy(&root, &siblings); + let mut target = TargetSubDAG::with_consumer_count(&root, consumer_count); + target.strictest_sibling_accuracy = strictest.as_ref(); + + let mut proposed = Vec::new(); + let mut rejected = Vec::new(); + for strategy in strategies { + if strategy.matches(&target) { + let name = strategy.name(); + let proposals = strategy.propose(&target); + proposed.extend(proposals.candidates.into_iter().map(|mut candidate| { + candidate.strategy = name; + candidate + })); + rejected.extend(proposals.rejected.into_iter().map(|mut rejection| { + rejection.strategy = name; + rejection + })); } } - let rate = inputs.composed_plan_cost_rate(composition.placement)?; - let baseline = raw_recompute_cost_rate(&inputs)?; - (rate < baseline).then_some((rate, baseline, inputs)) - }; - match composition.placement { - OperationPlacement::Read => { - let child_candidates: Vec<&'a ReplacementSubDAG> = match already_committed { - // SAFETY-free: the pointer was taken from `groups`'s own - // candidate storage, which outlives this borrow. - Some(ptr) => child_group - .candidates - .iter() - .filter(|c| std::ptr::eq(*c, ptr)) - .collect(), - None => child_group.candidates.iter().collect(), - }; - for child_candidate in child_candidates { - if !is_automatically_selectable(child_candidate, cost_model) { - continue; - } - let Replacement::SubDAG(summary) = &child_candidate.replacement else { - continue; - }; - if is_logical_rewrite(summary) || !composition.accepts_child(summary) { - continue; + if rollup_strategy.matches(&target) { + let name = rollup_strategy.name(); + proposed.extend(rollup_strategy.replacements(&target).into_iter().map( + |mut candidate| { + candidate.strategy = name; + candidate + }, + )); + } + if accuracy_reconciliation_strategy.matches(&target) { + let name = accuracy_reconciliation_strategy.name(); + proposed.extend( + accuracy_reconciliation_strategy + .replacements(&target) + .into_iter() + .map(|mut candidate| { + candidate.strategy = name; + candidate + }), + ); + } + if topk_reuse_strategy.matches(&target) { + let name = topk_reuse_strategy.name(); + proposed.extend(topk_reuse_strategy.replacements(&target).into_iter().map( + |mut candidate| { + candidate.strategy = name; + candidate + }, + )); + } + + for candidate in &proposed { + if let Replacement::SubDAG(rc) = &candidate.replacement { + if is_logical_rewrite(rc) { + discover_new_descendant_targets(rc, &mut order, &mut nodes, &mut counts); } - let Some(prepared) = plans.iter().find(|p| { - p.target == Rc::as_ptr(&group.target) - && p.operation.same_as(composition) - && Rc::ptr_eq(&p.child, summary) - }) else { - continue; - }; - let Some((rate, baseline, inputs)) = cost(summary, already_committed.is_some()) - else { - continue; - }; - options.push(CompositionOption { - candidate, - decision: CompositionDecision { - plan: Rc::clone(&prepared.plan), - child_target: &composition.child_target, - child_candidate: Some(child_candidate), - cost_rate: rate, - baseline_rate: baseline, - inputs, - }, - }); } } - OperationPlacement::Maintenance => { - let Some(prepared) = plans.iter().find(|p| { - p.target == Rc::as_ptr(&group.target) && p.operation.same_as(composition) - }) else { - continue; - }; - // An maintenance-time operation only pays off beneath a - // maintained summary; with nothing above it, its output is - // never read and the raw fallback is the same computation. - let Some(parent) = context.maintaining_parent.get(&Rc::as_ptr(&group.target)) - else { - continue; - }; - let Some((rate, baseline, inputs)) = cost(parent, false) else { - continue; - }; - options.push(CompositionOption { - candidate, - decision: CompositionDecision { - plan: Rc::clone(&prepared.plan), - child_target: &composition.child_target, - child_candidate: None, - cost_rate: rate, - baseline_rate: baseline, - inputs, - }, - }); + + let group = groups + .get_mut(ptr) + .expect("every discovered target has a group"); + for candidate in proposed { + group.add_candidate(candidate); } + group.rejected.extend(rejected); + } + + // Any pointer `discover_new_descendant_targets` appended to `order` + // this round is a genuinely new target — give it a group and process + // it next round. Targets already in `groups` are never revisited. + let new_targets = &order[targets_before..]; + for ptr in new_targets { + groups.entry(*ptr).or_insert_with(|| { + TargetSubDAGCandidates::new(Rc::clone(&nodes[ptr]), counts[ptr]) + }); } + frontier = new_targets.to_vec(); } - options -} -impl CandidateLogicalASAPDAGs { - /// The whole-plan (cross-group) selection step the module docs' - /// "Whole-plan (cross-group) selection" section describes: one - /// [`TargetSubDAGSelection`] per discovered site, each ranked against an - /// `effective_consumer_count` that accounts for every ancestor - /// [`SharedSubDAGStrategy`] decision on the path to it — unlike - /// [`Self::cost_sorted`], whose per-group ranking only ever sees a - /// group's own raw [`TargetSubDAGCandidates::consumer_count`]. - /// Uncertified DDSketch ratios remain in [`CandidateLogicalASAPDAGs`] for downstream - /// inspection but are not chosen automatically by this selector. - pub fn global_selection(&self, cost_model: &dyn CostModel) -> GlobalSelection<'_> { - self.global_selection_impl(cost_model, None, None) - .expect("structural global selection cannot produce a recurrence error") - } - - /// Recurrence-aware counterpart to [`Self::global_selection`]. The same - /// whole-plan traversal and effective structural consumer counts are - /// retained, while every CSE share/recompute choice is made from the - /// corresponding recurrence profile. - pub fn global_selection_with_recurrence( - &self, - cost_model: &dyn CostModel, - profiles: &RecurrenceProfileMap, - horizon: Option, - ) -> Result, RecurrenceError> { - self.global_selection_impl(cost_model, Some(profiles), horizon) + add_effective_count_cse_candidates(&order, &mut groups); + + CandidateLogicalASAPDAGs { + roots: cse_roots, + groups, + order, + composition_plans: Vec::new(), } +} - fn global_selection_impl( - &self, - cost_model: &dyn CostModel, - profiles: Option<&RecurrenceProfileMap>, - horizon: Option, - ) -> Result, RecurrenceError> { - let dag = reference_dag(self); - let topo = topological_order(&self.order, &dag); - - let mut effective_uses = dag.external_root_uses.clone(); - let mut chosen_share: HashMap<*const OperatorNode, ShareDecision> = HashMap::new(); - let mut groups: HashMap<*const OperatorNode, TargetSubDAGSelection<'_>> = HashMap::new(); - let mut context = CompositionContext::default(); - - for ptr in &topo { - let group = &self.groups[ptr]; - - let effective = effective_uses.get(ptr).copied().unwrap_or(0); - effective_uses.insert(*ptr, effective); - - // ── Exact compositions (issue #171) ───────────────────────── - // A child an earlier parent's composition committed to is - // forced to exactly that candidate — the parent/child pair is - // one decision. Otherwise, a composition here wins only when - // its cost-units-per-second rate is *known* and beats the raw - // recompute baseline; missing statistics keep the conservative - // path below. - let mut composition_decision = None; - let forced = context - .committed_child - .get(ptr) - .and_then(|&cptr| group.candidates.iter().find(|c| std::ptr::eq(*c, cptr))); - let composed = if forced.is_some() { - None - } else { - composition_options( - group, - &self.groups, - effective, - cost_model, - &context, - &self.composition_plans, - ) - .into_iter() - .min_by(|a, b| a.decision.cost_rate.0.total_cmp(&b.decision.cost_rate.0)) - }; - if let Some(option) = &composed { - if let Some(child_candidate) = option.decision.child_candidate { - context.committed_child.insert( - Rc::as_ptr(option.decision.child_target), - child_candidate as *const ReplacementSubDAG, - ); +/// Materialize share/recompute alternatives for descendants whose raw edge +/// count is one but whose effective count can exceed one when a repeated +/// ancestor is recomputed. We only do this when an ordinary repeated group +/// proves that `SharedSubDAGStrategy` is part of this search's strategy set. +fn add_effective_count_cse_candidates( + order: &[*const OperatorNode], + groups: &mut HashMap<*const OperatorNode, TargetSubDAGCandidates>, +) { + let mut possible_children: HashMap<*const OperatorNode, Vec<*const OperatorNode>> = + HashMap::new(); + for ptr in order { + let group = &groups[ptr]; + let children = possible_children.entry(*ptr).or_default(); + for (child, _) in direct_child_counts(&group.target) { + if !children.contains(&child) { + children.push(child); + } + } + for candidate in &group.candidates { + if let Replacement::SubDAG(rewrite) = &candidate.replacement { + if !is_logical_rewrite(rewrite) { + continue; } - if let Replacement::ExactComposition(composition) = &option.candidate.replacement { - if composition.placement == OperationPlacement::Maintenance { - // A chain of functions feeds the same summary. - if let Some(parent) = context.maintaining_parent.get(ptr).cloned() { - context - .maintaining_parent - .insert(Rc::as_ptr(&composition.child_target), parent); - } + for (child, _) in direct_child_counts(rewrite) { + if !children.contains(&child) { + children.push(child); } } } + } + } - let complete_plan_choice = (!forced.is_some() - && composed.is_none() - && cost_model.candidate_cost_covers_complete_plan()) - .then(|| { - let effective_target = TargetSubDAG::with_consumer_count(&group.target, effective); - let bound = group - .candidates - .iter() - .filter(|candidate| { - !is_cse_candidate(candidate) - && !is_composition_candidate(candidate) - && is_automatically_selectable(candidate, cost_model) - }) - .filter_map(|candidate| { - cost_model - .candidate_cost(candidate, &effective_target) - .map(|cost| (candidate, cost)) - }) - .min_by(|(_, left), (_, right)| left.0.total_cmp(&right.0)) - .map(|(candidate, _)| candidate); - bound.or_else(|| { - (cost_model.allow_uncosted_legacy_selection() && effective >= 2) - .then(|| { - decide_with_effective_count(group, effective, cost_model).and_then( - |decision| { - let candidate = pick_shared_sub_dag_candidate(group, decision)?; - chosen_share.insert(*ptr, decision); - Some(candidate) - }, - ) - }) - .flatten() - }) - }) - .flatten(); - - let chosen = if let Some(forced) = forced { - Some(forced) - } else if let Some(option) = composed { - composition_decision = Some(option.decision); - Some(option.candidate) - } else if cost_model.candidate_cost_covers_complete_plan() { - complete_plan_choice - } else if effective >= 2 && cse_candidate_pair(group).is_some() { - let decision = if let Some(profiles) = profiles { - decide_group_with_recurrence( - group, - effective, - profiles.for_target(&group.target), - horizon, - cost_model, - )? - } else { - decide_with_effective_count(group, effective, cost_model) - }; - match decision { - Some(decision) => { - let cse = pick_shared_sub_dag_candidate(group, decision); - let effective_target = - TargetSubDAG::with_consumer_count(&group.target, effective); - let logical = group - .candidates - .iter() - .filter(|candidate| { - !is_cse_candidate(candidate) - && !is_composition_candidate(candidate) - && is_automatically_selectable(candidate, cost_model) - }) - .filter_map(|candidate| { - cost_model - .candidate_cost(candidate, &effective_target) - .map(|cost| (candidate, cost)) - }) - .min_by(|(_, a), (_, b)| a.0.total_cmp(&b.0)) - .map(|(candidate, _)| candidate); - let cse = cse.filter(|candidate| { - cost_model - .candidate_cost(candidate, &effective_target) - .is_some() - || cost_model.allow_uncosted_legacy_selection() - }); - match (cse, logical) { - (Some(cse), Some(logical)) - if cost_model - .candidate_cost(cse, &effective_target) - .is_none_or(|cse_cost| { - cost_model - .candidate_cost(logical, &effective_target) - .is_some_and(|logical_cost| logical_cost.0 < cse_cost.0) - }) => - { - Some(logical) - } - (cse, _) => { - if cse.is_some() { - chosen_share.insert(*ptr, decision); - } - cse - } - } - } - // `realize_child` couldn't produce even a logical fallback — - // not expected in practice for a target that's already - // part of a legitimate workload DAG (mirrors - // `cse_preference`'s own doc on this same degrade). - // Falling back to ordinary local ranking is still a - // valid answer, just not a cross-group-aware one; this - // group also contributes no Share collapse to its own - // children (see `multiplier`'s `_ => effective` arm). - None => rank_group(group, cost_model).into_iter().find(|candidate| { - !is_composition_candidate(candidate) - && is_automatically_selectable(candidate, cost_model) - && (cost_model - .candidate_cost( - candidate, - &TargetSubDAG::with_consumer_count(&group.target, effective), - ) - .is_some() - || cost_model.allow_uncosted_legacy_selection()) - }), - } - } else { - let effective_target = TargetSubDAG::with_consumer_count(&group.target, effective); - rank_group(group, cost_model) - .into_iter() - .find(|candidate| { - !is_cse_candidate(candidate) - && !is_composition_candidate(candidate) - && is_automatically_selectable(candidate, cost_model) - && (cost_model - .candidate_cost(candidate, &effective_target) - .is_some() - || cost_model.allow_uncosted_legacy_selection()) - }) - .or_else(|| { - cse_candidate_pair(group) - .map(|(share, _)| share) - .filter(|candidate| { - cost_model - .candidate_cost(candidate, &effective_target) - .is_some() - || cost_model.allow_uncosted_legacy_selection() - }) - }) - }; - - // Record the maintained summary this site's bound candidate - // builds, for a child that may compose an `ValueOperationAtIngestionTime` - // beneath it. - if let (Some(Replacement::SubDAG(node)), Some(NonASAPOp::Aggregate { child, .. })) = - (chosen.map(|c| &c.replacement), group.target.non_asap()) - { - if let Some(summary) = maintained_summary(node) { - context - .maintaining_parent - .insert(Rc::as_ptr(child), Rc::clone(summary)); + let mut potentially_repeated = HashSet::new(); + let mut queue = VecDeque::new(); + for ptr in order { + let group = &groups[ptr]; + if group.consumer_count >= 2 && cse_candidate_pair(group).is_some() { + potentially_repeated.insert(*ptr); + queue.push_back(*ptr); + } + } + while let Some(parent) = queue.pop_front() { + if let Some(children) = possible_children.get(&parent) { + for child in children { + if groups.contains_key(child) && potentially_repeated.insert(*child) { + queue.push_back(*child); } } + } + } - let outgoing_multiplier = multiplier(*ptr, &effective_uses, &chosen_share); - match chosen { - Some(ReplacementSubDAG { - replacement: Replacement::SubDAG(source), - provenance: ReplacementProvenance::AccuracyReconciliation, - .. - }) => { - // Accuracy reconciliation reads another discovered memo - // group, rather than inlining that group's children. Let - // the source group receive the uses and propagate them - // through its own selected realization when its turn - // arrives in topological order. - *effective_uses.entry(Rc::as_ptr(source)).or_insert(0) += outgoing_multiplier; - } - _ => { - let selected_rewrite = match chosen.map(|candidate| &candidate.replacement) { - Some(Replacement::SubDAG(rewrite)) if is_logical_rewrite(rewrite) => { - rewrite - } - Some(Replacement::SubDAG(_) | Replacement::ExactComposition(_)) | None => { - &group.target - } - }; - for (child, edge_count) in direct_child_counts(selected_rewrite) { - *effective_uses.entry(child).or_insert(0) += - edge_count * outgoing_multiplier; + for ptr in order { + let group = groups + .get_mut(ptr) + .expect("every discovered site has a group"); + if potentially_repeated.contains(ptr) && cse_candidate_pair(group).is_none() { + let target = Rc::clone(&group.target); + let site = TargetSubDAG::with_consumer_count(&target, 2); + for mut candidate in SharedSubDAGStrategy.replacements(&site) { + candidate.rationale = format!( + "{}: this sub-DAG can become repeated when a repeated ancestor is recomputed; \ + global_selection decides using its effective consumer count", + match candidate.provenance { + ReplacementProvenance::CseShare => "build once and share", + ReplacementProvenance::CseRecompute => "recompute independently", + _ => unreachable!("SharedSubDAGStrategy only emits CSE candidates"), } - } + ); + group.add_candidate(candidate); } - - groups.insert( - *ptr, - TargetSubDAGSelection { - target: &group.target, - consumer_count: group.consumer_count, - effective_consumer_count: effective, - chosen, - composition: composition_decision, - }, - ); } - - Ok(GlobalSelection { - order: self.order.clone(), - groups, - assembled_nodes: RefCell::new(HashMap::new()), - }) } } -fn is_cse_candidate(candidate: &ReplacementSubDAG) -> bool { - matches!( - candidate.provenance, - ReplacementProvenance::CseShare | ReplacementProvenance::CseRecompute - ) +// ── target discovery ───────────────────────────────────────────────────── + +/// Walk every root's whole DAG, discovering one `TargetSubDAG` per distinct +/// `Rc` and its real `consumer_count` — see the module docs' "Where +/// `TargetSubDAG` discovery comes from" section for the full rationale. +pub(crate) fn discover_targets( + roots: &[(Id, Rc)], + order: &mut Vec<*const OperatorNode>, + nodes: &mut HashMap<*const OperatorNode, Rc>, + counts: &mut HashMap<*const OperatorNode, usize>, +) { + for (_, root) in roots { + walk(root, order, nodes, counts); + } } -fn is_automatically_selectable(candidate: &ReplacementSubDAG, cost_model: &dyn CostModel) -> bool { - candidate.provenance != ReplacementProvenance::RootPhysicalRealization - && !candidate.has_missing_accuracy_evidence() - && candidate.runtime_support_evidence(cost_model) != Some(false) +/// Scan `candidate`'s **children** (deliberately never `candidate`'s own +/// top-level pointer — see the module docs' "Termination" section: a +/// logical rewrite's value is an alternative *for* the target that +/// proposed it, never a new target of its own) for any `Rc` not already +/// known, appending each to `order`/`nodes`/`counts` so +/// [`search_workload_with`]'s next round processes it. A no-op when every +/// child is already known — the case both shipped strategies always produce +/// (see that section). +fn discover_new_descendant_targets( + candidate: &Rc, + order: &mut Vec<*const OperatorNode>, + nodes: &mut HashMap<*const OperatorNode, Rc>, + counts: &mut HashMap<*const OperatorNode, usize>, +) { + walk_children(candidate, order, nodes, counts); } -/// How much one direct reference to `parent_ptr` actually costs, once -/// `parent_ptr`'s own chosen candidate (if it has a Share/Recompute pair at -/// all) is taken into account: -/// -/// - `1`, if `parent_ptr` chose [`ShareDecision::Share`] — one shared -/// execution backs every reference to it, so referencing it costs no more -/// than referencing it once. -/// - `parent_ptr`'s own `effective_consumer_count` otherwise — either it -/// chose [`ShareDecision::RecomputeIndependently`] (each of its own uses -/// gets its own independent execution, so referencing it costs as much as -/// its *own* full multiplicity), or it has no Share/Recompute decision at -/// all (not a [`SharedSubDAGStrategy`] shape — nothing here collapses -/// its multiplicity to one, so whatever multiplicity *its* ancestors -/// established simply passes through). -/// -/// Composing this recurrence transitively up the whole ancestor chain (not -/// just the immediate parent) is exactly what makes -/// [`CandidateLogicalASAPDAGs::global_selection`]'s `effective_consumer_count` differ from -/// [`TargetSubDAGCandidates::consumer_count`] whenever a `RecomputeIndependently` -/// ancestor sits anywhere on the path from a root to a site — see the -/// module docs' "Whole-plan (cross-group) selection" section. -fn multiplier( - parent_ptr: *const OperatorNode, - effective_uses: &HashMap<*const OperatorNode, usize>, - chosen_share: &HashMap<*const OperatorNode, ShareDecision>, -) -> usize { - let effective = *effective_uses.get(&parent_ptr).expect( - "topological_order guarantees a parent is processed (and its effective_consumer_count \ - recorded) before any of its children", - ); - match chosen_share.get(&parent_ptr) { - Some(ShareDecision::Share) => 1, - _ => effective, +/// Visit `node`: count this occurrence, and — the first time this exact +/// `Rc` is seen — record it as a target and recurse into its children. +fn walk( + node: &Rc, + order: &mut Vec<*const OperatorNode>, + nodes: &mut HashMap<*const OperatorNode, Rc>, + counts: &mut HashMap<*const OperatorNode, usize>, +) { + let ptr = Rc::as_ptr(node); + let already_visited = counts.contains_key(&ptr); + *counts.entry(ptr).or_insert(0) += 1; + if !already_visited { + order.push(ptr); + nodes.insert(ptr, Rc::clone(node)); + walk_children(node, order, nodes, counts); } } -/// Find the explicitly-tagged CSE share/recompute pair inside `group`, even -/// when other strategies contributed additional alternatives to the same -/// memo group. Provenance makes these two orthogonal choices identifiable -/// without inferring semantics from pointer or expression shape. -fn cse_candidate_pair( - group: &TargetSubDAGCandidates, -) -> Option<(&ReplacementSubDAG, &ReplacementSubDAG)> { - let mut share = None; - let mut recompute = None; - for candidate in &group.candidates { - match candidate.provenance { - ReplacementProvenance::CseShare => { - let Replacement::SubDAG(rc) = &candidate.replacement else { - return None; - }; - if !Rc::ptr_eq(rc, &group.target) || share.replace(candidate).is_some() { - return None; - } - } - ReplacementProvenance::CseRecompute => { - let Replacement::SubDAG(rc) = &candidate.replacement else { - return None; - }; - if Rc::ptr_eq(rc, &group.target) - || rc.as_ref() != group.target.as_ref() - || recompute.replace(candidate).is_some() - { - return None; - } - } - _ => {} +/// `node`'s own operator children ([`OperatorNode::children`]: operator +/// inputs plus the operator nodes its scalar expressions read), the same +/// scope `asap_types::ir::cse::share_common_sub_dags` itself uses and +/// `tests::count_consumers` mirrors for its own fixtures. `Concat` is +/// transparent: its branches are walked in place of it. +fn walk_children( + node: &OperatorNode, + order: &mut Vec<*const OperatorNode>, + nodes: &mut HashMap<*const OperatorNode, Rc>, + counts: &mut HashMap<*const OperatorNode, usize>, +) { + if let Some(NonASAPOp::Concat { children, .. }) = node.non_asap() { + for c in children { + walk_children(c, order, nodes, counts); } + return; + } + for child in node.children() { + walk(child, order, nodes, counts); } - Some((share?, recompute?)) -} - -/// [`CostModel::cse_share_decision`] for `group`, against an explicit -/// `effective_consumer_count` instead of `group.consumer_count` — the -/// cross-group-aware counterpart to [`cse_preference`], which uses the raw -/// structural count. `None` only when [`realize_child`] can't produce even a -/// logical fallback for `group.target` (see that function's own doc). -fn decide_with_effective_count( - group: &TargetSubDAGCandidates, - effective_consumer_count: usize, - cost_model: &dyn CostModel, -) -> Option { - let bound = realize_child(&group.target, cost_model).ok()?; - let candidate = CseCandidate { - sub_dag: &group.target, - bound_summary: &bound, - consumer_count: effective_consumer_count, - }; - Some(cost_model.cse_share_decision(&candidate)) } -fn decide_group_with_recurrence( - group: &TargetSubDAGCandidates, - effective_consumer_count: usize, - recurrence: RecurrenceProfile, - horizon: Option, - cost_model: &dyn CostModel, -) -> Result, RecurrenceError> { - let Some(bound) = realize_child(&group.target, cost_model).ok() else { - return Ok(None); - }; - let candidate = CseCandidate { - sub_dag: &group.target, - bound_summary: &bound, - consumer_count: effective_consumer_count, +#[cfg(test)] +mod tests { + use super::*; + use crate::accuracy::PropagationStats; + use crate::plan_selection::candidate_selection::sketch_kind_of; + use crate::test_support::{agg, agg_per_entity, lower_promql, maintained, metric_scan, timed}; + use asap_types::ir::operator::agg_intent::{ + agg_is_exact, default_cardinality, default_quantile, MathFunc, TimeFunc, }; - Ok(Some( - cost_model - .cse_share_decision_with_recurrence(&candidate, &recurrence, horizon)? - .decision, - )) -} - -/// The [`SharedSubDAGStrategy`] candidate matching `decision`: the one -/// that shares `group.target`'s own `Rc` for [`ShareDecision::Share`], the -/// freshly-allocated one for [`ShareDecision::RecomputeIndependently`] — -/// the same `Rc`-identity distinction [`is_duplicate_rewrite`]'s own doc -/// explains is the *only* signal this IR carries for that choice. -fn pick_shared_sub_dag_candidate( - group: &TargetSubDAGCandidates, - decision: ShareDecision, -) -> Option<&ReplacementSubDAG> { - let (share, recompute) = cse_candidate_pair(group)?; - Some(match decision { - ShareDecision::Share => share, - ShareDecision::RecomputeIndependently => recompute, - }) -} + use asap_types::ir::operator::operator_properties::{Reduction as ReductionTy, Source}; + use asap_types::ir::schema::ColumnId; + use asap_types::ir::schema::{DataType, Field, Schema as SchemaTy}; + use asap_types::ir::Predicate; + use asap_types::ir::TimeRangeKind; -// ── reference DAG + topological order ───────────────────────────────── - -/// The parent/child structure [`CandidateLogicalASAPDAGs::global_selection`]'s DP walks — -/// built separately from [`discover_targets`]'s own `order`/`nodes`/`counts` -/// maps (which only track *aggregate* reference counts, not per-parent -/// breakdown or direction). Selection needs per-parent edge counts to -/// distinguish shared producers from repeated uses within one consumer. -struct ReferenceDAG { - /// child ptr -> `(parent ptr, edge count from that one parent)`, for - /// every direct operator-child edge in the relational-skeleton scope - /// [`walk_children`] itself uses (an edge count above 1 happens when - /// one parent references the same child from two different fields, - /// e.g. a `Join`'s `left`/`right` both being the same `Rc`). - parents_of: HashMap<*const OperatorNode, Vec<(*const OperatorNode, usize)>>, - /// parent ptr -> every distinct child ptr it directly references — the - /// reverse of `parents_of`, for [`topological_order`]'s Kahn's-algorithm - /// traversal. - children_of: HashMap<*const OperatorNode, Vec<*const OperatorNode>>, - /// How many of the workload's own `roots` point directly at each node — - /// a node's "external" use. Nothing inside the DAG decides this (it - /// isn't a reference from another discovered site), so it's never - /// subject to any ancestor's Share/Recompute choice — it's the base - /// case [`CandidateLogicalASAPDAGs::global_selection`]'s recurrence starts from. - external_root_uses: HashMap<*const OperatorNode, usize>, -} + use asap_types::types::AccuracyTarget; + use std::collections::HashMap; -/// Build an ordering DAG containing every edge that could be selected: -/// the original target's edges plus every rewrite candidate's edges. An -/// accuracy-reconciliation rewrite points at another discovered memo group, -/// so it contributes an edge to that group itself; other rewrites contribute -/// their relational children as before. The -/// DAG is deliberately only used for topological ordering; effective-use -/// counts are propagated through the one candidate actually selected. -fn reference_dag(space: &CandidateLogicalASAPDAGs) -> ReferenceDAG { - let mut dag = ReferenceDAG { - parents_of: HashMap::new(), - children_of: HashMap::new(), - external_root_uses: HashMap::new(), - }; - for (_, root) in &space.roots { - *dag.external_root_uses.entry(Rc::as_ptr(root)).or_insert(0) += 1; - } - for ptr in &space.order { - let group = &space.groups[ptr]; - record_possible_edges(*ptr, &group.target, &mut dag); - for candidate in &group.candidates { - if let Replacement::SubDAG(rewrite) = &candidate.replacement { - if !is_logical_rewrite(rewrite) { - continue; - } - if candidate.provenance == ReplacementProvenance::AccuracyReconciliation { - add_edge(*ptr, Rc::as_ptr(rewrite), 1, &mut dag); - } else { - record_possible_edges(*ptr, rewrite, &mut dag); + // Candidate shape without execution timing: what is computed, not where. + fn timing_free_shape(node: &Rc) -> serde_json::Value { + fn strip(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(fields) => { + fields.remove("timing"); + fields.values_mut().for_each(strip); } + serde_json::Value::Array(values) => values.iter_mut().for_each(strip), + _ => {} } } + let mut shape = serde_json::to_value( + asap_types::ir::export::compile_physical_asap_dag(&timed(node)).unwrap(), + ) + .unwrap(); + strip(&mut shape); + shape } - dag -} -/// Record one `parent_ptr -> child` edge (both directions — see -/// [`ReferenceDAG`]'s fields), retaining the greatest multiplicity seen -/// when the target and alternative rewrites expose the same edge. -fn add_edge( - parent_ptr: *const OperatorNode, - child_ptr: *const OperatorNode, - edge_count: usize, - dag: &mut ReferenceDAG, -) { - let siblings = dag.parents_of.entry(child_ptr).or_default(); - match siblings.iter_mut().find(|(p, _)| *p == parent_ptr) { - Some((_, count)) => *count = (*count).max(edge_count), - None => siblings.push((parent_ptr, edge_count)), - } - let kids = dag.children_of.entry(parent_ptr).or_default(); - if !kids.contains(&child_ptr) { - kids.push(child_ptr); - } -} - -fn record_possible_edges( - parent_ptr: *const OperatorNode, - node: &OperatorNode, - dag: &mut ReferenceDAG, -) { - for (child_ptr, edge_count) in direct_child_counts(node) { - add_edge(parent_ptr, child_ptr, edge_count, dag); + // Rate inventories never offer two candidates that differ only in timing. + #[test] + fn rate_candidate_inventories_have_no_timing_only_duplicates() { + for (query, accuracy) in [ + ("sum by(job)(rate(m[1m]))", AccuracyTarget::Exact), + ("topk by(job)(2, rate(m[1m]))", AccuracyTarget::Epsilon(0.1)), + ] { + let root = lower_promql(query, accuracy); + let inventory = search_workload(vec![(0usize, root)]) + .enumerate_candidate_dags(4096) + .unwrap(); + let shapes = inventory + .candidates + .iter() + .map(|forest| timing_free_shape(&forest[0].1)) + .collect::>(); + for (i, shape) in shapes.iter().enumerate() { + assert!(!shapes[..i].contains(shape), "{query}: duplicate {i}"); + } + } } -} -/// Direct relational-skeleton children and their edge multiplicities. -/// `Concat` is transparent, matching [`walk_children`]'s site scope. -fn direct_child_counts(node: &OperatorNode) -> Vec<(*const OperatorNode, usize)> { - fn push(children: &mut Vec<(*const OperatorNode, usize)>, child: &Rc) { - let ptr = Rc::as_ptr(child); - match children.iter_mut().find(|(existing, _)| *existing == ptr) { - Some((_, count)) => *count += 1, - None => children.push((ptr, 1)), - } + // Grouped Sum over Rate evaluations stays a summary state in the inventory, + // so materialization assignment can place it in precompute or at query time. + #[test] + fn grouped_rate_sum_inventory_keeps_sum_state_for_materialization_placement() { + let root = lower_promql("sum by(job)(rate(m[1m]))", AccuracyTarget::Exact); + let inventory = search_workload(vec![(0usize, root)]) + .enumerate_candidate_dags(4096) + .unwrap(); + let is_exact = |node: &OperatorNode, kind: ExactKind| { + matches!(&node.operator, Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(k, _), .. + }) if *k == kind) + }; + assert!(inventory.candidates.iter().any(|forest| { + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: sum }) = &forest[0].1.operator else { + return false; + }; + let Operator::ASAP(ASAPOp::SummaryAgg { child: rate, .. }) = &sum.operator else { + return false; + }; + is_exact(sum, ExactKind::Sum) + && matches!(&rate.operator, Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) if is_exact(child, ExactKind::Rate)) + })); } - fn collect(node: &OperatorNode, children: &mut Vec<(*const OperatorNode, usize)>) { - if let Some(NonASAPOp::Concat { - children: concat_children, - .. - }) = node.non_asap() - { - for c in concat_children { - collect(c, children); + // Every exposed query result has a evaluation; internal accumulator frontiers stay states. + #[test] + fn query_candidate_roots_do_not_leak_exact_accumulator_state() { + for query in [ + "sum by(job)(rate(m[1m]))", + "sum by(job)(m)", + "sum_over_time(m[1m])", + ] { + let root = lower_promql(query, AccuracyTarget::Exact); + let space = search_workload(vec![(0usize, root.clone())]); + let inventory = space.enumerate_candidate_dags(4096).unwrap(); + assert!(!inventory.candidates.is_empty()); + let strategy = ASAPStrategies::new(&DefaultCostModel); + for candidate in strategy.propose(&TargetSubDAG::new(&root)).candidates { + if let Replacement::SubDAG(node) = candidate.replacement { + let output = finalize_query_candidate(node, &root).unwrap(); + assert!( + output + .schema + .fields + .iter() + .all(|field| matches!(field.dtype, FieldDataType::Plain(_))), + "direct candidate {query} leaks state" + ); + } + } + let selected = space + .global_selection(&DefaultCostModel) + .assemble_selected_query(&space.roots[0].1) + .unwrap() + .unwrap(); + for node in inventory + .candidates + .iter() + .map(|forest| &forest[0].1) + .chain(std::iter::once(&selected)) + { + assert!( + node.schema + .fields + .iter() + .all(|field| matches!(field.dtype, FieldDataType::Plain(_))), + "{query}: query root leaks state: {:?}", + node.schema + ); } - return; - } - for child in node.children() { - push(children, child); } } - let mut children = Vec::new(); - collect(node, &mut children); - children -} - -/// A topological order over `order` (parent before every child) via Kahn's -/// algorithm on `dag`'s reverse adjacency — needed because -/// [`discover_targets`]'s own `order` is only a valid *discovery* order -/// (first-seen-first), not a valid topological one: a node reached via two -/// different root paths can have a parent that's discovered *after* it (see -/// this function's own test for a worked diamond example), which is exactly -/// backwards for [`CandidateLogicalASAPDAGs::global_selection`]'s recurrence. -fn topological_order( - order: &[*const OperatorNode], - dag: &ReferenceDAG, -) -> Vec<*const OperatorNode> { - let mut in_degree: HashMap<*const OperatorNode, usize> = HashMap::new(); - for ptr in order { - let degree = dag.parents_of.get(ptr).map(Vec::len).unwrap_or(0); - in_degree.insert(*ptr, degree); + #[test] + fn unpriced_inventory_retains_quantile_families_and_raw_execution() { + let query = agg(vec![2], default_quantile(0.9), metric_scan(&["job"])); + let space = search_workload(vec![(0usize, query)]); + let inventory = space.enumerate_candidate_dags(4096).unwrap(); + let roots = inventory + .candidates + .iter() + .map(|forest| format!("{:?}", forest[0].1)) + .collect::>(); + assert!(roots.iter().any(|root| root.contains("Kll"))); + assert!(roots.iter().any(|root| root.contains("DDSketch"))); + assert!(inventory + .candidates + .iter() + .any(|forest| !forest[0].1.contains_asap())); } - let mut queue: VecDeque<*const OperatorNode> = order - .iter() - .copied() - .filter(|ptr| in_degree[ptr] == 0) - .collect(); + // Independent roots must not require materializing their Cartesian product. + #[test] + fn root_inventory_preserves_choices_without_workload_cartesian_expansion() { + let roots = (0..24usize) + .map(|id| { + ( + id, + agg( + vec![2], + default_quantile((id + 1) as f64 / 25.0), + metric_scan(&["job"]), + ), + ) + }) + .collect(); + let space = search_workload(roots); + assert!(space.enumerate_candidate_dags(4096).is_err()); + for id in 0..24 { + let inventory = space.enumerate_candidate_dags_for_root(&id, 4096).unwrap(); + assert!(inventory + .candidates + .iter() + .all(|forest| forest.len() == 1 && forest[0].0 == id)); + let descriptions = inventory + .candidates + .iter() + .map(|forest| format!("{:?}", forest[0].1)) + .collect::>(); + assert!(descriptions.iter().any(|node| node.contains("Kll"))); + assert!(descriptions.iter().any(|node| node.contains("DDSketch"))); + assert!(inventory + .candidates + .iter() + .any(|forest| !forest[0].1.contains_asap())); + } + assert!(space.enumerate_candidate_dags_for_root(&24, 4096).is_err()); + assert!(space.enumerate_candidate_dags_for_root(&0, 0).is_err()); + } - let mut topo = Vec::with_capacity(order.len()); - while let Some(ptr) = queue.pop_front() { - topo.push(ptr); - if let Some(children) = dag.children_of.get(&ptr) { - for child in children { - if let Some(degree) = in_degree.get_mut(child) { - *degree -= 1; - if *degree == 0 { - queue.push_back(*child); - } - } + // Factoring changes enumeration, not the set of root computations. + #[test] + fn root_inventory_matches_projection_of_exhaustive_workload_inventory() { + let roots = (0..2usize) + .map(|id| { + ( + id, + agg( + vec![2], + default_quantile(0.5 + id as f64 * 0.4), + metric_scan(&["job"]), + ), + ) + }) + .collect(); + let space = search_workload(roots); + let full = space.enumerate_candidate_dags(4096).unwrap(); + for id in 0..2 { + let inventory = space.enumerate_candidate_dags_for_root(&id, 4096).unwrap(); + for forest in &full.candidates { + let node = &forest.iter().find(|(root, _)| *root == id).unwrap().1; + assert!(inventory.candidates.iter().any(|one| &one[0].1 == node)); + } + for one in &inventory.candidates { + assert!(full.candidates.iter().any(|forest| forest + .iter() + .any(|(root, node)| *root == id && node == &one[0].1))); } } } - assert_eq!( - topo.len(), - order.len(), - "topological_order: the discovered-site reference dag has a cycle — every \ - OperatorNode is built from Rc children, which can't form one, so this indicates a bug \ - in reference_dag rather than a real cyclic workload", - ); - topo -} + #[test] + fn inventory_budget_never_returns_a_silent_partial_search() { + let query = agg(vec![2], default_quantile(0.9), metric_scan(&["job"])); + let space = search_workload(vec![(0usize, query)]); + assert!(space.enumerate_candidate_dags(0).is_err()); + } -// ── default_strategies ────────────────────────────────────────────────── + fn equi_pred(left: ColumnId, right: ColumnId) -> Predicate { + Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(left)), + op: asap_types::ir::scalar::CompareOpKind::Eq, + right: Box::new(ScalarExpr::Column(right)), + semantics: asap_types::ir::ExprSemantics::Sql, + }) + } -/// The context-free strategies [`search_workload`] runs in the built-in -/// [`DefaultCostModel`] configuration. Workload-dependent strategies such as -/// [`RollupStrategy`] and [`AccuracyReconciliationStrategy`] (issue #273, -/// cross-consumer accuracy reconciliation for CSE sharing — see that -/// module's own docs) are added by [`search_workload`] after CSE and target -/// discovery, when their sibling context exists. -/// [`crate::explanation::explain_replacements`] (issue #257) uses -/// this same set (via [`search_workload`]) rather than keeping a second, -/// explanation-specific list to stay in sync with. Use -/// [`default_strategies_with`] to plug in a deployment-specific -/// [`CostModel`] instead. -/// -/// [`AvgToSumOverCountStrategy`](crate::rewrite::AvgToSumOverCountStrategy) is -/// included here (issue #253) even though it's a -/// [`Replacement::Rewrite`]-only strategy with no [`CostModel`] of its own to -/// plug in — it's context-free (`matches`/`replacements` need nothing beyond -/// the target itself) exactly like [`SharedSubDAGStrategy`], so it belongs -/// in this list rather than being derived per-workload the way -/// [`RollupStrategy`] is. Rewriting `avg` into `sum`/`count` upfront is what -/// lets [`ASAPStrategies`] and [`SharedSubDAGStrategy`] see a -/// mergeable accumulator to sketch or share at all — see that module's own -/// doc comment for why a bare `avg` node otherwise never becomes a -/// [`ReplacementStrategy`] target for anything. -pub fn default_strategies() -> Vec> { - vec![ - Box::new(ASAPStrategies::default_cost_model()), - Box::new(HydraGroupingStrategy::default_cost_model()), - Box::new(SharedSubDAGStrategy), - Box::new(crate::rewrite::AvgToSumOverCountStrategy), - Box::new(ExactCompositionStrategy::default_cost_model()), - ] -} - -/// Like [`default_strategies`], but [`ASAPStrategies`] ranks/binds via -/// `cost_model` instead of the built-in [`DefaultCostModel`] — the same -/// customization point [`ASAPStrategies::new`] itself offers. -pub fn default_strategies_with<'a>( - cost_model: &'a dyn CostModel, -) -> Vec> { - vec![ - Box::new(ASAPStrategies::new(cost_model)), - Box::new(HydraGroupingStrategy::new(cost_model)), - Box::new(SharedSubDAGStrategy), - Box::new(crate::rewrite::SemanticEquivalentRewriteStrategy), - Box::new(ExactCompositionStrategy::new(cost_model)), - ] -} - -/// Default context-free strategies with both deployment costing and typed -/// planning-time accuracy evidence. This is the production counterpart of -/// constructing [`ASAPStrategies::new_with_planning_inputs_and_evidence`] and -/// [`HydraGroupingStrategy::new_with_planning_inputs_and_evidence`] separately. -pub fn default_strategies_with_evidence<'a>( - cost_model: &'a dyn CostModel, - evidence: &'a dyn AccuracyEvidenceProvider, -) -> Vec> { - vec![ - Box::new(ASAPStrategies::new_with_planning_inputs_and_evidence( - cost_model, - &DEFAULT_ACCURACY_MODEL, - &DEFAULT_ALLOCATOR, - evidence, - )), - Box::new( - HydraGroupingStrategy::new_with_planning_inputs_and_evidence( - cost_model, - &DEFAULT_ACCURACY_MODEL, - &DEFAULT_ALLOCATOR, - evidence, - ), - ), - Box::new(SharedSubDAGStrategy), - Box::new(crate::rewrite::AvgToSumOverCountStrategy), - Box::new(ExactCompositionStrategy::new(cost_model)), - ] -} - -// ── search_workload ────────────────────────────────────────────────────── - -/// Search a whole workload's pre-ASAP roots for every candidate replacement -/// [`default_strategies`] can find, deduped into a [`CandidateLogicalASAPDAGs`]. Candidate -/// *generation* uses the built-in [`DefaultCostModel`] (via -/// [`default_strategies`], the same way [`ASAPStrategies::default_cost_model`] -/// does); call [`CandidateLogicalASAPDAGs::cost_sorted`] on the result for the final -/// `sorted_by(cost_model)` step. Use [`search_workload_with`] to plug in a -/// custom strategy set (e.g. built via [`default_strategies_with`] for a -/// deployment-specific [`CostModel`]). -pub fn search_workload(roots: Vec<(Id, Rc)>) -> CandidateLogicalASAPDAGs { - search_workload_with(roots, &default_strategies()) -} - -/// Like [`search_workload`], but with an explicit set of context-free -/// `strategies` (see [`default_strategies_with`] to plug in a -/// deployment-specific [`CostModel`]). The workload-dependent -/// [`RollupStrategy`] is derived and added automatically after CSE for both -/// entry points, because only this function owns the post-CSE sibling set. -/// -/// Runs [`share_common_sub_dags`] once over `roots` first — so every -/// strategy (and, transitively, every -/// [`crate::explanation::ReplacementExplanation`] a caller reads off the -/// result) sees the same already-deduplicated DAG — then discovers every -/// `TargetSubDAG` (see [`discover_targets`]) and runs the -/// fixpoint loop the module docs describe, capped at -/// [`MAX_SEARCH_ITERATIONS`] passes (see the module docs' "Termination" -/// section). Deduping candidate plans this way needs no -/// [`CostModel`] at all — that only enters at two well-defined points: each -/// [`ReplacementStrategy`] in `strategies` may already carry its own (e.g. -/// [`ASAPStrategies::new`]'s), and [`CandidateLogicalASAPDAGs::cost_sorted`]'s final -/// ranking step takes one explicitly. -pub fn search_workload_with<'s, Id>( - roots: Vec<(Id, Rc)>, - strategies: &[Box], -) -> CandidateLogicalASAPDAGs { - let mut space = search_cse_workload_with(cse_workload(roots), strategies); - space.prepare_compositions(&DefaultAccuracyModel, &HashMap::new()); - space -} - -/// [`search_workload_with`] plus a per-root end-to-end `AccuracyTarget` -/// (issue #172) — the workload's `QueryRequirements.accuracy`, threaded -/// alongside each root. After the search, every root that carries a target -/// has its group's bound-summary [`Replacement::SubDAG`] candidates checked with -/// `accuracy_model`'s [`AccuracyModel::satisfies`]: a candidate whose -/// guarantee is fully known and misses the target is moved from -/// [`TargetSubDAGCandidates::candidates`] to [`TargetSubDAGCandidates::rejected`] *before* -/// [`CandidateLogicalASAPDAGs::cost_sorted`]/[`CandidateLogicalASAPDAGs::global_selection`] ever rank the -/// group. A constructible candidate with unknown accuracy remains visible for -/// downstream review under an approximate target, but default whole-plan -/// selection does not commit it. An exact target cannot accept an unknown -/// approximate summary. A kept pre-ASAP candidate is -/// exact and always survives — the raw/pre-ASAP alternative is what an -/// unsatisfiable root keeps. Logical-rewrite [`Replacement::SubDAG`] -/// candidates are not bound values and are left alone; the targets *inside* -/// a rewrite are their own groups. -/// -/// Precedence against per-node `AggIntent.accuracy` is documented in -/// [`crate::accuracy`]'s module docs. -pub fn search_workload_with_targets<'s, Id>( - roots: Vec<(Id, Rc, Option)>, - strategies: &[Box], - accuracy_model: &dyn AccuracyModel, -) -> CandidateLogicalASAPDAGs { - let mut targets = Vec::with_capacity(roots.len()); - let roots = roots - .into_iter() - .map(|(id, root, target)| { - targets.push(target); - (id, root) - }) - .collect(); - let mut space = search_cse_workload_with(cse_workload(roots), strategies); - // `cse_workload` preserves root order, so targets zip by position. - let root_ptrs: Vec<(*const OperatorNode, AccuracyTarget)> = space - .roots - .iter() - .zip(targets) - .filter_map(|((_, root), target)| target.map(|t| (Rc::as_ptr(root), t))) - .collect(); - // Whole-root proposals join the root group before its target check. - for (index, (ptr, target)) in root_ptrs.iter().enumerate() { - if root_ptrs[..index].contains(&(*ptr, target.clone())) { - continue; - } - let group = space.groups.get_mut(ptr).expect("every root has a group"); - let root = Rc::clone(&group.target); - for strategy in strategies { - let name = strategy.name(); - let proposals = strategy.propose_for_root(&root, target); - for mut candidate in proposals.candidates { - candidate.strategy = name; - group.add_candidate(candidate); - } - group - .rejected - .extend(proposals.rejected.into_iter().map(|mut rejection| { - rejection.strategy = name; - rejection - })); - } - } - let mut composition_targets: HashMap<_, Vec<_>> = HashMap::new(); - for (ptr, target) in root_ptrs { - composition_targets - .entry(ptr) - .or_default() - .push(target.clone()); - let Some(group) = space.groups.get_mut(&ptr) else { - continue; - }; - let (legal, illegal): (Vec<_>, Vec<_>) = - group - .candidates - .drain(..) - .partition(|candidate| match &candidate.replacement { - Replacement::SubDAG(node) if is_logical_rewrite(node) => true, - Replacement::SubDAG(node) => node.guarantee.as_ref().map_or_else( - || !matches!(target, AccuracyTarget::Exact), - |g| accuracy_model.satisfies(&g.optimistic_floor(), &target), - ), - // A composition's guarantee depends on the concrete child; - // prepare_compositions checks those pairs after all roots. - Replacement::ExactComposition(_) => true, - }); - group.candidates = legal; - group.rejected.extend(illegal.into_iter().map(|candidate| { - let (metric, bound, failure_probability) = match &candidate.replacement { - Replacement::SubDAG(node) => node - .guarantee - .as_ref() - .map(|g| { - ( - g.metric, - g.bound.evaluate(), - g.failure_probability.evaluate(), - ) - }) - .unwrap_or(( - asap_types::ir::properties::ErrorMetric::AbsoluteValue, - None, - None, - )), - Replacement::ExactComposition(_) => ( - asap_types::ir::properties::ErrorMetric::AbsoluteValue, - None, - None, - ), - }; - RejectedCandidate { - strategy: candidate.strategy, - description: format!("{} (root end-to-end target check)", candidate.rationale), - error: AccuracyError::TargetNotSatisfied { - metric, - bound, - failure_probability, - target: target.clone(), - }, - } - })); - } - space.prepare_compositions(accuracy_model, &composition_targets); - space -} - -/// The strictest accuracy among `siblings` that read the same summary input -/// as `root` — same child, grouping and filters, and the same intent apart -/// from its accuracy (and a quantile's rank, a evaluation parameter) — when -/// stricter than `root`'s own. One summary sized for the strictest consumer -/// serves every sibling: #509's summary-capability rule. -fn strictest_sibling_accuracy( - root: &OperatorNode, - siblings: &[Rc], -) -> Option { - fn approximate(intent: &AggIntent) -> Option<&AccuracyTarget> { - accuracy_target(intent).filter(|accuracy| !matches!(accuracy, AccuracyTarget::Exact)) - } - let Some(NonASAPOp::Aggregate { - reduction, - filters, - child, - .. - }) = root.non_asap() - else { - return None; - }; - let intent = bindable_intent(root)?; - let own = accuracy_budget(approximate(intent)?); - let (mut eps, mut delta) = own; - for sibling in siblings { - let Some(NonASAPOp::Aggregate { - reduction: sibling_reduction, - filters: sibling_filters, - child: sibling_child, - .. - }) = sibling.non_asap() - else { - continue; - }; - let Some(other) = bindable_intent(sibling) else { - continue; - }; - let Some(accuracy) = approximate(other) else { - continue; - }; - let same_intent = match (intent, other) { - (AggIntent::Quantile { col, .. }, AggIntent::Quantile { col: other_col, .. }) => { - col == other_col - } - _ => override_accuracy(intent, accuracy) == *other, - }; - if same_intent - && sibling_reduction == reduction - && sibling_filters == filters - && (Rc::ptr_eq(sibling_child, child) || sibling_child == child) - { - let (sibling_eps, sibling_delta) = accuracy_budget(accuracy); - eps = eps.min(sibling_eps); - delta = delta.min(sibling_delta); - } - } - if (eps, delta) == own { - None - } else if delta == DEFAULT_DELTA { - Some(AccuracyTarget::Epsilon(eps)) - } else { - Some(AccuracyTarget::EpsilonDelta { - epsilon: eps, - delta, - }) - } -} - -fn cse_workload(roots: Vec<(Id, Rc)>) -> Vec<(Id, Rc)> { - share_common_sub_dags(roots) -} - -fn search_cse_workload_with<'s, Id>( - cse_roots: Vec<(Id, Rc)>, - strategies: &[Box], -) -> CandidateLogicalASAPDAGs { - for (_, root) in &cse_roots { - assert!( - !root.contains_asap(), - "search_workload: a workload root already contains an ASAP operator \ - ({}); replacement search takes the front end's pre-ASAP DAG only", - root.operator.kind_name() - ); - } - let mut order = Vec::new(); - let mut nodes = HashMap::new(); - let mut counts: HashMap<*const OperatorNode, usize> = HashMap::new(); - discover_targets(&cse_roots, &mut order, &mut nodes, &mut counts); - let siblings: Vec> = order - .iter() - .filter_map(|ptr| { - let node = &nodes[ptr]; - matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. })).then(|| Rc::clone(node)) - }) - .collect(); - let rollup_strategy = RollupStrategy::new(&siblings); - let accuracy_reconciliation_strategy = AccuracyReconciliationStrategy::new(&siblings); - let limits: Vec> = order - .iter() - .filter_map(|ptr| { - let node = &nodes[ptr]; - matches!(node.non_asap(), Some(NonASAPOp::Limit { .. })).then(|| Rc::clone(node)) - }) - .collect(); - let topk_reuse_strategy = TopKLimitReuseStrategy::new(&limits); - - let mut groups: HashMap<*const OperatorNode, TargetSubDAGCandidates> = HashMap::new(); - for ptr in &order { - groups.insert( - *ptr, - TargetSubDAGCandidates::new(Rc::clone(&nodes[ptr]), counts[ptr]), - ); - } - - // Round-based frontier: every target is asked exactly once per strategy - // (never re-asked — see the module docs' "Termination" section on why - // that matters for `Replacement::Summary` dedup specifically). A round - // can grow the *next* round's frontier only by a candidate's own - // reachable children exposing a genuinely new, not-yet-known `Rc` — see - // `discover_new_descendant_targets`. - let mut frontier = order.clone(); - let mut rounds = 0usize; - while !frontier.is_empty() { - rounds += 1; - assert!( - rounds <= MAX_SEARCH_ITERATIONS, - "search_workload: fixpoint search did not converge within {MAX_SEARCH_ITERATIONS} \ - rounds — a registered ReplacementStrategy's Replacement::Rewrite candidates keep \ - exposing new, never-before-seen descendant structure every round. \ - ASAPStrategies/SharedSubDAGStrategy never do this (see replacement.rs's \ - module docs' \"Termination\" section); check any custom strategies passed to \ - search_workload_with.", - ); - - let targets_before = order.len(); - for ptr in &frontier { - let (root, consumer_count) = { - let group = &groups[ptr]; - (Rc::clone(&group.target), group.consumer_count) - }; - let strictest = strictest_sibling_accuracy(&root, &siblings); - let mut target = TargetSubDAG::with_consumer_count(&root, consumer_count); - target.strictest_sibling_accuracy = strictest.as_ref(); - - let mut proposed = Vec::new(); - let mut rejected = Vec::new(); - for strategy in strategies { - if strategy.matches(&target) { - let name = strategy.name(); - let proposals = strategy.propose(&target); - proposed.extend(proposals.candidates.into_iter().map(|mut candidate| { - candidate.strategy = name; - candidate - })); - rejected.extend(proposals.rejected.into_iter().map(|mut rejection| { - rejection.strategy = name; - rejection - })); - } - } - if rollup_strategy.matches(&target) { - let name = rollup_strategy.name(); - proposed.extend(rollup_strategy.replacements(&target).into_iter().map( - |mut candidate| { - candidate.strategy = name; - candidate - }, - )); - } - if accuracy_reconciliation_strategy.matches(&target) { - let name = accuracy_reconciliation_strategy.name(); - proposed.extend( - accuracy_reconciliation_strategy - .replacements(&target) - .into_iter() - .map(|mut candidate| { - candidate.strategy = name; - candidate - }), - ); - } - if topk_reuse_strategy.matches(&target) { - let name = topk_reuse_strategy.name(); - proposed.extend(topk_reuse_strategy.replacements(&target).into_iter().map( - |mut candidate| { - candidate.strategy = name; - candidate - }, - )); - } - - for candidate in &proposed { - if let Replacement::SubDAG(rc) = &candidate.replacement { - if is_logical_rewrite(rc) { - discover_new_descendant_targets(rc, &mut order, &mut nodes, &mut counts); - } - } - } - - let group = groups - .get_mut(ptr) - .expect("every discovered target has a group"); - for candidate in proposed { - group.add_candidate(candidate); - } - group.rejected.extend(rejected); - } - - // Any pointer `discover_new_descendant_targets` appended to `order` - // this round is a genuinely new target — give it a group and process - // it next round. Targets already in `groups` are never revisited. - let new_targets = &order[targets_before..]; - for ptr in new_targets { - groups.entry(*ptr).or_insert_with(|| { - TargetSubDAGCandidates::new(Rc::clone(&nodes[ptr]), counts[ptr]) - }); - } - frontier = new_targets.to_vec(); - } - - add_effective_count_cse_candidates(&order, &mut groups); - - CandidateLogicalASAPDAGs { - roots: cse_roots, - groups, - order, - composition_plans: Vec::new(), - } -} - -/// Materialize share/recompute alternatives for descendants whose raw edge -/// count is one but whose effective count can exceed one when a repeated -/// ancestor is recomputed. We only do this when an ordinary repeated group -/// proves that `SharedSubDAGStrategy` is part of this search's strategy set. -fn add_effective_count_cse_candidates( - order: &[*const OperatorNode], - groups: &mut HashMap<*const OperatorNode, TargetSubDAGCandidates>, -) { - let mut possible_children: HashMap<*const OperatorNode, Vec<*const OperatorNode>> = - HashMap::new(); - for ptr in order { - let group = &groups[ptr]; - let children = possible_children.entry(*ptr).or_default(); - for (child, _) in direct_child_counts(&group.target) { - if !children.contains(&child) { - children.push(child); - } - } - for candidate in &group.candidates { - if let Replacement::SubDAG(rewrite) = &candidate.replacement { - if !is_logical_rewrite(rewrite) { - continue; - } - for (child, _) in direct_child_counts(rewrite) { - if !children.contains(&child) { - children.push(child); - } - } - } - } - } - - let mut potentially_repeated = HashSet::new(); - let mut queue = VecDeque::new(); - for ptr in order { - let group = &groups[ptr]; - if group.consumer_count >= 2 && cse_candidate_pair(group).is_some() { - potentially_repeated.insert(*ptr); - queue.push_back(*ptr); - } - } - while let Some(parent) = queue.pop_front() { - if let Some(children) = possible_children.get(&parent) { - for child in children { - if groups.contains_key(child) && potentially_repeated.insert(*child) { - queue.push_back(*child); - } - } - } - } - - for ptr in order { - let group = groups - .get_mut(ptr) - .expect("every discovered site has a group"); - if potentially_repeated.contains(ptr) && cse_candidate_pair(group).is_none() { - let target = Rc::clone(&group.target); - let site = TargetSubDAG::with_consumer_count(&target, 2); - for mut candidate in SharedSubDAGStrategy.replacements(&site) { - candidate.rationale = format!( - "{}: this sub-DAG can become repeated when a repeated ancestor is recomputed; \ - global_selection decides using its effective consumer count", - match candidate.provenance { - ReplacementProvenance::CseShare => "build once and share", - ReplacementProvenance::CseRecompute => "recompute independently", - _ => unreachable!("SharedSubDAGStrategy only emits CSE candidates"), - } - ); - group.add_candidate(candidate); - } - } - } -} - -// ── target discovery ───────────────────────────────────────────────────── - -/// Walk every root's whole DAG, discovering one `TargetSubDAG` per distinct -/// `Rc` and its real `consumer_count` — see the module docs' "Where -/// `TargetSubDAG` discovery comes from" section for the full rationale. -fn discover_targets( - roots: &[(Id, Rc)], - order: &mut Vec<*const OperatorNode>, - nodes: &mut HashMap<*const OperatorNode, Rc>, - counts: &mut HashMap<*const OperatorNode, usize>, -) { - for (_, root) in roots { - walk(root, order, nodes, counts); - } -} - -/// Scan `candidate`'s **children** (deliberately never `candidate`'s own -/// top-level pointer — see the module docs' "Termination" section: a -/// logical rewrite's value is an alternative *for* the target that -/// proposed it, never a new target of its own) for any `Rc` not already -/// known, appending each to `order`/`nodes`/`counts` so -/// [`search_workload_with`]'s next round processes it. A no-op when every -/// child is already known — the case both shipped strategies always produce -/// (see that section). -fn discover_new_descendant_targets( - candidate: &Rc, - order: &mut Vec<*const OperatorNode>, - nodes: &mut HashMap<*const OperatorNode, Rc>, - counts: &mut HashMap<*const OperatorNode, usize>, -) { - walk_children(candidate, order, nodes, counts); -} - -/// Visit `node`: count this occurrence, and — the first time this exact -/// `Rc` is seen — record it as a target and recurse into its children. -fn walk( - node: &Rc, - order: &mut Vec<*const OperatorNode>, - nodes: &mut HashMap<*const OperatorNode, Rc>, - counts: &mut HashMap<*const OperatorNode, usize>, -) { - let ptr = Rc::as_ptr(node); - let already_visited = counts.contains_key(&ptr); - *counts.entry(ptr).or_insert(0) += 1; - if !already_visited { - order.push(ptr); - nodes.insert(ptr, Rc::clone(node)); - walk_children(node, order, nodes, counts); - } -} - -/// `node`'s own operator children ([`OperatorNode::children`]: operator -/// inputs plus the operator nodes its scalar expressions read), the same -/// scope `asap_types::ir::cse::share_common_sub_dags` itself uses and -/// `tests::count_consumers` mirrors for its own fixtures. `Concat` is -/// transparent: its branches are walked in place of it. -fn walk_children( - node: &OperatorNode, - order: &mut Vec<*const OperatorNode>, - nodes: &mut HashMap<*const OperatorNode, Rc>, - counts: &mut HashMap<*const OperatorNode, usize>, -) { - if let Some(NonASAPOp::Concat { children, .. }) = node.non_asap() { - for c in children { - walk_children(c, order, nodes, counts); - } - return; - } - for child in node.children() { - walk(child, order, nodes, counts); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::accuracy::PropagationStats; - use crate::cost_model::Cost; - use crate::test_support::{agg, agg_per_entity, lower_promql, maintained, metric_scan, timed}; - use asap_types::ir::operator::agg_intent::{ - agg_is_exact, default_cardinality, default_quantile, MathFunc, TimeFunc, - }; - use asap_types::ir::operator::operator_properties::{Reduction as ReductionTy, Source}; - use asap_types::ir::schema::{DataType, Field, Schema as SchemaTy}; - use asap_types::ir::TimeRangeKind; - - use asap_types::types::AccuracyTarget; - use std::collections::HashMap; - - // Candidate shape without execution timing: what is computed, not where. - fn timing_free_shape(node: &Rc) -> serde_json::Value { - fn strip(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(fields) => { - fields.remove("timing"); - fields.values_mut().for_each(strip); - } - serde_json::Value::Array(values) => values.iter_mut().for_each(strip), - _ => {} - } - } - let mut shape = serde_json::to_value( - asap_types::ir::export::compile_physical_asap_dag(&timed(node)).unwrap(), - ) - .unwrap(); - strip(&mut shape); - shape - } - - // Rate inventories never offer two candidates that differ only in timing. - #[test] - fn rate_candidate_inventories_have_no_timing_only_duplicates() { - for (query, accuracy) in [ - ("sum by(job)(rate(m[1m]))", AccuracyTarget::Exact), - ("topk by(job)(2, rate(m[1m]))", AccuracyTarget::Epsilon(0.1)), - ] { - let root = lower_promql(query, accuracy); - let inventory = search_workload(vec![(0usize, root)]) - .enumerate_candidate_dags(4096) - .unwrap(); - let shapes = inventory - .candidates - .iter() - .map(|forest| timing_free_shape(&forest[0].1)) - .collect::>(); - for (i, shape) in shapes.iter().enumerate() { - assert!(!shapes[..i].contains(shape), "{query}: duplicate {i}"); - } - } - } - - // Grouped Sum over Rate evaluations stays a summary state in the inventory, - // so materialization assignment can place it in precompute or at query time. - #[test] - fn grouped_rate_sum_inventory_keeps_sum_state_for_materialization_placement() { - let root = lower_promql("sum by(job)(rate(m[1m]))", AccuracyTarget::Exact); - let inventory = search_workload(vec![(0usize, root)]) - .enumerate_candidate_dags(4096) - .unwrap(); - let is_exact = |node: &OperatorNode, kind: ExactKind| { - matches!(&node.operator, Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(k, _), .. - }) if *k == kind) - }; - assert!(inventory.candidates.iter().any(|forest| { - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: sum }) = &forest[0].1.operator else { - return false; - }; - let Operator::ASAP(ASAPOp::SummaryAgg { child: rate, .. }) = &sum.operator else { - return false; - }; - is_exact(sum, ExactKind::Sum) - && matches!(&rate.operator, Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) if is_exact(child, ExactKind::Rate)) - })); - } - - // Every exposed query result has a evaluation; internal accumulator frontiers stay states. - #[test] - fn query_candidate_roots_do_not_leak_exact_accumulator_state() { - for query in [ - "sum by(job)(rate(m[1m]))", - "sum by(job)(m)", - "sum_over_time(m[1m])", - ] { - let root = lower_promql(query, AccuracyTarget::Exact); - let space = search_workload(vec![(0usize, root.clone())]); - let inventory = space.enumerate_candidate_dags(4096).unwrap(); - assert!(!inventory.candidates.is_empty()); - let strategy = ASAPStrategies::new(&DefaultCostModel); - for candidate in strategy.propose(&TargetSubDAG::new(&root)).candidates { - if let Replacement::SubDAG(node) = candidate.replacement { - let output = finalize_query_candidate(node, &root).unwrap(); - assert!( - output - .schema - .fields - .iter() - .all(|field| matches!(field.dtype, FieldDataType::Plain(_))), - "direct candidate {query} leaks state" - ); - } - } - let selected = space - .global_selection(&DefaultCostModel) - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .unwrap(); - for node in inventory - .candidates - .iter() - .map(|forest| &forest[0].1) - .chain(std::iter::once(&selected)) - { - assert!( - node.schema - .fields - .iter() - .all(|field| matches!(field.dtype, FieldDataType::Plain(_))), - "{query}: query root leaks state: {:?}", - node.schema - ); - } - } - } - - #[test] - fn unpriced_inventory_retains_quantile_families_and_raw_execution() { - let query = agg(vec![2], default_quantile(0.9), metric_scan(&["job"])); - let space = search_workload(vec![(0usize, query)]); - let inventory = space.enumerate_candidate_dags(4096).unwrap(); - let roots = inventory - .candidates - .iter() - .map(|forest| format!("{:?}", forest[0].1)) - .collect::>(); - assert!(roots.iter().any(|root| root.contains("Kll"))); - assert!(roots.iter().any(|root| root.contains("DDSketch"))); - assert!(inventory - .candidates - .iter() - .any(|forest| !forest[0].1.contains_asap())); - } - - // Independent roots must not require materializing their Cartesian product. - #[test] - fn root_inventory_preserves_choices_without_workload_cartesian_expansion() { - let roots = (0..24usize) - .map(|id| { - ( - id, - agg( - vec![2], - default_quantile((id + 1) as f64 / 25.0), - metric_scan(&["job"]), - ), - ) - }) - .collect(); - let space = search_workload(roots); - assert!(space.enumerate_candidate_dags(4096).is_err()); - for id in 0..24 { - let inventory = space.enumerate_candidate_dags_for_root(&id, 4096).unwrap(); - assert!(inventory - .candidates - .iter() - .all(|forest| forest.len() == 1 && forest[0].0 == id)); - let descriptions = inventory - .candidates - .iter() - .map(|forest| format!("{:?}", forest[0].1)) - .collect::>(); - assert!(descriptions.iter().any(|node| node.contains("Kll"))); - assert!(descriptions.iter().any(|node| node.contains("DDSketch"))); - assert!(inventory - .candidates - .iter() - .any(|forest| !forest[0].1.contains_asap())); - } - assert!(space.enumerate_candidate_dags_for_root(&24, 4096).is_err()); - assert!(space.enumerate_candidate_dags_for_root(&0, 0).is_err()); - } - - // Factoring changes enumeration, not the set of root computations. - #[test] - fn root_inventory_matches_projection_of_exhaustive_workload_inventory() { - let roots = (0..2usize) - .map(|id| { - ( - id, - agg( - vec![2], - default_quantile(0.5 + id as f64 * 0.4), - metric_scan(&["job"]), - ), - ) - }) - .collect(); - let space = search_workload(roots); - let full = space.enumerate_candidate_dags(4096).unwrap(); - for id in 0..2 { - let inventory = space.enumerate_candidate_dags_for_root(&id, 4096).unwrap(); - for forest in &full.candidates { - let node = &forest.iter().find(|(root, _)| *root == id).unwrap().1; - assert!(inventory.candidates.iter().any(|one| &one[0].1 == node)); - } - for one in &inventory.candidates { - assert!(full.candidates.iter().any(|forest| forest - .iter() - .any(|(root, node)| *root == id && node == &one[0].1))); - } - } - } - - #[test] - fn inventory_budget_never_returns_a_silent_partial_search() { - let query = agg(vec![2], default_quantile(0.9), metric_scan(&["job"])); - let space = search_workload(vec![(0usize, query)]); - assert!(space.enumerate_candidate_dags(0).is_err()); - } - - fn equi_pred(left: ColumnId, right: ColumnId) -> Predicate { - Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(left)), - op: asap_types::ir::scalar::CompareOpKind::Eq, - right: Box::new(ScalarExpr::Column(right)), - semantics: asap_types::ir::ExprSemantics::Sql, - }) - } - - // Finite samples can overflow a sum although their native average is finite. - #[test] - fn temporal_average_requires_finite_division_guard() { - let root = lower_promql("avg_over_time(a[5m])", AccuracyTarget::Exact); - let candidates = - ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); - let operator = candidates - .iter() - .find_map(|c| match &c.replacement { - Replacement::SubDAG(node) => match &node.operator { - Operator::NonASAP(NonASAPOp::BinaryOp { operator, .. }) => Some(operator), - _ => None, - }, - _ => None, - }) - .expect("maintained average candidate"); - assert!(operator.checked_finite_division); - assert!( - crate::rewrite::SemanticEquivalentRewriteStrategy - .replacements(&TargetSubDAG::new(&root)) - .is_empty(), - "an unconditional pre-ASAP rewrite would bypass the runtime guard" - ); - } - - // Approximate requests also admit exact temporal ranking candidates. - #[test] - fn approximate_temporal_topk_admits_exact_maintained_values() { - let root = lower_promql( - "topk by(job)(1,count_over_time(a[5m]))", - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ); - let planning_inputs = - CandidatePlanningInputs::with_default_accuracy(&crate::cost_model::DefaultCostModel); - let node = exact_topk_over_temporal_values(&root, planning_inputs) - .unwrap() - .expect("exact ranking is legal for an approximate request"); - assert!(node.guarantee.as_ref().unwrap().is_exact()); - crate::test_support::time_and_export(&node).unwrap(); - } - - // Exact Top-K consumes the Planner's maintained temporal values. - #[test] - fn exact_temporal_topk_has_a_maintained_value_candidate() { - for query in [ - "topk(5, sum_over_time(a[5m]))", - "topk by(job)(5, count_over_time(a[5m]))", - ] { - let root = lower_promql(query, AccuracyTarget::Exact); - let planning_inputs = CandidatePlanningInputs::with_default_accuracy( - &crate::cost_model::DefaultCostModel, - ); - let node = exact_topk_over_temporal_values(&root, planning_inputs) - .unwrap() - .expect("exact Top-K candidate"); - assert!(node.guarantee.as_ref().unwrap().is_exact()); - let Operator::NonASAP(NonASAPOp::Limit { - child: sorted, - n, - offset, - partition_by, - }) = &node.operator - else { - panic!("temporal TopK must compose Sort and Limit"); - }; - assert_eq!((*n, *offset), (Some(5), 0)); - let Operator::NonASAP(NonASAPOp::Sort { - keys, - partition_by: sort_groups, - child: values, - }) = &sorted.operator - else { - panic!("Limit must consume sorted temporal values"); - }; - assert_eq!(sort_groups, partition_by); - assert_eq!( - partition_by.keys().len(), - usize::from(query.contains("by(job)")) - ); - assert_eq!(keys.len(), 1); - assert!(!keys[0].ascending); - assert_eq!(node.schema, values.schema); - crate::test_support::time_and_export(&node).unwrap(); - } - } - - // A bounded exact mean can share the relative division proof with a quantile. - #[test] - fn bounded_mean_quantile_ratio_is_certified() { - struct Domain; - impl AccuracyEvidenceProvider for Domain { - fn quantile_input_domain( - &self, - _: &OperatorNode, - ) -> Option { - Some(crate::accuracy::QuantileInputDomain { - lower: 1.0, - upper: 1000.0, - max_samples: 10000, - contract: "finite test population".into(), - }) - } - } - let target = AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }; - let inputs = CandidatePlanningInputs { - evidence: &Domain, - ..CandidatePlanningInputs::with_default_accuracy(&DefaultCostModel) - }; - for query in [ - "avg_over_time(a[5m]) / quantile_over_time(0.5,a[5m])", - "quantile_over_time(0.5,a[5m]) / avg_over_time(a[5m])", - ] { - let root = lower_promql(query, target.clone()); - let node = realize_binary(&root, inputs, Some(&target)) - .unwrap() - .expect("bounded ratio candidate"); - assert!(DefaultAccuracyModel.satisfies(node.guarantee.as_ref().unwrap(), &target)); - } - } - - // Missing domain proof permits an uncertified direct quantile ratio only. - #[test] - fn quantile_ratio_without_input_proof_has_no_root_guarantee() { - let target = AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }; - let root = lower_promql( - "quantile_over_time(0.5,a[5m]) / quantile_over_time(0.9,a[5m])", - target.clone(), - ); - let planning_inputs = - CandidatePlanningInputs::with_default_accuracy(&crate::cost_model::DefaultCostModel); - let candidate = realize_binary(&root, planning_inputs, Some(&target)) - .unwrap() - .expect("direct quantile ratio candidate"); - assert!(candidate.guarantee.is_none()); - - let other = lower_promql( - "avg_over_time(a[5m]) / quantile_over_time(0.5,a[5m])", - target.clone(), - ); - assert!(realize_binary(&other, planning_inputs, Some(&target)) - .unwrap() - .is_none()); - } - - #[test] - fn relational_join_predicate_requires_and_normalizes_cross_input_columns() { - let forward = normalize_cross_input_equi_predicate(&equi_pred(1, 3), 2, 4) - .expect("left-to-right equality"); - let reverse = normalize_cross_input_equi_predicate(&equi_pred(3, 1), 2, 4) - .expect("right-to-left equality"); - assert_eq!(forward, reverse, "reverse equality must be canonicalized"); - assert!(normalize_cross_input_equi_predicate(&equi_pred(0, 1), 2, 4).is_none()); - assert!(normalize_cross_input_equi_predicate(&equi_pred(0, 4), 2, 4).is_none()); - } - - #[test] - fn relational_join_is_exact_only_when_both_inputs_are_exact() { - // `relational_join_guarantee` folded into assembly's generic - // "keep the operator, assemble its children" branch: an assembled - // inner equi-`Join` is exact exactly when both assembled inputs are. - let join = |left_intent: AggIntent, right_intent: AggIntent| { - let left = agg(vec![2], left_intent, metric_scan(&["job"])); - let right = agg( - vec![2], - right_intent, - crate::test_support::scan("n", metric_scan(&["job"]).schema.clone()), - ); - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { - kind: asap_types::ir::operator::operator_properties::JoinKind::Inner, - pred: equi_pred(0, 2), - left, - right, - })) - .unwrap() - }; - let is_exact = |node: &OperatorNode| { - node.guarantee - .as_ref() - .is_some_and(ResultGuarantee::is_exact) - }; - for (root, both_exact_expected) in [ - ( - join(AggIntent::Sum { col: None }, AggIntent::Sum { col: None }), - true, - ), - ( - join(AggIntent::Sum { col: None }, quantile_eps_intent(0.5, 0.05)), - false, - ), - ] { - let space = search_workload(vec![(0usize, Rc::clone(&root))]); - let assembled = space - .global_selection(&DefaultCostModel) - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .unwrap(); - let Some(NonASAPOp::Join { left, right, .. }) = assembled.non_asap() else { - panic!("the join is kept and its inputs assembled: {assembled:?}"); - }; - assert_eq!( - is_exact(&assembled), - is_exact(left) && is_exact(right), - "join guarantee must be exact iff both inputs are exact" - ); - if both_exact_expected { - assert!(is_exact(&assembled), "exact inputs give an exact join"); - } - } - } - - fn quantile_eps_intent(q: f64, e: f64) -> AggIntent { - AggIntent::Quantile { - col: None, - q, - accuracy: AccuracyTarget::Epsilon(e), - } - } - - fn eps(e: f64) -> AccuracyTarget { - AccuracyTarget::Epsilon(e) - } - - // ── realizations_for_intent / sizing ─────────────────────────────── - - /// The most-preferred `Realization` — `realizations_for_intent(intent, - /// &DefaultCostModel)`'s head — for tests that only care about the - /// default pick, not the full candidate list. - fn preferred(intent: &AggIntent) -> Realization { - realizations_for_intent(intent, &DefaultCostModel) - .into_iter() - .next() - .expect("every intent has at least one Realization") - } - - /// Shorthand for asserting the realization *category*. - #[derive(Debug, PartialEq)] - enum Cat { - Sketch(SketchAlgorithm), - Acc(ExactKind), - Pass, - } - - fn cat(intent: &AggIntent) -> Cat { - match preferred(intent) { - Realization::ExactAggregate { kind, .. } => Cat::Acc(kind), - Realization::Sketch(kind) => Cat::Sketch(kind.algorithm().clone()), - Realization::PassThrough => Cat::Pass, - other => { - panic!("this coverage matrix expects only Exact/Sketch/PassThrough, got {other:?}") - } - } - } - - /// The `AggIntent → SummaryKind` coverage matrix (issue #98): every intent - /// variant maps to a sketch, an exact accumulator, or an explicit - /// pass-through. `realizations_for_intent`'s match is exhaustive, so a - /// new variant cannot compile without a decision; this matrix pins what - /// each decision *is* (its preferred/first candidate). - #[test] - fn agg_intent_to_summary_kind_coverage_matrix() { - use AggIntent as A; - use Cat::*; - use ExactKind as E; - use SketchAlgorithm as K; - let matrix: Vec<(A, Cat)> = vec![ - // approximate-capable, at an ε target → sketch - (default_quantile(0.99), Sketch(K::Kll)), - (default_cardinality(), Sketch(K::Hll)), - ( - A::Cardinality { - cols: vec![0, 1], - accuracy: eps(0.01), - }, - Sketch(K::Hll), - ), - ( - A::Count { - accuracy: eps(0.01), - }, - Sketch(K::Cms), - ), - ( - A::TopK { - k: 10, - accuracy: eps(0.01), - }, - Sketch(K::CmsWithHeap), - ), - // the same intents at Exact → exact realization - ( - A::Quantile { - col: None, - q: 0.5, - accuracy: AccuracyTarget::Exact, - }, - Pass, - ), - ( - A::Cardinality { - cols: vec![], - accuracy: AccuracyTarget::Exact, - }, - Pass, - ), - ( - A::Cardinality { - cols: vec![0, 1], - accuracy: AccuracyTarget::Exact, - }, - Pass, - ), - ( - A::Count { - accuracy: AccuracyTarget::Exact, - }, - Acc(E::Count), - ), - ( - A::TopK { - k: 10, - accuracy: AccuracyTarget::Exact, - }, - Pass, - ), - // exact mergeable accumulators - (A::Sum { col: None }, Acc(E::Sum)), - (A::Min { col: None }, Acc(E::Min)), - (A::Max { col: None }, Acc(E::Max)), - (A::Rate, Acc(E::Rate)), - (A::IRate, Acc(E::IRate)), - (A::Increase, Acc(E::Increase)), - // exact but non-mergeable → pass-through - (A::Avg { col: None }, Pass), - ( - A::StdDev { - col: None, - population: false, - }, - Pass, - ), - ( - A::Variance { - col: None, - population: true, - }, - Pass, - ), - // classic-bucket histogram_quantile is not re-sketchable (#79) - (A::HistogramQuantile { q: 0.99, le: 0 }, Pass), - // counter-derivative / range-vector functions (#44) - (A::Changes, Pass), - (A::Delta, Pass), - (A::IDelta, Pass), - (A::Deriv, Pass), - (A::Resets, Pass), - (A::PredictLinear { seconds: 60.0 }, Pass), - ( - A::DoubleExpSmoothing { - smoothing: 0.5, - trend: 0.5, - }, - Pass, - ), - // native-histogram accessors (#43) - (A::HistogramCount, Pass), - (A::HistogramSum, Pass), - (A::HistogramAvg, Pass), - (A::HistogramStdDev, Pass), - (A::HistogramStdVar, Pass), - ( - A::HistogramFraction { - lower: 0.0, - upper: 1.0, - }, - Pass, - ), - // per-sample transforms (#45, #46) + presence (#47) - (A::Math(MathFunc::Abs), Pass), - (A::TimeFn(TimeFunc::Hour), Pass), - (A::Absent, Pass), - (A::AbsentOverTime, Pass), - (A::PresentOverTime, Pass), - // extended aggregations (#49) - (A::Group, Pass), - (A::CountValues { label: "v".into() }, Pass), - // additional range reducers (#51) - (A::LastOverTime, Pass), - (A::FirstOverTime, Pass), - (A::MadOverTime, Pass), - (A::TsOfMinOverTime, Pass), - (A::TsOfMaxOverTime, Pass), - (A::TsOfFirstOverTime, Pass), - (A::TsOfLastOverTime, Pass), - ]; - for (intent, expected) in &matrix { - assert_eq!(&cat(intent), expected, "realization for {intent:?}"); - } - // Every accumulator pick is mergeable; every sketch pick is on a - // genuinely approximate target (the `agg_is_*` helpers stay truthful). - for (intent, expected) in &matrix { - if let Cat::Acc(_) = expected { - assert!(agg_is_mergeable(intent), "{intent:?}"); - } - if let Cat::Sketch(_) = expected { - assert!( - !agg_is_exact(intent) || matches!(intent, AggIntent::Count { .. }), - "{intent:?} sketches only under an approximate target" - ); - } - } - } - - // Correlation must never acquire a single-input sketch or scalar accumulator. - #[test] - fn pearson_corr_keeps_exact_paired_input() { - let intent = AggIntent::PearsonCorr { left: 0, right: 1 }; - assert!(matches!( - realizations_for_intent(&intent, &crate::cost_model::DefaultCostModel).as_slice(), - [Realization::PassThrough] - )); - assert!(summary_candidates(&intent).is_empty()); - } - - #[test] - fn accuracy_target_drives_the_boundary() { - // Same intent, three targets → three different decisions. - let exact = AggIntent::Quantile { - col: None, - q: 0.99, - accuracy: AccuracyTarget::Exact, - }; - assert_eq!(preferred(&exact), Realization::PassThrough); - - let approx = default_quantile(0.99); // ε = 0.01 - assert_eq!( - preferred(&approx), - Realization::Sketch(SketchKind::new( - SketchAlgorithm::Kll, - SketchParams::Kll { k: 269 }, - )) - ); - - let looser = AggIntent::Quantile { - col: None, - q: 0.99, - accuracy: eps(0.05), - }; - assert_eq!( - preferred(&looser), - Realization::Sketch(SketchKind::new( - SketchAlgorithm::Kll, - SketchParams::Kll { k: 52 }, - )) - ); - } - - #[test] - fn default_cardinality_sizes_hll_to_its_rse_magnitude() { - assert_eq!( - preferred(&default_cardinality()), - Realization::Sketch(SketchKind::new( - SketchAlgorithm::Hll, - SketchParams::Hll { precision: 14 }, - )) - ); - } - - // Exact counting remains a legal candidate under an approximate target. + // Finite samples can overflow a sum although their native average is finite. #[test] - fn approximate_count_includes_exact_accumulator_candidate() { - let intent = AggIntent::Count { - accuracy: eps(0.01), - }; - assert!(realizations_for_intent(&intent, &DefaultCostModel) + fn temporal_average_requires_finite_division_guard() { + let root = lower_promql("avg_over_time(a[5m])", AccuracyTarget::Exact); + let candidates = + ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let operator = candidates .iter() - .any(|candidate| matches!( - candidate, - Realization::ExactAggregate { - kind: ExactKind::Count, - .. - } - ))); - } - - #[test] - fn epsilon_delta_sizes_cms_depth() { - let intent = AggIntent::Count { - accuracy: AccuracyTarget::EpsilonDelta { - epsilon: 0.001, - delta: 0.001, - }, - }; - assert_eq!( - preferred(&intent), - Realization::Sketch(SketchKind::new( - SketchAlgorithm::Cms, - SketchParams::Cms { - width: 2719, - depth: 7 - }, // ⌈e/0.001⌉, ⌈ln 1000⌉ - )) - ); - // Epsilon-only falls back to DEFAULT_DELTA → depth 5. - let intent = AggIntent::Count { - accuracy: eps(0.001), - }; - assert_eq!( - preferred(&intent), - Realization::Sketch(SketchKind::new( - SketchAlgorithm::Cms, - SketchParams::Cms { - width: 2719, - depth: 5 - }, - )) - ); - } - - #[test] - fn topk_heap_capacity_respects_accuracy_and_output_count() { - let intent = AggIntent::TopK { - k: 25, - accuracy: eps(0.01), - }; - match preferred(&intent) { - Realization::Sketch(kind) if kind.algorithm() == &SketchAlgorithm::CmsWithHeap => { - let SketchParams::CmsWithHeap { - width, - depth, - heap_size, - } = kind.params() - else { - unreachable!("SketchKind validates CmsWithHeap params") - }; - assert_eq!(*heap_size, 100); - assert_eq!(*width, 272); // ⌈e/0.01⌉ - assert_eq!(*depth, 5); - } - other => panic!("expected CmsWithHeap, got {other:?}"), - } - } - - #[test] - fn candidate_lists_match_the_issue_map() { - assert_eq!( - summary_candidates(&default_quantile(0.5)), - &[SketchAlgorithm::Kll, SketchAlgorithm::DDSketch] - ); - assert_eq!( - summary_candidates(&default_cardinality()), - &[ - SketchAlgorithm::Hll, - SketchAlgorithm::Theta, - SketchAlgorithm::Kmv, - SketchAlgorithm::UnivMon - ] - ); - assert_eq!( - summary_candidates(&AggIntent::TopK { - k: 5, - accuracy: eps(0.01) - }), - &[ - SketchAlgorithm::CmsWithHeap, - SketchAlgorithm::CountSketchWithHeap - ] - ); - assert_eq!( - summary_candidates(&AggIntent::Count { - accuracy: eps(0.01) - }), - &[ - SketchAlgorithm::Cms, - SketchAlgorithm::CountSketch, - SketchAlgorithm::UnivMon - ] - ); - assert!(summary_candidates(&AggIntent::Rate).is_empty()); - } - - #[test] - fn realizations_for_intent_enumerates_every_candidate_ranked() { - // Quantile's candidate list is [Kll, DDSketch] — realizations_for_intent - // must return both, ranked with the DefaultCostModel's preferred - // (Kll) first. - let kinds: Vec = - realizations_for_intent(&default_quantile(0.99), &DefaultCostModel) - .into_iter() - .map(|realization| match realization { - Realization::Sketch(kind) => kind.algorithm().clone(), - other => panic!("expected Sketch, got {other:?}"), - }) - .collect(); - assert_eq!(kinds, vec![SketchAlgorithm::Kll, SketchAlgorithm::DDSketch]); - } - - #[test] - fn degenerate_epsilon_saturates_to_tightest_params() { - let intent = AggIntent::Quantile { - col: None, - q: 0.99, - accuracy: eps(0.0), - }; - assert_eq!( - preferred(&intent), - Realization::Sketch(SketchKind::new( - SketchAlgorithm::Kll, - SketchParams::Kll { k: 65_535 }, - )) - ); - } - - // ── posterior_aware_size_params (issue #239, integration point 2) ────── - - fn count_intent(e: f64) -> AggIntent { - AggIntent::Count { accuracy: eps(e) } - } - - #[test] - fn posterior_aware_sizing_shrinks_width_under_stated_assumption() { - let intent = count_intent(0.01); - let worst_case = default_size_params(SketchAlgorithm::Cms, &intent, 0.01, 0.01); - let relaxed = posterior_aware_size_params( - SketchAlgorithm::Cms, - &intent, - 0.01, - 0.01, - ExpectedCaseSizing { - width_relaxation: 0.5, - }, - ); - match (worst_case, relaxed) { - ( - SketchParams::Cms { - width: w0, - depth: d0, - }, - SketchParams::Cms { - width: w1, - depth: d1, + .find_map(|c| match &c.replacement { + Replacement::SubDAG(node) => match &node.operator { + Operator::NonASAP(NonASAPOp::BinaryOp { operator, .. }) => Some(operator), + _ => None, }, - ) => { - assert!( - w1 < w0, - "expected relaxed width {w1} to be strictly smaller than worst-case {w0}" - ); - assert_eq!(d0, d1, "depth must be unaffected by width_relaxation"); - } - other => panic!("expected Cms/Cms pair, got {other:?}"), - } + _ => None, + }) + .expect("maintained average candidate"); + assert!(operator.checked_finite_division); + assert!( + crate::rewrite::SemanticEquivalentRewriteStrategy + .replacements(&TargetSubDAG::new(&root)) + .is_empty(), + "an unconditional pre-ASAP rewrite would bypass the runtime guard" + ); } + // Approximate requests also admit exact temporal ranking candidates. #[test] - fn posterior_aware_sizing_at_full_relaxation_matches_worst_case() { - // width_relaxation = 1.0 must reproduce default_size_params exactly - // — the "no risk taken" boundary. - let intent = count_intent(0.01); - let worst_case = default_size_params(SketchAlgorithm::Cms, &intent, 0.01, 0.01); - let relaxed = posterior_aware_size_params( - SketchAlgorithm::Cms, - &intent, - 0.01, - 0.01, - ExpectedCaseSizing { - width_relaxation: 1.0, + fn approximate_temporal_topk_admits_exact_maintained_values() { + let root = lower_promql( + "topk by(job)(1,count_over_time(a[5m]))", + AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, }, ); - assert_eq!(worst_case, relaxed); + let planning_inputs = + CandidatePlanningInputs::with_default_accuracy(&crate::cost_model::DefaultCostModel); + let node = exact_topk_over_temporal_values(&root, planning_inputs) + .unwrap() + .expect("exact ranking is legal for an approximate request"); + assert!(node.guarantee.as_ref().unwrap().is_exact()); + crate::test_support::time_and_export(&node).unwrap(); } + // Exact Top-K consumes the Planner's maintained temporal values. #[test] - fn posterior_aware_sizing_invalid_relaxation_falls_back_to_worst_case() { - let intent = count_intent(0.01); - let worst_case = default_size_params(SketchAlgorithm::Cms, &intent, 0.01, 0.01); - for bad in [0.0, -0.5, 1.5, f64::NAN, f64::INFINITY] { - let relaxed = posterior_aware_size_params( - SketchAlgorithm::Cms, - &intent, - 0.01, - 0.01, - ExpectedCaseSizing { - width_relaxation: bad, - }, + fn exact_temporal_topk_has_a_maintained_value_candidate() { + for query in [ + "topk(5, sum_over_time(a[5m]))", + "topk by(job)(5, count_over_time(a[5m]))", + ] { + let root = lower_promql(query, AccuracyTarget::Exact); + let planning_inputs = CandidatePlanningInputs::with_default_accuracy( + &crate::cost_model::DefaultCostModel, ); + let node = exact_topk_over_temporal_values(&root, planning_inputs) + .unwrap() + .expect("exact Top-K candidate"); + assert!(node.guarantee.as_ref().unwrap().is_exact()); + let Operator::NonASAP(NonASAPOp::Limit { + child: sorted, + n, + offset, + partition_by, + }) = &node.operator + else { + panic!("temporal TopK must compose Sort and Limit"); + }; + assert_eq!((*n, *offset), (Some(5), 0)); + let Operator::NonASAP(NonASAPOp::Sort { + keys, + partition_by: sort_groups, + child: values, + }) = &sorted.operator + else { + panic!("Limit must consume sorted temporal values"); + }; + assert_eq!(sort_groups, partition_by); assert_eq!( - worst_case, relaxed, - "width_relaxation={bad} should fall back to the worst-case width" + partition_by.keys().len(), + usize::from(query.contains("by(job)")) ); + assert_eq!(keys.len(), 1); + assert!(!keys[0].ascending); + assert_eq!(node.schema, values.schema); + crate::test_support::time_and_export(&node).unwrap(); } } + // A bounded exact mean can share the relative division proof with a quantile. #[test] - fn posterior_aware_sizing_does_not_apply_cms_l1_relaxation_to_count_sketch() { - let cms_heap_intent = AggIntent::TopK { - k: 7, - accuracy: eps(0.01), - }; - let assumption = ExpectedCaseSizing { - width_relaxation: 0.25, - }; - // CountSketch - assert_eq!( - posterior_aware_size_params( - SketchAlgorithm::CountSketch, - &count_intent(0.01), - 0.01, - 0.01, - assumption - ), - default_size_params( - SketchAlgorithm::CountSketch, - &count_intent(0.01), - 0.01, - 0.01 - ), - ); - // CmsWithHeap / CountSketchWithHeap carry k through untouched. - match posterior_aware_size_params( - SketchAlgorithm::CmsWithHeap, - &cms_heap_intent, - 0.01, - 0.01, - assumption, - ) { - SketchParams::CmsWithHeap { - width, - depth, - heap_size, - } => { - assert_eq!(width, 68); - assert_eq!(depth, 5); - assert_eq!(heap_size, 100); + fn bounded_mean_quantile_ratio_is_certified() { + struct Domain; + impl AccuracyEvidenceProvider for Domain { + fn quantile_input_domain( + &self, + _: &OperatorNode, + ) -> Option { + Some(crate::accuracy::QuantileInputDomain { + lower: 1.0, + upper: 1000.0, + max_samples: 10000, + contract: "finite test population".into(), + }) } - other => panic!("expected CmsWithHeap, got {other:?}"), } - } - - #[test] - fn posterior_aware_sizing_leaves_non_cms_kinds_unchanged() { - // Kll/Hll/etc. have no width_relaxation concept — must be byte-for- - // byte identical to default_size_params. - let intent = default_quantile(0.99); - let assumption = ExpectedCaseSizing { - width_relaxation: 0.1, + let target = AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, }; - assert_eq!( - posterior_aware_size_params(SketchAlgorithm::Kll, &intent, 0.01, 0.01, assumption), - default_size_params(SketchAlgorithm::Kll, &intent, 0.01, 0.01), - ); - } - - #[test] - fn default_size_params_unchanged_by_new_function_existing() { - // Regression pin: default_size_params's own worst-case behavior for - // existing callers must be untouched by adding - // posterior_aware_size_params alongside it. - assert_eq!( - default_size_params(SketchAlgorithm::Cms, &count_intent(0.001), 0.001, 0.001), - SketchParams::Cms { - width: 2719, - depth: 7 - }, - ); - } - - // ── ASAPStrategies / SharedSubDAGStrategy fixtures ─────────── - - // ── ASAPStrategies ───────────────────────────────────────────── - - #[test] - fn matches_a_bindable_aggregate() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - assert!(ASAPStrategies::default_cost_model().matches(&target)); - } - - #[test] - fn does_not_match_a_multi_intent_or_having_aggregate() { - let strategy = ASAPStrategies::default_cost_model(); - - let multi = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: ReductionTy::by(vec![2]), - measures: vec![AggIntent::Sum { col: None }, AggIntent::Avg { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: metric_scan(&["job"]), - })) - .unwrap(); - let target = TargetSubDAG::new(&multi); - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - - let having_q = crate::test_support::aggregate( - ReductionTy::by(vec![2]), - vec![default_quantile(0.99)], - vec![], - Some(asap_types::ir::Predicate(ScalarExpr::Literal( - asap_types::ir::scalar::ScalarValue::Boolean(true), - ))), - metric_scan(&["job"]), - ); - let target = TargetSubDAG::new(&having_q); - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - } - - #[test] - fn does_not_match_a_non_aggregate_node() { - let scan = metric_scan(&["job"]); - let target = TargetSubDAG::new(&scan); - assert!(!ASAPStrategies::default_cost_model().matches(&target)); - assert!(ASAPStrategies::default_cost_model() - .replacements(&target) - .is_empty()); - } - - #[test] - fn approximate_quantile_enumerates_every_summary_candidate() { - // Quantile's candidate list is [Kll, DDSketch] (summary_candidates) — - // every entry must come back as its own bound summary candidate, - // not just Kll (the CostModel-ranked head realizations_for_intent commits to). - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - let replacements = ASAPStrategies::default_cost_model().replacements(&target); - assert_eq!( - replacements.len(), - 2, - "expected 2 candidates, got {replacements:?}" - ); - - let kinds: Vec = replacements - .iter() - .map(|r| match &r.replacement { - Replacement::SubDAG(node) => summary_family_algorithm(node), - Replacement::ExactComposition(_) => { - panic!("expected a Summary replacement") - } - }) - .collect(); - assert!(kinds.contains(&SketchAlgorithm::Kll), "{kinds:?}"); - assert!(kinds.contains(&SketchAlgorithm::DDSketch), "{kinds:?}"); - assert!( - replacements.iter().all(|r| !r.rationale.is_empty()), - "every candidate must carry a rationale" - ); + let inputs = CandidatePlanningInputs { + evidence: &Domain, + ..CandidatePlanningInputs::with_default_accuracy(&DefaultCostModel) + }; + for query in [ + "avg_over_time(a[5m]) / quantile_over_time(0.5,a[5m])", + "quantile_over_time(0.5,a[5m]) / avg_over_time(a[5m])", + ] { + let root = lower_promql(query, target.clone()); + let node = realize_binary(&root, inputs, Some(&target)) + .unwrap() + .expect("bounded ratio candidate"); + assert!(DefaultAccuracyModel.satisfies(node.guarantee.as_ref().unwrap(), &target)); + } } + // Missing domain proof permits an uncertified direct quantile ratio only. #[test] - fn cardinality_epsilon_delta_keeps_unknown_accuracy_candidates() { - let q = agg(vec![2], default_cardinality(), metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - let replacements = ASAPStrategies::default_cost_model().replacements(&target); - let kinds: Vec = replacements - .iter() - .map(|r| match &r.replacement { - Replacement::SubDAG(node) => summary_family_algorithm(node), - Replacement::ExactComposition(_) => { - panic!("expected a Summary replacement") - } - }) - .collect(); - assert_eq!( - kinds, - vec![ - SketchAlgorithm::Hll, - SketchAlgorithm::Theta, - SketchAlgorithm::Kmv, - SketchAlgorithm::UnivMon, - ] + fn quantile_ratio_without_input_proof_has_no_root_guarantee() { + let target = AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }; + let root = lower_promql( + "quantile_over_time(0.5,a[5m]) / quantile_over_time(0.9,a[5m])", + target.clone(), ); + let planning_inputs = + CandidatePlanningInputs::with_default_accuracy(&crate::cost_model::DefaultCostModel); + let candidate = realize_binary(&root, planning_inputs, Some(&target)) + .unwrap() + .expect("direct quantile ratio candidate"); + assert!(candidate.guarantee.is_none()); - let q = agg( - vec![2], - AggIntent::Cardinality { - cols: vec![], - accuracy: AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - }, - metric_scan(&["job"]), - ); - let kinds: Vec<_> = ASAPStrategies::default_cost_model() - .replacements(&TargetSubDAG::new(&q)) - .iter() - .map(|r| match &r.replacement { - Replacement::SubDAG(node) => summary_family_algorithm(node), - Replacement::ExactComposition(_) => { - panic!("expected a Summary replacement") - } - }) - .collect(); - assert_eq!( - kinds, - vec![ - SketchAlgorithm::Hll, - SketchAlgorithm::Theta, - SketchAlgorithm::Kmv, - SketchAlgorithm::UnivMon, - ] + let other = lower_promql( + "avg_over_time(a[5m]) / quantile_over_time(0.5,a[5m])", + target.clone(), ); + assert!(realize_binary(&other, planning_inputs, Some(&target)) + .unwrap() + .is_none()); } - #[test] - fn exact_accuracy_target_yields_exactly_one_pass_through_candidate() { - // Exact quantile has no sketch candidate at all — realizations_for_intent - // produces PassThrough, the only option, so exactly one candidate. - let intent = AggIntent::Quantile { - col: None, - q: 0.99, - accuracy: AccuracyTarget::Exact, - }; - let q = agg(vec![2], intent, metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - let replacements = ASAPStrategies::default_cost_model().replacements(&target); - assert_eq!(replacements.len(), 1, "{replacements:?}"); - assert!(matches!( - &replacements[0].replacement, - Replacement::SubDAG(node) if !node.contains_asap() - )); - assert!(replacements[0].rationale.contains("only realization")); + fn eps(e: f64) -> AccuracyTarget { + AccuracyTarget::Epsilon(e) } - #[test] - fn exact_mergeable_intent_yields_exactly_one_accumulator_candidate() { - let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - let replacements = ASAPStrategies::default_cost_model().replacements(&target); - assert_eq!(replacements.len(), 1, "{replacements:?}"); - assert!(matches!( - &replacements[0].replacement, - Replacement::SubDAG(node) if matches!( - node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { .. }) - ) - )); + // ── realizations_for_intent / sizing ─────────────────────────────── + + /// The most-preferred `Realization` — `realizations_for_intent(intent, + /// &DefaultCostModel)`'s head — for tests that only care about the + /// default pick, not the full candidate list. + fn preferred(intent: &AggIntent) -> Realization { + realizations_for_intent(intent, &DefaultCostModel) + .into_iter() + .next() + .expect("every intent has at least one Realization") } - /// A custom `CostModel` doesn't change *which* candidates are enumerated - /// (still every `summary_candidates` entry) — only which one - /// `realizations_for_intent` itself would prefer first, and how each - /// candidate's own params are sized. - struct PreferDDSketch; - impl CostModel for PreferDDSketch { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { - let dd = v.remove(pos); - v.insert(0, dd); + /// Shorthand for asserting the realization *category*. + #[derive(Debug, PartialEq)] + enum Cat { + Sketch(SketchAlgorithm), + Acc(ExactKind), + Pass, + } + + fn cat(intent: &AggIntent) -> Cat { + match preferred(intent) { + Realization::ExactAggregate { kind, .. } => Cat::Acc(kind), + Realization::Sketch(kind) => Cat::Sketch(kind.algorithm().clone()), + Realization::PassThrough => Cat::Pass, + other => { + panic!("this coverage matrix expects only Exact/Sketch/PassThrough, got {other:?}") } - v } - } - - #[test] - fn custom_cost_model_still_enumerates_every_candidate_not_just_its_own_pick() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - let custom = PreferDDSketch; - let replacements = ASAPStrategies::new(&custom).replacements(&target); - let kinds: Vec = replacements - .iter() - .map(|r| match &r.replacement { - Replacement::SubDAG(node) => summary_family_algorithm(node), - Replacement::ExactComposition(_) => { - panic!("expected a Summary replacement") - } - }) - .collect(); - assert!(kinds.contains(&SketchAlgorithm::Kll)); - assert!(kinds.contains(&SketchAlgorithm::DDSketch)); - assert_eq!(kinds.len(), 2); - } - - /// Constructing the outer target's candidates never leaks its algorithm - /// choice into the nested aggregate. Existing approximate composition - /// remains governed by the accuracy model, independently of #171's exact - /// value-operation candidates. - #[test] - fn enumerating_the_targets_candidates_does_not_leak_into_a_nested_aggregate() { - // outer: quantile(0.99, ...) over inner: quantile(0.5, m) — both - // Quantile, so both share the [Kll, DDSketch] candidate list. - // - // Rank-over-rank has no registered rule in `DefaultAccuracyModel` - // (issue #172 — see `approximate_over_approximate_is_rejected_by_default`), - // so this test injects `RankAdditiveModel` to admit the composition - // and keep exercising the per-node enumeration property it is about. - let inner = agg(vec![2], default_quantile(0.5), metric_scan(&["job"])); - let outer = agg(vec![], default_quantile(0.99), inner); - let target = TargetSubDAG::new(&outer); - let replacements = ASAPStrategies::new_with_planning_inputs( - &DefaultCostModel, - &RankAdditiveModel, - &EqualSplitAllocator, - ) - .replacements(&target); - - assert_eq!(replacements.len(), 2, "{replacements:?}"); - assert!(replacements.iter().all(|candidate| { - matches!(&candidate.replacement, Replacement::SubDAG(n) if n.contains_asap()) - })); - // The inner target is still independently enumerated and ranked — - // a custom cost model that prefers DDSketch for it is honored, and - // nothing about the outer target's choice reaches it. - let space = search_workload_with( - vec![("q", Rc::clone(&outer))], - &default_strategies_with(&PreferDDSketchViaCostModel), - ); - let Some(NonASAPOp::Aggregate { child, .. }) = space.roots[0].1.non_asap() else { - unreachable!() - }; - let inner_group = space - .candidates_for_target(child) - .expect("inner quantile is a target"); - let inner_kinds: Vec = inner_group - .candidates - .iter() - .filter_map(|c| match &c.replacement { - Replacement::SubDAG(node) => sketch_kind_of(node), - _ => None, - }) - .collect(); - assert_eq!( - inner_kinds, - vec![SketchAlgorithm::DDSketch, SketchAlgorithm::Kll], - "the nested inner aggregate keeps its own cost-model-ranked candidates" - ); - } - - /// The `FieldDataType`'s committed `SketchAlgorithm`, from the top - /// `SummaryAgg` reachable under a (possibly `SummaryEstimate`-wrapped) - /// bound root. - fn summary_family_algorithm(node: &OperatorNode) -> SketchAlgorithm { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - summary_family_algorithm(summary_input) + } + + /// The `AggIntent → SummaryKind` coverage matrix (issue #98): every intent + /// variant maps to a sketch, an exact accumulator, or an explicit + /// pass-through. `realizations_for_intent`'s match is exhaustive, so a + /// new variant cannot compile without a decision; this matrix pins what + /// each decision *is* (its preferred/first candidate). + #[test] + fn agg_intent_to_summary_kind_coverage_matrix() { + use AggIntent as A; + use Cat::*; + use ExactKind as E; + use SketchAlgorithm as K; + let matrix: Vec<(A, Cat)> = vec![ + // approximate-capable, at an ε target → sketch + (default_quantile(0.99), Sketch(K::Kll)), + (default_cardinality(), Sketch(K::Hll)), + ( + A::Cardinality { + cols: vec![0, 1], + accuracy: eps(0.01), + }, + Sketch(K::Hll), + ), + ( + A::Count { + accuracy: eps(0.01), + }, + Sketch(K::Cms), + ), + ( + A::TopK { + k: 10, + accuracy: eps(0.01), + }, + Sketch(K::CmsWithHeap), + ), + // the same intents at Exact → exact realization + ( + A::Quantile { + col: None, + q: 0.5, + accuracy: AccuracyTarget::Exact, + }, + Pass, + ), + ( + A::Cardinality { + cols: vec![], + accuracy: AccuracyTarget::Exact, + }, + Pass, + ), + ( + A::Cardinality { + cols: vec![0, 1], + accuracy: AccuracyTarget::Exact, + }, + Pass, + ), + ( + A::Count { + accuracy: AccuracyTarget::Exact, + }, + Acc(E::Count), + ), + ( + A::TopK { + k: 10, + accuracy: AccuracyTarget::Exact, + }, + Pass, + ), + // exact mergeable accumulators + (A::Sum { col: None }, Acc(E::Sum)), + (A::Min { col: None }, Acc(E::Min)), + (A::Max { col: None }, Acc(E::Max)), + (A::Rate, Acc(E::Rate)), + (A::IRate, Acc(E::IRate)), + (A::Increase, Acc(E::Increase)), + // exact but non-mergeable → pass-through + (A::Avg { col: None }, Pass), + ( + A::StdDev { + col: None, + population: false, + }, + Pass, + ), + ( + A::Variance { + col: None, + population: true, + }, + Pass, + ), + // classic-bucket histogram_quantile is not re-sketchable (#79) + (A::HistogramQuantile { q: 0.99, le: 0 }, Pass), + // counter-derivative / range-vector functions (#44) + (A::Changes, Pass), + (A::Delta, Pass), + (A::IDelta, Pass), + (A::Deriv, Pass), + (A::Resets, Pass), + (A::PredictLinear { seconds: 60.0 }, Pass), + ( + A::DoubleExpSmoothing { + smoothing: 0.5, + trend: 0.5, + }, + Pass, + ), + // native-histogram accessors (#43) + (A::HistogramCount, Pass), + (A::HistogramSum, Pass), + (A::HistogramAvg, Pass), + (A::HistogramStdDev, Pass), + (A::HistogramStdVar, Pass), + ( + A::HistogramFraction { + lower: 0.0, + upper: 1.0, + }, + Pass, + ), + // per-sample transforms (#45, #46) + presence (#47) + (A::Math(MathFunc::Abs), Pass), + (A::TimeFn(TimeFunc::Hour), Pass), + (A::Absent, Pass), + (A::AbsentOverTime, Pass), + (A::PresentOverTime, Pass), + // extended aggregations (#49) + (A::Group, Pass), + (A::CountValues { label: "v".into() }, Pass), + // additional range reducers (#51) + (A::LastOverTime, Pass), + (A::FirstOverTime, Pass), + (A::MadOverTime, Pass), + (A::TsOfMinOverTime, Pass), + (A::TsOfMaxOverTime, Pass), + (A::TsOfFirstOverTime, Pass), + (A::TsOfLastOverTime, Pass), + ]; + for (intent, expected) in &matrix { + assert_eq!(&cat(intent), expected, "realization for {intent:?}"); + } + // Every accumulator pick is mergeable; every sketch pick is on a + // genuinely approximate target (the `agg_is_*` helpers stay truthful). + for (intent, expected) in &matrix { + if let Cat::Acc(_) = expected { + assert!(agg_is_mergeable(intent), "{intent:?}"); + } + if let Cat::Sketch(_) = expected { + assert!( + !agg_is_exact(intent) || matches!(intent, AggIntent::Count { .. }), + "{intent:?} sketches only under an approximate target" + ); } - Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) => match family { - asap_types::ir::schema::FieldDataType::Sketch(kind, _) => kind.algorithm().clone(), - other => panic!("expected a Sketch family, got {other:?}"), - }, - other => panic!("expected SummaryAgg/SummaryEstimate, got {other:?}"), } } - // ── SharedSubDAGStrategy ──────────────────────────────────────────── - + // Correlation must never acquire a single-input sketch or scalar accumulator. #[test] - fn does_not_match_a_single_consumer_target() { - let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - assert_eq!(target.consumer_count, 1); - assert!(!SharedSubDAGStrategy.matches(&target)); - assert!(SharedSubDAGStrategy.replacements(&target).is_empty()); + fn pearson_corr_keeps_exact_paired_input() { + let intent = AggIntent::PearsonCorr { left: 0, right: 1 }; + assert!(matches!( + realizations_for_intent(&intent, &crate::cost_model::DefaultCostModel).as_slice(), + [Realization::PassThrough] + )); + assert!(summary_candidates(&intent).is_empty()); } #[test] - fn two_or_more_consumers_yields_the_share_vs_independent_pair() { - let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let target = TargetSubDAG::with_consumer_count(&q, 2); - assert!(SharedSubDAGStrategy.matches(&target)); - - let replacements = SharedSubDAGStrategy.replacements(&target); - assert_eq!(replacements.len(), 2, "{replacements:?}"); - - let shared = match &replacements[0].replacement { - Replacement::SubDAG(rc) => rc, - other => panic!("expected a Rewrite replacement, got {other:?}"), + fn accuracy_target_drives_the_boundary() { + // Same intent, three targets → three different decisions. + let exact = AggIntent::Quantile { + col: None, + q: 0.99, + accuracy: AccuracyTarget::Exact, }; - assert!( - Rc::ptr_eq(shared, &q), - "the 'build once and share' candidate must be the same Rc as the target" + assert_eq!(preferred(&exact), Realization::PassThrough); + + let approx = default_quantile(0.99); // ε = 0.01 + assert_eq!( + preferred(&approx), + Realization::Sketch(SketchKind::new( + SketchAlgorithm::Kll, + SketchParams::Kll { k: 269 }, + )) ); - assert!(replacements[0].rationale.contains("build once and share")); - let independent = match &replacements[1].replacement { - Replacement::SubDAG(rc) => rc, - other => panic!("expected a Rewrite replacement, got {other:?}"), + let looser = AggIntent::Quantile { + col: None, + q: 0.99, + accuracy: eps(0.05), }; - assert!( - !Rc::ptr_eq(independent, &q), - "the 'build independently' candidate must be a distinct Rc from the target" - ); assert_eq!( - **independent, *q, - "the 'build independently' candidate must still be structurally identical" + preferred(&looser), + Realization::Sketch(SketchKind::new( + SketchAlgorithm::Kll, + SketchParams::Kll { k: 52 }, + )) ); - assert!(replacements[1].rationale.contains("build independently")); - } - - #[test] - fn three_consumers_are_reported_verbatim_in_both_rationales() { - let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let target = TargetSubDAG::with_consumer_count(&q, 3); - let replacements = SharedSubDAGStrategy.replacements(&target); - assert!(replacements[0].rationale.contains('3')); - assert!(replacements[1].rationale.contains('3')); } - - /// Builds realistic multi-consumer `TargetSubDAG`s the same way this - /// module's own [`discover_targets`]/`walk` does: dedup by `Rc::as_ptr`, - /// walking only the relational-skeleton operator children - /// `asap_types::ir::cse::share_common_sub_dags` itself scopes to, - /// so a shared node nested below another shared node is only ever - /// counted at the highest (maximal) point sharing starts. Test-only: - /// this module deliberately does not ship a workload-wide discovery - /// pass of its own (see the module docs' "Non-goals"). - fn count_consumers(roots: &[Rc]) -> HashMap<*const OperatorNode, usize> { - fn walk(node: &Rc, counts: &mut HashMap<*const OperatorNode, usize>) { - let ptr = Rc::as_ptr(node); - let already_visited = counts.contains_key(&ptr); - *counts.entry(ptr).or_insert(0) += 1; - if !already_visited { - walk_children(node, counts); - } - } - fn walk_children(node: &OperatorNode, counts: &mut HashMap<*const OperatorNode, usize>) { - if let Some(NonASAPOp::Concat { children, .. }) = node.non_asap() { - for c in children { - walk_children(c, counts); - } - return; - } - for child in node.children() { - walk(child, counts); - } - } - - let mut counts = HashMap::new(); - for root in roots { - walk(root, &mut counts); - } - counts + + #[test] + fn default_cardinality_sizes_hll_to_its_rse_magnitude() { + assert_eq!( + preferred(&default_cardinality()), + Realization::Sketch(SketchKind::new( + SketchAlgorithm::Hll, + SketchParams::Hll { precision: 14 }, + )) + ); } + // Exact counting remains a legal candidate under an approximate target. #[test] - fn realistic_cse_output_produces_a_two_consumer_target() { - // Two workload roots that `share_common_sub_dags` collapses onto one - // Rc (mirrors `explanation`'s and `cse`'s own fixtures): a grouped - // Sum aggregate over the same scan, built independently at each root. - let a = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let b = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let shared = asap_types::ir::cse::share_common_sub_dags(vec![("a", a), ("b", b)]); - let [(_, ra), (_, rb)] = shared.as_slice() else { - panic!("expected 2 roots"); + fn approximate_count_includes_exact_accumulator_candidate() { + let intent = AggIntent::Count { + accuracy: eps(0.01), }; - assert!(Rc::ptr_eq(ra, rb), "fixture sanity: the two roots merged"); - - let roots: Vec> = shared.into_iter().map(|(_, rc)| rc).collect(); - let counts = count_consumers(&roots); - let count = counts[&Rc::as_ptr(&roots[0])]; - assert_eq!(count, 2); - - let target = TargetSubDAG::with_consumer_count(&roots[0], count); - assert!(SharedSubDAGStrategy.matches(&target)); - assert_eq!(SharedSubDAGStrategy.replacements(&target).len(), 2); + assert!(realizations_for_intent(&intent, &DefaultCostModel) + .iter() + .any(|candidate| matches!( + candidate, + Realization::ExactAggregate { + kind: ExactKind::Count, + .. + } + ))); } - // ── search_workload / CandidateLogicalASAPDAGs / TargetSubDAGCandidates (merged from search.rs) ── - // - // Reuses this test module's own `metric_scan`/`agg` fixture helpers - // above (identical to `search.rs`'s own copies, which are dropped here - // to avoid a duplicate-definition collision now that both test modules - // share one file) and `count_consumers` above (which mirrors - // `discover_targets`' own real, non-test traversal for these fixtures). - - // ── discovery + MEMO shape ─────────────────────────────────────────── - #[test] - fn single_bindable_aggregate_keeps_unprovable_hydra_candidates() { + fn epsilon_delta_sizes_cms_depth() { let intent = AggIntent::Count { accuracy: AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, + epsilon: 0.001, + delta: 0.001, }, }; - let root = agg(vec![2], intent, metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); + assert_eq!( + preferred(&intent), + Realization::Sketch(SketchKind::new( + SketchAlgorithm::Cms, + SketchParams::Cms { + width: 2719, + depth: 7 + }, // ⌈e/0.001⌉, ⌈ln 1000⌉ + )) + ); + // Epsilon-only falls back to DEFAULT_DELTA → depth 5. + let intent = AggIntent::Count { + accuracy: eps(0.001), + }; + assert_eq!( + preferred(&intent), + Realization::Sketch(SketchKind::new( + SketchAlgorithm::Cms, + SketchParams::Cms { + width: 2719, + depth: 5 + }, + )) + ); + } - // One group for the Aggregate, one for its Scan child. - assert_eq!(space.len(), 2); + #[test] + fn topk_heap_capacity_respects_accuracy_and_output_count() { + let intent = AggIntent::TopK { + k: 25, + accuracy: eps(0.01), + }; + match preferred(&intent) { + Realization::Sketch(kind) if kind.algorithm() == &SketchAlgorithm::CmsWithHeap => { + let SketchParams::CmsWithHeap { + width, + depth, + heap_size, + } = kind.params() + else { + unreachable!("SketchKind validates CmsWithHeap params") + }; + assert_eq!(*heap_size, 100); + assert_eq!(*width, 272); // ⌈e/0.01⌉ + assert_eq!(*depth, 5); + } + other => panic!("expected CmsWithHeap, got {other:?}"), + } + } - let agg_group = space - .target_subdag_candidates() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .expect("an Aggregate group must be discovered"); - assert_eq!(agg_group.consumer_count, 1); + #[test] + fn candidate_lists_match_the_issue_map() { assert_eq!( - agg_group.candidates.len(), - 6, - "Hydra candidates with unknown evidence remain available: {:?}", - agg_group.candidates + summary_candidates(&default_quantile(0.5)), + &[SketchAlgorithm::Kll, SketchAlgorithm::DDSketch] ); - assert!(agg_group - .candidates - .iter() - .all(|c| matches!(&c.replacement, Replacement::SubDAG(n) if n.contains_asap()))); assert_eq!( - agg_group - .candidates - .iter() - .filter(|candidate| { - let Replacement::SubDAG(node) = &candidate.replacement else { - return false; - }; - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = - &node.operator - else { - return false; - }; - matches!( - &summary_input.operator, - Operator::ASAP(ASAPOp::SummaryAgg { - grouping: GroupingStrategy::SharedMultiSubpopulation { .. }, - .. - }) - ) - }) - .count(), - 2, - "Hydra candidates remain visible with symbolic shared-grid error" + summary_candidates(&default_cardinality()), + &[ + SketchAlgorithm::Hll, + SketchAlgorithm::Theta, + SketchAlgorithm::Kmv, + SketchAlgorithm::UnivMon + ] ); assert_eq!( - agg_group - .candidates - .iter() - .filter(|candidate| candidate.has_missing_accuracy_evidence()) - .count(), - 2 + summary_candidates(&AggIntent::TopK { + k: 5, + accuracy: eps(0.01) + }), + &[ + SketchAlgorithm::CmsWithHeap, + SketchAlgorithm::CountSketchWithHeap + ] ); - let selected = space.global_selection(&DefaultCostModel); - assert!(!selected - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_some_and(ReplacementSubDAG::has_missing_accuracy_evidence)); - - let scan_group = space - .target_subdag_candidates() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Scan { .. }))) - .expect("a Scan group must be discovered"); - assert_eq!(scan_group.consumer_count, 1); - assert!( - scan_group.candidates.is_empty(), - "no strategy matches a bare Scan" + assert_eq!( + summary_candidates(&AggIntent::Count { + accuracy: eps(0.01) + }), + &[ + SketchAlgorithm::Cms, + SketchAlgorithm::CountSketch, + SketchAlgorithm::UnivMon + ] ); + assert!(summary_candidates(&AggIntent::Rate).is_empty()); } #[test] - fn cardinality_group_keeps_all_four_candidates() { - let root = agg(vec![2], default_cardinality(), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); - let agg_group = space - .target_subdag_candidates() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .unwrap(); - assert_eq!(agg_group.candidates.len(), 4); - assert!(agg_group.candidates.iter().any(|candidate| matches!( - &candidate.replacement, - Replacement::SubDAG(node) if node.guarantee.is_none() - && candidate.has_missing_accuracy_evidence() - ))); - - let root = agg(vec![2], default_cardinality(), metric_scan(&["job"])); - let targeted = search_workload_with_targets( - vec![( - "q", - root, - Some(AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }), - )], - &default_strategies(), - &DefaultAccuracyModel, - ); - let target = &targeted.roots[0].1; - assert!(targeted - .candidates_for_target(target) - .unwrap() - .candidates - .iter() - .any(|candidate| matches!( - &candidate.replacement, - Replacement::SubDAG(node) if node.guarantee.is_none() - && candidate.has_missing_accuracy_evidence() - ))); - assert!(!targeted - .global_selection(&DefaultCostModel) - .for_target(target) - .unwrap() - .chosen - .is_some_and(ReplacementSubDAG::has_missing_accuracy_evidence)); - - let exact_target = search_workload_with_targets( - vec![( - "q", - agg(vec![2], default_cardinality(), metric_scan(&["job"])), - Some(AccuracyTarget::Exact), - )], - &default_strategies(), - &DefaultAccuracyModel, - ); - assert!(exact_target - .candidates_for_target(&exact_target.roots[0].1) - .unwrap() - .candidates - .iter() - .all(|candidate| !candidate.has_missing_accuracy_evidence())); + fn realizations_for_intent_enumerates_every_candidate_ranked() { + // Quantile's candidate list is [Kll, DDSketch] — realizations_for_intent + // must return both, ranked with the DefaultCostModel's preferred + // (Kll) first. + let kinds: Vec = + realizations_for_intent(&default_quantile(0.99), &DefaultCostModel) + .into_iter() + .map(|realization| match realization { + Realization::Sketch(kind) => kind.algorithm().clone(), + other => panic!("expected Sketch, got {other:?}"), + }) + .collect(); + assert_eq!(kinds, vec![SketchAlgorithm::Kll, SketchAlgorithm::DDSketch]); } #[test] - fn shared_aggregate_across_two_roots_gets_both_strategies_candidates() { - // Two independently-built, structurally identical Sum aggregates: - // share_common_sub_dags (run inside search_workload) collapses them - // onto one Rc with consumer_count 2, so this single group should - // carry ASAPStrategies's one ExactAggregate candidate *and* - // SharedSubDAGStrategy's share-vs-recompute pair. - let a = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let b = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let space = search_workload(vec![("a", a), ("b", b)]); - - // roots[0] and roots[1] must have merged onto the same Rc. - assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); - - let group = space.candidates_for_target(&space.roots[0].1).unwrap(); - assert_eq!(group.consumer_count, 2); + fn degenerate_epsilon_saturates_to_tightest_params() { + let intent = AggIntent::Quantile { + col: None, + q: 0.99, + accuracy: eps(0.0), + }; assert_eq!( - group.candidates.len(), - 3, - "1 ExactAggregate Summary + 2 Rewrite (share/recompute): {:?}", - group.candidates + preferred(&intent), + Realization::Sketch(SketchKind::new( + SketchAlgorithm::Kll, + SketchParams::Kll { k: 65_535 }, + )) ); + } - // Old `Replacement::Summary` ↔ a `Subtree` containing an ASAP node; - // old `Replacement::Rewrite` ↔ a pure pre-ASAP `Subtree`. - let summary_count = group - .candidates - .iter() - .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if n.contains_asap())) - .count(); - let rewrite_count = group - .candidates - .iter() - .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if !n.contains_asap())) - .count(); - assert_eq!(summary_count, 1); - assert_eq!(rewrite_count, 2); + // ── posterior_aware_size_params (issue #239, integration point 2) ────── - // The two Rewrite candidates must NOT have collapsed into one - // (the "false-positive dedup" failure mode `is_duplicate_rewrite` - // exists to prevent). - let one_is_the_target = group.candidates.iter().any( - |c| matches!(&c.replacement, Replacement::SubDAG(rc) if Rc::ptr_eq(rc, &group.target)), - ); - let one_is_not = group.candidates.iter().any( - |c| matches!(&c.replacement, Replacement::SubDAG(rc) if !Rc::ptr_eq(rc, &group.target)), - ); - assert!(one_is_the_target && one_is_not); + fn count_intent(e: f64) -> AggIntent { + AggIntent::Count { accuracy: eps(e) } } #[test] - fn nested_shared_sub_dag_below_an_unshared_parent_is_still_discovered() { - // A shared grouped Aggregate nested under two *different*, - // unshared Filter parents — real consumer_count must come from - // walking the whole DAG, not just root-level pointer identity - // (a naive whole-root-only consumer-count pass would miss this; - // this module's discover_targets must not). - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; - - let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - // Different predicates so the two Filter *parents* stay distinct - // (don't themselves merge under CSE) — only their shared `child` - // should collapse onto one `Rc`. - let root_a = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(1))), - child: Rc::clone(&shared), - })) - .unwrap(); - let root_b = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(2))), - child: Rc::clone(&shared), - })) - .unwrap(); - - let space = search_workload(vec![("a", root_a), ("b", root_b)]); - assert_eq!( - space.len(), - 4, - "2 distinct Filters + 1 shared Aggregate + 1 shared Scan" + fn posterior_aware_sizing_shrinks_width_under_stated_assumption() { + let intent = count_intent(0.01); + let worst_case = default_size_params(SketchAlgorithm::Cms, &intent, 0.01, 0.01); + let relaxed = posterior_aware_size_params( + SketchAlgorithm::Cms, + &intent, + 0.01, + 0.01, + ExpectedCaseSizing { + width_relaxation: 0.5, + }, ); + match (worst_case, relaxed) { + ( + SketchParams::Cms { + width: w0, + depth: d0, + }, + SketchParams::Cms { + width: w1, + depth: d1, + }, + ) => { + assert!( + w1 < w0, + "expected relaxed width {w1} to be strictly smaller than worst-case {w0}" + ); + assert_eq!(d0, d1, "depth must be unaffected by width_relaxation"); + } + other => panic!("expected Cms/Cms pair, got {other:?}"), + } + } - // `share_common_sub_dags` re-clones+re-interns anything that already - // had more than one owner going in (see `cse.rs`'s own doc on - // `intern_child`'s clone-fallback path) — so the post-CSE shared - // node is a *fresh* Rc, structurally equal to (but not the same - // pointer as) the pre-search `shared` variable. Recover it from the - // post-CSE root's own `child` field instead of the stale `shared` - // handle. - let Some(NonASAPOp::Filter { - child: post_cse_shared_a, - .. - }) = space.roots[0].1.non_asap() - else { - panic!("expected a Filter root"); - }; - let Some(NonASAPOp::Filter { - child: post_cse_shared_b, - .. - }) = space.roots[1].1.non_asap() - else { - panic!("expected a Filter root"); - }; - assert!( - Rc::ptr_eq(post_cse_shared_a, post_cse_shared_b), - "fixture sanity: the two Filters' children must still merge" - ); - let post_cse_shared = post_cse_shared_a; - let group = space - .candidates_for_target(post_cse_shared) - .expect("shared node must be a discovered target"); - assert_eq!(group.consumer_count, 2); - assert!( - SharedSubDAGStrategy.matches(&TargetSubDAG::with_consumer_count( - post_cse_shared, - group.consumer_count - )) + #[test] + fn posterior_aware_sizing_at_full_relaxation_matches_worst_case() { + // width_relaxation = 1.0 must reproduce default_size_params exactly + // — the "no risk taken" boundary. + let intent = count_intent(0.01); + let worst_case = default_size_params(SketchAlgorithm::Cms, &intent, 0.01, 0.01); + let relaxed = posterior_aware_size_params( + SketchAlgorithm::Cms, + &intent, + 0.01, + 0.01, + ExpectedCaseSizing { + width_relaxation: 1.0, + }, ); + assert_eq!(worst_case, relaxed); } - // ── dedup ──────────────────────────────────────────────────────────── - #[test] - fn add_candidate_rejects_a_true_rewrite_duplicate() { - // SharedSubDAGStrategy's `Replacement::Rewrite` candidates are - // real `OperatorNode` values with `PartialEq`, so `add_candidate` can - // (and must) actually reject a genuine repeat — unlike the - // `Replacement::Summary` case (see the test below). - let root = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let mut group = TargetSubDAGCandidates::new(Rc::clone(&root), 2); - let target = TargetSubDAG::with_consumer_count(&root, 2); - let mut inserted = 0; - for candidate in SharedSubDAGStrategy.replacements(&target) { - if group.add_candidate(candidate) { - inserted += 1; - } + fn posterior_aware_sizing_invalid_relaxation_falls_back_to_worst_case() { + let intent = count_intent(0.01); + let worst_case = default_size_params(SketchAlgorithm::Cms, &intent, 0.01, 0.01); + for bad in [0.0, -0.5, 1.5, f64::NAN, f64::INFINITY] { + let relaxed = posterior_aware_size_params( + SketchAlgorithm::Cms, + &intent, + 0.01, + 0.01, + ExpectedCaseSizing { + width_relaxation: bad, + }, + ); + assert_eq!( + worst_case, relaxed, + "width_relaxation={bad} should fall back to the worst-case width" + ); } - assert_eq!(inserted, 2, "share + recompute-independently candidates"); + } - // Re-adding the identical candidate list must add nothing new: the - // "share" candidate is literally the same Rc as before, and the - // "recompute independently" candidate is a fresh Rc but - // structurally identical value, both already covered by - // `is_duplicate_rewrite`. - let mut re_inserted = 0; - for candidate in SharedSubDAGStrategy.replacements(&target) { - if group.add_candidate(candidate) { - re_inserted += 1; + #[test] + fn posterior_aware_sizing_does_not_apply_cms_l1_relaxation_to_count_sketch() { + let cms_heap_intent = AggIntent::TopK { + k: 7, + accuracy: eps(0.01), + }; + let assumption = ExpectedCaseSizing { + width_relaxation: 0.25, + }; + // CountSketch + assert_eq!( + posterior_aware_size_params( + SketchAlgorithm::CountSketch, + &count_intent(0.01), + 0.01, + 0.01, + assumption + ), + default_size_params( + SketchAlgorithm::CountSketch, + &count_intent(0.01), + 0.01, + 0.01 + ), + ); + // CmsWithHeap / CountSketchWithHeap carry k through untouched. + match posterior_aware_size_params( + SketchAlgorithm::CmsWithHeap, + &cms_heap_intent, + 0.01, + 0.01, + assumption, + ) { + SketchParams::CmsWithHeap { + width, + depth, + heap_size, + } => { + assert_eq!(width, 68); + assert_eq!(depth, 5); + assert_eq!(heap_size, 100); } + other => panic!("expected CmsWithHeap, got {other:?}"), } - assert_eq!( - re_inserted, 0, - "re-proposing the same Rewrite candidates must not grow the group" - ); - assert_eq!(group.candidates.len(), 2); } #[test] - fn add_candidate_never_dedups_summary_candidates() { - // Documented, deliberate consequence of `is_duplicate_summary` - // refusing value equality on `f64`-bearing summaries: re-proposing - // the same `Replacement::Summary` candidates DOES grow the group — - // this module refuses to guess at an equality check it can't back - // with a real `PartialEq`. `search_workload_with` never actually - // does this in practice (every target is asked exactly once — see the - // module docs' "Termination" section), so this test exists to pin - // the documented behavior, not to endorse calling `replacements` - // twice for the same target. - let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let mut group = TargetSubDAGCandidates::new(Rc::clone(&root), 1); - let strategy = ASAPStrategies::default_cost_model(); - let target = TargetSubDAG::new(&root); - for candidate in strategy.replacements(&target) { - group.add_candidate(candidate); - } - assert_eq!(group.candidates.len(), 2); - - for candidate in strategy.replacements(&target) { - group.add_candidate(candidate); - } + fn posterior_aware_sizing_leaves_non_cms_kinds_unchanged() { + // Kll/Hll/etc. have no width_relaxation concept — must be byte-for- + // byte identical to default_size_params. + let intent = default_quantile(0.99); + let assumption = ExpectedCaseSizing { + width_relaxation: 0.1, + }; assert_eq!( - group.candidates.len(), - 4, - "Summary candidates are never deduped by this module — see is_duplicate_summary" + posterior_aware_size_params(SketchAlgorithm::Kll, &intent, 0.01, 0.01, assumption), + default_size_params(SketchAlgorithm::Kll, &intent, 0.01, 0.01), ); } #[test] - fn is_duplicate_rewrite_never_merges_share_with_recompute() { - let target = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let share = Rc::clone(&target); - let recompute = Rc::new((*target).clone()); - assert!(!Rc::ptr_eq(&share, &recompute)); + fn default_size_params_unchanged_by_new_function_existing() { + // Regression pin: default_size_params's own worst-case behavior for + // existing callers must be untouched by adding + // posterior_aware_size_params alongside it. assert_eq!( - *share, *recompute, - "fixture sanity: same value, different Rc" + default_size_params(SketchAlgorithm::Cms, &count_intent(0.001), 0.001, 0.001), + SketchParams::Cms { + width: 2719, + depth: 7 + }, ); - assert!(!is_duplicate_rewrite(&share, &recompute, &target)); - assert!(!is_duplicate_rewrite(&recompute, &share, &target)); } + // ── ASAPStrategies / SharedSubDAGStrategy fixtures ─────────── + + // ── ASAPStrategies ───────────────────────────────────────────── + #[test] - fn is_duplicate_rewrite_catches_a_real_repeat() { - let target = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let first_recompute = Rc::new((*target).clone()); - let second_recompute = Rc::new((*target).clone()); - assert!(!Rc::ptr_eq(&first_recompute, &second_recompute)); - assert!(is_duplicate_rewrite( - &first_recompute, - &second_recompute, - &target - )); + fn matches_a_bindable_aggregate() { + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + let target = TargetSubDAG::new(&q); + assert!(ASAPStrategies::default_cost_model().matches(&target)); } - // ── cost-based ranking ─────────────────────────────────────────────── - #[test] - fn cost_sorted_orders_shared_sub_dag_candidates_by_cse_share_decision() { - // Many consumers of a cheap-to-recompute, cheap-to-maintain exact - // accumulator: cse_share_decision should prefer Share (see - // cost_model.rs's own `cse_share_decision_shares_when_recompute_dominates_maintenance`). - let mut roots = Vec::new(); - let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - for i in 0..20 { - roots.push((i, Rc::new((*shared).clone()))); - } - let space = search_workload(roots); - let group = space.candidates_for_target(&space.roots[0].1).unwrap(); - assert_eq!(group.consumer_count, 20); + fn does_not_match_a_multi_intent_or_having_aggregate() { + let strategy = ASAPStrategies::default_cost_model(); - let ranked = space.cost_sorted(&DefaultCostModel); - let ranked_group = ranked - .iter() - .find(|g| Rc::ptr_eq(g.target, &space.roots[0].1)) + let multi = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: ReductionTy::by(vec![2]), + measures: vec![AggIntent::Sum { col: None }, AggIntent::Avg { col: None }], + output_names: vec![], + filters: vec![], + having: None, + child: metric_scan(&["job"]), + })) .unwrap(); - assert!(matches!( - &ranked_group.candidates[0].replacement, - Replacement::SubDAG(rc) if Rc::ptr_eq(rc, &group.target) - )); - let rewrites: Vec<&ReplacementSubDAG> = ranked_group - .candidates - .iter() - .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if !n.contains_asap())) - .copied() - .collect(); - assert_eq!(rewrites.len(), 2); - let first_shares_target = match &rewrites[0].replacement { - Replacement::SubDAG(rc) => Rc::ptr_eq(rc, &group.target), - Replacement::ExactComposition(_) => false, - }; - assert!( - first_shares_target, - "with 20 cheap consumers, Share should rank first: {rewrites:?}" + let target = TargetSubDAG::new(&multi); + assert!(!strategy.matches(&target)); + assert!(strategy.replacements(&target).is_empty()); + + let having_q = crate::test_support::aggregate( + ReductionTy::by(vec![2]), + vec![default_quantile(0.99)], + vec![], + Some(asap_types::ir::Predicate(ScalarExpr::Literal( + asap_types::ir::scalar::ScalarValue::Boolean(true), + ))), + metric_scan(&["job"]), ); + let target = TargetSubDAG::new(&having_q); + assert!(!strategy.matches(&target)); + assert!(strategy.replacements(&target).is_empty()); } #[test] - fn cost_sorted_orders_sketch_candidates_by_rank_candidates() { - struct PreferDDSketch; - impl CostModel for PreferDDSketch { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { - let dd = v.remove(pos); - v.insert(0, dd); - } - v - } - } - - let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); - let ranked = space.cost_sorted(&PreferDDSketch); - let agg_group = ranked - .iter() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .unwrap(); - assert_eq!(agg_group.candidates.len(), 2); - let first_kind = match &agg_group.candidates[0].replacement { - Replacement::SubDAG(node) => sketch_kind_of(node), - Replacement::ExactComposition(_) => None, - }; - assert_eq!(first_kind, Some(SketchAlgorithm::DDSketch)); + fn does_not_match_a_non_aggregate_node() { + let scan = metric_scan(&["job"]); + let target = TargetSubDAG::new(&scan); + assert!(!ASAPStrategies::default_cost_model().matches(&target)); + assert!(ASAPStrategies::default_cost_model() + .replacements(&target) + .is_empty()); } #[test] - fn grouping_cost_cannot_resurrect_unprovable_hydra_candidates() { - struct EstimatedSubpopulations(usize); + fn approximate_quantile_enumerates_every_summary_candidate() { + // Quantile's candidate list is [Kll, DDSketch] (summary_candidates) — + // every entry must come back as its own bound summary candidate, + // not just Kll (the CostModel-ranked head realizations_for_intent commits to). + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + let target = TargetSubDAG::new(&q); + let replacements = ASAPStrategies::default_cost_model().replacements(&target); + assert_eq!( + replacements.len(), + 2, + "expected 2 candidates, got {replacements:?}" + ); - impl CostModel for EstimatedSubpopulations { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } + let kinds: Vec = replacements + .iter() + .map(|r| match &r.replacement { + Replacement::SubDAG(node) => summary_family_algorithm(node), + Replacement::ExactComposition(_) => { + panic!("expected a Summary replacement") + } + }) + .collect(); + assert!(kinds.contains(&SketchAlgorithm::Kll), "{kinds:?}"); + assert!(kinds.contains(&SketchAlgorithm::DDSketch), "{kinds:?}"); + assert!( + replacements.iter().all(|r| !r.rationale.is_empty()), + "every candidate must carry a rationale" + ); + } - fn estimated_subpopulation_count(&self, _target: &OperatorNode) -> Option { - Some(self.0) - } - } + #[test] + fn cardinality_epsilon_delta_keeps_unknown_accuracy_candidates() { + let q = agg(vec![2], default_cardinality(), metric_scan(&["job"])); + let target = TargetSubDAG::new(&q); + let replacements = ASAPStrategies::default_cost_model().replacements(&target); + let kinds: Vec = replacements + .iter() + .map(|r| match &r.replacement { + Replacement::SubDAG(node) => summary_family_algorithm(node), + Replacement::ExactComposition(_) => { + panic!("expected a Summary replacement") + } + }) + .collect(); + assert_eq!( + kinds, + vec![ + SketchAlgorithm::Hll, + SketchAlgorithm::Theta, + SketchAlgorithm::Kmv, + SketchAlgorithm::UnivMon, + ] + ); - fn first_grouping(estimated_count: usize) -> GroupingStrategy { - let model = EstimatedSubpopulations(estimated_count); - let intent = AggIntent::Count { + let q = agg( + vec![2], + AggIntent::Cardinality { + cols: vec![], accuracy: AccuracyTarget::EpsilonDelta { epsilon: 0.01, delta: 0.01, }, - }; - let root = agg(vec![2, 3], intent, metric_scan(&["tenant_id", "endpoint"])); - let strategies = default_strategies_with(&model); - let space = search_workload_with(vec![("tenant_endpoint_count", root)], &strategies); - let ranked = space.cost_sorted(&model); - let aggregate = ranked - .iter() - .find(|group| matches!(group.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .expect("aggregate group"); - let Replacement::SubDAG(node) = &aggregate.candidates[0].replacement else { - panic!("grouping candidate must be a summary") - }; - summary_grouping(node) - .expect("bound summary grouping") - .clone() - } - - assert_eq!( - first_grouping(10_000), - GroupingStrategy::PerSubpopulationInstance + }, + metric_scan(&["job"]), ); + let kinds: Vec<_> = ASAPStrategies::default_cost_model() + .replacements(&TargetSubDAG::new(&q)) + .iter() + .map(|r| match &r.replacement { + Replacement::SubDAG(node) => summary_family_algorithm(node), + Replacement::ExactComposition(_) => { + panic!("expected a Summary replacement") + } + }) + .collect(); assert_eq!( - first_grouping(10), - GroupingStrategy::PerSubpopulationInstance + kinds, + vec![ + SketchAlgorithm::Hll, + SketchAlgorithm::Theta, + SketchAlgorithm::Kmv, + SketchAlgorithm::UnivMon, + ] ); } - /// [`RankedTargetSubDAGCandidates::costs`] is a per-candidate annotation, aligned - /// index-for-index with `candidates` — each entry must equal what - /// calling [`CostModel::estimate_cost`] directly on that same candidate - /// and target produces, not some other (or stale) number. #[test] - fn cost_sorted_pairs_each_candidate_with_its_own_estimate_cost() { - let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); - let ranked = space.cost_sorted(&DefaultCostModel); - let agg_group = ranked - .iter() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .unwrap(); - assert_eq!( - agg_group.costs.len(), - agg_group.candidates.len(), - "costs must be aligned 1:1 with candidates" - ); - assert!(!agg_group.costs.is_empty()); - - let target = TargetSubDAG::with_consumer_count(agg_group.target, agg_group.consumer_count); - for (candidate, &cost) in agg_group.candidates.iter().zip(&agg_group.costs) { - assert_eq!( - cost, - DefaultCostModel.estimate_cost(candidate, &target), - "RankedTargetSubDAGCandidates::costs must match calling CostModel::estimate_cost directly \ - for the same candidate/target" - ); - } + fn exact_accuracy_target_yields_exactly_one_pass_through_candidate() { + // Exact quantile has no sketch candidate at all — realizations_for_intent + // produces PassThrough, the only option, so exactly one candidate. + let intent = AggIntent::Quantile { + col: None, + q: 0.99, + accuracy: AccuracyTarget::Exact, + }; + let q = agg(vec![2], intent, metric_scan(&["job"])); + let target = TargetSubDAG::new(&q); + let replacements = ASAPStrategies::default_cost_model().replacements(&target); + assert_eq!(replacements.len(), 1, "{replacements:?}"); + assert!(matches!( + &replacements[0].replacement, + Replacement::SubDAG(node) if !node.contains_asap() + )); + assert!(replacements[0].rationale.contains("only realization")); } - // ── global_selection (issue #271) ─────────────────────────────────── - - /// A `CostModel` with a constant, `sub-DAG`-independent recompute cost - /// and shared-maintenance cost, chosen (40 recompute-per-use, 100 - /// maintenance) so that a `SharedSubDAGStrategy` group's - /// `cse_share_decision` flips exactly between a consumer count of 2 - /// (recompute total 80, below maintenance: `RecomputeIndependently`) - /// and a consumer count of 3 (recompute total 120, above - /// maintenance: `Share`) — the precise threshold - /// `effective_consumer_count_corrects_a_nested_groups_share_decision` - /// needs to cross. - struct ConstantCseCost; - impl CostModel for ConstantCseCost { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } + #[test] + fn exact_mergeable_intent_yields_exactly_one_accumulator_candidate() { + let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let target = TargetSubDAG::new(&q); + let replacements = ASAPStrategies::default_cost_model().replacements(&target); + assert_eq!(replacements.len(), 1, "{replacements:?}"); + assert!(matches!( + &replacements[0].replacement, + Replacement::SubDAG(node) if matches!( + node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { .. }) + ) + )); + } + /// A custom `CostModel` doesn't change *which* candidates are enumerated + /// (still every `summary_candidates` entry) — only which one + /// `realizations_for_intent` itself would prefer first, and how each + /// candidate's own params are sized. + struct PreferDDSketch; + impl CostModel for PreferDDSketch { fn rank_candidates( &self, _intent: &AggIntent, candidates: &[SketchAlgorithm], ) -> Vec { - candidates.to_vec() - } - fn cse_recompute_cost(&self, _candidate: &CseCandidate) -> Cost { - Cost(40.0) - } - fn cse_shared_maintenance_cost(&self, _candidate: &CseCandidate) -> Cost { - Cost(100.0) + let mut v = candidates.to_vec(); + if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { + let dd = v.remove(pos); + v.insert(0, dd); + } + v } } - /// A costed logical choice must not panic when an explicitly allowed CSE - /// choice has no numeric cost. #[test] - fn costed_logical_candidate_beats_uncosted_legacy_cse_choice() { - struct MixedCost; - impl CostModel for MixedCost { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } + fn custom_cost_model_still_enumerates_every_candidate_not_just_its_own_pick() { + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + let target = TargetSubDAG::new(&q); + let custom = PreferDDSketch; + let replacements = ASAPStrategies::new(&custom).replacements(&target); + let kinds: Vec = replacements + .iter() + .map(|r| match &r.replacement { + Replacement::SubDAG(node) => summary_family_algorithm(node), + Replacement::ExactComposition(_) => { + panic!("expected a Summary replacement") + } + }) + .collect(); + assert!(kinds.contains(&SketchAlgorithm::Kll)); + assert!(kinds.contains(&SketchAlgorithm::DDSketch)); + assert_eq!(kinds.len(), 2); + } - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } + /// Constructing the outer target's candidates never leaks its algorithm + /// choice into the nested aggregate. Existing approximate composition + /// remains governed by the accuracy model, independently of #171's exact + /// value-operation candidates. + #[test] + fn enumerating_the_targets_candidates_does_not_leak_into_a_nested_aggregate() { + // outer: quantile(0.99, ...) over inner: quantile(0.5, m) — both + // Quantile, so both share the [Kll, DDSketch] candidate list. + // + // Rank-over-rank has no registered rule in `DefaultAccuracyModel` + // (issue #172 — see `approximate_over_approximate_is_rejected_by_default`), + // so this test injects `RankAdditiveModel` to admit the composition + // and keep exercising the per-node enumeration property it is about. + let inner = agg(vec![2], default_quantile(0.5), metric_scan(&["job"])); + let outer = agg(vec![], default_quantile(0.99), inner); + let target = TargetSubDAG::new(&outer); + let replacements = ASAPStrategies::new_with_planning_inputs( + &DefaultCostModel, + &RankAdditiveModel, + &EqualSplitAllocator, + ) + .replacements(&target); - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - _target: &TargetSubDAG<'_>, - ) -> Option { - (!is_cse_candidate(candidate)).then_some(Cost(1.0)) + assert_eq!(replacements.len(), 2, "{replacements:?}"); + assert!(replacements.iter().all(|candidate| { + matches!(&candidate.replacement, Replacement::SubDAG(n) if n.contains_asap()) + })); + // The inner target is still independently enumerated and ranked — + // a custom cost model that prefers DDSketch for it is honored, and + // nothing about the outer target's choice reaches it. + let space = search_workload_with( + vec![("q", Rc::clone(&outer))], + &default_strategies_with(&PreferDDSketchViaCostModel), + ); + let Some(NonASAPOp::Aggregate { child, .. }) = space.roots[0].1.non_asap() else { + unreachable!() + }; + let inner_group = space + .candidates_for_target(child) + .expect("inner quantile is a target"); + let inner_kinds: Vec = inner_group + .candidates + .iter() + .filter_map(|c| match &c.replacement { + Replacement::SubDAG(node) => sketch_kind_of(node), + _ => None, + }) + .collect(); + assert_eq!( + inner_kinds, + vec![SketchAlgorithm::DDSketch, SketchAlgorithm::Kll], + "the nested inner aggregate keeps its own cost-model-ranked candidates" + ); + } + + /// The `FieldDataType`'s committed `SketchAlgorithm`, from the top + /// `SummaryAgg` reachable under a (possibly `SummaryEstimate`-wrapped) + /// bound root. + fn summary_family_algorithm(node: &OperatorNode) -> SketchAlgorithm { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + summary_family_algorithm(summary_input) } + Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) => match family { + asap_types::ir::schema::FieldDataType::Sketch(kind, _) => kind.algorithm().clone(), + other => panic!("expected a Sketch family, got {other:?}"), + }, + other => panic!("expected SummaryAgg/SummaryEstimate, got {other:?}"), } + } - let aggregate = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("left", Rc::clone(&aggregate)), ("right", aggregate)]); - let root = &space.roots[0].1; - assert!(cse_candidate_pair(space.candidates_for_target(root).unwrap()).is_some()); - let selected = space.global_selection(&MixedCost); - let chosen = selected.for_target(root).unwrap().chosen.unwrap(); - assert!(!is_cse_candidate(chosen)); + // ── SharedSubDAGStrategy ──────────────────────────────────────────── + + #[test] + fn does_not_match_a_single_consumer_target() { + let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let target = TargetSubDAG::new(&q); + assert_eq!(target.consumer_count, 1); + assert!(!SharedSubDAGStrategy.matches(&target)); + assert!(SharedSubDAGStrategy.replacements(&target).is_empty()); } #[test] - fn global_selection_matches_cost_sorted_for_a_non_interacting_workload() { - // No nested sharing at all — global_selection's effective_consumer_count - // must equal the group's own raw consumer_count, and its `chosen` - // candidate must be cost_sorted's top pick, for both the sketch - // group and its child Scan. - let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); + fn two_or_more_consumers_yields_the_share_vs_independent_pair() { + let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let target = TargetSubDAG::with_consumer_count(&q, 2); + assert!(SharedSubDAGStrategy.matches(&target)); - let ranked = space.cost_sorted(&DefaultCostModel); - let selected = space.global_selection(&DefaultCostModel); - assert_eq!(ranked.len(), selected.target_selections().count()); + let replacements = SharedSubDAGStrategy.replacements(&target); + assert_eq!(replacements.len(), 2, "{replacements:?}"); - for ranked_group in &ranked { - let selected_group = selected.for_target(ranked_group.target).unwrap(); - assert_eq!( - selected_group.effective_consumer_count, ranked_group.consumer_count, - "no ancestor is ever RecomputeIndependently here, so effective must equal raw" - ); - assert_eq!( - selected_group.chosen.map(|c| &c.rationale), - ranked_group.candidates.first().map(|c| &c.rationale), - "with no cross-group interaction, global_selection's pick must match \ - cost_sorted's top-ranked candidate" - ); - } + let shared = match &replacements[0].replacement { + Replacement::SubDAG(rc) => rc, + other => panic!("expected a Rewrite replacement, got {other:?}"), + }; + assert!( + Rc::ptr_eq(shared, &q), + "the 'build once and share' candidate must be the same Rc as the target" + ); + assert!(replacements[0].rationale.contains("build once and share")); + + let independent = match &replacements[1].replacement { + Replacement::SubDAG(rc) => rc, + other => panic!("expected a Rewrite replacement, got {other:?}"), + }; + assert!( + !Rc::ptr_eq(independent, &q), + "the 'build independently' candidate must be a distinct Rc from the target" + ); + assert_eq!( + **independent, *q, + "the 'build independently' candidate must still be structurally identical" + ); + assert!(replacements[1].rationale.contains("build independently")); } #[test] - fn global_selection_leaves_an_unmatched_group_as_none() { - // A bare Scan: no registered strategy has an opinion on it, so it - // gets a group with an empty candidate list (see TargetSubDAGCandidates's own - // doc) — global_selection must not invent a candidate for it. - let root = metric_scan(&["job"]); - let space = search_workload(vec![("q", root)]); - let selected = space.global_selection(&DefaultCostModel); - let scan_group = selected - .target_selections() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Scan { .. }))) - .unwrap(); - assert!(scan_group.chosen.is_none()); - assert_eq!(scan_group.effective_consumer_count, 1); + fn three_consumers_are_reported_verbatim_in_both_rationales() { + let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let target = TargetSubDAG::with_consumer_count(&q, 3); + let replacements = SharedSubDAGStrategy.replacements(&target); + assert!(replacements[0].rationale.contains('3')); + assert!(replacements[1].rationale.contains('3')); } - #[test] - fn global_selection_falls_back_to_local_ranking_for_sketch_family_groups() { - // ASAPStrategies groups have no cross-group-aware cost hook - // (rank_candidates takes no consumer_count) — global_selection must - // still return cost_sorted's own top pick for them (documented in - // the module docs' "Whole-plan (cross-group) selection" section), - // not silently drop the candidate or fall back to discovery order. - struct PreferDDSketch; - impl CostModel for PreferDDSketch { - fn allow_uncosted_legacy_selection(&self) -> bool { - true + /// Builds realistic multi-consumer `TargetSubDAG`s the same way this + /// module's own [`discover_targets`]/`walk` does: dedup by `Rc::as_ptr`, + /// walking only the relational-skeleton operator children + /// `asap_types::ir::cse::share_common_sub_dags` itself scopes to, + /// so a shared node nested below another shared node is only ever + /// counted at the highest (maximal) point sharing starts. Test-only: + /// this module deliberately does not ship a workload-wide discovery + /// pass of its own (see the module docs' "Non-goals"). + fn count_consumers(roots: &[Rc]) -> HashMap<*const OperatorNode, usize> { + fn walk(node: &Rc, counts: &mut HashMap<*const OperatorNode, usize>) { + let ptr = Rc::as_ptr(node); + let already_visited = counts.contains_key(&ptr); + *counts.entry(ptr).or_insert(0) += 1; + if !already_visited { + walk_children(node, counts); } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { - let dd = v.remove(pos); - v.insert(0, dd); + } + fn walk_children(node: &OperatorNode, counts: &mut HashMap<*const OperatorNode, usize>) { + if let Some(NonASAPOp::Concat { children, .. }) = node.non_asap() { + for c in children { + walk_children(c, counts); } - v + return; + } + for child in node.children() { + walk(child, counts); } } - let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); - let selected = space.global_selection(&PreferDDSketch); - let agg_group = selected - .target_selections() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .unwrap(); - let kind = match &agg_group.chosen.unwrap().replacement { - Replacement::SubDAG(node) => sketch_kind_of(node), - Replacement::ExactComposition(_) => None, - }; - assert_eq!(kind, Some(SketchAlgorithm::DDSketch)); - - struct Uncosted; - impl CostModel for Uncosted { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } + let mut counts = HashMap::new(); + for root in roots { + walk(root, &mut counts); } - assert!(space - .global_selection(&Uncosted) - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_none()); + counts } #[test] - fn mixed_rewrite_group_keeps_and_selects_its_explicit_cse_pair() { - let target = metric_scan(&["job"]); - let mut group = TargetSubDAGCandidates::new(Rc::clone(&target), 2); - group.candidates = vec![ - ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG(Rc::clone(&target)), - provenance: ReplacementProvenance::CseShare, - rationale: "share".into(), - }, - ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG(Rc::new(target.as_ref().clone())), - provenance: ReplacementProvenance::CseRecompute, - rationale: "recompute".into(), - }, - ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG( - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( - NonASAPOp::PromqlVectorFromScalar(ScalarExpr::EvalTimestamp), - )) - .unwrap(), - ), - provenance: ReplacementProvenance::LogicalRewrite, - rationale: "different rewrite strategy".into(), - }, - ]; + fn realistic_cse_output_produces_a_two_consumer_target() { + // Two workload roots that `share_common_sub_dags` collapses onto one + // Rc (mirrors `explanation`'s and `cse`'s own fixtures): a grouped + // Sum aggregate over the same scan, built independently at each root. + let a = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let b = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let shared = asap_types::ir::cse::share_common_sub_dags(vec![("a", a), ("b", b)]); + let [(_, ra), (_, rb)] = shared.as_slice() else { + panic!("expected 2 roots"); + }; + assert!(Rc::ptr_eq(ra, rb), "fixture sanity: the two roots merged"); + + let roots: Vec> = shared.into_iter().map(|(_, rc)| rc).collect(); + let counts = count_consumers(&roots); + let count = counts[&Rc::as_ptr(&roots[0])]; + assert_eq!(count, 2); - assert!(cse_candidate_pair(&group).is_some()); - let ranked = rank_group(&group, &ConstantCseCost); - assert_eq!( - ranked - .iter() - .map(|c| c.rationale.as_str()) - .collect::>(), - vec!["recompute", "different rewrite strategy", "share"], - "the preferred CSE choice must be ranked without losing the unrelated rewrite" - ); - let chosen = pick_shared_sub_dag_candidate( - &group, - decide_with_effective_count(&group, 2, &ConstantCseCost).unwrap(), - ) - .unwrap(); - assert_eq!(chosen.provenance, ReplacementProvenance::CseRecompute); + let target = TargetSubDAG::with_consumer_count(&roots[0], count); + assert!(SharedSubDAGStrategy.matches(&target)); + assert_eq!(SharedSubDAGStrategy.replacements(&target).len(), 2); } - #[test] - fn effective_consumer_count_corrects_a_nested_groups_share_decision() { - // The interaction issue #271 describes: an outer shared sub-DAG `a` - // (referenced by 2 roots, so consumer_count == 2) wraps an inner - // shared sub-DAG `c` (referenced once through `a`'s own child edge, - // plus once more directly by a third, separate root — so `c`'s own - // *raw* structural consumer_count is also 2, independent of `a`). - // - // root1 ─┐ - // ├─▶ a = Filter(child = c) ─▶ c = Dedup(job) - // root2 ─┘ - // root3 ───────────────────────────▶ c (same shared Rc) - // - // `a` and `c` are both non-`Aggregate` nodes (`Filter`/`Dedup`) so - // neither is bindable — each group is a *clean* two-candidate - // SharedSubDAGStrategy share-vs-recompute pair, with no - // ASAPStrategies `Summary` candidate mixed in to complicate - // ranking (see `shared_aggregate_across_two_roots_gets_both_strategies_candidates` - // for what a *mixed*-shape group looks like — deliberately avoided - // here to isolate the SharedSubDAGStrategy-only interaction). - // - // Under ConstantCseCost, consumer_count == 2 loses to maintenance - // (2 * 40 = 80 < 100 ⇒ RecomputeIndependently); consumer_count == 3 wins - // (3 * 40 = 120 > 100 ⇒ Share). `cost_sorted` only ever sees `c`'s raw - // count (2) and picks RecomputeIndependently for it — the WRONG - // answer once `a` itself is accounted for: `a`'s own decision is - // also RecomputeIndependently (same 80-vs-100 threshold), so `a` - // actually runs twice, and each run recomputes `c` once more — - // `c`'s *true* effective count is 2 (via `a`) + 1 (via root3) = 3, - // which flips its own decision to Share. Only global_selection, - // which folds `a`'s decision into `c`'s effective_consumer_count - // before deciding `c`, gets this right. - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; + // ── search_workload / CandidateLogicalASAPDAGs / TargetSubDAGCandidates (merged from search.rs) ── + // + // Reuses this test module's own `metric_scan`/`agg` fixture helpers + // above (identical to `search.rs`'s own copies, which are dropped here + // to avoid a duplicate-definition collision now that both test modules + // share one file) and `count_consumers` above (which mirrors + // `discover_targets`' own real, non-test traversal for these fixtures). - let c = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["job"]), - })) - .unwrap() - }; - let a = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), - child: c(), - })) - .unwrap() + // ── discovery + MEMO shape ─────────────────────────────────────────── + + #[test] + fn single_bindable_aggregate_keeps_unprovable_hydra_candidates() { + let intent = AggIntent::Count { + accuracy: AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }, }; + let root = agg(vec![2], intent, metric_scan(&["job"])); + let space = search_workload(vec![("q", root)]); - let space = search_workload(vec![("root1", a()), ("root2", a()), ("root3", c())]); + // One group for the Aggregate, one for its Scan child. + assert_eq!(space.len(), 2); - // Fixture sanity: root1/root2 merged onto one shared `a`, and `c` - // (root1/root2's shared child, and root3 itself) merged onto one - // shared `c` with raw consumer_count 2, and both groups are clean - // (non-mixed) two-candidate SharedSubDAGStrategy pairs. - assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); - let a_rc = &space.roots[0].1; - let Some(NonASAPOp::Filter { child: c_via_a, .. }) = a_rc.non_asap() else { - panic!("expected root1/root2 to still be a Filter"); - }; - assert!(Rc::ptr_eq(c_via_a, &space.roots[2].1)); - let a_group = space.candidates_for_target(a_rc).unwrap(); - let c_group = space.candidates_for_target(c_via_a).unwrap(); - assert_eq!( - a_group.consumer_count, 2, - "fixture sanity: a has 2 consumers" - ); - assert_eq!( - c_group.consumer_count, 2, - "fixture sanity: c has 2 raw consumers (via a's child edge, and via root3)" - ); + let agg_group = space + .target_subdag_candidates() + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) + .expect("an Aggregate group must be discovered"); + assert_eq!(agg_group.consumer_count, 1); assert_eq!( - a_group.candidates.len(), - 2, - "fixture sanity: a is a clean Rewrite pair" + agg_group.candidates.len(), + 6, + "Hydra candidates with unknown evidence remain available: {:?}", + agg_group.candidates ); + assert!(agg_group + .candidates + .iter() + .all(|c| matches!(&c.replacement, Replacement::SubDAG(n) if n.contains_asap()))); assert_eq!( - c_group.candidates.len(), + agg_group + .candidates + .iter() + .filter(|candidate| { + let Replacement::SubDAG(node) = &candidate.replacement else { + return false; + }; + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = + &node.operator + else { + return false; + }; + matches!( + &summary_input.operator, + Operator::ASAP(ASAPOp::SummaryAgg { + grouping: GroupingStrategy::SharedMultiSubpopulation { .. }, + .. + }) + ) + }) + .count(), 2, - "fixture sanity: c is a clean Rewrite pair" - ); - - // The naive/local answer: cost_sorted ranks c using its raw count - // (2) alone and prefers RecomputeIndependently. - let ranked = space.cost_sorted(&ConstantCseCost); - let c_ranked = ranked - .iter() - .find(|g| Rc::ptr_eq(g.target, c_via_a)) - .unwrap(); - let c_top_shares = matches!( - &c_ranked.candidates[0].replacement, - Replacement::SubDAG(rc) if Rc::ptr_eq(rc, c_via_a) - ); - assert!( - !c_top_shares, - "cost_sorted, blind to a's own decision, must (wrongly) prefer \ - RecomputeIndependently for c using its raw consumer_count of 2" + "Hydra candidates remain visible with symbolic shared-grid error" ); - - // The corrected, cross-group-aware answer: global_selection folds - // a's own RecomputeIndependently choice into c's effective count - // (2 from a + 1 from root3 = 3) and flips to Share. - let selected = space.global_selection(&ConstantCseCost); - let a_selected = selected.for_target(a_rc).unwrap(); - let c_selected = selected.for_target(c_via_a).unwrap(); - assert_eq!( - a_selected.effective_consumer_count, 2, - "a has no interacting ancestor" - ); - let a_shares = matches!( - &a_selected.chosen.unwrap().replacement, - Replacement::SubDAG(rc) if Rc::ptr_eq(rc, a_rc) - ); - assert!( - !a_shares, - "fixture sanity: a itself must also choose RecomputeIndependently" + agg_group + .candidates + .iter() + .filter(|candidate| candidate.has_missing_accuracy_evidence()) + .count(), + 2 ); + let selected = space.global_selection(&DefaultCostModel); + assert!(!selected + .for_target(&space.roots[0].1) + .unwrap() + .chosen + .is_some_and(ReplacementSubDAG::has_missing_accuracy_evidence)); - assert_eq!( - c_selected.effective_consumer_count, 3, - "c's effective count must be 2 (a, itself recomputed twice) + 1 (root3)" - ); - let c_shares = matches!( - &c_selected.chosen.unwrap().replacement, - Replacement::SubDAG(rc) if Rc::ptr_eq(rc, c_via_a) - ); + let scan_group = space + .target_subdag_candidates() + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Scan { .. }))) + .expect("a Scan group must be discovered"); + assert_eq!(scan_group.consumer_count, 1); assert!( - c_shares, - "global_selection must flip c to Share once a's own recomputation is accounted for" + scan_group.candidates.is_empty(), + "no strategy matches a bare Scan" ); } #[test] - fn complete_plan_costs_reject_unbound_cse_arms() { - struct CompletePlanCost; - impl CostModel for CompletePlanCost { - fn candidate_cost_covers_complete_plan(&self) -> bool { - true - } - - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - _target: &TargetSubDAG<'_>, - ) -> Option { - assert!(!is_cse_candidate(candidate)); - None - } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn cse_share_decision(&self, _candidate: &CseCandidate) -> ShareDecision { - ShareDecision::RecomputeIndependently - } - } - - let shared = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["job"]), - })) + fn cardinality_group_keeps_all_four_candidates() { + let root = agg(vec![2], default_cardinality(), metric_scan(&["job"])); + let space = search_workload(vec![("q", root)]); + let agg_group = space + .target_subdag_candidates() + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .unwrap(); - let space = search_workload(vec![ - ("left", Rc::clone(&shared)), - ("right", Rc::clone(&shared)), - ]); - let planned = &space.roots[0].1; - - let selected = space.global_selection(&CompletePlanCost); - assert!(selected.for_target(planned).unwrap().chosen.is_none()); - } - - #[test] - fn effective_repetition_materializes_a_cse_choice_for_a_single_edge_child() { - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; + assert_eq!(agg_group.candidates.len(), 4); + assert!(agg_group.candidates.iter().any(|candidate| matches!( + &candidate.replacement, + Replacement::SubDAG(node) if node.guarantee.is_none() + && candidate.has_missing_accuracy_evidence() + ))); - let c = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["job"]), - })) + let root = agg(vec![2], default_cardinality(), metric_scan(&["job"])); + let targeted = search_workload_with_targets( + vec![( + "q", + root, + Some(AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }), + )], + &default_strategies(), + &DefaultAccuracyModel, + ); + let target = &targeted.roots[0].1; + assert!(targeted + .candidates_for_target(target) .unwrap() - }; - let a = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), - child: c(), - })) + .candidates + .iter() + .any(|candidate| matches!( + &candidate.replacement, + Replacement::SubDAG(node) if node.guarantee.is_none() + && candidate.has_missing_accuracy_evidence() + ))); + assert!(!targeted + .global_selection(&DefaultCostModel) + .for_target(target) .unwrap() - }; - let space = search_workload(vec![("root1", a()), ("root2", a())]); - let a_rc = &space.roots[0].1; - let Some(NonASAPOp::Filter { child: c_rc, .. }) = a_rc.non_asap() else { - panic!("expected Filter root"); - }; - - assert_eq!(space.candidates_for_target(c_rc).unwrap().consumer_count, 1); - assert!(cse_candidate_pair(space.candidates_for_target(c_rc).unwrap()).is_some()); + .chosen + .is_some_and(ReplacementSubDAG::has_missing_accuracy_evidence)); - let selected = space.global_selection(&ConstantCseCost); - let child = selected.for_target(c_rc).unwrap(); - assert_eq!(child.effective_consumer_count, 2); - assert!(child.chosen.is_some()); + let exact_target = search_workload_with_targets( + vec![( + "q", + agg(vec![2], default_cardinality(), metric_scan(&["job"])), + Some(AccuracyTarget::Exact), + )], + &default_strategies(), + &DefaultAccuracyModel, + ); + assert!(exact_target + .candidates_for_target(&exact_target.roots[0].1) + .unwrap() + .candidates + .iter() + .all(|candidate| !candidate.has_missing_accuracy_evidence())); } #[test] - fn shared_ancestor_keeps_a_single_use_cse_descendant_selected() { - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; - - struct AlwaysShare; - impl CostModel for AlwaysShare { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } + fn shared_aggregate_across_two_roots_gets_both_strategies_candidates() { + // Two independently-built, structurally identical Sum aggregates: + // share_common_sub_dags (run inside search_workload) collapses them + // onto one Rc with consumer_count 2, so this single group should + // carry ASAPStrategies's one ExactAggregate candidate *and* + // SharedSubDAGStrategy's share-vs-recompute pair. + let a = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let b = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let space = search_workload(vec![("a", a), ("b", b)]); - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } + // roots[0] and roots[1] must have merged onto the same Rc. + assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); - fn cse_share_decision(&self, _candidate: &CseCandidate) -> ShareDecision { - ShareDecision::Share - } - } + let group = space.candidates_for_target(&space.roots[0].1).unwrap(); + assert_eq!(group.consumer_count, 2); + assert_eq!( + group.candidates.len(), + 3, + "1 ExactAggregate Summary + 2 Rewrite (share/recompute): {:?}", + group.candidates + ); - let child = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["job"]), - })) - .unwrap() - }; - let parent = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), - child: child(), - })) - .unwrap() - }; - let space = search_workload(vec![("root1", parent()), ("root2", parent())]); - let parent_rc = &space.roots[0].1; - let Some(NonASAPOp::Filter { - child: child_rc, .. - }) = parent_rc.non_asap() - else { - panic!("expected Filter root"); - }; + // Old `Replacement::Summary` ↔ a `Subtree` containing an ASAP node; + // old `Replacement::Rewrite` ↔ a pure pre-ASAP `Subtree`. + let summary_count = group + .candidates + .iter() + .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if n.contains_asap())) + .count(); + let rewrite_count = group + .candidates + .iter() + .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if !n.contains_asap())) + .count(); + assert_eq!(summary_count, 1); + assert_eq!(rewrite_count, 2); - let selected = space.global_selection(&AlwaysShare); - assert_eq!( - selected - .for_target(parent_rc) - .unwrap() - .effective_consumer_count, - 2 + // The two Rewrite candidates must NOT have collapsed into one + // (the "false-positive dedup" failure mode `is_duplicate_rewrite` + // exists to prevent). + let one_is_the_target = group.candidates.iter().any( + |c| matches!(&c.replacement, Replacement::SubDAG(rc) if Rc::ptr_eq(rc, &group.target)), ); - let child_selection = selected.for_target(child_rc).unwrap(); - assert_eq!(child_selection.effective_consumer_count, 1); - assert_eq!( - child_selection.chosen.map(|candidate| candidate.provenance), - Some(ReplacementProvenance::CseShare), - "a descendant collapsed to one execution still needs a selected plan" + let one_is_not = group.candidates.iter().any( + |c| matches!(&c.replacement, Replacement::SubDAG(rc) if !Rc::ptr_eq(rc, &group.target)), ); + assert!(one_is_the_target && one_is_not); } #[test] - fn global_selection_propagates_uses_through_the_selected_rewrite() { + fn nested_shared_sub_dag_below_an_unshared_parent_is_still_discovered() { + // A shared grouped Aggregate nested under two *different*, + // unshared Filter parents — real consumer_count must come from + // walking the whole DAG, not just root-level pointer identity + // (a naive whole-root-only consumer-count pass would miss this; + // this module's discover_targets must not). use asap_types::ir::scalar::ScalarValue; use asap_types::ir::Predicate; - struct ReplaceFilterChild; - impl ReplacementStrategy for ReplaceFilterChild { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - matches!(target.root.non_asap(), Some(NonASAPOp::Filter { .. })) - } + let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + // Different predicates so the two Filter *parents* stay distinct + // (don't themselves merge under CSE) — only their shared `child` + // should collapse onto one `Rc`. + let root_a = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(1))), + child: Rc::clone(&shared), + })) + .unwrap(); + let root_b = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(2))), + child: Rc::clone(&shared), + })) + .unwrap(); - fn replacements(&self, _target: &TargetSubDAG<'_>) -> Vec { - vec![ReplacementSubDAG { - strategy: "ReplaceFilterChild", - replacement: Replacement::SubDAG( - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( - NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["replacement"]), - }, - )) - .unwrap(), - ), - provenance: ReplacementProvenance::LogicalRewrite, - rationale: "replace the Filter and its input".into(), - }] - } - } + let space = search_workload(vec![("a", root_a), ("b", root_b)]); + assert_eq!( + space.len(), + 4, + "2 distinct Filters + 1 shared Aggregate + 1 shared Scan" + ); - let original_child = metric_scan(&["original"]); - let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), - child: Rc::clone(&original_child), - })) - .unwrap(); - let strategies: Vec> = vec![Box::new(ReplaceFilterChild)]; - let space = search_workload_with(vec![("q", root)], &strategies); - let root = &space.roots[0].1; - let selected = space.global_selection(&DefaultCostModel); - let Replacement::SubDAG(rewrite) = &selected - .for_target(root) - .unwrap() - .chosen - .unwrap() - .replacement - else { - panic!("expected logical rewrite"); - }; - let Some(NonASAPOp::Dedup { - child: replacement_child, + // `share_common_sub_dags` re-clones+re-interns anything that already + // had more than one owner going in (see `cse.rs`'s own doc on + // `intern_child`'s clone-fallback path) — so the post-CSE shared + // node is a *fresh* Rc, structurally equal to (but not the same + // pointer as) the pre-search `shared` variable. Recover it from the + // post-CSE root's own `child` field instead of the stale `shared` + // handle. + let Some(NonASAPOp::Filter { + child: post_cse_shared_a, .. - }) = rewrite.non_asap() + }) = space.roots[0].1.non_asap() else { - panic!("expected Dedup rewrite"); + panic!("expected a Filter root"); }; let Some(NonASAPOp::Filter { - child: original_child, + child: post_cse_shared_b, .. - }) = root.non_asap() + }) = space.roots[1].1.non_asap() else { - panic!("expected Filter root"); + panic!("expected a Filter root"); }; - - assert_eq!( - selected - .for_target(original_child) - .unwrap() - .effective_consumer_count, - 0 + assert!( + Rc::ptr_eq(post_cse_shared_a, post_cse_shared_b), + "fixture sanity: the two Filters' children must still merge" ); - assert_eq!( - selected - .for_target(replacement_child) - .unwrap() - .effective_consumer_count, - 1 + let post_cse_shared = post_cse_shared_a; + let group = space + .candidates_for_target(post_cse_shared) + .expect("shared node must be a discovered target"); + assert_eq!(group.consumer_count, 2); + assert!( + SharedSubDAGStrategy.matches(&TargetSubDAG::with_consumer_count( + post_cse_shared, + group.consumer_count + )) ); } - // A cheap but physically infeasible candidate must not be selected. - #[test] - fn explicit_summary_infeasibility_prevents_selection() { - struct Unsupported; - impl CostModel for Unsupported { - fn rank_candidates( - &self, - _: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn candidate_cost(&self, _: &ReplacementSubDAG, _: &TargetSubDAG<'_>) -> Option { - Some(Cost(1.0)) - } - fn summary_support_evidence(&self, _: &OperatorNode) -> Option { - Some(false) - } - } - let root = lower_promql("sum_over_time(a[1m])", AccuracyTarget::Exact); - let space = search_workload(vec![("q", root)]); - let selected = space.global_selection(&Unsupported); - assert!(selected - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_none()); - } + // ── dedup ──────────────────────────────────────────────────────────── - // Composable temporal/grouped Sum must be executable as one producer. #[test] - fn grouped_temporal_sum_has_one_summary_producer_candidate() { - let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); - let candidates = - ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); - assert!(candidates - .iter() - .any(|candidate| matches!(&candidate.replacement, - Replacement::SubDAG(node) if matches!(&node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) - if !child.contains_asap())))); - struct PreferComposed; - impl CostModel for PreferComposed { - fn rank_candidates( - &self, - _: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - _: &TargetSubDAG<'_>, - ) -> Option { - Some(Cost( - if matches!(&candidate.replacement, - Replacement::SubDAG(node) if matches!(&node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) - if !child.contains_asap())) - { - 1.0 - } else { - 100.0 - }, - )) + fn add_candidate_rejects_a_true_rewrite_duplicate() { + // SharedSubDAGStrategy's `Replacement::Rewrite` candidates are + // real `OperatorNode` values with `PartialEq`, so `add_candidate` can + // (and must) actually reject a genuine repeat — unlike the + // `Replacement::Summary` case (see the test below). + let root = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let mut group = TargetSubDAGCandidates::new(Rc::clone(&root), 2); + let target = TargetSubDAG::with_consumer_count(&root, 2); + let mut inserted = 0; + for candidate in SharedSubDAGStrategy.replacements(&target) { + if group.add_candidate(candidate) { + inserted += 1; } } - let space = search_workload(vec![("q", root.clone())]); - let selected = space.global_selection(&PreferComposed); - let node = selected.assemble_target(&space.roots[0].1).unwrap(); - assert!(matches!(&node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) - if !child.contains_asap())); - } + assert_eq!(inserted, 2, "share + recompute-independently candidates"); - // Mixed candidate ranking must honor explicit costs, not legacy estimates. - #[test] - fn mixed_candidate_ranking_uses_explicit_candidate_costs() { - struct ExplicitCosts; - impl CostModel for ExplicitCosts { - fn rank_candidates( - &self, - _: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - _: &TargetSubDAG<'_>, - ) -> Option { - Some(Cost( - if candidate.provenance == ReplacementProvenance::LogicalRewrite { - 1.0 - } else { - 100.0 - }, - )) + // Re-adding the identical candidate list must add nothing new: the + // "share" candidate is literally the same Rc as before, and the + // "recompute independently" candidate is a fresh Rc but + // structurally identical value, both already covered by + // `is_duplicate_rewrite`. + let mut re_inserted = 0; + for candidate in SharedSubDAGStrategy.replacements(&target) { + if group.add_candidate(candidate) { + re_inserted += 1; } } - let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); - let space = search_workload(vec![("q", root)]); - let selection = space.global_selection(&ExplicitCosts); - let selected = selection - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .unwrap(); - assert_eq!(selected.provenance, ReplacementProvenance::LogicalRewrite); + assert_eq!( + re_inserted, 0, + "re-proposing the same Rewrite candidates must not grow the group" + ); + assert_eq!(group.candidates.len(), 2); } #[test] - fn global_selection_compares_a_logical_rewrite_with_the_cse_choice() { - struct PreferLogicalRewrite; - - impl CostModel for PreferLogicalRewrite { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn estimate_cost( - &self, - candidate: &ReplacementSubDAG, - _target: &TargetSubDAG<'_>, - ) -> f64 { - match candidate.provenance { - ReplacementProvenance::LogicalRewrite => 0.0, - _ => 100.0, - } - } + fn add_candidate_never_dedups_summary_candidates() { + // Documented, deliberate consequence of `is_duplicate_summary` + // refusing value equality on `f64`-bearing summaries: re-proposing + // the same `Replacement::Summary` candidates DOES grow the group — + // this module refuses to guess at an equality check it can't back + // with a real `PartialEq`. `search_workload_with` never actually + // does this in practice (every target is asked exactly once — see the + // module docs' "Termination" section), so this test exists to pin + // the documented behavior, not to endorse calling `replacements` + // twice for the same target. + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + let mut group = TargetSubDAGCandidates::new(Rc::clone(&root), 1); + let strategy = ASAPStrategies::default_cost_model(); + let target = TargetSubDAG::new(&root); + for candidate in strategy.replacements(&target) { + group.add_candidate(candidate); } + assert_eq!(group.candidates.len(), 2); - let a = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); - let b = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); - let space = search_workload(vec![("a", a), ("b", b)]); - let root = &space.roots[0].1; - let selected = space.global_selection(&PreferLogicalRewrite); - + for candidate in strategy.replacements(&target) { + group.add_candidate(candidate); + } assert_eq!( - selected - .for_target(root) - .and_then(|group| group.chosen) - .map(|candidate| candidate.provenance), - Some(ReplacementProvenance::LogicalRewrite) + group.candidates.len(), + 4, + "Summary candidates are never deduped by this module — see is_duplicate_summary" ); } #[test] - fn topological_order_puts_a_later_discovered_parent_before_its_child() { - // Mirrors nested_shared_sub-DAG_below_an_unshared_parent_is_still_discovered's - // diamond fixture: discover_targets's own `order` visits root_b (a - // parent of `shared`) *after* `shared` itself, because `shared` was - // already fully walked via root_a first. A naive "process - // discover_targets's own order" DP would see root_b's child edge - // after already processing `shared` — topological_order must not - // make that mistake. - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; - - let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let root_a = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(1))), - child: shared.clone(), - })) - .unwrap(); - let root_b = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(2))), - child: shared, - })) - .unwrap(); - let roots = vec![("a", root_a), ("b", root_b)]; - - let mut order = Vec::new(); - let mut nodes = HashMap::new(); - let mut counts = HashMap::new(); - discover_targets(&roots, &mut order, &mut nodes, &mut counts); - let groups = order - .iter() - .map(|ptr| { - ( - *ptr, - TargetSubDAGCandidates::new(Rc::clone(&nodes[ptr]), counts[ptr]), - ) - }) - .collect(); - let space = CandidateLogicalASAPDAGs { - roots, - groups, - order: order.clone(), - composition_plans: Vec::new(), - }; - let dag = reference_dag(&space); - - // Discovery-order sanity: root_b comes after the shared child in - // discover_targets's own order (the exact non-topological case this - // test exists to cover). - let Some(NonASAPOp::Filter { - child: shared_via_a, - .. - }) = space.roots[0].1.non_asap() - else { - panic!("expected a Filter root"); - }; - let shared_ptr = Rc::as_ptr(shared_via_a); - let root_b_ptr = Rc::as_ptr(&space.roots[1].1); - let shared_discovery_pos = order.iter().position(|p| *p == shared_ptr).unwrap(); - let root_b_discovery_pos = order.iter().position(|p| *p == root_b_ptr).unwrap(); - assert!( - root_b_discovery_pos > shared_discovery_pos, - "fixture sanity: discover_targets's own order must NOT already be topological here" + fn is_duplicate_rewrite_never_merges_share_with_recompute() { + let target = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let share = Rc::clone(&target); + let recompute = Rc::new((*target).clone()); + assert!(!Rc::ptr_eq(&share, &recompute)); + assert_eq!( + *share, *recompute, + "fixture sanity: same value, different Rc" ); + assert!(!is_duplicate_rewrite(&share, &recompute, &target)); + assert!(!is_duplicate_rewrite(&recompute, &share, &target)); + } - let topo = topological_order(&order, &dag); - let shared_topo_pos = topo.iter().position(|p| *p == shared_ptr).unwrap(); - let root_b_topo_pos = topo.iter().position(|p| *p == root_b_ptr).unwrap(); - assert!( - root_b_topo_pos < shared_topo_pos, - "topological_order must place root_b (a parent of the shared node) before it, \ - unlike discover_targets's own discovery order" - ); + #[test] + fn is_duplicate_rewrite_catches_a_real_repeat() { + let target = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + let first_recompute = Rc::new((*target).clone()); + let second_recompute = Rc::new((*target).clone()); + assert!(!Rc::ptr_eq(&first_recompute, &second_recompute)); + assert!(is_duplicate_rewrite( + &first_recompute, + &second_recompute, + &target + )); } // ── termination ────────────────────────────────────────────────────── @@ -11165,52 +8319,6 @@ mod tests { } } - // A value projection cannot consume an opaque exact accumulator edge. - #[test] - fn residual_projection_finalizes_selected_exact_state() { - let inner = agg(vec![], AggIntent::Sum { col: None }, metric_scan(&[])); - let root = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { - cols: vec![ProjectItem { - expr: ScalarExpr::Column(0), - alias: Some("result".into()), - }], - qualifier: None, - child: inner.clone(), - })) - .unwrap(); - let space = search_workload_with_targets( - vec![("q", root.clone(), Some(AccuracyTarget::Exact))], - &default_strategies(), - &DefaultAccuracyModel, - ); - let selected = space.global_selection(&DefaultCostModel); - // CSE re-interns the workload, so the space's root/child `Rc`s are not - // the fixture's. Assembly only assembles children that are discovered - // targets, so seed the memo under the space's own child pointer. - let root = Rc::clone(&space.roots[0].1); - let Some(NonASAPOp::Project { child: inner, .. }) = root.non_asap() else { - unreachable!() - }; - assert!(space.candidates_for_target(inner).is_some()); - selected - .assembled_nodes - .borrow_mut() - .insert(Rc::as_ptr(inner), realize(inner.as_ref()).unwrap()); - let node = selected.assemble_target(&root).unwrap(); - let Operator::NonASAP(NonASAPOp::Project { child, .. }) = &node.operator else { - panic!("expected Project"); - }; - assert!(matches!( - child.operator, - Operator::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) - )); - assert!(child - .schema - .fields - .iter() - .all(|field| matches!(field.dtype, FieldDataType::Plain(_)))); - } // A numeric group key must not be mistaken for the ranked aggregate score. #[test] fn ranking_uses_aggregate_output_position_not_first_numeric_column() { From d8b82269e2ed31c7f4a1d6d52c1289280d0738c8 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:42:52 +0000 Subject: [PATCH 2/4] refactor(planner): Stage 1 candidate generation no longer takes a cost model `realizations_for_intent` lists sketch candidates in `summary_candidates` order and sizes them with the analytical `accuracy::estimators::size_params`, which is what `CostModel::size_params` defaulted to. Extension intents stay PassThrough, the previous default. - `CandidatePlanningInputs` drops its `cost` field; `realize_child` drops its cost argument. - `ASAPStrategies` and `HydraGroupingStrategy` are built with `default()` or `new_with_planning_inputs*` without a cost model; `ASAPStrategies::new`, the `default_cost_model` constructors and `default_strategies_with` are removed, and `default_strategies_with_evidence` takes only the evidence. - `ExactCompositionStrategy` is a unit struct and no longer checks runtime support; selection already requires positive support evidence. - `CostModel` loses `size_params`, `realize_extension` and `evaluation_extension`. Tests that exercised only the removed hooks are deleted; call sites across the workspace use the new constructors. Co-Authored-By: Claude Opus 5.5 --- .../src/accuracy/reconciliation.rs | 8 +- crates/asap-aware-mapping/src/cost_model.rs | 166 +---- .../asap-aware-mapping/src/empirical_cost.rs | 28 - .../src/exact_composition.rs | 196 ++---- crates/asap-aware-mapping/src/explanation.rs | 43 +- crates/asap-aware-mapping/src/grouping.rs | 85 +-- crates/asap-aware-mapping/src/lib.rs | 10 +- .../src/physical_plan_cost_model.rs | 18 +- .../src/plan_selection/candidate_selection.rs | 22 +- crates/asap-aware-mapping/src/replacement.rs | 622 ++++-------------- .../tests/deployment_computation.rs | 2 +- .../tests/planspace_series_identity_heap.rs | 15 +- .../tests/weighted_topk_binding.rs | 5 - crates/devtools/src/bin/dag_export.rs | 2 +- crates/devtools/src/bin/show_post_asap_ir.rs | 4 +- .../frontend-promql/tests/count_planning.rs | 8 +- .../observability/metrics_observability.rs | 2 +- .../tests/observability/promql_corpus.rs | 2 +- .../tests/univmon_candidates.rs | 5 +- .../tests/exact_composition.rs | 85 +-- .../tests/precompute_raw_samples.rs | 2 +- .../tests/promql_numeric_regressions.rs | 4 +- .../tests/promql_to_post_asap.rs | 21 +- .../tests/sql_to_post_asap.rs | 2 +- crates/planner/tests/summary_sharing.rs | 9 +- crates/types/src/ir/schema/state_type.rs | 2 +- 26 files changed, 310 insertions(+), 1058 deletions(-) diff --git a/crates/asap-aware-mapping/src/accuracy/reconciliation.rs b/crates/asap-aware-mapping/src/accuracy/reconciliation.rs index 6f7817ae..8c850c0f 100644 --- a/crates/asap-aware-mapping/src/accuracy/reconciliation.rs +++ b/crates/asap-aware-mapping/src/accuracy/reconciliation.rs @@ -63,13 +63,11 @@ //! //! [`crate::replacement::accuracy_budget`] resolves *every* `AccuracyTarget` //! (`Epsilon`/`EpsilonDelta`) to the literal `(eps, delta)` pair -//! `realizations_for_intent`'s `sketch_realizations` feeds into -//! `CostModel::size_params` — the same numbers `default_size_params`' +//! `realizations_for_intent`'s `sketch_realizations` feeds into the +//! analytical sizing — the same numbers `default_size_params`' //! `kll_k` / `cms_width` / `cms_depth` / `hll_precision` / `kmv_k` / DDSketch's //! own `alpha == eps` invert. Every shipped formula is monotonic in its -//! input, and custom [`crate::cost_model::CostModel::size_params`] -//! implementations are contractually required to return parameters that -//! satisfy their supplied budget. So a sketch satisfying budget `(e1, d1)` +//! input. So a sketch satisfying budget `(e1, d1)` //! also satisfies any //! requirement `(e2, d2)` with `e1 <= e2 && d1 <= d2` — [`dominates`]'s exact //! check — regardless of which of `Epsilon`/`EpsilonDelta` either side is diff --git a/crates/asap-aware-mapping/src/cost_model.rs b/crates/asap-aware-mapping/src/cost_model.rs index dfb39a4a..0ea26b45 100644 --- a/crates/asap-aware-mapping/src/cost_model.rs +++ b/crates/asap-aware-mapping/src/cost_model.rs @@ -6,31 +6,20 @@ //! needs knowledge this crate doesn't have and shouldn't acquire: the crate //! doc's layering invariant is that `asap-plan` depends only on [`asap_ir`], //! never on a runtime or a deployment model. What it *can* own is the -//! interface every deployment's cost model plugs into, so [`replacement`]'s -//! summary selection has exactly one extension point instead of forcing -//! each downstream (ASAPCollector + ASAPQuery-backend, ASAPFusion, …) to -//! fork `replacement::realizations_for_intent`. +//! interface every deployment's cost model plugs into, so selection over the +//! candidates [`replacement`] generates has exactly one extension point. +//! Candidate generation itself does not consult a cost model. //! -//! This trait is scoped to the approximate-**sketch** family specifically -//! ([`CostModel::rank_candidates`]/[`size_params`](CostModel::size_params) -//! take/return [`SketchAlgorithm`]/[`SketchParams`]) — `asap_sketch` also has +//! [`CostModel::rank_candidates`] is scoped to the approximate-**sketch** +//! family specifically (it takes [`SketchAlgorithm`]s) — `asap_sketch` also has //! sibling families for sampling-based, wavelet-transform, and fitted //! statistical-model summaries //! ([`asap_types::ir::schema::SamplingKind`]/…/[`asap_types::ir::schema::StatModelKind`]), //! each with its own `(Kind, Params)` pair, deliberately *not* folded into -//! this trait: no core `AggIntent` picks one of those families today (only -//! [`CostModel::realize_extension`] can, for a deployment-specific -//! `AggIntent::Extension`), so there is no ranking/sizing decision for this -//! trait to own yet. Should a family other than `Sketch` ever need its own -//! `rank_candidates`/`size_params`, it gets its own trait methods rather -//! than overloading these ones across incompatible `Kind`/`Params` types. -//! -//! Every entry point that doesn't take an explicit `&dyn CostModel` -//! ([`ASAPStrategies::default_cost_model`](crate::replacement::ASAPStrategies::default_cost_model), -//! [`search_workload`](crate::replacement::search_workload)) runs against -//! [`DefaultCostModel`], so a deployment that never plugs in its own cost -//! model keeps today's static-preference-order behavior exactly, byte for -//! byte. +//! this trait: no core `AggIntent` picks one of those families today, so +//! there is no ranking decision for this trait to own yet. Should a family +//! other than `Sketch` ever need its own ranking, it gets its own trait +//! method rather than overloading this one across incompatible `Kind` types. //! //! ## CSE sharing (issue #237, #223 stage 4) //! @@ -50,9 +39,8 @@ use std::rc::Rc; use crate::exact_composition::ExactOperation; use asap_types::ir::operator::agg_intent::AggIntent; -use asap_types::ir::scalar::ColumnRef; use asap_types::ir::schema::{ - FieldDataType, GroupingStrategy, HydraParams, SketchAlgorithm, SketchParams, SketchStatistic, + FieldDataType, GroupingStrategy, HydraParams, SketchAlgorithm, SketchParams, }; use asap_types::ir::{ASAPOp, Operator, OperatorNode}; @@ -62,7 +50,7 @@ use crate::recurrence::{ RecurrenceProfile, }; use crate::replacement::{ - realize_child, Realization, Replacement, ReplacementProvenance, ReplacementSubDAG, TargetSubDAG, + realize_child, Replacement, ReplacementProvenance, ReplacementSubDAG, TargetSubDAG, }; // ── Recurring-cost vocabulary for mixed exact/summary plans (issue #171) ── @@ -433,39 +421,6 @@ pub trait CostModel { candidates: &[SketchAlgorithm], ) -> Vec; - /// Size [`SketchParams`] for `kind` (one of the candidates - /// [`rank_candidates`](Self::rank_candidates) put first) under the - /// resolved `(eps, delta)` accuracy budget. - /// - /// Splitting sizing out from candidate selection lets a deployment own - /// its own parameter-sizing math (e.g. an empirically-tuned table, or - /// discrete rungs required by a downstream catalog) without forking - /// `replacement::realizations_for_intent` — the same "one extension - /// point" rationale as `rank_candidates`, one level deeper. Default: - /// [`replacement::default_size_params`], `asap-plan`'s built-in formulas - /// (unchanged) — a deployment that only needs to reorder candidates, - /// not resize them, can leave this method unimplemented. - /// - /// # Contract - /// - /// The returned parameters MUST make `kind` satisfy the supplied - /// `(eps, delta)` accuracy budget. This is a semantic requirement, not a - /// requirement that parameter fields themselves be numerically monotonic: - /// catalog rungs and empirically tuned layouts are allowed, but returning - /// a configuration that misses the requested budget makes the resulting - /// plan invalid. Accuracy reconciliation relies on this same contract; - /// any implementation satisfying a tighter budget necessarily satisfies - /// a looser budget for the identical aggregate query. - fn size_params( - &self, - kind: SketchAlgorithm, - intent: &AggIntent, - eps: f64, - delta: f64, - ) -> SketchParams { - crate::replacement::default_size_params(kind, intent, eps, delta) - } - /// Estimated number of distinct subpopulations produced by `target`'s /// grouping keys. `None` means the deployment has no cardinality estimate; /// grouping alternatives remain legal but keep their discovery order. @@ -497,39 +452,6 @@ pub trait CostModel { Some(Cost(units)) } - /// Realize an `AggIntent::Extension { ext_kind, payload }` — a - /// deployment-specific intent shape core has no realization opinion - /// for (issue #131). `replacement::realizations_for_intent` consults this - /// for every `Extension` node instead of hardcoding `PassThrough` - /// (issue #150). Default: `PassThrough` — preserves today's behavior - /// for every deployment that doesn't override this, exactly like - /// `size_params`'s default-delegates pattern above. - fn realize_extension(&self, _ext_kind: &str, _payload: &serde_json::Value) -> Realization { - Realization::PassThrough - } - - /// Build the `SummaryEstimate` evaluation for an `Extension` intent this - /// same `CostModel` realized as `Realization::Sketch` via - /// [`realize_extension`](Self::realize_extension). Only ever called - /// when `realize_extension` returned `Sketch` for the same - /// `(ext_kind, payload)` — `replacement::evaluation` has no other way to build a - /// `SketchStatistic` for a shape core doesn't know. A deployment that - /// overrides `realize_extension` to return `Sketch` for some - /// `ext_kind` MUST also override this for that same `ext_kind`, or - /// this default panics loudly (rather than silently misinterpreting - /// `payload`) the first time that intent is actually read out. - fn evaluation_extension( - &self, - ext_kind: &str, - _payload: &serde_json::Value, - _col: &ColumnRef, - ) -> SketchStatistic { - unimplemented!( - "CostModel::realize_extension returned Sketch for ext_kind={ext_kind:?} but \ - evaluation_extension wasn't overridden to match" - ) - } - /// Estimate the one-time cost of recomputing `candidate.sub-DAG` /// independently at a single use site. Default: /// [`default_cse_recompute_cost`] (a structural-size proxy). See @@ -867,7 +789,7 @@ pub(crate) fn validated_candidate_ranking( } /// The default cost model: preserves [`summary_candidates`]'s built-in static -/// order and [`replacement::default_size_params`]'s built-in sizing unchanged. +/// order. /// /// [`summary_candidates`]: crate::replacement::summary_candidates pub struct DefaultCostModel; @@ -934,7 +856,7 @@ impl CostModel for DefaultCostModel { Replacement::SubDAG(rc) if candidate.provenance == ReplacementProvenance::AccuracyReconciliation => { - let Ok(sibling_bound) = realize_child(rc, self) else { + let Ok(sibling_bound) = realize_child(rc) else { return f64::NAN; }; let cse = CseCandidate { @@ -951,7 +873,7 @@ impl CostModel for DefaultCostModel { self.cse_shared_maintenance_cost(&cse).0 } Replacement::SubDAG(rc) => { - let Ok(bound) = realize_child(target.root, self) else { + let Ok(bound) = realize_child(target.root) else { return f64::NAN; }; let cse = CseCandidate { @@ -1055,66 +977,6 @@ mod tests { validated_candidate_ranking(&DuplicatesFirst, &intent, candidates); } - /// A deployment that only overrides `rank_candidates` keeps - /// `asap-plan`'s built-in sizing via the trait's default `size_params` - /// body — the split is opt-in per method, not all-or-nothing. - #[test] - fn size_params_default_body_matches_default_size_params() { - let intent = default_cardinality(); - assert_eq!( - AlwaysPreferLast.size_params(SketchAlgorithm::Hll, &intent, 0.01, 0.01), - crate::replacement::default_size_params(SketchAlgorithm::Hll, &intent, 0.01, 0.01), - ); - } - - /// A deployment CAN override `size_params` independently of - /// `rank_candidates` — e.g. to size against a catalog-constrained set - /// of discrete parameter rungs instead of `asap-plan`'s continuous - /// formulas. - struct DiscreteKllRungs; - - impl CostModel for DiscreteKllRungs { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn size_params( - &self, - kind: SketchAlgorithm, - intent: &AggIntent, - eps: f64, - delta: f64, - ) -> SketchParams { - match kind { - SketchAlgorithm::Kll => { - let k = if eps >= 0.01 { 200 } else { 2048 }; - SketchParams::Kll { k } - } - other => crate::replacement::default_size_params(other, intent, eps, delta), - } - } - } - - #[test] - fn custom_cost_model_can_override_sizing_independently_of_ranking() { - use asap_types::ir::operator::agg_intent::default_quantile; - - let intent = default_quantile(0.99); - assert_eq!( - DiscreteKllRungs.size_params(SketchAlgorithm::Kll, &intent, 0.001, 0.01), - SketchParams::Kll { k: 2048 }, - ); - // Untouched kinds still fall through to the default formula. - assert_eq!( - DiscreteKllRungs.size_params(SketchAlgorithm::Hll, &intent, 0.01, 0.01), - crate::replacement::default_size_params(SketchAlgorithm::Hll, &intent, 0.01, 0.01), - ); - } - // ── Recurring-cost formulas (issue #171) ───────────────────────────── fn known_inputs() -> ExactCompositionCostInputs { diff --git a/crates/asap-aware-mapping/src/empirical_cost.rs b/crates/asap-aware-mapping/src/empirical_cost.rs index 59d49feb..96822bcc 100644 --- a/crates/asap-aware-mapping/src/empirical_cost.rs +++ b/crates/asap-aware-mapping/src/empirical_cost.rs @@ -409,7 +409,6 @@ fn valid_params(algorithm: &SketchAlgorithm, params: &SketchParams) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::replacement::{realizations_for_intent, Realization}; use asap_types::types::AccuracyTarget; /// The documented synthetic wire-format example remains importable and @@ -561,33 +560,6 @@ mod tests { ) } - /// Real replacement generation follows measured update ranking while keeping - /// every candidate and the same formally sized parameter configurations. - #[test] - fn public_cost_model_changes_replacement_order_without_changing_guarantees() { - let (artifact, context, intent) = fixture(); - let model = - EmpiricalCostModel::new(EmpiricalEvidenceProvider::new(artifact, context).unwrap()); - let default = realizations_for_intent(&intent, &DefaultCostModel); - let measured = realizations_for_intent(&intent, &model); - assert_eq!(default.len(), measured.len()); - let Realization::Sketch(first_default) = &default[0] else { - panic!("expected sketch") - }; - let Realization::Sketch(first_measured) = &measured[0] else { - panic!("expected sketch") - }; - assert_eq!(first_default.algorithm(), &SketchAlgorithm::Cms); - assert_eq!(first_measured.algorithm(), &SketchAlgorithm::CountSketch); - for candidate in &measured { - assert!(default.contains(candidate)); - } - assert_eq!( - model.size_params(SketchAlgorithm::Cms, &intent, 0.001, 0.001), - DefaultCostModel.size_params(SketchAlgorithm::Cms, &intent, 0.001, 0.001) - ); - } - /// Missing, mismatched and expired evidence preserve the original ranking; /// a measurement from another configuration is never extrapolated. #[test] diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index dac7dd23..c2c9cbdc 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -42,11 +42,10 @@ //! transform: the target is a per-entity exact function with no //! accumulator form (its only implementation is `PassThrough`); //! - the exact operator consumes only `Plain` values in its data_state — checked -//! again, structurally, when the pair is composed; -//! - the plugged-in [`CostModel`] has not disproven the matching -//! [`ValueOperationCapabilities`](crate::cost_model::ValueOperationCapabilities). -//! Unknown support keeps the candidate visible; only explicit positive -//! support evidence permits global selection. +//! again, structurally, when the pair is composed. +//! +//! Runtime support is not checked here: selection admits a composition only +//! with explicit positive support evidence from its cost model. //! //! `avg` gets a read-time operation candidate *and* keeps //! [`crate::rewrite::AvgToSumOverCountStrategy`]'s rewrite in the same @@ -78,7 +77,6 @@ use asap_types::physical::execution_data_state::lift_plain; use asap_types::physical::ExactOperationSchemaError; use asap_types::types::AccuracyTarget; -use crate::cost_model::CostModel; use crate::replacement::{ bindable_intent, describe_intent, realizations_for_intent, Realization, RealizationError, Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, @@ -306,10 +304,7 @@ fn needs_evaluation(implementation: &Realization) -> bool { } /// The `(op, child)` of a read-time operation-shaped target, or `None`. -fn query_time_shape( - root: &OperatorNode, - cost_model: &dyn CostModel, -) -> Option<(ExactOperation, Rc, AggIntent)> { +fn query_time_shape(root: &OperatorNode) -> Option<(ExactOperation, Rc, AggIntent)> { let Some(NonASAPOp::Aggregate { reduction, measures, @@ -337,7 +332,7 @@ fn query_time_shape( return None; } let child_intent = bindable_intent(child)?; - if !realizations_for_intent(child_intent, cost_model) + if !realizations_for_intent(child_intent) .iter() .any(needs_evaluation) { @@ -362,7 +357,6 @@ fn query_time_shape( /// transform with no accumulator form — or `None`. fn ingestion_time_shape( root: &OperatorNode, - cost_model: &dyn CostModel, ) -> Option<(ExactOperation, Rc, AggIntent)> { let Some(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, @@ -387,7 +381,7 @@ fn ingestion_time_shape( // Exact accumulators (`Rate`/`Increase`) are already directly nestable // as `SummaryAgg(ExactAggregate)`; only a pass-through function needs // an explicit update-path node. - if realizations_for_intent(intent, cost_model) + if realizations_for_intent(intent) .iter() .any(|i| *i != Realization::PassThrough) { @@ -407,94 +401,62 @@ fn ingestion_time_shape( } /// Proposes [`Replacement::ExactComposition`] candidates — see the module -/// docs. Holds a [`CostModel`] only to ask it which mixed-execution shapes -/// the runtime advertises and which implementations the child has; it -/// never uses it to *rank* anything. -pub struct ExactCompositionStrategy<'a> { - cost_model: &'a dyn CostModel, -} - -static DEFAULT_COST_MODEL: crate::cost_model::DefaultCostModel = - crate::cost_model::DefaultCostModel; - -impl ExactCompositionStrategy<'static> { - /// A strategy consulting the built-in [`DefaultCostModel`](crate::cost_model::DefaultCostModel). - pub fn default_cost_model() -> Self { - Self { - cost_model: &DEFAULT_COST_MODEL, - } - } -} - -impl<'a> ExactCompositionStrategy<'a> { - pub fn new(cost_model: &'a dyn CostModel) -> Self { - Self { cost_model } - } +/// docs. +pub struct ExactCompositionStrategy; +impl ExactCompositionStrategy { fn candidates(&self, target: &TargetSubDAG<'_>) -> Vec { let schema = lift_plain(&target.root.schema); let mut out = Vec::new(); - if let Some((op, child, intent)) = query_time_shape(target.root, self.cost_model) { - if self - .cost_model - .value_operation_support_evidence(&op, OperationPlacement::Read) - != Some(false) - { - let child_desc = - describe_intent(bindable_intent(&child).expect("checked by query_time_shape")); - out.push(ReplacementSubDAG { - strategy: "ExactCompositionStrategy", - replacement: Replacement::ExactComposition(ExactComposition { - placement: OperationPlacement::Read, - op, - child_target: child, - schema: schema.clone(), - }), - provenance: ReplacementProvenance::ValueOperationAtQueryTime, - rationale: format!( - "{} is an exact fold whose input is the evaluation of {} — a maintained \ - accumulator cannot consume query-time values, so instead of keeping \ - the whole tree pre-ASAP this applies the fold as an \ - ExactRead over whichever summary evaluation global_selection \ - commits for the child target (asap_aware_mapping::exact_composition)", - describe_intent(&intent), - child_desc - ), - }); - } + if let Some((op, child, intent)) = query_time_shape(target.root) { + let child_desc = + describe_intent(bindable_intent(&child).expect("checked by query_time_shape")); + out.push(ReplacementSubDAG { + strategy: "ExactCompositionStrategy", + replacement: Replacement::ExactComposition(ExactComposition { + placement: OperationPlacement::Read, + op, + child_target: child, + schema: schema.clone(), + }), + provenance: ReplacementProvenance::ValueOperationAtQueryTime, + rationale: format!( + "{} is an exact fold whose input is the evaluation of {} — a maintained \ + accumulator cannot consume query-time values, so instead of keeping \ + the whole tree pre-ASAP this applies the fold as an \ + ExactRead over whichever summary evaluation global_selection \ + commits for the child target (asap_aware_mapping::exact_composition)", + describe_intent(&intent), + child_desc + ), + }); } - if let Some((op, child, intent)) = ingestion_time_shape(target.root, self.cost_model) { - if self - .cost_model - .value_operation_support_evidence(&op, OperationPlacement::Maintenance) - != Some(false) - { - out.push(ReplacementSubDAG { - strategy: "ExactCompositionStrategy", - replacement: Replacement::ExactComposition(ExactComposition { - placement: OperationPlacement::Maintenance, - op, - child_target: child, - schema, - }), - provenance: ReplacementProvenance::ValueOperationAtIngestionTime, - rationale: format!( - "{} is an exact per-entity function with no accumulator form; as an \ - explicit ExactMaintenance on the update path its output can feed a \ - maintained summary above it instead of being handed over as an opaque \ - raw kept sub_dag (asap_aware_mapping::exact_composition)", - describe_intent(&intent) - ), - }); - } + if let Some((op, child, intent)) = ingestion_time_shape(target.root) { + out.push(ReplacementSubDAG { + strategy: "ExactCompositionStrategy", + replacement: Replacement::ExactComposition(ExactComposition { + placement: OperationPlacement::Maintenance, + op, + child_target: child, + schema, + }), + provenance: ReplacementProvenance::ValueOperationAtIngestionTime, + rationale: format!( + "{} is an exact per-entity function with no accumulator form; as an \ + explicit ExactMaintenance on the update path its output can feed a \ + maintained summary above it instead of being handed over as an opaque \ + raw kept sub_dag (asap_aware_mapping::exact_composition)", + describe_intent(&intent) + ), + }); } out } } -impl ReplacementStrategy for ExactCompositionStrategy<'_> { +impl ReplacementStrategy for ExactCompositionStrategy { fn matches(&self, target: &TargetSubDAG<'_>) -> bool { !self.candidates(target).is_empty() } @@ -507,12 +469,11 @@ impl ReplacementStrategy for ExactCompositionStrategy<'_> { #[cfg(test)] mod tests { use super::*; - use crate::cost_model::{DefaultCostModel, ValueOperationCapabilities}; use crate::replacement::retain_exact; use crate::test_support::{agg, agg_per_entity as per_entity, metric_scan, timed}; use asap_types::ir::operator::agg_intent::default_quantile; use asap_types::ir::properties::ExecutionDataStateError; - use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; + use asap_types::ir::schema::FieldDataType; use asap_types::ir::ASAPOp; /// `max by (zone) (quantile by (zone, host) (m))`. @@ -529,7 +490,7 @@ mod tests { fn proposes_query_time_operation_for_max_over_quantile() { let root = max_over_quantile(); let target = TargetSubDAG::new(&root); - let strategy = ExactCompositionStrategy::default_cost_model(); + let strategy = ExactCompositionStrategy; assert!(strategy.matches(&target)); let candidates = strategy.replacements(&target); assert_eq!(candidates.len(), 1); @@ -560,12 +521,7 @@ mod tests { let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); let root = agg(vec![0], AggIntent::Avg { col: None }, inner); let target = TargetSubDAG::new(&root); - assert_eq!( - ExactCompositionStrategy::default_cost_model() - .replacements(&target) - .len(), - 1 - ); + assert_eq!(ExactCompositionStrategy.replacements(&target).len(), 1); // `avg` competes with AvgToSumOverCountStrategy in the same group. assert!(crate::rewrite::AvgToSumOverCountStrategy.matches(&target)); } @@ -574,7 +530,7 @@ mod tests { fn proposes_ingestion_time_operation_for_a_per_entity_pass_through_over_raw_input() { let root = per_entity(AggIntent::Deriv, metric_scan(&["zone"])); let target = TargetSubDAG::new(&root); - let candidates = ExactCompositionStrategy::default_cost_model().replacements(&target); + let candidates = ExactCompositionStrategy.replacements(&target); assert_eq!(candidates.len(), 1); assert_eq!( candidates[0].provenance, @@ -592,53 +548,27 @@ mod tests { metric_scan(&["zone", "host"]), ); let root = agg(vec![0], AggIntent::Sum { col: None }, inner); - assert!(!ExactCompositionStrategy::default_cost_model().matches(&TargetSubDAG::new(&root))); + assert!(!ExactCompositionStrategy.matches(&TargetSubDAG::new(&root))); // rate is an exact accumulator — directly nestable, no separate value operation. let rate = per_entity(AggIntent::Rate, metric_scan(&[])); - assert!(!ExactCompositionStrategy::default_cost_model().matches(&TargetSubDAG::new(&rate))); + assert!(!ExactCompositionStrategy.matches(&TargetSubDAG::new(&rate))); // A sketch-capable outer intent is not an exact fold. let inner = agg(vec![2], default_quantile(0.5), metric_scan(&["zone"])); let root = agg(vec![0], default_quantile(0.99), inner); - assert!(!ExactCompositionStrategy::default_cost_model().matches(&TargetSubDAG::new(&root))); - } - - struct NoMixedExecution; - impl CostModel for NoMixedExecution { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn value_operation_capabilities(&self) -> ValueOperationCapabilities { - ValueOperationCapabilities::NONE - } - } - - #[test] - fn a_runtime_without_the_capability_gets_no_candidate() { - let root = max_over_quantile(); - let target = TargetSubDAG::new(&root); - let strategy = ExactCompositionStrategy::new(&NoMixedExecution); - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - let deriv = per_entity(AggIntent::Deriv, metric_scan(&[])); - assert!(!strategy.matches(&TargetSubDAG::new(&deriv))); + assert!(!ExactCompositionStrategy.matches(&TargetSubDAG::new(&root))); } #[test] fn compose_rejects_a_maintained_state_child_for_a_query_time_operation() { let root = max_over_quantile(); let target = TargetSubDAG::new(&root); - let candidates = ExactCompositionStrategy::default_cost_model().replacements(&target); + let candidates = ExactCompositionStrategy.replacements(&target); let Replacement::ExactComposition(comp) = &candidates[0].replacement else { unreachable!() }; // A bare SummaryAgg (state, no evaluation) is not a legal read-time operation // input — the operator would be consuming sketch state. - let state_child = - crate::replacement::realize_child(&comp.child_target, &DefaultCostModel).unwrap(); + let state_child = crate::replacement::realize_child(&comp.child_target).unwrap(); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &state_child.operator else { panic!("expected the child to realize to a evaluation"); @@ -676,13 +606,11 @@ mod tests { fn compose_rejects_a_evaluation_child_for_a_ingestion_time_operation() { let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); let root = per_entity(AggIntent::Deriv, inner); - let candidates = - ExactCompositionStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let candidates = ExactCompositionStrategy.replacements(&TargetSubDAG::new(&root)); let Replacement::ExactComposition(comp) = &candidates[0].replacement else { unreachable!() }; - let evaluation = - crate::replacement::realize_child(&comp.child_target, &DefaultCostModel).unwrap(); + let evaluation = crate::replacement::realize_child(&comp.child_target).unwrap(); assert!(!comp.accepts_child(&evaluation)); assert!(matches!( comp.compose(evaluation), diff --git a/crates/asap-aware-mapping/src/explanation.rs b/crates/asap-aware-mapping/src/explanation.rs index 8bbe0a02..2f7f4178 100644 --- a/crates/asap-aware-mapping/src/explanation.rs +++ b/crates/asap-aware-mapping/src/explanation.rs @@ -102,8 +102,7 @@ //! produced. So this module ships no extension-point trait of its own: //! [`ReplacementStrategy`] already *is* that extension point, one layer //! down, and [`explain_replacements_with`]'s own `strategies` -//! parameter is where a caller plugs in a custom one (or a custom -//! `CostModel`, via [`crate::replacement::ASAPStrategies::new`]) — the identical spot +//! parameter is where a caller plugs in a custom one — the identical spot //! [`crate::replacement::search_workload_with`] itself exposes. //! //! ## Two guarantees the old traversal made, re-verified against the new one @@ -163,7 +162,7 @@ //! |---|---|---| //! | Semantic-equivalent rewriting (e.g. `avg` → `sum`/`count`) | [`AvgToSumOverCountStrategy`](crate::rewrite::AvgToSumOverCountStrategy) exists and is wired into `default_strategies()` (issue #253) — but still no `ExplanationKind` of its own below, since this table is about *direct* findings for a catalog entry, and this strategy's whole point is indirect: its `Replacement::SubDAG` rewrite candidate exposes `sum`/`count` as independently bindable discovered targets, which can then earn `CommonSubexpressionReuse` findings when the workload actually reuses them | A dedicated variant would need `findings_from_candidate_logical_asap_dags` to recognize a `LogicalRewrite`-provenance candidate as a finding in its own right, not just rely on what it exposes downstream | //! | Roll-ups (fine-to-coarse group-by reuse) | [`RollupStrategy`](crate::rollup::RollupStrategy), derived from workload siblings after CSE/target discovery (issue #254) | Any `Replacement::SubDAG` rewrite candidate that rolls a coarse aggregate up from a compatible finer aggregate | -//! | Wavelets/OMP | Params type exists (`WaveletKind`/`WaveletParams`), reachable only via a deployment `CostModel::realize_extension` (no core `AggIntent` dispatch picks it) | A `ReplacementStrategy` that inspects a deployment's own `CostModel`, once some intent shape actually maps to `Realization::Wavelet` | +//! | Wavelets/OMP | Params type exists (`WaveletKind`/`WaveletParams`), not reachable (no core `AggIntent` dispatch picks it) | A `ReplacementStrategy` for it, once some intent shape actually maps to `Realization::Wavelet` | //! | Sampling | Same story as Wavelets: `SamplingKind`/`SamplingParams` exist, unreachable from core dispatch | Same hook as Wavelets, for `Realization::Sample` | //! | Deep generative compression | No representation at all — no `Realization`/`FieldDataType` variant | Needs a new summary family added to `asap_types::ir::schema::state_type` first | //! | Approximation frameworks for windows | No representation — `TimeRange`/`PromqlSubquery` windows are always evaluated exactly | Would key off those node types once an approximate-window operator exists | @@ -271,10 +270,7 @@ pub fn explain_replacements( /// Like [`explain_replacements`], but searches with `strategies` /// instead of [`crate::replacement::default_strategies`] — the extension -/// point for a deployment-specific [`ReplacementStrategy`], or a custom -/// `CostModel` plugged into -/// [`crate::replacement::ASAPStrategies::new`] (e.g. via -/// [`crate::replacement::default_strategies_with`]). +/// point for a deployment-specific [`ReplacementStrategy`]. /// /// [`ReplacementStrategy`]: crate::replacement::ReplacementStrategy pub fn explain_replacements_with<'s, Id: Display>( @@ -814,37 +810,4 @@ mod tests { assert!(reuse[0].location.contains('a')); assert!(reuse[0].location.contains('b')); } - - // ── Custom strategy set / cost model plumbing ─────────────────────── - - struct AlwaysDDSketch; - impl crate::cost_model::CostModel for AlwaysDDSketch { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[asap_types::ir::schema::SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v - .iter() - .position(|k| *k == asap_types::ir::schema::SketchAlgorithm::DDSketch) - { - let dd = v.remove(pos); - v.insert(0, dd); - } - v - } - } - - #[test] - fn custom_cost_model_changes_the_reported_sketch_kind() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let custom_model = AlwaysDDSketch; - let strategies: Vec> = vec![Box::new( - crate::replacement::ASAPStrategies::new(&custom_model), - )]; - let findings = explain_replacements_with(vec![("q", q)], &strategies); - assert_eq!(findings.len(), 1); - assert!(findings[0].reason.to_lowercase().contains("ddsketch")); - } } diff --git a/crates/asap-aware-mapping/src/grouping.rs b/crates/asap-aware-mapping/src/grouping.rs index 1afd3113..a683130f 100644 --- a/crates/asap-aware-mapping/src/grouping.rs +++ b/crates/asap-aware-mapping/src/grouping.rs @@ -86,7 +86,6 @@ use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode}; use crate::accuracy::{ AccuracyBudgetAllocator, AccuracyEvidenceProvider, AccuracyModel, PropagationStats, }; -use crate::cost_model::{CostModel, DefaultCostModel}; use crate::replacement::{ accuracy_target, bindable_intent, construct_summary_with, describe_intent, realizations_for_intent, summary_candidates, CandidatePlanningInputs, Proposals, Realization, @@ -112,11 +111,6 @@ pub fn has_subpopulations(reduction: &Reduction) -> bool { } } -/// A single static instance so [`HydraGroupingStrategy::default_cost_model`] -/// can hand out a `&'static dyn CostModel` without heap-allocating one — same -/// pattern [`crate::replacement::ASAPStrategies`] uses. -static DEFAULT_COST_MODEL: DefaultCostModel = DefaultCostModel; - /// Wraps the `GroupingStrategy` axis (issue #256) as a /// [`ReplacementStrategy`]: for a target [`ASAPStrategies`](crate::replacement::ASAPStrategies) /// already has an opinion on, offers an additional @@ -133,37 +127,24 @@ pub struct HydraGroupingStrategy<'a> { planning_inputs: CandidatePlanningInputs<'a>, } -impl HydraGroupingStrategy<'static> { - /// A strategy that ranks/binds via the built-in [`DefaultCostModel`] — - /// what a deployment gets with no custom cost model plugged in, the same - /// default [`crate::replacement::ASAPStrategies::default_cost_model`] - /// offers. - pub fn default_cost_model() -> Self { +impl Default for HydraGroupingStrategy<'static> { + /// The built-in accuracy models, the same default + /// [`crate::replacement::ASAPStrategies`] uses. + fn default() -> Self { Self { - planning_inputs: CandidatePlanningInputs::with_default_accuracy(&DEFAULT_COST_MODEL), + planning_inputs: CandidatePlanningInputs::with_default_accuracy(), } } } impl<'a> HydraGroupingStrategy<'a> { - /// A strategy that ranks/binds via `cost_model` instead of the built-in - /// static preference order — the same customization point - /// [`crate::replacement::ASAPStrategies::new`] already offers. - pub fn new(cost_model: &'a dyn CostModel) -> Self { - Self { - planning_inputs: CandidatePlanningInputs::with_default_accuracy(cost_model), - } - } - pub fn new_with_planning_inputs_and_evidence( - cost_model: &'a dyn CostModel, accuracy_model: &'a dyn AccuracyModel, allocator: &'a dyn AccuracyBudgetAllocator, evidence: &'a dyn AccuracyEvidenceProvider, ) -> Self { Self { planning_inputs: CandidatePlanningInputs { - cost: cost_model, accuracy: accuracy_model, allocator, evidence, @@ -222,7 +203,7 @@ impl<'a> HydraGroupingStrategy<'a> { hydra_kind: HydraKind, rejected: &mut Vec, ) -> Option { - let realization = realizations_for_intent(intent, self.planning_inputs.cost) + let realization = realizations_for_intent(intent) .into_iter() .find(|candidate| { matches!(candidate, Realization::Sketch(kind) if *kind.algorithm() == sketch_kind) @@ -326,7 +307,7 @@ impl ReplacementStrategy for HydraGroupingStrategy<'_> { let Some(intent) = bindable_intent(target.root) else { return false; }; - realizations_for_intent(intent, self.planning_inputs.cost) + realizations_for_intent(intent) .into_iter() .any(|realization| { matches!(realization, @@ -575,7 +556,7 @@ mod tests { }; let q = agg(vec![2], intent, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - assert!(HydraGroupingStrategy::default_cost_model().matches(&target)); + assert!(HydraGroupingStrategy::default().matches(&target)); } #[test] @@ -583,7 +564,7 @@ mod tests { // Global reduction — no subpopulation concept, no Hydra alternative. let q = agg(vec![], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let strategy = HydraGroupingStrategy::default_cost_model(); + let strategy = HydraGroupingStrategy::default(); assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); } @@ -592,7 +573,7 @@ mod tests { fn does_not_match_a_per_entity_aggregate() { let q = agg_per_entity(default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let strategy = HydraGroupingStrategy::default_cost_model(); + let strategy = HydraGroupingStrategy::default(); assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); } @@ -601,14 +582,14 @@ mod tests { fn does_not_match_a_non_aggregate_node() { let scan = metric_scan(&["job"]); let target = TargetSubDAG::new(&scan); - assert!(!HydraGroupingStrategy::default_cost_model().matches(&target)); + assert!(!HydraGroupingStrategy::default().matches(&target)); } #[test] fn quantile_has_no_hydra_candidate_without_a_modeled_error_bound() { let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = HydraGroupingStrategy::default_cost_model().replacements(&target); + let replacements = HydraGroupingStrategy::default().replacements(&target); assert!(replacements.is_empty(), "{replacements:?}"); } @@ -622,7 +603,7 @@ mod tests { }; let q = agg(vec![2], intent, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = HydraGroupingStrategy::default_cost_model().replacements(&target); + let replacements = HydraGroupingStrategy::default().replacements(&target); assert_eq!(replacements.len(), 2, "{replacements:?}"); assert!(replacements.iter().all(|candidate| matches!( &candidate.replacement, @@ -660,7 +641,6 @@ mod tests { }; let q = agg(vec![2], intent, metric_scan(&["job"])); let strategy = HydraGroupingStrategy::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &ZeroSharedGridEvidence, @@ -700,7 +680,6 @@ mod tests { }; let q = agg(vec![2], intent, metric_scan(&["job"])); let strategy = HydraGroupingStrategy::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &InvalidEvidence, @@ -749,7 +728,6 @@ mod tests { metric_scan(&["job"]), ); let strategy = HydraGroupingStrategy::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &ExcessiveCollision, @@ -770,7 +748,7 @@ mod tests { // an empty result, same conservatism as every other strategy here). let q = agg(vec![2], default_cardinality(), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let strategy = HydraGroupingStrategy::default_cost_model(); + let strategy = HydraGroupingStrategy::default(); assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); } @@ -786,7 +764,7 @@ mod tests { }; let q = agg(vec![2], intent, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let strategy = HydraGroupingStrategy::default_cost_model(); + let strategy = HydraGroupingStrategy::default(); assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); } @@ -797,14 +775,14 @@ mod tests { // (summary_candidates only covers approximate-capable intents). let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let strategy = HydraGroupingStrategy::default_cost_model(); + let strategy = HydraGroupingStrategy::default(); assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); } #[test] fn does_not_match_a_multi_intent_or_having_aggregate() { - let strategy = HydraGroupingStrategy::default_cost_model(); + let strategy = HydraGroupingStrategy::default(); let multi = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { @@ -820,33 +798,4 @@ mod tests { assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); } - - /// A custom `CostModel` doesn't change *which* candidate is offered — - /// only which sketch candidate `realizations_for_intent` itself would - /// have ranked first, and how that candidate's own params are sized — - /// same guarantee `ASAPStrategies` makes for its own candidates. - struct PreferDDSketch; - impl CostModel for PreferDDSketch { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { - let dd = v.remove(pos); - v.insert(0, dd); - } - v - } - } - - #[test] - fn custom_cost_model_cannot_enable_unproven_hydra_kll() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - let custom = PreferDDSketch; - let replacements = HydraGroupingStrategy::new(&custom).replacements(&target); - assert!(replacements.is_empty(), "{replacements:?}"); - } } diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 46cb1103..aaeec08b 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -205,11 +205,11 @@ pub use recurrence::{ UpdateRate, }; pub use replacement::{ - default_strategies, default_strategies_with, is_logical_rewrite, search_workload, - search_workload_with, search_workload_with_targets, summary_candidates, ASAPStrategies, - CandidateLogicalASAPDAGs, Matcher, Proposals, Realization, RealizationError, RejectedCandidate, - Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, - SharedSubDAGStrategy, TargetSubDAG, TargetSubDAGCandidates, MAX_SEARCH_ITERATIONS, + default_strategies, is_logical_rewrite, search_workload, search_workload_with, + search_workload_with_targets, summary_candidates, ASAPStrategies, CandidateLogicalASAPDAGs, + Matcher, Proposals, Realization, RealizationError, RejectedCandidate, Replacement, + ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, SharedSubDAGStrategy, + TargetSubDAG, TargetSubDAGCandidates, MAX_SEARCH_ITERATIONS, }; pub use rewrite::{AvgToSumOverCountStrategy, SemanticEquivalentRewriteStrategy}; pub use topk_reuse::TopKLimitReuseStrategy; diff --git a/crates/asap-aware-mapping/src/physical_plan_cost_model.rs b/crates/asap-aware-mapping/src/physical_plan_cost_model.rs index 85a4f850..b787267d 100644 --- a/crates/asap-aware-mapping/src/physical_plan_cost_model.rs +++ b/crates/asap-aware-mapping/src/physical_plan_cost_model.rs @@ -691,8 +691,7 @@ mod tests { cost_per_retained_byte: 0.0, version: "unused-base-v1".into(), }; - let candidates = - crate::replacement::ASAPStrategies::default_cost_model().replacements(&target); + let candidates = crate::replacement::ASAPStrategies::default().replacements(&target); provider.storage_io = Some(profile.clone()); let model = PhysicalPlanCostModel::new(&provider, base.clone()).unwrap(); let estimate = model.estimate_candidate(&candidates[0], &target).unwrap(); @@ -722,8 +721,7 @@ mod tests { fn missing_storage_profile_remains_unestimated() { let root = query(); let target = TargetSubDAG::new(&root); - let candidates = - crate::replacement::ASAPStrategies::default_cost_model().replacements(&target); + let candidates = crate::replacement::ASAPStrategies::default().replacements(&target); let provider = TestProvider::new(true, 800); let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); let estimate = model.estimate_candidate(&candidates[0], &target).unwrap(); @@ -1020,8 +1018,8 @@ mod tests { } let root = query(); - let candidates = crate::replacement::ASAPStrategies::default_cost_model() - .replacements(&TargetSubDAG::new(&root)); + let candidates = + crate::replacement::ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); let provider = WrongScope(TestProvider::new(true, 800)); let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); assert_eq!( @@ -1066,8 +1064,8 @@ mod tests { } let root = query(); - let candidates = crate::replacement::ASAPStrategies::default_cost_model() - .replacements(&TargetSubDAG::new(&root)); + let candidates = + crate::replacement::ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); let model = PhysicalPlanCostModel::new(&BlankVersionProvider, calibration()).unwrap(); assert_eq!( model.candidate_cost(&candidates[0], &TargetSubDAG::new(&root)), @@ -1093,8 +1091,8 @@ mod tests { #[test] fn sibling_candidates_share_one_scope_and_raw_baseline() { let root = query(); - let candidates = crate::replacement::ASAPStrategies::default_cost_model() - .replacements(&TargetSubDAG::new(&root)); + let candidates = + crate::replacement::ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); assert!(candidates.len() >= 2); let provider = TestProvider::new(true, 800); let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); diff --git a/crates/asap-aware-mapping/src/plan_selection/candidate_selection.rs b/crates/asap-aware-mapping/src/plan_selection/candidate_selection.rs index a332a900..a57fb039 100644 --- a/crates/asap-aware-mapping/src/plan_selection/candidate_selection.rs +++ b/crates/asap-aware-mapping/src/plan_selection/candidate_selection.rs @@ -534,7 +534,7 @@ fn cse_preference(group: &TargetSubDAGCandidates, cost_model: &dyn CostModel) -> if group.consumer_count < 2 { return None; } - let bound = realize_one(&group.target, cost_model)?; + let bound = realize_one(&group.target)?; let candidate = CseCandidate { sub_dag: &group.target, bound_summary: &bound, @@ -554,8 +554,8 @@ fn cse_preference(group: &TargetSubDAGCandidates, cost_model: &dyn CostModel) -> /// `construct_summary_agg`'s own recursion and /// [`crate::cost_model::DefaultCostModel::estimate_cost`] already use, /// wrapped to swallow the (here, uninteresting) error into `None`. -fn realize_one(target: &Rc, cost_model: &dyn CostModel) -> Option> { - realize_child(target, cost_model).ok() +fn realize_one(target: &Rc) -> Option> { + realize_child(target).ok() } /// The `SketchAlgorithm` a bound [`Replacement::SubDAG`] candidate ultimately @@ -1569,7 +1569,7 @@ fn decide_with_effective_count( effective_consumer_count: usize, cost_model: &dyn CostModel, ) -> Option { - let bound = realize_child(&group.target, cost_model).ok()?; + let bound = realize_child(&group.target).ok()?; let candidate = CseCandidate { sub_dag: &group.target, bound_summary: &bound, @@ -1585,7 +1585,7 @@ fn decide_group_with_recurrence( horizon: Option, cost_model: &dyn CostModel, ) -> Result, RecurrenceError> { - let Some(bound) = realize_child(&group.target, cost_model).ok() else { + let Some(bound) = realize_child(&group.target).ok() else { return Ok(None); }; let candidate = CseCandidate { @@ -1761,8 +1761,8 @@ mod tests { use crate::accuracy::DefaultAccuracyModel; use crate::cost_model::{Cost, DefaultCostModel}; use crate::replacement::{ - default_strategies, default_strategies_with, discover_targets, search_workload, - search_workload_with, search_workload_with_targets, ASAPStrategies, ReplacementStrategy, + default_strategies, discover_targets, search_workload, search_workload_with, + search_workload_with_targets, ASAPStrategies, ReplacementStrategy, }; use crate::test_support::{agg, lower_promql, metric_scan}; use asap_types::ir::operator::agg_intent::default_quantile; @@ -1788,7 +1788,7 @@ mod tests { } fn realize(expr: &OperatorNode) -> Result, RealizationError> { - realize_child(&Rc::new(expr.clone()), &DefaultCostModel) + realize_child(&Rc::new(expr.clone())) } #[test] @@ -1959,8 +1959,7 @@ mod tests { }, }; let root = agg(vec![2, 3], intent, metric_scan(&["tenant_id", "endpoint"])); - let strategies = default_strategies_with(&model); - let space = search_workload_with(vec![("tenant_endpoint_count", root)], &strategies); + let space = search_workload(vec![("tenant_endpoint_count", root)]); let ranked = space.cost_sorted(&model); let aggregate = ranked .iter() @@ -2623,8 +2622,7 @@ mod tests { #[test] fn grouped_temporal_sum_has_one_summary_producer_candidate() { let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); - let candidates = - ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); assert!(candidates .iter() .any(|candidate| matches!(&candidate.replacement, diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 095be55e..657f18f2 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -11,11 +11,11 @@ //! [`ReplacementSubDAG`]: //! //! 1. **Decide**: [`realizations_for_intent`] enumerates every valid -//! [`Realization`] for the target's intent — exhaustive, and ranked -//! most-preferred-first via a [`CostModel`] (candidate sketch family/kind, -//! already sized to the target's own accuracy target: `Realization::Sketch`'s -//! `params` are the output of inverting that accuracy target through -//! `CostModel::size_params`, not a placeholder filled in later). +//! [`Realization`] for the target's intent — exhaustive, in a static +//! order (candidate sketch family/kind, already sized to the target's own +//! accuracy target: `Realization::Sketch`'s `params` are the output of +//! inverting that accuracy target through the analytical estimators, not a +//! placeholder filled in later). //! 2. **Build**: for each candidate in that list, [`construct_summary`] //! mechanically turns the already-decided `(kind, params)` into a real //! [`OperatorNode`] — derives the child schema, resolves the summarized @@ -41,7 +41,7 @@ //! (still logical, structurally different from the target but semantically //! equivalent) — see [`Replacement`] — plus a human-readable `rationale`. //! - [`ReplacementStrategy`] — `matches` + `replacements`, the same -//! extension-point shape [`CostModel`] and [`Matcher`] already use in this +//! extension-point shape [`CostModel`](crate::cost_model::CostModel) and [`Matcher`] already use in this //! crate: a new replacement source is a new `impl ReplacementStrategy`, not //! a restructuring of this trait or of any existing strategy. `replacements` //! is **exhaustive, not ranked, not filtered** — reporting "every valid @@ -234,7 +234,7 @@ //! ### Cost-based final selection — reusing `CostModel`, not a second interface //! //! [`CandidateLogicalASAPDAGs::cost_sorted`] is the `sorted_by(cost_model)` step, and it -//! reuses this crate's existing [`CostModel`] trait rather than inventing a +//! reuses this crate's existing [`CostModel`](crate::cost_model::CostModel) trait rather than inventing a //! second cost interface (`docs/design_docs/cse-cost-model-decision.md`, //! issue #237, explicitly reasoned about *why* a narrow, direct cost //! comparison was enough for the CSE share/recompute decision alone, and @@ -244,15 +244,15 @@ //! //! - A group whose candidates are the [`SharedSubDAGStrategy`] //! share-vs-recompute pair is ranked by calling -//! [`CostModel::cse_share_decision`] via this module's own +//! [`CostModel::cse_share_decision`](crate::cost_model::CostModel::cse_share_decision) via this module's own //! [`cse_preference`] — rather than re-deriving a competing comparison. //! - A group whose candidates are [`ASAPStrategies`]'s sketch-family -//! candidates is ranked via [`CostModel::rank_candidates`] (the same hook +//! candidates is ranked via [`CostModel::rank_candidates`](crate::cost_model::CostModel::rank_candidates) (the same hook //! `realizations_for_intent` itself consults), applied to the //! candidates' own [`SketchAlgorithm`]s. //! - Any other shape (a single candidate, or a mix this module doesn't have //! a defined comparison for) keeps discovery order — there is nothing to -//! rank, or no [`CostModel`] hook this module knows how to apply; it never +//! rank, or no [`CostModel`](crate::cost_model::CostModel) hook this module knows how to apply; it never //! invents a comparison `CostModel` doesn't already define. //! //! ## Whole-plan (cross-group) selection — issue #271 @@ -262,7 +262,7 @@ //! group is costed. That's the right behavior when groups genuinely don't //! interact — which both shipped strategies' one-round convergence (see //! "Termination" above) makes the common case — but it's the wrong answer -//! whenever they do. Concretely: [`CostModel::cse_share_decision`] costs a +//! whenever they do. Concretely: [`CostModel::cse_share_decision`](crate::cost_model::CostModel::cse_share_decision) costs a //! [`SharedSubDAGStrategy`] group by comparing a `consumer_count`-scaled //! recompute cost against a fixed maintenance cost — but a **nested** //! `SharedSubDAGStrategy` group's *true* recompute burden isn't its own @@ -284,11 +284,11 @@ //! **effective consumer count** — how many times that site actually runs //! once every ancestor's own selected candidate is accounted for — and, for //! every [`SharedSubDAGStrategy`]-shaped group, re-decides -//! [`CostModel::cse_share_decision`] against *that* corrected count instead +//! [`CostModel::cse_share_decision`](crate::cost_model::CostModel::cse_share_decision) against *that* corrected count instead //! of the group's raw structural one. When that group also contains a //! non-CSE alternative such as a semantic rewrite, the chosen CSE candidate //! and the cheapest non-CSE candidate additionally compete through -//! [`CostModel::estimate_cost`]; the CSE pair is no longer allowed to hide an +//! [`CostModel::estimate_cost`](crate::cost_model::CostModel::estimate_cost); the CSE pair is no longer allowed to hide an //! otherwise valid logical alternative. See [`multiplier`]'s doc for the //! exact recurrence: a group that chooses `Share` collapses its own //! multiplicity to exactly `1` for everything beneath it (one shared @@ -314,8 +314,8 @@ //! Two things this deliberately does **not** attempt, both left as //! documented follow-up rather than silently overclaimed: //! -//! - [`CostModel::rank_candidates`]/[`CostModel::size_params`] — the hooks -//! [`ASAPStrategies`] groups rank by — take no `consumer_count` +//! - [`CostModel::rank_candidates`](crate::cost_model::CostModel::rank_candidates) — the hook +//! [`ASAPStrategies`] groups rank by — takes no `consumer_count` //! parameter at all today, so a `ASAPStrategies` group's selection //! here still falls back to [`rank_group`]'s ordinary (consumer-count- //! blind) local ranking, even though its own @@ -327,7 +327,7 @@ //! would need, and this module now computes it for every group, sketch //! groups included. //! - This is not an exhaustive search over combinations of choices for a -//! provably-global optimum in every case. [`CostModel::cse_share_decision`] +//! provably-global optimum in every case. [`CostModel::cse_share_decision`](crate::cost_model::CostModel::cse_share_decision) //! is still a *local*, pairwise comparison at each `SharedSubDAGStrategy` //! site (recompute-total vs. one fixed maintenance cost) — this module //! just now feeds it a *correct* input instead of an *incorrect* one. Two @@ -341,7 +341,7 @@ use crate::accuracy::estimators::{ cms::{cms_depth, cms_width}, - saturating_ceil, + saturating_ceil, size_params, }; use asap_types::ir::operator::non_asap::any_measure_filtered; use asap_types::ir::scalar::resolve_column_ref; @@ -378,7 +378,6 @@ use crate::accuracy::{ AccuracyBudgetAllocator, AccuracyEvidenceProvider, AccuracyModel, CompositionShape, DefaultAccuracyModel, EqualSplitAllocator, NoAccuracyEvidence, }; -use crate::cost_model::{CostModel, DefaultCostModel}; use crate::exact_composition::{ExactComposition, ExactCompositionStrategy, OperationPlacement}; use crate::grouping::HydraGroupingStrategy; use crate::plan_selection::candidate_selection::{GlobalSelection, TargetSubDAGSelection}; @@ -576,7 +575,7 @@ pub enum ReplacementProvenance { /// accuracy-legality grounds (issue #172) — kept alongside the group's /// legal candidates in [`TargetSubDAGCandidates::rejected`] so a rejection is as /// inspectable (and exportable) as a selection. Never ranked: a -/// [`CostModel`] only ever sees [`TargetSubDAGCandidates::candidates`]. +/// [`CostModel`](crate::cost_model::CostModel) only ever sees [`TargetSubDAGCandidates::candidates`]. #[derive(Debug, Clone)] pub struct RejectedCandidate { /// Name of the [`ReplacementStrategy`] that considered it. @@ -603,7 +602,7 @@ pub struct Proposals { /// replacement (`replacements`)? /// /// The extension point this module exists for — the same shape -/// [`CostModel`] and [`Matcher`] already use elsewhere in this crate: a new +/// [`CostModel`](crate::cost_model::CostModel) and [`Matcher`] already use elsewhere in this crate: a new /// replacement source is a new `impl ReplacementStrategy`, no restructuring /// of this trait or any existing strategy required. /// @@ -628,7 +627,7 @@ pub trait ReplacementStrategy { /// Every valid replacement for `target` — not ranked, not filtered. /// Reporting "every valid candidate" is this method's whole job; picking - /// the best one is a [`CostModel`]'s job, out of scope here. + /// the best one is a [`CostModel`](crate::cost_model::CostModel)'s job, out of scope here. fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec; /// [`replacements`](Self::replacements) plus the accuracy-illegal @@ -816,8 +815,8 @@ pub fn accuracy_target(intent: &AggIntent) -> Option<&AccuracyTarget> { } } -/// Every valid [`Realization`] for `intent`, exhaustive and ranked -/// (most-preferred first via `cost_model`) — the *only* place this crate +/// Every valid [`Realization`] for `intent`, exhaustive, in +/// [`summary_candidates`]' static order — the *only* place this crate /// decides what an `AggIntent` may become. Nothing in this crate computes /// "the one" `Realization` independently of this list: /// [`ASAPStrategies`] keeps every entry as a candidate, and a caller @@ -833,10 +832,7 @@ pub fn accuracy_target(intent: &AggIntent) -> Option<&AccuracyTarget> { /// caller outside it, needing the exact same already-ranked candidate list /// to find the `Realization::Sketch` matching the Hydra-eligible kind it /// is building a candidate for. -pub(crate) fn realizations_for_intent( - intent: &AggIntent, - cost_model: &dyn CostModel, -) -> Vec { +pub(crate) fn realizations_for_intent(intent: &AggIntent) -> Vec { match intent { // ── Approximate-capable intents — the AccuracyTarget decides ──────── AggIntent::Quantile { accuracy, .. } @@ -854,11 +850,11 @@ pub(crate) fn realizations_for_intent( ], AccuracyTarget::Exact => vec![exact_realization(intent)], _ if matches!(intent, AggIntent::Count { .. }) => { - let mut candidates = sketch_realizations(intent, accuracy, cost_model); + let mut candidates = sketch_realizations(intent, accuracy); candidates.push(exact_realization(intent)); candidates } - _ => sketch_realizations(intent, accuracy, cost_model), + _ => sketch_realizations(intent, accuracy), }, // ── Exact mergeable accumulators ───────────────────────────────────── @@ -924,17 +920,9 @@ pub(crate) fn realizations_for_intent( AggIntent::Group | AggIntent::CountValues { .. } => vec![Realization::PassThrough], // ── Extension (deployment-model-specific, issue #131) — core has no - // realization opinion for a shape it doesn't know, so it defers - // entirely to the `CostModel` (issue #150): `realize_extension` - // defaults to `PassThrough`, preserving today's behavior for - // every deployment that doesn't override it. Core has no way to - // enumerate alternatives for an opaque deployment-defined shape, - // so this is always exactly one candidate. This is also the only - // path that can currently produce `Realization::Sample`/ - // `Wavelet`/`StatModel` — see the module docs. - AggIntent::Extension { ext_kind, payload } => { - vec![cost_model.realize_extension(ext_kind, payload)] - } + // realization opinion for a shape it doesn't know, so it stays + // logical. + AggIntent::Extension { .. } => vec![Realization::PassThrough], } } @@ -959,8 +947,8 @@ fn exact_accumulator(intent: &AggIntent, kind: ExactKind, params: ExactParams) - Realization::ExactAggregate { kind, params } } -/// Resolve an [`AccuracyTarget`] into the `(eps, delta)` budget -/// [`CostModel::size_params`] needs. Shared by [`sketch_realizations`] and +/// Resolve an [`AccuracyTarget`] into the `(eps, delta)` budget sketch +/// sizing needs. Shared by [`sketch_realizations`] and /// this crate's own sizing — one place this resolution happens, so nothing /// can drift apart on it. /// @@ -976,23 +964,15 @@ pub fn accuracy_budget(accuracy: &AccuracyTarget) -> (f64, f64) { } /// Every candidate sketch [`Realization`] for an approximate-capable -/// intent, sized to `accuracy` and ranked via `cost_model.rank_candidates` -/// (most-preferred first) — [`realizations_for_intent`]'s Sketch branch. -fn sketch_realizations( - intent: &AggIntent, - accuracy: &AccuracyTarget, - cost_model: &dyn CostModel, -) -> Vec { +/// intent, sized analytically to `accuracy`, in [`summary_candidates`]' +/// order — [`realizations_for_intent`]'s Sketch branch. +fn sketch_realizations(intent: &AggIntent, accuracy: &AccuracyTarget) -> Vec { let (eps, delta) = accuracy_budget(accuracy); - let ranked = crate::cost_model::validated_candidate_ranking( - cost_model, - intent, - summary_candidates(intent), - ); - ranked - .into_iter() + summary_candidates(intent) + .iter() + .cloned() .filter_map(|algorithm| { - let params = cost_model.size_params(algorithm.clone(), intent, eps, delta); + let params = size_params(algorithm.clone(), intent, eps, delta); sketch_state_bytes(¶ms) .is_none_or(|bytes| bytes <= DEFAULT_MAX_SKETCH_STATE_BYTES) .then(|| Realization::Sketch(SketchKind::new(algorithm, params))) @@ -1045,10 +1025,7 @@ pub fn sketch_state_bytes(params: &SketchParams) -> Option { } /// `asap-plan`'s built-in `SketchParams` sizing, keyed off the resolved -/// `(eps, delta)` accuracy budget. [`CostModel::size_params`]'s default -/// body — factored out to a free function so a deployment's own -/// `CostModel` impl can still delegate to it for the candidates it -/// doesn't want to resize itself. +/// `(eps, delta)` accuracy budget. /// /// Each formula inverts the sketch family's standard error bound to the /// smallest parameter satisfying the target, clamped to the family's sane @@ -1181,34 +1158,26 @@ pub fn posterior_aware_size_params( // ── ASAPStrategies ───────────────────────────────────────────────── -/// A single static instance so [`ASAPStrategies::default_cost_model`] -/// can hand out a `&'static dyn CostModel` without heap-allocating one — -/// `DefaultCostModel` is a unit struct with no state, so one instance serves -/// every caller. -static DEFAULT_COST_MODEL: DefaultCostModel = DefaultCostModel; static DEFAULT_ACCURACY_MODEL: DefaultAccuracyModel = DefaultAccuracyModel; static DEFAULT_ALLOCATOR: EqualSplitAllocator = EqualSplitAllocator; static NO_ACCURACY_EVIDENCE: NoAccuracyEvidence = NoAccuracyEvidence; -/// The cost, accuracy, allocation, and evidence inputs consulted during -/// candidate construction, bundled so the construction path threads one argument. `cost` ranks and sizes; `accuracy` and `allocator` decide -/// legality (issue #172) — see [`crate::accuracy`]'s module docs for why -/// those are separate from `cost` and run before it. +/// The accuracy, allocation, and evidence inputs consulted during +/// candidate construction, bundled so the construction path threads one +/// argument. `accuracy` and `allocator` decide legality (issue #172) — see +/// [`crate::accuracy`]'s module docs. #[derive(Clone, Copy)] pub(crate) struct CandidatePlanningInputs<'a> { - pub cost: &'a dyn CostModel, pub accuracy: &'a dyn AccuracyModel, pub allocator: &'a dyn AccuracyBudgetAllocator, pub evidence: &'a dyn AccuracyEvidenceProvider, } -impl<'a> CandidatePlanningInputs<'a> { - /// `cost` with the built-in [`DefaultAccuracyModel`]/ - /// [`EqualSplitAllocator`] — what every entry point that only takes a - /// `CostModel` uses. - pub(crate) fn with_default_accuracy(cost: &'a dyn CostModel) -> Self { +impl CandidatePlanningInputs<'static> { + /// The built-in [`DefaultAccuracyModel`]/[`EqualSplitAllocator`], with no + /// planning-time evidence. + pub(crate) fn with_default_accuracy() -> Self { Self { - cost, accuracy: &DEFAULT_ACCURACY_MODEL, allocator: &DEFAULT_ALLOCATOR, evidence: &NO_ACCURACY_EVIDENCE, @@ -1220,17 +1189,12 @@ impl<'a> CandidatePlanningInputs<'a> { /// exact accumulators, approximate sketches, and supported maintained populations. /// Each valid realization becomes its own [`ReplacementSubDAG`]. /// -/// [`realizations_for_intent`] enumerates summary families; extension hooks can -/// supply additional supported families. This is not limited to sketch algorithms. +/// [`realizations_for_intent`] enumerates summary families. This is not +/// limited to sketch algorithms. Nothing here ranks candidates by cost; that +/// is plan selection's job. /// -/// Ranked (only to *order the enumeration*, never to drop a candidate) via a -/// [`CostModel`] — [`DefaultCostModel`] unless constructed with -/// [`ASAPStrategies::new`] — so a deployment-specific cost model's -/// other hooks (`size_params`, `realize_extension`, `evaluation_extension`) are -/// still consulted while binding each candidate. -/// -/// The one thing that *does* drop a candidate is accuracy legality (issue -/// #172), decided by the [`AccuracyModel`] — never by the cost model: a +/// The one thing that drops a candidate is accuracy legality (issue +/// #172), decided by the [`AccuracyModel`]: a /// sketch over an approximate child is proposed only if its composed /// guarantee has a sound propagation rule and satisfies the node's own /// `AccuracyTarget`; otherwise it is reported through @@ -1241,40 +1205,25 @@ pub struct ASAPStrategies<'a> { planning_inputs: CandidatePlanningInputs<'a>, } -impl ASAPStrategies<'static> { - /// A strategy that ranks/binds via the built-in [`DefaultCostModel`] — - /// what a deployment gets with no custom cost model plugged in. - pub fn default_cost_model() -> Self { +impl Default for ASAPStrategies<'static> { + /// The built-in [`DefaultAccuracyModel`]/[`EqualSplitAllocator`]. + fn default() -> Self { Self { - planning_inputs: CandidatePlanningInputs::with_default_accuracy(&DEFAULT_COST_MODEL), + planning_inputs: CandidatePlanningInputs::with_default_accuracy(), } } } impl<'a> ASAPStrategies<'a> { - /// A strategy that ranks/binds via `cost_model` instead of the built-in - /// static preference order — the same customization point - /// [`realizations_for_intent`] already offers. Accuracy legality stays - /// with the built-in [`DefaultAccuracyModel`]/[`EqualSplitAllocator`]. - pub fn new(cost_model: &'a dyn CostModel) -> Self { - Self { - planning_inputs: CandidatePlanningInputs::with_default_accuracy(cost_model), - } - } - - /// A strategy with every model plugged in explicitly: `cost_model` for - /// ranking/sizing, `accuracy_model` for guarantee derivation/propagation/ - /// satisfaction, `allocator` for end-to-end budget splits. One model - /// never overrides another: legality is settled by `accuracy_model` - /// before `cost_model` ranks what is left. + /// A strategy with every model plugged in explicitly: `accuracy_model` + /// for guarantee derivation/propagation/satisfaction, `allocator` for + /// end-to-end budget splits. pub fn new_with_planning_inputs( - cost_model: &'a dyn CostModel, accuracy_model: &'a dyn AccuracyModel, allocator: &'a dyn AccuracyBudgetAllocator, ) -> Self { Self { planning_inputs: CandidatePlanningInputs { - cost: cost_model, accuracy: accuracy_model, allocator, evidence: &NO_ACCURACY_EVIDENCE, @@ -1285,14 +1234,12 @@ impl<'a> ASAPStrategies<'a> { /// Like [`Self::new_with_planning_inputs`], with typed planning-time evidence for /// rules such as TopK membership and Hydra shared-grid composition. pub fn new_with_planning_inputs_and_evidence( - cost_model: &'a dyn CostModel, accuracy_model: &'a dyn AccuracyModel, allocator: &'a dyn AccuracyBudgetAllocator, evidence: &'a dyn AccuracyEvidenceProvider, ) -> Self { Self { planning_inputs: CandidatePlanningInputs { - cost: cost_model, accuracy: accuracy_model, allocator, evidence, @@ -1535,7 +1482,7 @@ impl<'a> ASAPStrategies<'a> { // candidate in practice (every other variant's own dispatch produces // exactly one `Realization`), but this loop doesn't need to know // that; it just constructs whatever the list contains. - for realization in realizations_for_intent(intent, planning_inputs.cost) { + for realization in realizations_for_intent(intent) { let rationale = describe_realization(intent, &realization); // The as-declared composition: every layer sized to its own // declared `AccuracyTarget`. Legal iff the composed guarantee @@ -1558,10 +1505,7 @@ impl<'a> ASAPStrategies<'a> { if let (Some(stricter), Realization::Sketch(kind)) = (strictest_sibling, &realization) { let (eps, delta) = accuracy_budget(stricter); let algorithm = kind.algorithm().clone(); - let params = - planning_inputs - .cost - .size_params(algorithm.clone(), intent, eps, delta); + let params = size_params(algorithm.clone(), intent, eps, delta); if params != *kind.params() { proposals.record( format!( @@ -1598,7 +1542,7 @@ impl<'a> ASAPStrategies<'a> { else { continue; }; - let evaluation_query = evaluation(intent, &input.input, planning_inputs.cost); + let evaluation_query = evaluation(intent, &input.input); let Some(local) = planning_inputs .accuracy .local_guarantee(&family, &evaluation_query) @@ -1630,9 +1574,7 @@ impl<'a> ASAPStrategies<'a> { let (eps, delta) = accuracy_budget(outer_target); let resized = Realization::Sketch(SketchKind::new( kind.algorithm().clone(), - planning_inputs - .cost - .size_params(kind.algorithm().clone(), intent, eps, delta), + size_params(kind.algorithm().clone(), intent, eps, delta), )); // Identical to the as-declared composition already recorded // above — nothing new to propose. @@ -1788,18 +1730,18 @@ fn describe_realization(intent: &AggIntent, realization: &Realization) -> String describe_intent(intent) ), Realization::Sample { kind, .. } => format!( - "{} realizes as a {kind:?} sample — the only realization the plugged-in \ - CostModel produced for this intent", + "{} realizes as a {kind:?} sample — the only realization produced for \ + this intent", describe_intent(intent) ), Realization::Wavelet { kind, .. } => format!( - "{} realizes as a {kind:?} wavelet transform — the only realization the \ - plugged-in CostModel produced for this intent", + "{} realizes as a {kind:?} wavelet transform — the only realization \ + produced for this intent", describe_intent(intent) ), Realization::StatModel { kind, .. } => format!( - "{} realizes as a {kind:?} statistical model — the only realization the \ - plugged-in CostModel produced for this intent", + "{} realizes as a {kind:?} statistical model — the only realization \ + produced for this intent", describe_intent(intent) ), } @@ -1847,20 +1789,13 @@ pub(crate) fn describe_intent(intent: &AggIntent) -> String { /// independent enumeration instead of inheriting the parent's forced /// candidate), from this module's own [`realize_one`] (the representative /// bound `OperatorNode` [`cse_preference`] needs for a -/// [`CostModel::cse_share_decision`] comparison), and from +/// [`CostModel::cse_share_decision`](crate::cost_model::CostModel::cse_share_decision) comparison), and from /// [`crate::cost_model::DefaultCostModel::estimate_cost`] (the same /// representative-node need, for a [`Replacement::Rewrite`] candidate's own /// cost estimate). Every other caller goes through /// [`ASAPStrategies::replacements`] directly and decides for itself. -pub(crate) fn realize_child( - root: &Rc, - cost_model: &dyn CostModel, -) -> Result, RealizationError> { - realize_child_with( - root, - CandidatePlanningInputs::with_default_accuracy(cost_model), - None, - ) +pub(crate) fn realize_child(root: &Rc) -> Result, RealizationError> { + realize_child_with(root, CandidatePlanningInputs::with_default_accuracy(), None) } /// [`realize_child`] with every model explicit, plus an optional @@ -2448,9 +2383,7 @@ fn realize_ddsketch_quantile_operand( let (epsilon, delta) = accuracy_budget(target); let realization = Realization::Sketch(SketchKind::new( SketchAlgorithm::DDSketch, - planning_inputs - .cost - .size_params(SketchAlgorithm::DDSketch, &intent, epsilon, delta), + size_params(SketchAlgorithm::DDSketch, &intent, epsilon, delta), )); construct_summary_with(operand, &intent, realization, planning_inputs, None, None) } @@ -3043,7 +2976,7 @@ fn construct_summary_agg( }; } } - evaluation(intent, &summary_input, planning_inputs.cost) + evaluation(intent, &summary_input) }); let mut state_schema = out_schema.clone(); @@ -3134,7 +3067,7 @@ fn construct_summary_agg( local_target, ); let membership_query = if snapshot_weighted { - Some(evaluation(intent, &summary_input, planning_inputs.cost)) + Some(evaluation(intent, &summary_input)) } else { query.clone() }; @@ -3846,11 +3779,7 @@ pub(crate) fn summarised_input( } /// The `SummaryEstimate` evaluation for a summary-bound intent. -fn evaluation( - intent: &AggIntent, - input: &SummaryUpdate, - cost_model: &dyn CostModel, -) -> PostAsapSketchStatistic { +fn evaluation(intent: &AggIntent, input: &SummaryUpdate) -> PostAsapSketchStatistic { match intent { AggIntent::Quantile { q, .. } => PostAsapSketchStatistic::Quantile { q: *q }, AggIntent::Cardinality { .. } => PostAsapSketchStatistic::Cardinality, @@ -3865,17 +3794,6 @@ fn evaluation( }, value: None, }, - // Core doesn't know the shape of a deployment-specific `Extension` - // intent, so it can't build its evaluation either — delegate to the - // same `CostModel` that decided (via `realize_extension`) this - // intent gets a summary realization at all. See `evaluation_extension`'s - // doc for the invariant this depends on. - AggIntent::Extension { ext_kind, payload } => match &input.weight { - SummaryInputExpr::Column(col) => { - cost_model.evaluation_extension(ext_kind, payload, col) - } - _ => unreachable!("extension evaluation requires one column"), - }, other => { unreachable!("no summary realization for {other:?} (realizations_for_intent)") } @@ -3898,7 +3816,7 @@ fn evaluation( /// "Non-goals" on why that traversal isn't itself part of this strategy). /// This strategy only reframes "two or more consumers already share this /// `Rc`" as the two-way choice a downstream cost model (today, -/// [`CostModel::cse_share_decision`]) picks between: build once and share, or +/// [`CostModel::cse_share_decision`](crate::cost_model::CostModel::cse_share_decision)) picks between: build once and share, or /// build independently at each consumer. pub struct SharedSubDAGStrategy; @@ -3985,7 +3903,7 @@ pub struct TargetSubDAGCandidates { /// accuracy-legality grounds (issue #172), plus any `candidates` entry /// the root-target check ([`search_workload_with_targets`]) moved here. /// Never ranked — [`CandidateLogicalASAPDAGs::cost_sorted`]/[`CandidateLogicalASAPDAGs::global_selection`] - /// read only `candidates`, so a [`CostModel`] cannot resurrect one. + /// read only `candidates`, so a [`CostModel`](crate::cost_model::CostModel) cannot resurrect one. pub rejected: Vec, } @@ -4471,22 +4389,19 @@ pub(crate) fn direct_child_counts(node: &OperatorNode) -> Vec<(*const OperatorNo } // ── default_strategies ────────────────────────────────────────────────── -/// The context-free strategies [`search_workload`] runs in the built-in -/// [`DefaultCostModel`] configuration. Workload-dependent strategies such as +/// The context-free strategies [`search_workload`] runs with the built-in +/// accuracy models. Workload-dependent strategies such as /// [`RollupStrategy`] and [`AccuracyReconciliationStrategy`] (issue #273, /// cross-consumer accuracy reconciliation for CSE sharing — see that /// module's own docs) are added by [`search_workload`] after CSE and target /// discovery, when their sibling context exists. /// [`crate::explanation::explain_replacements`] (issue #257) uses /// this same set (via [`search_workload`]) rather than keeping a second, -/// explanation-specific list to stay in sync with. Use -/// [`default_strategies_with`] to plug in a deployment-specific -/// [`CostModel`] instead. +/// explanation-specific list to stay in sync with. /// /// [`AvgToSumOverCountStrategy`](crate::rewrite::AvgToSumOverCountStrategy) is /// included here (issue #253) even though it's a -/// [`Replacement::Rewrite`]-only strategy with no [`CostModel`] of its own to -/// plug in — it's context-free (`matches`/`replacements` need nothing beyond +/// [`Replacement::Rewrite`]-only strategy — it's context-free (`matches`/`replacements` need nothing beyond /// the target itself) exactly like [`SharedSubDAGStrategy`], so it belongs /// in this list rather than being derived per-workload the way /// [`RollupStrategy`] is. Rewriting `avg` into `sum`/`count` upfront is what @@ -4496,47 +4411,29 @@ pub(crate) fn direct_child_counts(node: &OperatorNode) -> Vec<(*const OperatorNo /// [`ReplacementStrategy`] target for anything. pub fn default_strategies() -> Vec> { vec![ - Box::new(ASAPStrategies::default_cost_model()), - Box::new(HydraGroupingStrategy::default_cost_model()), + Box::new(ASAPStrategies::default()), + Box::new(HydraGroupingStrategy::default()), Box::new(SharedSubDAGStrategy), Box::new(crate::rewrite::AvgToSumOverCountStrategy), - Box::new(ExactCompositionStrategy::default_cost_model()), + Box::new(ExactCompositionStrategy), ] } -/// Like [`default_strategies`], but [`ASAPStrategies`] ranks/binds via -/// `cost_model` instead of the built-in [`DefaultCostModel`] — the same -/// customization point [`ASAPStrategies::new`] itself offers. -pub fn default_strategies_with<'a>( - cost_model: &'a dyn CostModel, -) -> Vec> { - vec![ - Box::new(ASAPStrategies::new(cost_model)), - Box::new(HydraGroupingStrategy::new(cost_model)), - Box::new(SharedSubDAGStrategy), - Box::new(crate::rewrite::SemanticEquivalentRewriteStrategy), - Box::new(ExactCompositionStrategy::new(cost_model)), - ] -} - -/// Default context-free strategies with both deployment costing and typed -/// planning-time accuracy evidence. This is the production counterpart of +/// Default context-free strategies with typed planning-time accuracy +/// evidence. This is the production counterpart of /// constructing [`ASAPStrategies::new_with_planning_inputs_and_evidence`] and /// [`HydraGroupingStrategy::new_with_planning_inputs_and_evidence`] separately. pub fn default_strategies_with_evidence<'a>( - cost_model: &'a dyn CostModel, evidence: &'a dyn AccuracyEvidenceProvider, ) -> Vec> { vec![ Box::new(ASAPStrategies::new_with_planning_inputs_and_evidence( - cost_model, &DEFAULT_ACCURACY_MODEL, &DEFAULT_ALLOCATOR, evidence, )), Box::new( HydraGroupingStrategy::new_with_planning_inputs_and_evidence( - cost_model, &DEFAULT_ACCURACY_MODEL, &DEFAULT_ALLOCATOR, evidence, @@ -4544,27 +4441,22 @@ pub fn default_strategies_with_evidence<'a>( ), Box::new(SharedSubDAGStrategy), Box::new(crate::rewrite::AvgToSumOverCountStrategy), - Box::new(ExactCompositionStrategy::new(cost_model)), + Box::new(ExactCompositionStrategy), ] } // ── search_workload ────────────────────────────────────────────────────── /// Search a whole workload's pre-ASAP roots for every candidate replacement -/// [`default_strategies`] can find, deduped into a [`CandidateLogicalASAPDAGs`]. Candidate -/// *generation* uses the built-in [`DefaultCostModel`] (via -/// [`default_strategies`], the same way [`ASAPStrategies::default_cost_model`] -/// does); call [`CandidateLogicalASAPDAGs::cost_sorted`] on the result for the final -/// `sorted_by(cost_model)` step. Use [`search_workload_with`] to plug in a -/// custom strategy set (e.g. built via [`default_strategies_with`] for a -/// deployment-specific [`CostModel`]). +/// [`default_strategies`] can find, deduped into a [`CandidateLogicalASAPDAGs`]. +/// Candidate generation is cost-free; ranking happens in plan selection. Use +/// [`search_workload_with`] to plug in a custom strategy set. pub fn search_workload(roots: Vec<(Id, Rc)>) -> CandidateLogicalASAPDAGs { search_workload_with(roots, &default_strategies()) } /// Like [`search_workload`], but with an explicit set of context-free -/// `strategies` (see [`default_strategies_with`] to plug in a -/// deployment-specific [`CostModel`]). The workload-dependent +/// `strategies`. The workload-dependent /// [`RollupStrategy`] is derived and added automatically after CSE for both /// entry points, because only this function owns the post-CSE sibling set. /// @@ -4575,11 +4467,7 @@ pub fn search_workload(roots: Vec<(Id, Rc)>) -> CandidateLogic /// `TargetSubDAG` (see [`discover_targets`]) and runs the /// fixpoint loop the module docs describe, capped at /// [`MAX_SEARCH_ITERATIONS`] passes (see the module docs' "Termination" -/// section). Deduping candidate plans this way needs no -/// [`CostModel`] at all — that only enters at two well-defined points: each -/// [`ReplacementStrategy`] in `strategies` may already carry its own (e.g. -/// [`ASAPStrategies::new`]'s), and [`CandidateLogicalASAPDAGs::cost_sorted`]'s final -/// ranking step takes one explicitly. +/// section). Deduping candidate plans this way needs no cost model. pub fn search_workload_with<'s, Id>( roots: Vec<(Id, Rc)>, strategies: &[Box], @@ -5095,6 +4983,7 @@ fn walk_children( mod tests { use super::*; use crate::accuracy::PropagationStats; + use crate::cost_model::DefaultCostModel; use crate::plan_selection::candidate_selection::sketch_kind_of; use crate::test_support::{agg, agg_per_entity, lower_promql, maintained, metric_scan, timed}; use asap_types::ir::operator::agg_intent::{ @@ -5188,7 +5077,7 @@ mod tests { let space = search_workload(vec![(0usize, root.clone())]); let inventory = space.enumerate_candidate_dags(4096).unwrap(); assert!(!inventory.candidates.is_empty()); - let strategy = ASAPStrategies::new(&DefaultCostModel); + let strategy = ASAPStrategies::default(); for candidate in strategy.propose(&TargetSubDAG::new(&root)).candidates { if let Replacement::SubDAG(node) = candidate.replacement { let output = finalize_query_candidate(node, &root).unwrap(); @@ -5333,8 +5222,7 @@ mod tests { #[test] fn temporal_average_requires_finite_division_guard() { let root = lower_promql("avg_over_time(a[5m])", AccuracyTarget::Exact); - let candidates = - ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); let operator = candidates .iter() .find_map(|c| match &c.replacement { @@ -5364,8 +5252,7 @@ mod tests { delta: 0.01, }, ); - let planning_inputs = - CandidatePlanningInputs::with_default_accuracy(&crate::cost_model::DefaultCostModel); + let planning_inputs = CandidatePlanningInputs::with_default_accuracy(); let node = exact_topk_over_temporal_values(&root, planning_inputs) .unwrap() .expect("exact ranking is legal for an approximate request"); @@ -5381,9 +5268,7 @@ mod tests { "topk by(job)(5, count_over_time(a[5m]))", ] { let root = lower_promql(query, AccuracyTarget::Exact); - let planning_inputs = CandidatePlanningInputs::with_default_accuracy( - &crate::cost_model::DefaultCostModel, - ); + let planning_inputs = CandidatePlanningInputs::with_default_accuracy(); let node = exact_topk_over_temporal_values(&root, planning_inputs) .unwrap() .expect("exact Top-K candidate"); @@ -5441,7 +5326,7 @@ mod tests { }; let inputs = CandidatePlanningInputs { evidence: &Domain, - ..CandidatePlanningInputs::with_default_accuracy(&DefaultCostModel) + ..CandidatePlanningInputs::with_default_accuracy() }; for query in [ "avg_over_time(a[5m]) / quantile_over_time(0.5,a[5m])", @@ -5466,8 +5351,7 @@ mod tests { "quantile_over_time(0.5,a[5m]) / quantile_over_time(0.9,a[5m])", target.clone(), ); - let planning_inputs = - CandidatePlanningInputs::with_default_accuracy(&crate::cost_model::DefaultCostModel); + let planning_inputs = CandidatePlanningInputs::with_default_accuracy(); let candidate = realize_binary(&root, planning_inputs, Some(&target)) .unwrap() .expect("direct quantile ratio candidate"); @@ -5492,7 +5376,7 @@ mod tests { /// &DefaultCostModel)`'s head — for tests that only care about the /// default pick, not the full candidate list. fn preferred(intent: &AggIntent) -> Realization { - realizations_for_intent(intent, &DefaultCostModel) + realizations_for_intent(intent) .into_iter() .next() .expect("every intent has at least one Realization") @@ -5681,7 +5565,7 @@ mod tests { fn pearson_corr_keeps_exact_paired_input() { let intent = AggIntent::PearsonCorr { left: 0, right: 1 }; assert!(matches!( - realizations_for_intent(&intent, &crate::cost_model::DefaultCostModel).as_slice(), + realizations_for_intent(&intent).as_slice(), [Realization::PassThrough] )); assert!(summary_candidates(&intent).is_empty()); @@ -5737,7 +5621,7 @@ mod tests { let intent = AggIntent::Count { accuracy: eps(0.01), }; - assert!(realizations_for_intent(&intent, &DefaultCostModel) + assert!(realizations_for_intent(&intent) .iter() .any(|candidate| matches!( candidate, @@ -5849,14 +5733,13 @@ mod tests { // Quantile's candidate list is [Kll, DDSketch] — realizations_for_intent // must return both, ranked with the DefaultCostModel's preferred // (Kll) first. - let kinds: Vec = - realizations_for_intent(&default_quantile(0.99), &DefaultCostModel) - .into_iter() - .map(|realization| match realization { - Realization::Sketch(kind) => kind.algorithm().clone(), - other => panic!("expected Sketch, got {other:?}"), - }) - .collect(); + let kinds: Vec = realizations_for_intent(&default_quantile(0.99)) + .into_iter() + .map(|realization| match realization { + Realization::Sketch(kind) => kind.algorithm().clone(), + other => panic!("expected Sketch, got {other:?}"), + }) + .collect(); assert_eq!(kinds, vec![SketchAlgorithm::Kll, SketchAlgorithm::DDSketch]); } @@ -6037,12 +5920,12 @@ mod tests { fn matches_a_bindable_aggregate() { let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - assert!(ASAPStrategies::default_cost_model().matches(&target)); + assert!(ASAPStrategies::default().matches(&target)); } #[test] fn does_not_match_a_multi_intent_or_having_aggregate() { - let strategy = ASAPStrategies::default_cost_model(); + let strategy = ASAPStrategies::default(); let multi = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { @@ -6076,10 +5959,8 @@ mod tests { fn does_not_match_a_non_aggregate_node() { let scan = metric_scan(&["job"]); let target = TargetSubDAG::new(&scan); - assert!(!ASAPStrategies::default_cost_model().matches(&target)); - assert!(ASAPStrategies::default_cost_model() - .replacements(&target) - .is_empty()); + assert!(!ASAPStrategies::default().matches(&target)); + assert!(ASAPStrategies::default().replacements(&target).is_empty()); } #[test] @@ -6089,7 +5970,7 @@ mod tests { // not just Kll (the CostModel-ranked head realizations_for_intent commits to). let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = ASAPStrategies::default_cost_model().replacements(&target); + let replacements = ASAPStrategies::default().replacements(&target); assert_eq!( replacements.len(), 2, @@ -6117,7 +5998,7 @@ mod tests { fn cardinality_epsilon_delta_keeps_unknown_accuracy_candidates() { let q = agg(vec![2], default_cardinality(), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = ASAPStrategies::default_cost_model().replacements(&target); + let replacements = ASAPStrategies::default().replacements(&target); let kinds: Vec = replacements .iter() .map(|r| match &r.replacement { @@ -6148,7 +6029,7 @@ mod tests { }, metric_scan(&["job"]), ); - let kinds: Vec<_> = ASAPStrategies::default_cost_model() + let kinds: Vec<_> = ASAPStrategies::default() .replacements(&TargetSubDAG::new(&q)) .iter() .map(|r| match &r.replacement { @@ -6180,7 +6061,7 @@ mod tests { }; let q = agg(vec![2], intent, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = ASAPStrategies::default_cost_model().replacements(&target); + let replacements = ASAPStrategies::default().replacements(&target); assert_eq!(replacements.len(), 1, "{replacements:?}"); assert!(matches!( &replacements[0].replacement, @@ -6193,7 +6074,7 @@ mod tests { fn exact_mergeable_intent_yields_exactly_one_accumulator_candidate() { let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = ASAPStrategies::default_cost_model().replacements(&target); + let replacements = ASAPStrategies::default().replacements(&target); assert_eq!(replacements.len(), 1, "{replacements:?}"); assert!(matches!( &replacements[0].replacement, @@ -6204,46 +6085,6 @@ mod tests { )); } - /// A custom `CostModel` doesn't change *which* candidates are enumerated - /// (still every `summary_candidates` entry) — only which one - /// `realizations_for_intent` itself would prefer first, and how each - /// candidate's own params are sized. - struct PreferDDSketch; - impl CostModel for PreferDDSketch { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { - let dd = v.remove(pos); - v.insert(0, dd); - } - v - } - } - - #[test] - fn custom_cost_model_still_enumerates_every_candidate_not_just_its_own_pick() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - let custom = PreferDDSketch; - let replacements = ASAPStrategies::new(&custom).replacements(&target); - let kinds: Vec = replacements - .iter() - .map(|r| match &r.replacement { - Replacement::SubDAG(node) => summary_family_algorithm(node), - Replacement::ExactComposition(_) => { - panic!("expected a Summary replacement") - } - }) - .collect(); - assert!(kinds.contains(&SketchAlgorithm::Kll)); - assert!(kinds.contains(&SketchAlgorithm::DDSketch)); - assert_eq!(kinds.len(), 2); - } - /// Constructing the outer target's candidates never leaks its algorithm /// choice into the nested aggregate. Existing approximate composition /// remains governed by the accuracy model, independently of #171's exact @@ -6260,24 +6101,17 @@ mod tests { let inner = agg(vec![2], default_quantile(0.5), metric_scan(&["job"])); let outer = agg(vec![], default_quantile(0.99), inner); let target = TargetSubDAG::new(&outer); - let replacements = ASAPStrategies::new_with_planning_inputs( - &DefaultCostModel, - &RankAdditiveModel, - &EqualSplitAllocator, - ) - .replacements(&target); + let replacements = + ASAPStrategies::new_with_planning_inputs(&RankAdditiveModel, &EqualSplitAllocator) + .replacements(&target); assert_eq!(replacements.len(), 2, "{replacements:?}"); assert!(replacements.iter().all(|candidate| { matches!(&candidate.replacement, Replacement::SubDAG(n) if n.contains_asap()) })); - // The inner target is still independently enumerated and ranked — - // a custom cost model that prefers DDSketch for it is honored, and - // nothing about the outer target's choice reaches it. - let space = search_workload_with( - vec![("q", Rc::clone(&outer))], - &default_strategies_with(&PreferDDSketchViaCostModel), - ); + // The inner target is still independently enumerated, and nothing + // about the outer target's choice reaches it. + let space = search_workload(vec![("q", Rc::clone(&outer))]); let Some(NonASAPOp::Aggregate { child, .. }) = space.roots[0].1.non_asap() else { unreachable!() }; @@ -6294,8 +6128,8 @@ mod tests { .collect(); assert_eq!( inner_kinds, - vec![SketchAlgorithm::DDSketch, SketchAlgorithm::Kll], - "the nested inner aggregate keeps its own cost-model-ranked candidates" + vec![SketchAlgorithm::Kll, SketchAlgorithm::DDSketch], + "the nested inner aggregate keeps its own candidates" ); } @@ -6753,7 +6587,7 @@ mod tests { // twice for the same target. let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let mut group = TargetSubDAGCandidates::new(Rc::clone(&root), 1); - let strategy = ASAPStrategies::default_cost_model(); + let strategy = ASAPStrategies::default(); let target = TargetSubDAG::new(&root); for candidate in strategy.replacements(&target) { group.add_candidate(candidate); @@ -6886,15 +6720,8 @@ mod tests { .unwrap_or_else(|| panic!("no field {name:?} in {schema:?}")) } - fn realize_first( - expr: &OperatorNode, - cost_model: &dyn CostModel, - ) -> Result, RealizationError> { - realize_child(&Rc::new(expr.clone()), cost_model) - } - fn realize(expr: &OperatorNode) -> Result, RealizationError> { - realize_first(expr, &DefaultCostModel) + realize_child(&Rc::new(expr.clone())) } #[test] @@ -6955,122 +6782,10 @@ mod tests { assert!(!child.contains_asap()); } - /// A deployment-supplied [`CostModel`] can override the default KLL - /// choice — `realize_first` (via `realize_child`) must actually consult - /// it, not just accept and ignore it (issue: cost model interface, see - /// `crate::cost_model`). - struct PreferDDSketchViaCostModel; - - impl CostModel for PreferDDSketchViaCostModel { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { - let ddsketch = v.remove(pos); - v.insert(0, ddsketch); - } - v - } - } - - #[test] - fn realize_with_custom_cost_model_overrides_default_summary_choice() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - - // Default: KLL (see `quantile_realizes_kll_wrapped_in_estimate` above). - let default_root = realize(&q).unwrap(); - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &default_root.operator - else { - panic!( - "expected SummaryEstimate root, got {:?}", - default_root.operator - ); - }; - let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &summary_input.operator else { - panic!("expected SummaryAgg, got {:?}", summary_input.operator); - }; - assert!(matches!( - family, - FieldDataType::Sketch(kind, _) if kind.algorithm() == &SketchAlgorithm::Kll - )); - - // With `PreferDDSketchViaCostModel`: DDSketch instead, same query. - let custom_root = realize_first(&q, &PreferDDSketchViaCostModel).unwrap(); - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &custom_root.operator - else { - panic!( - "expected SummaryEstimate root, got {:?}", - custom_root.operator - ); - }; - let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &summary_input.operator else { - panic!("expected SummaryAgg, got {:?}", summary_input.operator); - }; - assert_eq!( - family, - &FieldDataType::Sketch( - SketchKind::new( - SketchAlgorithm::DDSketch, - SketchParams::DDSketch { alpha: 0.01 } - ), - GroupingStrategy::default() - ) - ); - } - - /// A deployment-supplied `CostModel` can realize an `AggIntent::Extension` - /// intent as a real sketch instead of the default `PassThrough` (issue - /// #150) — `realizations_for_intent` must consult `realize_extension` - /// for the `Extension` arm, and `evaluation` must consult - /// `evaluation_extension` to build its `SketchStatistic` without panicking. - struct FrequencyCostModel; - - impl CostModel for FrequencyCostModel { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn realize_extension(&self, ext_kind: &str, _payload: &serde_json::Value) -> Realization { - if ext_kind == "frequency" { - Realization::Sketch(SketchKind::new( - SketchAlgorithm::CountSketch, - SketchParams::CountSketch { - width: 256, - depth: 4, - }, - )) - } else { - Realization::PassThrough - } - } - - fn evaluation_extension( - &self, - ext_kind: &str, - payload: &serde_json::Value, - _col: &ColumnRef, - ) -> PostAsapSketchStatistic { - assert_eq!(ext_kind, "frequency"); - let value = payload["item"].as_str().map(str::to_string); - PostAsapSketchStatistic::PointCount { - key: ColumnRef::Named("item".into()), - value, - } - } - } - #[test] fn extension_intent_stays_logical_by_default() { - // Without a CostModel overriding `realize_extension`, an - // `Extension` intent must stay `PassThrough` -- today's behavior, - // unchanged. + // Core has no realization for a deployment-specific `Extension` + // intent, so it stays `PassThrough`. let intent = AggIntent::Extension { ext_kind: "frequency".to_string(), payload: serde_json::json!({ "item": "checkout" }), @@ -7080,46 +6795,6 @@ mod tests { assert!(!root.contains_asap()); } - #[test] - fn extension_intent_realizes_via_custom_cost_model() { - let intent = AggIntent::Extension { - ext_kind: "frequency".to_string(), - payload: serde_json::json!({ "item": "checkout" }), - }; - let q = agg(vec![], intent, metric_scan(&[])); - let root = realize_first(&q, &FrequencyCostModel).unwrap(); - - let Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) = &root.operator - else { - panic!("expected SummaryEstimate root, got {:?}", root.operator); - }; - assert!(matches!( - query, - PostAsapSketchStatistic::PointCount { key: ColumnRef::Named(k), value: Some(v) } - if k == "item" && v == "checkout" - )); - - let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &summary_input.operator else { - panic!("expected SummaryAgg, got {:?}", summary_input.operator); - }; - assert_eq!( - family, - &FieldDataType::Sketch( - SketchKind::new( - SketchAlgorithm::CountSketch, - SketchParams::CountSketch { - width: 256, - depth: 4 - } - ), - GroupingStrategy::default() - ) - ); - } - #[test] fn exact_sum_realizes_accumulator_without_estimate() { let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); @@ -7437,8 +7112,7 @@ mod tests { }, inner, ); - let proposals = - ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let proposals = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); assert!(!proposals.is_empty()); assert!(proposals.iter().any(|candidate| matches!( &candidate.replacement, @@ -7494,7 +7168,6 @@ mod tests { inner, ); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopKEvidence, @@ -7528,7 +7201,6 @@ mod tests { inner, ); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopKEvidence, @@ -7595,7 +7267,6 @@ mod tests { inner, ); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopKEvidence, @@ -7670,7 +7341,6 @@ mod tests { inner, ); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopKEvidence, @@ -7722,7 +7392,6 @@ mod tests { inner, ); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopKEvidence, @@ -7775,7 +7444,6 @@ mod tests { inner, ); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopKEvidence, @@ -7915,7 +7583,7 @@ mod tests { // typed reason and the raw/pre-ASAP alternative is what remains. let inner = agg(vec![2], default_quantile(0.5), metric_scan(&["job"])); let outer = agg(vec![], default_quantile(0.99), inner); - let proposals = ASAPStrategies::default_cost_model().propose(&TargetSubDAG::new(&outer)); + let proposals = ASAPStrategies::default().propose(&TargetSubDAG::new(&outer)); assert!( proposals.candidates.is_empty(), "no outer sketch may be proposed over an approximate child without a rule: {:?}", @@ -7937,7 +7605,7 @@ mod tests { ); } // Fallback keeps the whole sub-DAG pre-ASAP — executed exactly. - let realized = realize_child(&outer, &DefaultCostModel).unwrap(); + let realized = realize_child(&outer).unwrap(); assert!(!realized.contains_asap()); assert!(realized .guarantee @@ -7947,7 +7615,7 @@ mod tests { // Cross-metric: a quantile over a cardinality estimate. let inner = agg(vec![2], default_cardinality(), metric_scan(&["job"])); let outer = agg(vec![], default_quantile(0.99), inner); - let proposals = ASAPStrategies::default_cost_model().propose(&TargetSubDAG::new(&outer)); + let proposals = ASAPStrategies::default().propose(&TargetSubDAG::new(&outer)); assert!(proposals.candidates.is_empty()); assert!(proposals.rejected.iter().all(|r| matches!( &r.error, @@ -8032,11 +7700,8 @@ mod tests { // summary levels explicit while preserving the composed guarantee. let inner = agg(vec![2], quantile_eps(0.5, 0.1), metric_scan(&["job"])); let outer = agg(vec![], quantile_eps(0.99, 0.1), inner); - let strategy = ASAPStrategies::new_with_planning_inputs( - &DefaultCostModel, - &RankAdditiveModel, - &EqualSplitAllocator, - ); + let strategy = + ASAPStrategies::new_with_planning_inputs(&RankAdditiveModel, &EqualSplitAllocator); let proposals = strategy.propose(&TargetSubDAG::new(&outer)); assert!(!proposals.candidates.is_empty()); @@ -8059,12 +7724,9 @@ mod tests { // and global cost ranking. let inner = agg(vec![2], quantile_eps(0.5, 0.1), metric_scan(&["job"])); let outer = agg(vec![], quantile_eps(0.99, 0.1), inner); - let strategies: Vec> = - vec![Box::new(ASAPStrategies::new_with_planning_inputs( - &DefaultCostModel, - &RankAdditiveModel, - &EqualSplitAllocator, - ))]; + let strategies: Vec> = vec![Box::new( + ASAPStrategies::new_with_planning_inputs(&RankAdditiveModel, &EqualSplitAllocator), + )]; let space = search_workload_with(vec![("q", Rc::clone(&outer))], &strategies); let root = &space.roots[0].1; let group = space.candidates_for_target(root).unwrap(); @@ -8237,7 +7899,6 @@ mod tests { max_distinct: 128, }; let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &evidence, @@ -8276,7 +7937,7 @@ mod tests { precision: expected } ); - let absent = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let absent = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); assert!(!absent.iter().any(|candidate| matches!(&candidate.replacement, Replacement::SubDAG(node) if summary_family_algorithm(node) == SketchAlgorithm::Hll && node.guarantee.as_ref().is_some_and(|g| DefaultAccuracyModel.satisfies(g, &target))))); // Invalid contracts, infeasible targets and evidence for another source @@ -8308,7 +7969,6 @@ mod tests { max_distinct, }; let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &evidence, @@ -8380,7 +8040,7 @@ mod tests { metric_scan(&["job"]), ); let source = Source::TimeSeries { metric: "m".into() }; - let proposals = ASAPStrategies::default_cost_model().propose(&TargetSubDAG::new(&root)); + let proposals = ASAPStrategies::default().propose(&TargetSubDAG::new(&root)); let states: Vec<_> = proposals .candidates .iter() diff --git a/crates/asap-physical-operators/tests/deployment_computation.rs b/crates/asap-physical-operators/tests/deployment_computation.rs index 0395aaa0..623d65ca 100644 --- a/crates/asap-physical-operators/tests/deployment_computation.rs +++ b/crates/asap-physical-operators/tests/deployment_computation.rs @@ -628,7 +628,7 @@ fn stored_count_min_bare_count_compiles_to_a_evaluation() { use asap_aware_mapping::{Replacement, ReplacementStrategy, TargetSubDAG}; use asap_physical_operators::summary_kernels::CountMinSketchAccumulator; let root = lower_with("count(up)", AccuracyTarget::Epsilon(0.02)); - let dag = asap_aware_mapping::ASAPStrategies::new(&asap_aware_mapping::DefaultCostModel) + let dag = asap_aware_mapping::ASAPStrategies::default() .replacements(&TargetSubDAG::new(&root)) .into_iter() .find_map(|candidate| match candidate.replacement { diff --git a/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs b/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs index b9f99104..a8f5728a 100644 --- a/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs +++ b/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs @@ -111,12 +111,11 @@ fn inventories(query: &str, accuracy: AccuracyTarget) -> (Vec, Vec ), (1, lower(query, &accuracy), Some(accuracy)), ]; - let full = default_strategies_with_evidence(&DefaultCostModel, &Evidence); - let logical: Vec> = - default_strategies_with_evidence(&DefaultCostModel, &Evidence) - .into_iter() - .map(|strategy| Box::new(LogicalOnly(strategy)) as Box) - .collect(); + let full = default_strategies_with_evidence(&Evidence); + let logical: Vec> = default_strategies_with_evidence(&Evidence) + .into_iter() + .map(|strategy| Box::new(LogicalOnly(strategy)) as Box) + .collect(); let enumerate = |strategies: &[Box]| { search_workload_with_targets(roots.clone(), strategies, &DefaultAccuracyModel) .enumerate_candidate_dags_for_root(&1, 65_536) @@ -215,7 +214,7 @@ fn unrelated_queries_keep_their_inventory() { fn global_selection_never_commits_a_series_identity_heap() { let accuracy = AccuracyTarget::Epsilon(0.1); let root = lower(CURRENT_SERIES_TOPK, &accuracy); - let strategies = default_strategies_with_evidence(&DefaultCostModel, &Evidence); + let strategies = default_strategies_with_evidence(&Evidence); let space = search_workload_with_targets( vec![(0, root, Some(accuracy))], &strategies, @@ -233,7 +232,7 @@ fn global_selection_never_commits_a_series_identity_heap() { #[test] fn repeated_roots_do_not_duplicate_alternatives() { let accuracy = AccuracyTarget::Epsilon(0.1); - let strategies = default_strategies_with_evidence(&DefaultCostModel, &Evidence); + let strategies = default_strategies_with_evidence(&Evidence); let count = |copies: usize| { let roots = (0..copies) .map(|id| { diff --git a/crates/asap-physical-operators/tests/weighted_topk_binding.rs b/crates/asap-physical-operators/tests/weighted_topk_binding.rs index 31ab8ed4..115369c9 100644 --- a/crates/asap-physical-operators/tests/weighted_topk_binding.rs +++ b/crates/asap-physical-operators/tests/weighted_topk_binding.rs @@ -4,7 +4,6 @@ use asap_aware_mapping::{ accuracy::{ AccuracyEvidenceProvider, DefaultAccuracyModel, EqualSplitAllocator, PropagationStats, }, - cost_model::DefaultCostModel, ASAPStrategies, Replacement, ReplacementStrategy, TargetSubDAG, }; use asap_physical_operators::dag::{ @@ -70,7 +69,6 @@ fn assert_weighted_binding(evidence: &dyn AccuracyEvidenceProvider, algorithm: S ) .unwrap(); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, evidence, @@ -311,7 +309,6 @@ fn check_direct_rate_topk(dynamic: bool) { } let root = logical; let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &Evidence, @@ -636,7 +633,6 @@ fn spatial_topk_exposes_signed_heap_candidate_over_complete_snapshot() { let logical = lower_promql("topk by(job)(1, m)", AccuracyTarget::Epsilon(0.1)).unwrap(); let root = Rc::new(with_series_identity(&logical).unwrap()); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &Evidence, @@ -820,7 +816,6 @@ fn maintained_rate_heap_compiles_fixed_window_precompute() { .unwrap(), ); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &Evidence, diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index d2d5e072..43d107e6 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -1236,7 +1236,7 @@ fn run_post_asap_with_progress( .collect(); let strategies; let space = if let Some(evidence) = evidence { - strategies = default_strategies_with_evidence(cost_model, evidence); + strategies = default_strategies_with_evidence(evidence); search_workload_with(roots, &strategies) } else { search_workload(roots) diff --git a/crates/devtools/src/bin/show_post_asap_ir.rs b/crates/devtools/src/bin/show_post_asap_ir.rs index 7599ce14..757809fc 100644 --- a/crates/devtools/src/bin/show_post_asap_ir.rs +++ b/crates/devtools/src/bin/show_post_asap_ir.rs @@ -39,7 +39,7 @@ const ACCURACY: AccuracyTarget = AccuracyTarget::Epsilon(0.01); /// If the strategy has none, preserve the single pre-ASAP fallback output. fn bind_all(root: &Rc) -> Result>, String> { let target = TargetSubDAG::new(root); - let candidates = ASAPStrategies::default_cost_model() + let candidates = ASAPStrategies::default() .replacements(&target) .into_iter() .filter_map(|candidate| match candidate { @@ -149,7 +149,7 @@ mod tests { 1_000, ) .expect("query lowers to pre-ASAP IR"); - let expected = ASAPStrategies::default_cost_model() + let expected = ASAPStrategies::default() .replacements(&TargetSubDAG::new(&expr)) .len(); diff --git a/crates/frontend-promql/tests/count_planning.rs b/crates/frontend-promql/tests/count_planning.rs index 8e21e732..fd2b7128 100644 --- a/crates/frontend-promql/tests/count_planning.rs +++ b/crates/frontend-promql/tests/count_planning.rs @@ -52,8 +52,7 @@ fn grouped_count_keeps_uncertified_hydra_candidates_for_backend_review() { fn exact_counts_select_count_accumulators() { for query in ["count(up)", "count by(job)(up)", "count_over_time(up[5m])"] { let root = lower_promql(query, AccuracyTarget::Exact).unwrap(); - let candidates = - ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); assert!( candidates.iter().any(|candidate| { matches!(&candidate.replacement, Replacement::SubDAG(node) @@ -70,8 +69,7 @@ fn exact_counts_select_count_accumulators() { fn frequency_count_candidates_use_unit_weights() { for query in ["count_over_time(up[5m])", "count(up)"] { let root = lower_promql(query, AccuracyTarget::Epsilon(0.02)).unwrap(); - let candidates = - ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); let mut algorithms = Vec::new(); for candidate in &candidates { let Replacement::SubDAG(node) = &candidate.replacement else { @@ -228,7 +226,7 @@ fn count_over_time_counts_scrapes_not_sample_values() { fn cms_count_updates_total_ten_for_zero_positive_and_negative_samples() { use asap_types::ir::scalar::ColumnRef; let root = lower_promql("count_over_time(up[5m])", AccuracyTarget::Epsilon(0.02)).unwrap(); - let candidates = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); let dag = candidates .iter() .find_map(|candidate| { diff --git a/crates/frontend-promql/tests/observability/metrics_observability.rs b/crates/frontend-promql/tests/observability/metrics_observability.rs index d40df623..5875dbc9 100644 --- a/crates/frontend-promql/tests/observability/metrics_observability.rs +++ b/crates/frontend-promql/tests/observability/metrics_observability.rs @@ -65,7 +65,7 @@ fn queries(corpus: &str) -> impl Iterator { fn post_asap_candidate(root: &Rc) -> Result, RealizationError> { let target = TargetSubDAG::new(root); - match ASAPStrategies::default_cost_model() + match ASAPStrategies::default() .replacements(&target) .into_iter() .next() diff --git a/crates/frontend-promql/tests/observability/promql_corpus.rs b/crates/frontend-promql/tests/observability/promql_corpus.rs index 45afdeb5..3c6b923e 100644 --- a/crates/frontend-promql/tests/observability/promql_corpus.rs +++ b/crates/frontend-promql/tests/observability/promql_corpus.rs @@ -34,7 +34,7 @@ use support::lower_promql; /// check over the whole corpus wants. fn bind(root: &Rc) -> Result, RealizationError> { let target = TargetSubDAG::new(root); - match ASAPStrategies::default_cost_model() + match ASAPStrategies::default() .replacements(&target) .into_iter() .next() diff --git a/crates/frontend-promql/tests/univmon_candidates.rs b/crates/frontend-promql/tests/univmon_candidates.rs index 0a1709da..b5817f4a 100644 --- a/crates/frontend-promql/tests/univmon_candidates.rs +++ b/crates/frontend-promql/tests/univmon_candidates.rs @@ -52,7 +52,7 @@ impl AccuracyModel for TestEvidence { fn candidate(query: &str, accuracy: AccuracyTarget) -> Rc { let root = lower_promql(query, accuracy).unwrap(); - ASAPStrategies::new_with_planning_inputs(&DefaultCostModel, &TestEvidence, &EqualSplitAllocator) + ASAPStrategies::new_with_planning_inputs(&TestEvidence, &EqualSplitAllocator) .replacements(&TargetSubDAG::new(&root)) .into_iter() .find_map(|candidate| { @@ -136,8 +136,7 @@ fn uncalibrated_frequency_evaluations_do_not_bypass_accuracy_targets() { }, ] { let root = lower_promql(query, target.clone()).unwrap(); - let candidates = - ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); let unknown = candidates .iter() .filter(|candidate| { diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs index 25679a94..b978f603 100644 --- a/crates/integration-tests/tests/exact_composition.rs +++ b/crates/integration-tests/tests/exact_composition.rs @@ -5,8 +5,8 @@ //! //! Covers the issue's integration matrix: both nesting directions, grouped //! fine-to-coarse and identity folds, one inner summary shared by several -//! queries, phase-aware summary construction, a runtime without -//! the capability, a cost model without statistics, and pre/post-ASAP +//! queries, phase-aware summary construction, unknown runtime support, +//! a cost model without statistics, and pre/post-ASAP //! schemas plus shared `Rc` identity — along with pins for every //! already-supported exact-accumulator nesting. @@ -14,12 +14,11 @@ use std::rc::Rc; use asap_aware_mapping::cost_model::{ CostProvenance, CostUnit, ExactCompositionCostInputs, ExactCompositionCostRequest, - ValueOperationCapabilities, }; use asap_aware_mapping::exact_composition::ExactOperation; use asap_aware_mapping::replacement::{ - default_strategies_with, search_workload_with, ASAPStrategies, Replacement, - ReplacementProvenance, ReplacementStrategy, TargetSubDAG, + default_strategies, search_workload_with, ASAPStrategies, Replacement, ReplacementProvenance, + ReplacementStrategy, TargetSubDAG, }; use asap_aware_mapping::{ CostModel, DefaultCostModel, EvaluationRate, ExplanationKind, OperationPlacement, @@ -135,7 +134,7 @@ fn custom_accuracy_rule_survives_root_target_and_materialization() { let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); let space = asap_aware_mapping::replacement::search_workload_with_targets( vec![("q", root, Some(AccuracyTarget::Exact))], - &default_strategies_with(&StatsModel), + &default_strategies(), &Model, ); let selection = space.global_selection(&StatsModel); @@ -159,7 +158,7 @@ fn root_target_rejects_unproven_composition() { let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); let space = asap_aware_mapping::replacement::search_workload_with_targets( vec![("q", root, Some(AccuracyTarget::Exact))], - &default_strategies_with(&StatsModel), + &default_strategies(), &asap_aware_mapping::DefaultAccuracyModel, ); let selection = space.global_selection(&StatsModel); @@ -211,32 +210,6 @@ impl CostModel for StatsModel { } } -/// Same statistics, but the runtime advertises no mixed-execution shape. -struct NoCapabilityModel; - -impl CostModel for NoCapabilityModel { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn value_operation_capabilities(&self) -> ValueOperationCapabilities { - ValueOperationCapabilities::NONE - } - fn exact_composition_cost_inputs( - &self, - request: &ExactCompositionCostRequest<'_>, - ) -> ExactCompositionCostInputs { - StatsModel.exact_composition_cost_inputs(request) - } -} - /// Complete cost evidence does not imply runtime support evidence. struct UnknownCapabilityModel; @@ -259,7 +232,7 @@ impl CostModel for UnknownCapabilityModel { #[test] fn unknown_runtime_capability_keeps_candidate_but_prevents_selection() { let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = plan(vec![("q", root)], &UnknownCapabilityModel); + let space = plan(vec![("q", root)]); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); assert!(group.candidates.iter().any(|candidate| { matches!(candidate.replacement, Replacement::ExactComposition(_)) @@ -287,9 +260,8 @@ fn unknown_runtime_capability_keeps_candidate_but_prevents_selection() { fn plan( roots: Vec<(&'static str, Rc)>, - cost_model: &dyn CostModel, ) -> asap_aware_mapping::CandidateLogicalASAPDAGs<&'static str> { - search_workload_with(roots, &default_strategies_with(cost_model)) + search_workload_with(roots, &default_strategies()) } fn is_plain(node: &OperatorNode) -> bool { @@ -379,7 +351,7 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { for (inner, kind) in cases { let outer = agg(vec![], default_quantile(0.9), inner); let target = TargetSubDAG::new(&outer); - let candidates = ASAPStrategies::default_cost_model().replacements(&target); + let candidates = ASAPStrategies::default().replacements(&target); let Replacement::SubDAG(root) = &candidates[0].replacement else { unreachable!() }; @@ -423,7 +395,7 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { for intent in [AggIntent::Max { col: None }, AggIntent::Avg { col: None }] { let root = agg(vec![0], intent.clone(), fine_quantile()); - let space = plan(vec![("q", Rc::clone(&root))], &StatsModel); + let space = plan(vec![("q", Rc::clone(&root))]); let root = Rc::clone(&space.roots[0].1); let Some(NonASAPOp::Aggregate { child: inner, .. }) = root.non_asap() else { unreachable!() @@ -516,7 +488,7 @@ fn avg_over_quantile_keeps_the_sum_over_count_rewrite_as_a_competitor() { // non-null quantile output, which is what the rewrite requires. let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); let root = agg(vec![0], AggIntent::Avg { col: None }, inner); - let space = plan(vec![("q", root)], &StatsModel); + let space = plan(vec![("q", root)]); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); let provenances: Vec<_> = group.candidates.iter().map(|c| c.provenance).collect(); assert!(provenances.contains(&ReplacementProvenance::LogicalRewrite)); @@ -534,7 +506,7 @@ fn identity_and_genuine_multi_row_folds_both_compose() { ("fine-to-coarse", fine_quantile()), ] { let root = agg(vec![0], AggIntent::Max { col: None }, inner); - let space = plan(vec![("q", root)], &StatsModel); + let space = plan(vec![("q", root)]); let root = &space.roots[0].1; let composed = space .global_selection(&StatsModel) @@ -567,7 +539,7 @@ fn identity_and_genuine_multi_row_folds_both_compose() { fn a_shared_inner_summary_is_materialized_once_for_several_outer_folds() { let max = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); let min = agg(vec![0], AggIntent::Min { col: None }, fine_quantile()); - let space = plan(vec![("max", max), ("min", min)], &StatsModel); + let space = plan(vec![("max", max), ("min", min)]); let selection = space.global_selection(&StatsModel); let roots: Vec> = space.roots.iter().map(|(_, r)| Rc::clone(r)).collect(); @@ -643,7 +615,7 @@ fn outer_summary_over_an_exact_function_composes_at_ingestion_time() { }), ); let root = agg(vec![], default_quantile(0.99), deriv); - let space = plan(vec![("q", root)], &StatsModel); + let space = plan(vec![("q", root)]); let root = Rc::clone(&space.roots[0].1); let Some(NonASAPOp::Aggregate { child: deriv, .. }) = root.non_asap() else { unreachable!() @@ -694,7 +666,7 @@ fn outer_summary_over_an_exact_function_composes_at_ingestion_time() { #[test] fn summary_construction_follows_its_value_input_phase() { let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = plan(vec![("q", Rc::clone(&root))], &StatsModel); + let space = plan(vec![("q", Rc::clone(&root))]); let post = space .global_selection(&StatsModel) .assemble_selected_dag(&space.roots[0].1) @@ -724,27 +696,6 @@ fn summary_construction_follows_its_value_input_phase() { asap_types::ir::validate_maintained(&illegal, state.timing).unwrap(); } -#[test] -fn a_runtime_without_mixed_execution_gets_no_composition_candidates() { - let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = plan(vec![("q", root)], &NoCapabilityModel); - let root = Rc::clone(&space.roots[0].1); - let group = space.candidates_for_target(&root).unwrap(); - assert!(group - .candidates - .iter() - .all(|c| !matches!(c.replacement, Replacement::ExactComposition(_)))); - let selection = space.global_selection(&NoCapabilityModel); - assert!(selection.for_target(&root).unwrap().composition.is_none()); - let node = selection.assemble_selected_dag(&root).unwrap().unwrap(); - assert!(!is_query_time_fold(&node)); - // The inner quantile is still independently selectable. - let Some(NonASAPOp::Aggregate { child, .. }) = root.non_asap() else { - unreachable!() - }; - assert!(selection.for_target(child).unwrap().chosen.is_some()); -} - /// Without statistics (the built-in model) the composition is *proposed* /// — visible in `CandidateLogicalASAPDAGs` and explanations — but never *selected*: the /// site keeps a non-composed alternative, and the inner summary stays @@ -752,7 +703,7 @@ fn a_runtime_without_mixed_execution_gets_no_composition_candidates() { #[test] fn missing_cost_statistics_preserve_the_conservative_retain_exact() { let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = plan(vec![("q", root)], &DefaultCostModel); + let space = plan(vec![("q", root)]); let root = Rc::clone(&space.roots[0].1); assert!(space .candidates_for_target(&root) @@ -781,7 +732,7 @@ fn missing_cost_statistics_preserve_the_conservative_retain_exact() { #[test] fn dag_export_carries_explicit_stage_and_plain_schema_for_a_composed_plan() { let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = plan(vec![("q", root)], &StatsModel); + let space = plan(vec![("q", root)]); let root = &space.roots[0].1; let composed = space .global_selection(&StatsModel) @@ -824,7 +775,7 @@ fn promql_max_by_zone_over_quantile_over_time_composes() { AccuracyTarget::Epsilon(0.01), ) .unwrap(); - let space = plan(vec![("q", expr)], &StatsModel); + let space = plan(vec![("q", expr)]); let root = &space.roots[0].1; let selection = space.global_selection(&StatsModel); let selected = selection.for_target(root).unwrap(); diff --git a/crates/integration-tests/tests/precompute_raw_samples.rs b/crates/integration-tests/tests/precompute_raw_samples.rs index 1e2328e3..c0157511 100644 --- a/crates/integration-tests/tests/precompute_raw_samples.rs +++ b/crates/integration-tests/tests/precompute_raw_samples.rs @@ -57,7 +57,7 @@ fn canonical(labels: &Series) -> Series { /// summary replacement of the root. fn candidates(query: &str, accuracy: AccuracyTarget) -> Vec> { let root = lower_promql(query, accuracy).expect("lowering failed"); - let mut result = ASAPStrategies::default_cost_model() + let mut result = ASAPStrategies::default() .replacements(&TargetSubDAG::new(&root)) .into_iter() .filter_map(|candidate| match candidate { diff --git a/crates/integration-tests/tests/promql_numeric_regressions.rs b/crates/integration-tests/tests/promql_numeric_regressions.rs index b844849e..292c9a0e 100644 --- a/crates/integration-tests/tests/promql_numeric_regressions.rs +++ b/crates/integration-tests/tests/promql_numeric_regressions.rs @@ -13,7 +13,7 @@ use std::rc::Rc; fn plan(query: &str, accuracy: AccuracyTarget) -> Rc { let pre = lower_promql(query, accuracy).unwrap(); - ASAPStrategies::default_cost_model() + ASAPStrategies::default() .replacements(&TargetSubDAG::new(&pre)) .into_iter() .find_map(|r| match r.replacement { @@ -200,10 +200,8 @@ impl asap_aware_mapping::accuracy::AccuracyEvidenceProvider for OneKeyTopKEviden #[test] fn sketch_counts_use_unit_weights_and_signed_sums_keep_value_weights() { use asap_aware_mapping::accuracy::{DefaultAccuracyModel, EqualSplitAllocator}; - use asap_aware_mapping::cost_model::DefaultCostModel; use asap_types::ir::schema::{NonNegativeWeightProof, SketchAlgorithm, WeightDomain}; let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &OneKeyTopKEvidence, diff --git a/crates/integration-tests/tests/promql_to_post_asap.rs b/crates/integration-tests/tests/promql_to_post_asap.rs index bae63fef..92632c0e 100644 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ b/crates/integration-tests/tests/promql_to_post_asap.rs @@ -41,7 +41,7 @@ use asap_types::types::AccuracyTarget; /// single-answer pins below don't all repeat it by hand. fn realize(root: &Rc) -> Result, RealizationError> { let target = TargetSubDAG::new(root); - match ASAPStrategies::default_cost_model() + match ASAPStrategies::default() .replacements(&target) .into_iter() .next() @@ -69,7 +69,7 @@ fn distinct_over_time_offers_hll_cardinality_evaluation() { AccuracyTarget::Epsilon(0.02), ) .unwrap(); - let candidates = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); for candidate in &candidates { if let Replacement::SubDAG(node) = &candidate.replacement { node.validate_structure().unwrap(); @@ -269,7 +269,6 @@ fn grouped_rate_topk_consumes_finalized_rate_values() { ) .unwrap(); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopK, @@ -335,7 +334,6 @@ fn weighted_topk_keeps_candidates_with_missing_population_evidence() { ) .unwrap(); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &NoPopulationBound, @@ -358,7 +356,7 @@ fn weighted_topk_exports_symbolic_evidence_requirements() { }, ) .unwrap(); - let candidates = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); let candidate = candidates .iter() .find(|candidate| candidate.rationale.contains("CmsWithHeap")) @@ -389,7 +387,6 @@ fn weighted_topk_rejects_invalid_population_evidence() { ) .unwrap(); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &InvalidPopulation, @@ -414,7 +411,6 @@ fn rate_and_increase_topk_use_summary_scores_and_grouped_limits() { ) .unwrap(); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopK, @@ -577,10 +573,7 @@ fn ddsketch_quantile_ratio_meets_the_shared_relative_error_target() { }; let space = search_workload_with_targets( vec![("ratio", query, Some(target.clone()))], - &asap_aware_mapping::replacement::default_strategies_with_evidence( - &DefaultCostModel, - &evidence, - ), + &asap_aware_mapping::replacement::default_strategies_with_evidence(&evidence), &DefaultAccuracyModel, ); let root = &space.roots[0].1; @@ -642,7 +635,6 @@ fn planner_only_e2e_temporal_topk_preserves_query_update_and_evaluation_contract ) .expect("lower temporal Top-K"); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopK, @@ -811,7 +803,6 @@ fn planner_heap_topk_reference_execution_matches_ground_truth() { ) .unwrap(); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &SeparatedTopK, @@ -1232,7 +1223,6 @@ fn ddsketch_ratio_rejects_unsafe_domains() { ) .unwrap(); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &evidence, @@ -1273,7 +1263,6 @@ fn ddsketch_ratio_rejects_one_invalid_domain_when_the_other_is_missing() { ) .unwrap(); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &PartialUnsafeDomain, @@ -1295,7 +1284,6 @@ fn ddsketch_ratio_bound_holds_for_signed_pinned_sketch_evaluations() { ) .unwrap(); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &evidence, @@ -1371,7 +1359,6 @@ fn ddsketch_ratio_requires_a_supported_population_size() { for count in [0, (1u64 << 53) + 1] { let evidence = PopulationEvidence(count); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &evidence, diff --git a/crates/integration-tests/tests/sql_to_post_asap.rs b/crates/integration-tests/tests/sql_to_post_asap.rs index 06201590..39f5741f 100644 --- a/crates/integration-tests/tests/sql_to_post_asap.rs +++ b/crates/integration-tests/tests/sql_to_post_asap.rs @@ -48,7 +48,7 @@ use asap_types::workload::SqlDialect; /// single-answer pins below don't all repeat it by hand. fn realize(target: &Rc) -> Result, RealizationError> { let target_dag = TargetSubDAG::new(target); - match ASAPStrategies::default_cost_model() + match ASAPStrategies::default() .replacements(&target_dag) .into_iter() .next() diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index 910853ab..fd3657d4 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -473,12 +473,9 @@ fn certified_frequency_evaluations_share_one_univmon_state() { .enumerate() .map(|(index, (expr, (_, epsilon)))| (index, expr, Some(AccuracyTarget::Epsilon(epsilon)))) .collect(); - let strategies: Vec> = - vec![Box::new(ASAPStrategies::new_with_planning_inputs( - &PREFER_UNIVMON, - &UnivMonEvidence, - &EqualSplitAllocator, - ))]; + let strategies: Vec> = vec![Box::new( + ASAPStrategies::new_with_planning_inputs(&UnivMonEvidence, &EqualSplitAllocator), + )]; let space = search_workload_with_targets(roots, &strategies, &UnivMonEvidence); let selection = space.global_selection(&PREFER_UNIVMON); let assembled = space diff --git a/crates/types/src/ir/schema/state_type.rs b/crates/types/src/ir/schema/state_type.rs index 2c26b0f3..e05802e0 100644 --- a/crates/types/src/ir/schema/state_type.rs +++ b/crates/types/src/ir/schema/state_type.rs @@ -625,7 +625,7 @@ pub enum SketchStatistic { /// `count(cms_metric{item="checkout"})` — `key` is `item`, `value` is /// `"checkout"`). `value` is carried here rather than resolved by the /// `SummaryExecutor` from a `Filter` predicate because `evaluation`'s - /// trait signature has no dag access — see `CostModel::evaluation_extension`. + /// signature has no dag access. PointCount { key: ColumnRef, value: Option, From 6f14fdb0b44e9539029f0d509d3e2742ae89c4bf Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:42:52 +0000 Subject: [PATCH 3/4] test(planner): guard that Stage 1 does not import the cost model Scans the Stage 1 modules' non-test code and fails on any import of `cost_model` or `recurrence`, including names `lib.rs` re-exports from them. Co-Authored-By: Claude Opus 5.5 --- .../tests/stage1_cost_independence.rs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 crates/asap-aware-mapping/tests/stage1_cost_independence.rs diff --git a/crates/asap-aware-mapping/tests/stage1_cost_independence.rs b/crates/asap-aware-mapping/tests/stage1_cost_independence.rs new file mode 100644 index 00000000..0467563b --- /dev/null +++ b/crates/asap-aware-mapping/tests/stage1_cost_independence.rs @@ -0,0 +1,116 @@ +//! Stage 1 (logical candidate generation) stays independent of the cost +//! model: only Stage 3 prices plans (#572, decision Q36(a)). + +use std::path::Path; + +/// The Stage 1 modules, relative to `src/`. +const STAGE1_MODULES: &[&str] = &[ + "replacement.rs", + "exact_composition.rs", + "grouping.rs", + "rollup.rs", + "topk_reuse.rs", + "rewrite.rs", + "maintained_population.rs", + "accuracy/reconciliation.rs", + "logical_candidates.rs", + "function_rules.rs", +]; + +const FORBIDDEN_MODULES: &[&str] = &["cost_model", "recurrence"]; + +/// The source lines outside `#[cfg(test)]` items and comments, numbered. +fn production_lines(source: &str) -> Vec<(usize, &str)> { + let mut lines = Vec::new(); + let mut skip_next_item = false; + let mut depth = 0i64; + for (index, line) in source.lines().enumerate() { + let trimmed = line.trim(); + if depth > 0 { + depth += brace_balance(line); + continue; + } + if trimmed == "#[cfg(test)]" { + skip_next_item = true; + continue; + } + if skip_next_item { + if trimmed.starts_with("#[") || trimmed.is_empty() { + continue; + } + skip_next_item = false; + depth = brace_balance(line); + continue; + } + if !trimmed.starts_with("//") { + lines.push((index + 1, line)); + } + } + lines +} + +fn brace_balance(line: &str) -> i64 { + line.chars() + .map(|c| match c { + '{' => 1, + '}' => -1, + _ => 0, + }) + .sum() +} + +/// Names `lib.rs` re-exports from `module` (`pub use module::{...};`). +fn reexports<'a>(lib: &'a str, module: &str) -> Vec<&'a str> { + let start = format!("pub use {module}::{{"); + let Some(begin) = lib.find(&start) else { + return Vec::new(); + }; + let rest = &lib[begin + start.len()..]; + rest[..rest.find('}').expect("re-export list is closed")] + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect() +} + +/// Stage 1 production code imports neither `cost_model` nor `recurrence`. +#[test] +fn stage1_does_not_import_cost_model_or_recurrence() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let lib = std::fs::read_to_string(src.join("lib.rs")).unwrap(); + let forbidden: Vec<&str> = FORBIDDEN_MODULES + .iter() + .copied() + .chain( + FORBIDDEN_MODULES + .iter() + .flat_map(|module| reexports(&lib, module)), + ) + .collect(); + let mut offenders = Vec::new(); + for module in STAGE1_MODULES { + let source = std::fs::read_to_string(src.join(module)).unwrap(); + let mut in_use = false; + for (number, line) in production_lines(&source) { + in_use |= line.trim_start().starts_with("use ") || line.contains(" use "); + let path_use = FORBIDDEN_MODULES + .iter() + .any(|module| line.contains(&format!("{module}::"))); + let imported = in_use + && line + .split(|c: char| !(c.is_alphanumeric() || c == '_')) + .any(|token| forbidden.contains(&token)); + if path_use || imported { + offenders.push(format!("{module}:{number}: {}", line.trim())); + } + if line.contains(';') { + in_use = false; + } + } + } + assert!( + offenders.is_empty(), + "Stage 1 must not depend on the cost model:\n{}", + offenders.join("\n") + ); +} From 07c4e47a33dcd70223a2a57995f024798fa9d28a Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:48:27 +0000 Subject: [PATCH 4/4] docs(planner): describe cost-free Stage 1 candidate generation Update the developer docs and the `CostModel` trait doc: strategies take no cost model, sketch sizing is analytical, extension intents stay pass-through, and the cost model is consulted only at selection. Co-Authored-By: Claude Opus 5.5 --- crates/asap-aware-mapping/src/cost_model.rs | 11 +- .../asap-aware-mapping-architecture.md | 28 ++-- .../asap-aware-mapping-contracts.md | 57 ++----- .../end-to-end-accuracy-guarantees.md | 2 +- .../develop_docs/extend-asap-aware-mapping.md | 144 ++++-------------- docs/develop_docs/library-api.md | 47 +++--- .../metrics-observability-corpora.md | 2 +- 7 files changed, 86 insertions(+), 205 deletions(-) diff --git a/crates/asap-aware-mapping/src/cost_model.rs b/crates/asap-aware-mapping/src/cost_model.rs index 0ea26b45..8735cb8d 100644 --- a/crates/asap-aware-mapping/src/cost_model.rs +++ b/crates/asap-aware-mapping/src/cost_model.rs @@ -362,15 +362,14 @@ pub fn default_cse_shared_maintenance_cost(family: &FieldDataType) -> Cost { Cost(weight * UNIT) } -/// Ranks the candidate sketch algorithms for one [`AggIntent`], best choice -/// first. +/// Selection-time preferences and costs over the candidates Stage 1 +/// generates. /// /// [`replacement::summary_candidates`] returns every algorithm that *can* answer an /// intent, in an arbitrary static preference order (issue #98's "one home" -/// for the candidate set). A `CostModel` re-orders that list under real, -/// deployment-specific cost knowledge this crate has no way to know about — -/// `replacement::realizations_for_intent` constructs every candidate in the -/// resulting order. +/// for the candidate set), and candidate generation keeps that order. A +/// `CostModel` re-orders the candidates when they are selected, under real, +/// deployment-specific cost knowledge this crate has no way to know about. pub trait CostModel { /// Whether [`Self::candidate_cost`] prices a complete physical /// alternative, including its raw baseline, rather than a local diff --git a/docs/develop_docs/asap-aware-mapping-architecture.md b/docs/develop_docs/asap-aware-mapping-architecture.md index eaf3d879..a71a0166 100644 --- a/docs/develop_docs/asap-aware-mapping-architecture.md +++ b/docs/develop_docs/asap-aware-mapping-architecture.md @@ -42,7 +42,9 @@ A strategy should answer: A cost model should answer: -> Given valid choices, which choices are preferable, and how should they be parameterized? +> Given valid choices, which choices are preferable? + +It is consulted only at selection time; sketch parameters come from the analytical estimators. Do not put cost-based pruning into a `ReplacementStrategy`. A strategy must enumerate every valid alternative, even when the default cost model clearly prefers one. See [Rule 2](extend-asap-aware-mapping.md#rule-2-enumerate-do-not-rank). @@ -98,8 +100,6 @@ flowchart TB STRATEGY["ReplacementStrategy
when a target matches, enumerate every legal replacement;
implementations generate but do not choose"]:::generate CAND["ReplacementSubDAG candidates
each contains a Subtree (summary or logical rewrite) or ExactComposition
plus typed provenance and rationale;
no alternative is removed solely on cost"]:::store TARGET -->|"try every registered strategy"| STRATEGY --> CAND - CM(["CostModel
orders candidates and supplies
deployment-specific parameters"]):::choose - CM -. "rank and parameterize; accuracy checks remain required" .-> STRATEGY end subgraph SEARCHSPACE[3. Store the workload-wide search space] @@ -108,7 +108,9 @@ flowchart TB end subgraph RANKING[Optional ranked view] + CM(["CostModel
selection-time preferences and costs"]):::choose SORT["CandidateLogicalASAPDAGs::cost_sorted
use the CostModel to order each candidate set
and cost every candidate"]:::choose + CM -.-> SORT RANKED["RankedTargetSubDAGCandidates
the same candidates in preferred order,
with costs aligned by index"]:::choose SPACE --> SORT -->|"reorder only; preserve every candidate"| RANKED end @@ -193,9 +195,8 @@ capability and accuracy checks; supported algorithm applicability alone is not a result certificate. The complete `replacements()` result is the candidate set produced by one -strategy for one target. A strategy may order or parameterize candidates with -help from a `CostModel`, but it must not remove a valid candidate because of -cost. +strategy for one target. Strategies take no `CostModel` and must not remove a +valid candidate because of cost; ranking happens at selection time. ### 3.3 Current concrete strategies @@ -204,9 +205,9 @@ The default context-free registry contains five `ReplacementStrategy` implementa - `ASAPStrategies` matches supported aggregate and binary shapes. Its `replacements(target)` method constructs every legal post-ASAP summary sub-DAG, including applicable sketch, exact-accumulator, and pass-through - realizations. Candidates are sized and ordered for the target's accuracy - requirement; candidates without a sufficient guarantee are rejected before - costing. + realizations. Candidates are sized analytically for the target's accuracy + requirement and listed in `summary_candidates` order; candidates without a + sufficient guarantee are rejected before costing. - `SharedSubDAGStrategy` uses `consumer_count` to identify shared targets. It emits both build-once-and-share and recompute-independently rewrites when a target has multiple consumers. @@ -215,8 +216,8 @@ The default context-free registry contains five `ReplacementStrategy` implementa - `ExactCompositionStrategy` preserves child-target references for compatible composition selection. -`default_strategies_with` uses `SemanticEquivalentRewriteStrategy` in its rewrite -slot. The evidence-aware registry supplies the accuracy evidence provider to +`default_strategies` uses `SemanticEquivalentRewriteStrategy` (via its +`AvgToSumOverCountStrategy` alias) in its rewrite slot. The evidence-aware registry supplies the accuracy evidence provider to summary and Hydra construction. Search derives `RollupStrategy` after CSE from the actual sibling set. See the [registry definitions](../../crates/asap-aware-mapping/src/replacement.rs). @@ -251,8 +252,9 @@ matter. A single-target inspection caller may take the first candidate with `.into_iter().next()` and handle the empty case according to its -execution policy. Constructing all candidates before taking the first costs -more than constructing only the preferred candidate, but it keeps the strategy +execution policy; the first candidate is in `summary_candidates` order, not cost +order. Constructing all candidates before taking the first costs more than +constructing only one, but it keeps the strategy contract consistent and preserves the full choice set for other callers. `CandidateLogicalASAPDAGs::global_selection` optionally coordinates cross-target sharing and diff --git a/docs/develop_docs/asap-aware-mapping-contracts.md b/docs/develop_docs/asap-aware-mapping-contracts.md index 1980dafd..7e820e3f 100644 --- a/docs/develop_docs/asap-aware-mapping-contracts.md +++ b/docs/develop_docs/asap-aware-mapping-contracts.md @@ -147,9 +147,9 @@ Search calls `propose`, so rejected candidates remain available for explanation. > What are all semantically valid alternatives for this target? -`replacements` must be **exhaustive and not cost-filtered**. When its output has -a preferred order, that ordering must come from the supplied `CostModel`; the -strategy must still return every supported legal candidate. Required accuracy, +`replacements` must be **exhaustive and not cost-filtered**. Strategies take no +`CostModel`; output order carries no cost preference, and ranking happens at +selection time. The strategy must return every supported legal candidate. Required accuracy, schema and capability checks can reject an otherwise applicable algorithm; exhaustiveness is not a promise of all theoretically possible plans. @@ -164,7 +164,9 @@ accumulator, or a pass-through that keeps the original operation instead of building a summary. `realizations_for_intent` enumerates these concrete realizations; `ASAPStrategies::replacements()` constructs each one as a `ReplacementSubDAG`. It returns all -candidates in preferred order without selecting a winner. At workload scale, +candidates in `summary_candidates` order, sized by the analytical estimators, +without selecting a winner; `AggIntent::Extension` intents stay +`Realization::PassThrough`. At workload scale, `search_workload`/`search_workload_with` preserve all supported legal alternatives across every `TargetSubDAG`. Optional planner APIs coordinate compatible semantic selections; physical commitment and placement remain downstream deployment decisions. @@ -201,7 +203,7 @@ see [code architecture §3](asap-aware-mapping-architecture.md#3-how-the-current ### `CostModel` -`CostModel` covers every deployment-specific numeric or configuration decision—not only which candidate is cheapest. For example, sketch sizing trades memory and update cost for accuracy, so it belongs here too. +`CostModel` covers deployment-specific preference and cost decisions. It is consulted only at selection time (`cost_sorted`, `global_selection` and their `_with_recurrence` variants), never during candidate generation: sketch parameters come from the analytical estimators (`accuracy::estimators::size_params`), and extension intents stay pass-through. The crate cannot hardcode real deployment costs: `asap-aware-mapping` uses `asap-types` and pinned `asap_sketchlib` mapping bounds, but does not execute workloads or own deployment measurements. Most hooks therefore provide the crate's built-in static behavior as a default. Override only the decisions your deployment needs to change. @@ -209,9 +211,6 @@ bounds, but does not execute workloads or own deployment measurements. Most hook | Hook | Use it to | Default? | |---|---|---| | `rank_candidates` | Order valid sketch algorithms | No | -| `size_params` | Convert an accuracy target into sketch parameters | Yes | -| `realize_extension` | Map a custom intent to a realization | Yes | -| `evaluation_extension` | Query a custom extension summary | Panics until paired with a custom realization | | `cse_recompute_cost` | Estimate independent recomputation | Yes | | `cse_shared_maintenance_cost` | Estimate shared maintenance | Yes | | `cse_share_decision` | Choose sharing or recomputation | Yes | @@ -223,41 +222,6 @@ bounds, but does not execute workloads or own deployment measurements. Most hook fn rank_candidates(&self, intent: &AggIntent, candidates: &[SketchAlgorithm]) -> Vec; ``` -- **`size_params`** — choose parameters, such as sketch capacity, for an already-selected `SketchAlgorithm` and accuracy target `(eps, delta)`, where `eps` is the tolerated error and `delta` is the tolerated probability of exceeding that error. It is separate from ranking so a deployment can customize sizing without changing algorithm preference. The trait provides a default implementation. - - ```rust - fn size_params(&self, kind: SketchAlgorithm, intent: &AggIntent, eps: f64, delta: f64) -> SketchParams; - ``` - -- **`realize_extension`** — map a deployment-defined `AggIntent::Extension` to a post-ASAP `Realization`. The default is `Realization::PassThrough`. - - Use `AggIntent::Extension { ext_kind, payload }` for intent shapes that only your deployment needs. Core treats both fields as opaque. For example, a deployment can tag an approximate-frequency intent with `ext_kind: "frequency"` and recognize it in `realize_extension`: - - ```rust - fn realize_extension(&self, ext_kind: &str, _payload: &serde_json::Value) -> Realization { - if ext_kind == "frequency" { - Realization::Sketch(SketchKind::new( - SketchAlgorithm::CountSketch, - SketchParams::CountSketch { width: 1024, depth: 5 }, - )) - } else { - Realization::PassThrough // fall back to the default for anything else - } - } - ``` - - Return `Realization::PassThrough` for unrecognized extension kinds. Do not panic. - - ```rust - fn realize_extension(&self, ext_kind: &str, payload: &serde_json::Value) -> Realization; - ``` - -- **`evaluation_extension`** — define how queries read an extension summary that `realize_extension` mapped to a `Sketch`. The two hooks are a pair: realization defines what is maintained; evaluation defines how it is queried. Override both for the same `ext_kind`. The default evaluation panics to prevent a silent wrong answer. - - ```rust - fn evaluation_extension(&self, ext_kind: &str, payload: &serde_json::Value, col: &ColumnRef) -> SketchStatistic; - ``` - - **`cse_recompute_cost`** — estimate the one-time cost of recomputing a CSE candidate's sub-DAG independently at a single consumer. Default: `default_cse_recompute_cost`, a structural-size proxy. ```rust @@ -297,7 +261,8 @@ A custom cost model does not necessarily need to override every hook. The curren `ReplacementStrategy` answers "what are the candidates for this one target?" `CandidateLogicalASAPDAGs` answers the same question for every target in a whole workload at once, without enumerating `2^N` fully-copied plans for `N` independently-choosable sites. ```rust -// replacement.rs +// replacement.rs (TargetSubDAGCandidates) and +// plan_selection/candidate_selection.rs (RankedTargetSubDAGCandidates) // One TargetSubDAGCandidates per distinct TargetSubDAG in the whole workload — // never a flat list of fully assembled plans. @@ -345,7 +310,7 @@ to the selected algorithm and classifies the pair into its category. The public `.category()`, `.algorithm()`, and `.params()` accessors expose the committed values without permitting an invalid combination. -Where this matters in practice: `CostModel::rank_candidates`, `CostModel::size_params`, and `ASAPStrategies::replacements` operate at the **algorithm** level. `summary_candidates(intent)` returns a list of `SketchAlgorithm`s (`[Kll, DDSketch]` for a `Quantile` intent), never a bare `SketchKind` with nothing chosen underneath it. `SketchKind` appears after an algorithm has been selected and sized—on `Realization::Sketch(SketchKind)` and `FieldDataType::Sketch(SketchKind, GroupingStrategy)`. +Where this matters in practice: `CostModel::rank_candidates` and `ASAPStrategies::replacements` operate at the **algorithm** level. `summary_candidates(intent)` returns a list of `SketchAlgorithm`s (`[Kll, DDSketch]` for a `Quantile` intent), never a bare `SketchKind` with nothing chosen underneath it. `SketchKind` appears after an algorithm has been selected and sized—on `Realization::Sketch(SketchKind)` and `FieldDataType::Sketch(SketchKind, GroupingStrategy)`. `Sample`, `Wavelet`, and `StatModel` each use a flat `(Kind, Params)` pair. `Sketch` needs the additional algorithm level because multiple algorithms can serve the same purpose—for example, KLL and DDSketch both answer quantile queries. @@ -395,7 +360,7 @@ Each `ReplacementExplanation::reason` is copied verbatim from the matching candi ### Why there is no `ExplanationRule` trait -Explanations are derived from candidates already present in `CandidateLogicalASAPDAGs`. A new candidate kind therefore requires an `impl ReplacementStrategy` wired into `default_strategies`/`default_strategies_with`; a second explanation-specific trait would duplicate registration and could drift from the actual search space. Custom callers supply strategies through `explain_replacements_with`, using the same extension point exposed by `search_workload_with`. +Explanations are derived from candidates already present in `CandidateLogicalASAPDAGs`. A new candidate kind therefore requires an `impl ReplacementStrategy` wired into `default_strategies`; a second explanation-specific trait would duplicate registration and could drift from the actual search space. Custom callers supply strategies through `explain_replacements_with`, using the same extension point exposed by `search_workload_with`. ### How it derives `location` text diff --git a/docs/develop_docs/end-to-end-accuracy-guarantees.md b/docs/develop_docs/end-to-end-accuracy-guarantees.md index 9478b689..213e6e48 100644 --- a/docs/develop_docs/end-to-end-accuracy-guarantees.md +++ b/docs/develop_docs/end-to-end-accuracy-guarantees.md @@ -43,7 +43,7 @@ The main implementation locations are: | Guarantee and error vocabulary | `asap_types::ir::properties::guarantee` | | Accuracy model and built-in propagation | `asap_aware_mapping::accuracy` | | Candidate construction and legality filtering | `asap_aware_mapping::replacement` | -| Parameter sizing hooks | `asap_aware_mapping::cost_model` | +| Parameter sizing | `asap_aware_mapping::accuracy::estimators` | | Guarantee and rejection export | `asap_types::dag_export` and the `dag_export` devtool | Read the sections below when changing one of those contracts. diff --git a/docs/develop_docs/extend-asap-aware-mapping.md b/docs/develop_docs/extend-asap-aware-mapping.md index b9a83014..4674e44e 100644 --- a/docs/develop_docs/extend-asap-aware-mapping.md +++ b/docs/develop_docs/extend-asap-aware-mapping.md @@ -234,16 +234,10 @@ produces constructed post-ASAP summaries. Construction: ```rust -let strategy = - ASAPStrategies::default_cost_model(); +let strategy = ASAPStrategies::default(); ``` -or with a custom cost model: - -```rust -let model = MyCostModel; // illustrative -let strategy = ASAPStrategies::new(&model); -``` +It takes no cost model; a cost model is consumed only at selection time. The strategy matches supported aggregate nodes. @@ -252,14 +246,14 @@ At a high level: ```mermaid flowchart LR A["Input TargetSubDAG
root is a supported Aggregate"] --> B["ASAPStrategies::matches
check whether the target shape can produce summaries"] - B -->|"true"| C["ASAPStrategies::replacements
use CostModel preferences and sizing while preserving
every semantically valid realization"] + B -->|"true"| C["ASAPStrategies::replacements
size each summary_candidates entry analytically,
preserving every semantically valid realization"] B -->|"false"| NONE["Empty candidate list"] - C --> F["Output Vec<ReplacementSubDAG>
each entry contains a constructed summary sub-DAG and rationale;
all candidates retained in preferred order"] + C --> F["Output Vec<ReplacementSubDAG>
each entry contains a constructed summary sub-DAG and rationale;
all candidates retained in summary_candidates order"] ``` For an approximate quantile, both KLL and DDSketch remain candidates when their committed parameters and evidence satisfy the applicable accuracy checks, -even if the cost model prefers one. When only one realization is legal, such as an exact accumulator or pass-through, the strategy returns that single candidate. +regardless of which one a cost model later prefers. When only one realization is legal, such as an exact accumulator or pass-through, the strategy returns that single candidate. --- @@ -268,7 +262,7 @@ even if the cost model prefers one. When only one realization is legal, such as Call the public strategy interface and inspect every returned candidate: ```rust -let strategy = ASAPStrategies::new(&cost_model); +let strategy = ASAPStrategies::default(); let candidates = strategy.replacements(&target); for candidate in candidates { @@ -285,7 +279,7 @@ for candidate in candidates { ``` The public contract is the behavior contributors should preserve: every legal -candidate is returned, ordering follows the supplied `CostModel`, each summary +candidate is returned in `summary_candidates` order, each summary is fully constructed, and each candidate carries a useful rationale. Nested aggregate choices remain independent. @@ -350,8 +344,7 @@ The basic calling pattern is: ```rust let target = TargetSubDAG::new(&root); -let strategy = - ASAPStrategies::default_cost_model(); +let strategy = ASAPStrategies::default(); if strategy.matches(&target) { let candidates = @@ -488,15 +481,14 @@ Do not test only the rationale string; test the actual replacement semantics. #### Custom cost model behavior -If a strategy accepts a cost model, verify that a custom model changes the intended costing behavior without changing the exhaustive candidate set. - -The current sketch strategy does exactly this: +Strategies do not take a cost model. Verify instead that a custom model changes +the selection-time ranking without changing the exhaustive candidate set: ```mermaid flowchart LR - INPUT["Legal candidate set
KLL + DDSketch"] --> MODEL["Custom CostModel
prefers DDSketch for this AggIntent"] + INPUT["Strategy output
KLL + DDSketch"] --> MODEL["cost_sorted / global_selection
with a CostModel preferring DDSketch"] MODEL --> ORDER["rank_candidates output
DDSketch first, KLL second"] - ORDER --> RESULT["Strategy output
both candidates remain; only their order changes"] + ORDER --> RESULT["Ranked view
both candidates remain; only their order changes"] ``` That is the expected separation between enumeration and ranking. @@ -538,19 +530,17 @@ impl CostModel for PreferDDSketch { } ``` -Then inject it into code that accepts a `&dyn CostModel`: +Then pass it to the selection-time APIs that accept a `&dyn CostModel`: ```rust let model = PreferDDSketch; -let strategy = - ASAPStrategies::new(&model); - -let replacements = - strategy.replacements(&target); +let space = search_workload_with(roots, &default_strategies()); +let ranked = space.cost_sorted(&model); +let selection = space.global_selection(&model); ``` -Important: changing `rank_candidates` changes the preferred ordering, but `ASAPStrategies` still enumerates every valid sketch candidate. +Important: `rank_candidates` changes only the selection-time ordering; `ASAPStrategies` still enumerates every valid sketch candidate, in `summary_candidates` order, sized analytically. A custom cost model should not change which alternatives are semantically legal. @@ -587,72 +577,7 @@ It must return a permutation of the supplied candidates: every input candidate e --- -#### `size_params` - -Use when the sketch algorithm is already known and you want to choose its parameters from an accuracy target. - -Signature: - -```rust -fn size_params( - &self, - kind: SketchAlgorithm, - intent: &AggIntent, - eps: f64, - delta: f64, -) -> SketchParams; -``` - -Typical uses include: - -- choosing KLL capacity, -- choosing HLL precision, -- selecting sketch-specific error parameters. - -Conceptually: - -```mermaid -flowchart LR - ALG["Chosen SketchAlgorithm
for example, KLL or HLL"] --> SIZE["CostModel::size_params
translate a requested accuracy budget into
algorithm-specific storage parameters"] - INTENT["AggIntent
what the query is computing"] --> SIZE - ACC["Accuracy budget
epsilon and delta"] --> SIZE - SIZE --> PARAMS["SketchParams
for example, KLL capacity or HLL precision"] -``` - ---- - -#### `realize_extension` - -Use for extension-defined implementation kinds. - -```rust -fn realize_extension( - &self, - ext_kind: &str, - payload: &serde_json::Value, -) -> Realization; -``` - -This is the hook for turning an extension description into a concrete `Realization`. - -Use it for implementation families that are intentionally outside the built-in enum dispatch. - ---- - -#### `evaluation_extension` - -Use when an extension-defined summary also needs custom query/evaluation behavior. - -```rust -fn evaluation_extension( - &self, - ext_kind: &str, - payload: &serde_json::Value, - col: &ColumnRef, -) -> SketchStatistic; -``` - -This complements `realize_extension`: realization defines what gets maintained; evaluation defines how it is queried (see the [CostModel reference](asap-aware-mapping-contracts.md#costmodel)). +Sketch parameters are not a `CostModel` hook: candidates are sized by the analytical estimators (`accuracy::estimators::size_params`, also exposed as `replacement::default_size_params`), and `AggIntent::Extension` intents always stay `Realization::PassThrough`. --- @@ -738,22 +663,17 @@ Then test integration through a consumer of the cost model. For example: ```rust -let strategy = - ASAPStrategies::new(&model); - -let replacements = - strategy.replacements(&target); +let ranked = + space.cost_sorted(&model); ``` The important assertion is usually not that other valid candidates disappeared. They should not. Instead verify that: -- the model changes ordering or parameters as intended, +- the model changes ordering as intended, - all legal candidates remain available to the replacement layer. -For sizing, test representative accuracy targets and assert the resulting `SketchParams`. - For CSE costing, create a representative `CseCandidate` and test recompute cost, shared-maintenance cost, and the resulting `ShareDecision`. --- @@ -775,7 +695,7 @@ Therefore, when adding a new built-in sketch algorithm, the intended flow is: ```mermaid flowchart LR MAP["1. Declare legality
add the algorithm to summary_candidates
for each AggIntent it can answer"] - MAP --> MODEL["2. Define costing
rank it, derive its SketchParams,
and provide a comparable numeric cost"] + MAP --> MODEL["2. Define sizing and costing
derive its SketchParams in the analytical estimators;
rank it and provide a comparable numeric cost"] MODEL --> BUILD["3. Define realization behavior
ensure the public strategy output contains a valid summary sub-DAG
with the correct maintained state and evaluation"] BUILD --> ACC["4. Certify accuracy
derive from committed parameters;
propagate and check the final target"] ACC --> ENUM["5. Verify integration
ASAPStrategies includes it automatically;
tests confirm enumeration, ordering, sizing, and cost"] @@ -802,7 +722,7 @@ or malformed evidence, incompatible metrics and unsupported composition. Test root-target checking before cost ranking, exact fallback, and exported rejection or guarantee data. A cheaper estimate must never admit an accuracy-illegal plan. -After wiring the new algorithm into `summary_candidates` and giving the cost model a real `rank_candidates`/`size_params` opinion about it, check two things. First, that `ASAPStrategies::replacements()` for a matching `TargetSubDAG` actually includes a candidate realizing the new algorithm — extend a test shaped like `replacement.rs`'s own test-module coverage-matrix tests (e.g. `agg_intent_to_summary_kind_coverage_matrix`) to cover the new algorithm's `AggIntent`. Second, that `cost_sorted`/`estimate_cost` produce sane, comparable numbers for the new candidate rather than a `NaN` placeholder or an outlier that swamps every other candidate. +After wiring the new algorithm into `summary_candidates` and giving the analytical estimators a sizing rule and the cost model a real `rank_candidates` opinion about it, check two things. First, that `ASAPStrategies::replacements()` for a matching `TargetSubDAG` actually includes a candidate realizing the new algorithm — extend a test shaped like `replacement.rs`'s own test-module coverage-matrix tests (e.g. `agg_intent_to_summary_kind_coverage_matrix`) to cover the new algorithm's `AggIntent`. Second, that `cost_sorted`/`estimate_cost` produce sane, comparable numbers for the new candidate rather than a `NaN` placeholder or an outlier that swamps every other candidate. --- @@ -944,19 +864,17 @@ When adding a new strategy: - [ ] Test positive and negative applicability. - [ ] Test exhaustive enumeration. - [ ] Test the actual structural semantics of each replacement. -- [ ] Test behavior with a custom cost model if the strategy uses one. +- [ ] Test that a custom cost model reorders, but does not remove, the strategy's candidates at selection. When adding a new cost model: - [ ] Override only the hooks whose behavior should change. - [ ] Keep semantic applicability outside the cost model. - [ ] Use `rank_candidates` for algorithm preference; return every input candidate exactly once. -- [ ] Use `size_params` for accuracy-to-parameter mapping. -- [ ] Use extension hooks for extension-defined implementations/evaluations. - [ ] Use CSE hooks for recompute-vs.-sharing costs. - [ ] Override `estimate_cost` if consumers require numeric costs instead of `NaN`. - [ ] Test the hook directly. -- [ ] Test integration through a consumer such as `ASAPStrategies`. +- [ ] Test integration through a selection-time consumer such as `cost_sorted` or `global_selection`. - [ ] Verify that changing cost preferences does not silently remove valid replacement candidates. --- @@ -972,14 +890,12 @@ Use this table to find the right place for a change. | Change when a strategy applies | `ReplacementStrategy::matches` | | Add a new built-in sketch candidate | `replacement.rs`'s summary-candidate mapping plus realization and accuracy contracts | | Prefer one sketch algorithm over another | `CostModel::rank_candidates` | -| Change sketch sizing for an accuracy target | `CostModel::size_params` | -| Add extension-defined implementation behavior | `CostModel::realize_extension` | -| Add extension-defined evaluation behavior | `CostModel::evaluation_extension` | +| Change sketch sizing for an accuracy target | `accuracy::estimators::size_params` (analytical; not a `CostModel` hook) | | Change CSE recomputation cost | `CostModel::cse_recompute_cost` | | Change shared-maintenance cost | `CostModel::cse_shared_maintenance_cost` | | Change current share/recompute choice | `CostModel::cse_share_decision` | | Decide whether an available implementation satisfies a required one | `impl Matcher` | -| Produce a normal (ranked-first) post-ASAP summary for one target | `ASAPStrategies::replacements(...).into_iter().next()` | +| Produce the first-listed post-ASAP summary for one target (unranked, `summary_candidates` order) | `ASAPStrategies::replacements(...).into_iter().next()` | | Search a whole workload for supported legal candidates | `search_workload`/`search_workload_with` | | Enforce per-root result accuracy requirements | `search_workload_with_targets` | | Coordinate compatible choices across groups | `CandidateLogicalASAPDAGs::global_selection` | @@ -990,7 +906,7 @@ Use this table to find the right place for a change. | Build a target with no workload context | `TargetSubDAG::new` | | Build a target with known sharing context | `TargetSubDAG::with_consumer_count` | | Explain why a replacement exists, where, and why | `explanation::explain_replacements`/`explain_replacements_with` | -| Add a new kind of replacement explanation | new `impl ReplacementStrategy`, wired into `default_strategies`/`default_strategies_with` — not a new explanation-specific trait, see §8 | +| Add a new kind of replacement explanation | new `impl ReplacementStrategy`, wired into `default_strategies` — not a new explanation-specific trait, see §8 | --- @@ -1011,8 +927,8 @@ for explanation in &explanations { } ``` -To plug in a deployment-specific strategy or `CostModel`, use `explain_replacements_with` with a strategy set built the same way `default_strategies_with` builds one — see [§2](#2-adding-or-customizing-a-costmodel) and [§4](#4-adding-both-a-strategy-and-a-cost-model). +To plug in a deployment-specific strategy, use `explain_replacements_with` with a strategy set built the same way `default_strategies` builds one — see [§1](#1-adding-a-new-replacementstrategy) and [§4](#4-adding-both-a-strategy-and-a-cost-model). Explanations do not consult a `CostModel`. ### Adding a new kind of replacement explanation -There is no separate checklist here: follow [§1](#1-adding-a-new-replacementstrategy) to add the new `ReplacementStrategy` and wire it into `default_strategies`/`default_strategies_with`, then add an `ExplanationKind` variant and ensure `explain_replacements` returns that kind for the new public candidate shape. Test the behavior through `explain_replacements` or `explain_replacements_with`; explanation reporting should not introduce a second discovery rule. +There is no separate checklist here: follow [§1](#1-adding-a-new-replacementstrategy) to add the new `ReplacementStrategy` and wire it into `default_strategies`, then add an `ExplanationKind` variant and ensure `explain_replacements` returns that kind for the new public candidate shape. Test the behavior through `explain_replacements` or `explain_replacements_with`; explanation reporting should not introduce a second discovery rule. diff --git a/docs/develop_docs/library-api.md b/docs/develop_docs/library-api.md index db0e618f..f7abce1a 100644 --- a/docs/develop_docs/library-api.md +++ b/docs/develop_docs/library-api.md @@ -204,7 +204,7 @@ use asap_types::workload::{ PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{ - default_strategies_with, search_workload_with_targets, + default_strategies, search_workload_with_targets, DefaultAccuracyModel, DefaultCostModel, }; use asap_types::types::AccuracyTarget; @@ -237,7 +237,7 @@ fn main() -> Result<(), Box> { }; let root = lower_promql_workload(&workload, 0)?.remove(0); let cost_model = DefaultCostModel; - let strategies = default_strategies_with(&cost_model); + let strategies = default_strategies(); let space = search_workload_with_targets( vec![("q1", root, Some(accuracy))], &strategies, @@ -301,11 +301,11 @@ pass. An omitted strategy contributes no proposals of its own. | Value to put inside `Box::new(...)` | Meaning | In default factories? | | --- | --- | --- | -| `ASAPStrategies::new(&model)` | Enumerates supported exact/sketch implementations and parameter choices for aggregate targets | Yes | -| `HydraGroupingStrategy::new(&model)` | Considers a shared multi-subpopulation structure for supported grouped sketch families, subject to accuracy evidence | Yes | +| `ASAPStrategies::default()` | Enumerates supported exact/sketch implementations and parameter choices for aggregate targets | Yes | +| `HydraGroupingStrategy::default()` | Considers a shared multi-subpopulation structure for supported grouped sketch families, subject to accuracy evidence | Yes | | `SharedSubDAGStrategy` | Proposes sharing versus independent recomputation at reused sub-DAGs | Yes | | `SemanticEquivalentRewriteStrategy` | Proposes supported equivalent aggregate rewrites, including decomposing average into sum/count | Yes | -| `ExactCompositionStrategy::new(&model)` | Proposes supported exact operations around summary evaluations or in maintenance | Yes | +| `ExactCompositionStrategy` | Proposes exact operations around summary evaluations or in maintenance; not filtered by runtime support; `global_selection` commits one only with positive (`Some(true)`) cost-model support evidence | Yes | | Your `ReplacementStrategy` implementation | Adds domain-specific legal replacement proposals | No | `AvgToSumOverCountStrategy` is an alias for `SemanticEquivalentRewriteStrategy` @@ -329,20 +329,20 @@ whole-workload search. Selecting a strategy does not force its candidate to win. ```text default_strategies() -> Vec> -default_strategies_with<'a>(cost_model: &'a dyn CostModel) - -> Vec> replacement::default_strategies_with_evidence<'a>( - cost_model: &'a dyn CostModel, evidence: &'a dyn AccuracyEvidenceProvider, + evidence: &'a dyn AccuracyEvidenceProvider, ) -> Vec> ``` | Factory | Use when | Models used | | --- | --- | --- | -| `default_strategies()` | Exploring with built-in defaults | Built-in cost/accuracy/allocation defaults | -| `default_strategies_with(&model)` | Supplying deployment-specific costing/sizing | Supplied cost model; default accuracy/allocation | -| `default_strategies_with_evidence(&model, &evidence)` | Supplying planning-time accuracy evidence as well | Supplied cost and evidence; default accuracy/allocation | +| `default_strategies()` | Exploring with built-in defaults | Built-in accuracy/allocation defaults; no extra evidence | +| `default_strategies_with_evidence(&evidence)` | Supplying planning-time accuracy evidence | Supplied evidence; default accuracy/allocation | | Explicit vector | Controlling which context-free strategies are supplied | Models passed into each constructor | +No factory takes a cost model: candidate generation is cost-model independent. +Pass the deployment cost model to `cost_sorted`/`global_selection`. + ### Example: supply two strategies and run search ```rust @@ -386,7 +386,7 @@ fn main() -> Result<(), Box> { let root = lower_promql_workload(&workload, 0)?.remove(0); let model = DefaultCostModel; let strategies: Vec> = vec![ - Box::new(ASAPStrategies::new(&model)), + Box::new(ASAPStrategies::default()), Box::new(SharedSubDAGStrategy), ]; let space = search_workload_with_targets( @@ -408,7 +408,7 @@ Module-qualified paths below are relative to `asap_aware_mapping`. | Parameter | Available value / constructor | Meaning | | --- | --- | --- | -| `&dyn CostModel` | `DefaultCostModel` | Built-in ordering/sizing and structural estimates; no measured deployment guarantee | +| `&dyn CostModel` | `DefaultCostModel` | Built-in ordering and structural estimates; no measured deployment guarantee | | `&dyn CostModel` | `empirical_cost::EmpiricalCostModel::new(provider)` | Offline sketch-benchmark model: ranks algorithms using matching offline measurements | | `&dyn CostModel` | `physical_plan_cost_model::PhysicalPlanCostModel::new(&provider, calibration)?` | Deployment-specific physical-plan model: compares complete physical alternatives using provider evidence and resource calibration; evidence may be offline or online | | `&dyn AccuracyModel` | `DefaultAccuracyModel` | Built-in guarantee rules and satisfaction checks | @@ -441,18 +441,17 @@ accuracy guarantees. ```rust use asap_aware_mapping::{ - DefaultAccuracyModel, DefaultCostModel, EqualSplitAllocator, + DefaultAccuracyModel, EqualSplitAllocator, NoAccuracyEvidence, ReplacementStrategy, ASAPStrategies, }; fn main() { - let cost = DefaultCostModel; let accuracy = DefaultAccuracyModel; let allocation = EqualSplitAllocator; let evidence = NoAccuracyEvidence; let strategies: Vec> = vec![Box::new( ASAPStrategies::new_with_planning_inputs_and_evidence( - &cost, &accuracy, &allocation, &evidence, + &accuracy, &allocation, &evidence, ), )]; // Use &strategies and &accuracy in search_workload_with_targets. @@ -464,7 +463,6 @@ Constructor definition: ```text ASAPStrategies::new_with_planning_inputs_and_evidence( - cost_model: &dyn CostModel, accuracy_model: &dyn AccuracyModel, allocator: &dyn AccuracyBudgetAllocator, evidence: &dyn AccuracyEvidenceProvider, @@ -472,20 +470,21 @@ ASAPStrategies::new_with_planning_inputs_and_evidence( ``` All provider arguments are required for this constructor. They must outlive the -strategy vector. `ASAPStrategies::new(&cost_model)` is the shorter -constructor using default accuracy/allocation and no extra evidence. +strategy vector. `ASAPStrategies::default()` uses default accuracy/allocation +and no extra evidence. | Extension point | What it controls | What it cannot establish alone | | --- | --- | --- | | `ReplacementStrategy` | Proposed semantic alternatives | Permission to violate query semantics or downstream support | -| `CostModel` | Candidate ordering/sizing hooks and recurrence cost hooks | Correctness, measured costs without evidence, or installed runtime support | +| `CostModel` | Selection-time ranking, cost, support-evidence and recurrence cost hooks | Correctness, measured costs without evidence, or installed runtime support | | `AccuracyModel` | Derivation, propagation and satisfaction of guarantees | A meaningful guarantee without its required assumptions/evidence | | `AccuracyBudgetAllocator` | Local accuracy requirements proposed within composition | End-to-end correctness without subsequent validation | | `AccuracyEvidenceProvider` | Planning-time statistics used by supported strategies | Authority to change query requirements | -Models may be consumed during generation as well as ranking. Construct strategies -with the intended model/evidence; replacing only the final sorting model does not -regenerate parameter choices. For evidence-aware defaults, use +Accuracy models, allocators and evidence are consumed during generation; the cost +model is consumed only at selection (`cost_sorted`, `global_selection` and their +`_with_recurrence` variants). Sketch parameters come from the analytical +estimators, not the cost model. For evidence-aware defaults, use `asap_aware_mapping::replacement::default_strategies_with_evidence`. For custom accuracy/allocation/evidence on sketches, `ASAPStrategies::new_with_planning_inputs_and_evidence` exposes these providers. @@ -506,7 +505,7 @@ ingestion rate. `PlanningWorkload::validate()` shares these checks. | `QueryRequirements::default()` | `ImplicitExact`, unspecified response latency | Pass approximation explicitly and thread per-root requirements into search | | `DataWorkload::default()` | Unknown arrival, unknown evidence | Supply facts needed for the requested comparisons | | `Evidence::default()` | No value, unknown source | Unknown/stale evidence is not zero; provide scoped valid observations | -| `DefaultCostModel` | Built-in ordering/sizing and structural cost hooks | Supply deployment evidence for calibrated comparisons | +| `DefaultCostModel` | Built-in ordering and structural cost hooks | Supply deployment evidence for calibrated comparisons | `Default` is a Rust constructor contract, not a general serde omission rule. Several workload fields require explicit serialized values. A struct field being diff --git a/docs/develop_docs/metrics-observability-corpora.md b/docs/develop_docs/metrics-observability-corpora.md index 129ea0af..b5472ef8 100644 --- a/docs/develop_docs/metrics-observability-corpora.md +++ b/docs/develop_docs/metrics-observability-corpora.md @@ -57,7 +57,7 @@ for which that strategy returned only the kept pre-ASAP sub-DAG (`retain_exact`) ## Strategies The corpus measurement deliberately uses only -`ASAPStrategies::default_cost_model().replacements(...)` on each +`ASAPStrategies::default().replacements(...)` on each query root. It does not measure workload-wide search or the other default strategies.