diff --git a/crates/asap-aware-mapping/src/accuracy/composition.rs b/crates/asap-aware-mapping/src/accuracy/composition.rs index 5871cb1f..d91ba576 100644 --- a/crates/asap-aware-mapping/src/accuracy/composition.rs +++ b/crates/asap-aware-mapping/src/accuracy/composition.rs @@ -697,16 +697,6 @@ fn composed_provenance( provenance } -pub(super) fn exact_operation_rule(operation: &ExactOperation) -> Option { - let ExactOperation::Aggregate { measures, .. } = operation; - match measures.as_slice() { - [intent] => crate::function_rules::function_rules(intent).map(|rules| rules.accuracy), - // The remaining functions are exact over exact samples, but have - // no definition-backed rule over approximate values yet. - _ => None, - } -} - pub(super) fn propagate( op: &CompositionOperator, inputs: &[AccuracyGuarantee], @@ -1002,7 +992,6 @@ mod tests { assert_eq!(parse_source_norm_statistic(other), None, "{other}"); } } - use asap_types::pre_asap::AggIntent; fn abs(bound: f64, delta: f64) -> AccuracyGuarantee { AccuracyGuarantee::new( AccuracyMetric::ABSOLUTE, @@ -1411,28 +1400,6 @@ mod tests { assert_eq!(out.spec.failure_probability.evaluate(), Some(0.04)); } #[test] - fn counter_functions_have_distinct_definition_rules() { - let operation = |intent| ExactOperation::Aggregate { - reduction: asap_types::pre_asap::Reduction::PerEntity, - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - }; - assert_eq!( - DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::Rate)), - Some(CompositionOperator::CounterRate) - ); - assert_eq!( - DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::IRate)), - Some(CompositionOperator::InstantCounterRate) - ); - assert_eq!( - DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::Increase)), - Some(CompositionOperator::CounterIncrease) - ); - } - #[test] fn topk_selection_requires_a_separated_margin_certificate() { let unknown = DefaultAccuracyModel .propagate( diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs b/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs index c19cd713..41bec6a7 100644 --- a/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs +++ b/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs @@ -86,4 +86,57 @@ mod tests { AccuracyMetric::ABSOLUTE_STREAM_L1 ); } + + /// CMS width is `⌈e/ε⌉` and depth `⌈ln(1/δ)⌉`, with an ε-only target + /// taking `DEFAULT_DELTA` (depth 5). + #[test] + fn epsilon_delta_sizes_cms_depth() { + use crate::realization::{accuracy_budget, default_size_params, DEFAULT_DELTA}; + let intent = AggIntent::Count { + accuracy: AccuracyTarget::max_error(0.001).failure_probability(0.001), + }; + assert_eq!( + default_size_params(SketchAlgorithm::Cms, &intent, 0.001, 0.001), + SketchParams::Cms { + width: 2719, + depth: 7 + } // ⌈e/0.001⌉, ⌈ln 1000⌉ + ); + let target = AccuracyTarget::max_error(0.001); + let (eps, delta) = accuracy_budget(&target); + assert_eq!((eps, delta), (0.001, DEFAULT_DELTA)); + let intent = AggIntent::Count { accuracy: target }; + assert_eq!( + default_size_params(SketchAlgorithm::Cms, &intent, eps, delta), + SketchParams::Cms { + width: 2719, + depth: 5 + } + ); + assert_eq!( + default_size_params(SketchAlgorithm::Cms, &intent, 0.01, 0.01), + SketchParams::Cms { + width: 272, + depth: 5 + } + ); + } + + /// A top-k heap holds `max(k, ⌈1/ε⌉)` entries over a CMS sized as for counts. + #[test] + fn topk_heap_capacity_respects_accuracy_and_output_count() { + use crate::realization::default_size_params; + let intent = AggIntent::TopK { + k: 25, + accuracy: AccuracyTarget::max_error(0.01), + }; + assert_eq!( + default_size_params(SketchAlgorithm::CmsWithHeap, &intent, 0.01, 0.01), + SketchParams::CmsWithHeap { + width: 272, // ⌈e/0.01⌉ + depth: 5, + heap_size: 100, + } + ); + } } diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs b/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs index 8c1fb52c..95150dda 100644 --- a/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs +++ b/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs @@ -348,4 +348,40 @@ mod tests { ); } } + + /// KLL `k` inverts the rank-error fit: ε = 0.01 → 269, ε = 0.05 → 52. + #[test] + fn accuracy_target_drives_kll_k() { + use crate::realization::default_size_params; + use asap_types::pre_asap::agg_intent::default_quantile; + let q = default_quantile(0.99); + assert_eq!( + default_size_params(SketchAlgorithm::Kll, &q, 0.01, 0.01), + SketchParams::Kll { + k: 269, + failure_probability: None + } + ); + assert_eq!( + default_size_params(SketchAlgorithm::Kll, &q, 0.05, 0.01), + SketchParams::Kll { + k: 52, + failure_probability: None + } + ); + } + + /// A zero ε saturates KLL `k` to the tightest buildable value. + #[test] + fn degenerate_epsilon_saturates_to_tightest_kll_params() { + use crate::realization::default_size_params; + use asap_types::pre_asap::agg_intent::default_quantile; + assert_eq!( + default_size_params(SketchAlgorithm::Kll, &default_quantile(0.99), 0.0, 0.01), + SketchParams::Kll { + k: 26_602, + failure_probability: None + } + ); + } } diff --git a/crates/asap-aware-mapping/src/accuracy/evidence.rs b/crates/asap-aware-mapping/src/accuracy/evidence.rs index 3c98f94f..fce69d5d 100644 --- a/crates/asap-aware-mapping/src/accuracy/evidence.rs +++ b/crates/asap-aware-mapping/src/accuracy/evidence.rs @@ -20,29 +20,6 @@ pub struct OperandDomain { pub contract: String, } -impl OperandDomain { - pub(crate) fn supports_ddsketch(&self, alpha: f64) -> bool { - if !alpha.is_finite() - || alpha <= 0.0 - || alpha >= 1.0 - || !self.lower.is_finite() - || !self.upper.is_finite() - || self.lower > self.upper - || self.max_samples == 0 - || self.max_samples > (1u64 << 53) - || self.contract.trim().is_empty() - { - return false; - } - let (min, max) = asap_sketchlib::sketches::ddsketch::ddsketch_indexable_bounds(alpha); - // Same-sign interpolation preserves relative error. Zero alone is - // exact; an interval touching zero also admits tiny zero-mapped values. - (self.lower >= min && self.upper <= max) - || (self.upper <= -min && self.lower >= -max) - || (self.lower == 0.0 && self.upper == 0.0) - } -} - /// Statistics a propagation rule may consult. Every field is optional and /// defaults to "unknown": a rule that needs a missing statistic emits a /// [`BoundExpr::Unknown`] leaf (or rejects) rather than guessing. @@ -81,21 +58,10 @@ pub struct PropagationStats { /// Union-bound failure probability of all intervals used by the margin /// certificate. pub topk_interval_failure_probability: Option, - /// Hydra shared-grid collision error in the inner guarantee's metric. - pub hydra_shared_grid_collision_bound: Option, - /// Failure probability assigned to the Hydra shared-grid term. - pub hydra_shared_grid_failure_probability: Option, } /// Supplies typed planning-time evidence required by propagation rules. pub trait AccuracyEvidenceProvider { - /// Enforced upper bound on distinct (partition, item) identities across a - /// complete TopK evaluation. Used to union-bound score errors for adaptively - /// selected candidates. Observed cardinality is not sufficient evidence. - fn topk_max_distinct_items(&self, _expression: &OperatorNode) -> Option { - None - } - /// Enforced domain of `operand`'s true values ([`OperandDomain`]), scoped /// to this complete expression, including its source, filters, grouping /// and window: a quantile ratio's operands, or the operands of a @@ -115,15 +81,6 @@ pub trait AccuracyEvidenceProvider { fn output_guarantee(&self, _output: &QueryRoot) -> Option { None } - - fn propagation_stats( - &self, - _op: &CompositionOperator, - _family: &FieldDataType, - _query: Option<&SketchStatistic>, - ) -> PropagationStats { - PropagationStats::default() - } } #[derive(Debug, Default, Clone, Copy)] diff --git a/crates/asap-aware-mapping/src/accuracy/mod.rs b/crates/asap-aware-mapping/src/accuracy/mod.rs index 05b1a634..0a517730 100644 --- a/crates/asap-aware-mapping/src/accuracy/mod.rs +++ b/crates/asap-aware-mapping/src/accuracy/mod.rs @@ -10,7 +10,6 @@ pub mod budget; pub mod composition; pub mod estimators; pub mod evidence; -pub mod reconciliation; pub use budget::per_input_target; pub use evidence::{AccuracyEvidenceProvider, NoAccuracyEvidence, OperandDomain, PropagationStats}; @@ -23,21 +22,12 @@ use asap_types::post_asap::{ }; use asap_types::types::{AccuracyScope, AccuracyTarget}; -use crate::exact_composition::ExactOperation; - /// The deployment-extensible accuracy algebra. `asap-aware-mapping` ships /// [`DefaultAccuracyModel`]; a deployment with a proof for a composition the /// default rejects (a registered cross-metric conversion, say) implements -/// this trait and passes it to -/// [`crate::replacement::ASAPStrategies::new_with_planning_inputs`]. +/// this trait and passes it to plan selection in +/// [`crate::PlanningModels::accuracy`]. pub trait AccuracyModel { - /// The definition-registered rule for applying `operation` to an - /// approximate input. `None` means the function is exact only over exact - /// inputs; callers must fail closed for approximate input. - fn exact_operation_rule(&self, _operation: &ExactOperation) -> Option { - None - } - /// The guarantee of reading `query` out of a summary of family `family` /// built over an **exact** input — derived from the family's committed /// parameters by inverting the same sizing formulas @@ -93,9 +83,6 @@ impl DefaultAccuracyModel { } impl AccuracyModel for DefaultAccuracyModel { - fn exact_operation_rule(&self, operation: &ExactOperation) -> Option { - composition::exact_operation_rule(operation) - } fn local_guarantee( &self, family: &FieldDataType, @@ -171,8 +158,6 @@ pub fn native_target(target: &AccuracyTarget, guarantee: &AccuracyGuarantee) -> .accuracy_scope(AccuracyScope::PerValue) } -pub(crate) use estimators::topk_capacity; - #[cfg(test)] mod tests { use super::*; diff --git a/crates/asap-aware-mapping/src/accuracy/reconciliation.rs b/crates/asap-aware-mapping/src/accuracy/reconciliation.rs deleted file mode 100644 index 9f95ef20..00000000 --- a/crates/asap-aware-mapping/src/accuracy/reconciliation.rs +++ /dev/null @@ -1,983 +0,0 @@ -//! [`AccuracyReconciliationStrategy`] — cross-consumer accuracy -//! reconciliation for CSE sharing (issue #273, part of #33). -//! -//! ## The gap this closes -//! -//! `asap_types::ir::cse::share_common_sub_dags` (pre-ASAP CSE) only -//! ever merges two sub-DAGs that are *exactly* [`PartialEq`]-equal, -//! including their [`AggIntent`]'s `accuracy: AccuracyTarget` field. Two -//! otherwise-identical aggregates that differ *only* in how tight an -//! accuracy bound they ask for — `quantile(0.99, x)` at `epsilon=0.01` for -//! one consumer, the same `quantile(0.99, x)` at `epsilon=0.05` for -//! another — are therefore never the same `Rc`, never collapse into one -//! [`crate::replacement::TargetSubDAGCandidates`], and [`crate::replacement::SharedSubDAGStrategy`] -//! never even gets a `TargetSubDAG` with `consumer_count >= 2` to propose -//! sharing for. This crate would build two entirely independent sketches -//! for what is conceptually one computation, even though a single sketch -//! built to the tighter of the two bounds would answer both. -//! -//! This module is **additive**, not a relaxation of `share_common_sub_dags` -//! itself: `accuracy` still participates in exact structural equality -//! everywhere else in this crate (correctness elsewhere — e.g. a downstream -//! consumer that pattern-matches on a specific `AccuracyTarget` — depends on -//! that). What this module adds is a *second*, narrower notion of "close -//! enough to share" that sits entirely inside the [`ReplacementStrategy`] -//! extension point: one more candidate a [`crate::cost_model::CostModel`] -//! may or may not prefer, never a forced rewrite and never a change to what -//! `share_common_sub_dags` itself merges. -//! -//! ## What counts as a "near-duplicate", and why -//! -//! Two `NonASAPOp::Aggregate` nodes are accuracy-near-duplicates here iff, -//! **in this order**: -//! -//! 1. Both are the same bindable shape [`crate::replacement::ASAPStrategies`] -//! itself targets — a single measure, no `HAVING` (`bindable_intent`'s own -//! scope) — **and** that one measure is one of the four accuracy-bearing -//! [`AggIntent`] variants ([`crate::realization::accuracy_target`]'s own -//! scope: `Count` / `Quantile` / `Cardinality` / `TopK`). Every other -//! intent has no `AccuracyTarget` to reconcile in the first place. -//! 2. Same `reduction` (grouping), same `output_names`, and the same shared -//! `child` (`Rc::ptr_eq`, or value-equal for two independently-built but -//! identical sub-DAGs CSE conservatively declined to alias) — the same -//! "identical everything else" bar [`crate::rollup::RollupStrategy`] and -//! [`crate::topk_reuse::TopKLimitReuseStrategy`] already hold their own -//! sibling-reuse candidates to. -//! 3. The one measure is identical **except** for `accuracy` — same variant, -//! same `col`/`q`/`k` (see [`same_intent_except_accuracy`]). -//! 4. Neither side's `accuracy` is [`AccuracyTarget::exact()`] (see -//! [`dominates`]'s doc for why exact accuracy is excluded rather than -//! trivially "always tightest"). -//! 5. The tighter candidate's own **output** schema carries a provable -//! unique key (`Schema::has_unique_key`) — the exact legality gate -//! `share_common_sub_dags` itself applies (see `cse.rs`'s "Legality" -//! section) and [`crate::rollup::RollupStrategy::is_legal_rollup_source`] -//! already reuses verbatim for the identical reason: a producer's output -//! is only safely reusable across a second, independent consumer when -//! its row identity is provably stable across reads. A global or -//! `without(...)`-grouped aggregate reports no unique key, so it is never -//! proposed as a reconciliation *source* (it may still be a looser -//! *target* reading from something else that does carry one). -//! -//! ## Safety of tightening: why reading the tighter build is always sound -//! -//! [`crate::realization::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`' -//! `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)` -//! also satisfies any -//! requirement `(e2, d2)` with `e1 <= e2 && d1 <= d2` — [`dominates`]'s exact -//! check — regardless of which of `Epsilon`/`EpsilonDelta` either side is -//! spelled as, because both resolve through the identical `accuracy_budget` -//! mapping before sizing ever happens. Combined with "same [`AggIntent`] -//! variant apart from accuracy" (point 3 above), every algorithm the tighter -//! sibling may select answers the identical aggregate query and must satisfy -//! the tighter budget; the two groups need not independently choose the same -//! algorithm. That is the whole safety argument this module leans on: -//! **never** a claim that some -//! `AccuracyTarget` is "close enough" by fuzzy/heuristic similarity, always -//! a literal Pareto-domination check on the exact numbers a build would be -//! sized with. -//! -//! `AccuracyTarget::exact()` is deliberately excluded from both sides (see -//! [`dominates`]): `realizations_for_intent` routes it to `exact_realization` -//! instead of `sketch_realizations` — a different `Realization` family -//! entirely, not just a tighter budget within the same one — so "build once -//! at the tighter of the two" doesn't mean the same thing there. Reconciling -//! an exact consumer with an approximate one is a different, larger question -//! (does an exact accumulator ever make sense to share with a sketch -//! consumer, cost-wise?) this module leaves alone rather than answers -//! speculatively. -//! -//! Cross-shape comparison (`Epsilon` vs. `EpsilonDelta`) is *not* a design -//! question left open here the way the issue's own "Why this wasn't done in -//! #259" section worried about — `accuracy_budget` already commits both -//! shapes to concrete `(eps, delta)` numbers today (an `Epsilon(e)` resolves -//! to `(e, DEFAULT_DELTA)`), so [`dominates`] compares those resolved numbers -//! directly rather than inventing a second, shape-aware ordering. -//! -//! ## Never forced -//! -//! Like every [`ReplacementStrategy`], this only ever *proposes* — the -//! looser-accuracy consumer's own independently-sized candidate (from -//! [`crate::replacement::ASAPStrategies`]) stays in its -//! [`crate::replacement::TargetSubDAGCandidates`] right alongside this strategy's -//! "read the tighter sibling instead" [`Replacement::Rewrite`] candidate; -//! [`crate::cost_model::CostModel`]-driven ranking picks between them; -//! nothing here removes or filters the independent candidate. -//! -//! ## Costing this candidate shape: a dedicated arm, not a reused one -//! -//! This strategy's candidates carry their own -//! [`crate::replacement::ReplacementProvenance::AccuracyReconciliation`] -//! rather than reusing `LogicalRewrite` -//! ([`crate::rollup::RollupStrategy`]/[`crate::topk_reuse::TopKLimitReuseStrategy`]'s -//! tag), because it needs its own cost treatment in -//! [`crate::cost_model::DefaultCostModel::estimate_cost`], not just its own -//! label. Every other `Replacement::Rewrite` shape that reaches -//! `estimate_cost` (`SharedSubDAGStrategy`'s `CseRecompute`, `Rollup`'s and -//! `TopKLimitReuse`'s `LogicalRewrite`) really does rebuild `target` from a -//! different source, so pricing it as "one `cse_recompute_cost` of `target` -//! itself, per consumer" is the right shape of cost. This strategy's -//! candidate never rebuilds `target` at all — it reads `rc` (the tighter -//! sibling), which — per this module's own safety argument — is a sub-DAG -//! this crate is already going to build regardless of whether `target` -//! reads from it too. Pricing it with the same "rebuild `target`, once per -//! consumer" formula would charge it for work it never does, and — because -//! that formula scales with `target.consumer_count` — makes the candidate -//! *more* expensive exactly when sharing would help *more* (more of -//! `target`'s own consumers piggybacking on one already-necessary build): -//! the literal inversion this module's tests -//! (`estimate_cost_does_not_scale_with_the_readers_own_consumer_count`) -//! pin against. `estimate_cost` instead prices this shape as a -//! [`crate::cost_model::CostModel::cse_shared_maintenance_cost`] read -//! against `rc`'s **own** bound summary — the same order-of-magnitude, -//! per-family cost `SharedSubDAGStrategy`'s own `CseShare` candidate is -//! priced with, reflecting "one more reference into a structure that's -//! already being maintained" rather than "build a whole new one." -//! -//! `CandidateLogicalASAPDAGs::global_selection` treats this rewrite as a cross-group edge: -//! selecting it increments `rc`'s own `effective_consumer_count`, then lets -//! that sibling group propagate the uses through its selected implementation. -//! Accuracy edges are directed strictly from looser to tighter budgets, so -//! they cannot cycle among themselves; both near-duplicates also have the -//! same structural child, so adding the edge preserves the reference DAG's -//! parent-before-child topological ordering. - -use asap_types::ir::non_asap::any_measure_filtered; -use std::cmp::Ordering; -use std::rc::Rc; - -use asap_types::ir::operator_properties::Reduction; -use asap_types::ir::{NonASAPOp, OperatorNode}; -use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::types::AccuracyTarget; - -use crate::realization::{accuracy_budget, accuracy_target}; -use crate::replacement::{ - Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, -}; - -/// `bindable_accuracy_aggregate`'s return shape: `(reduction, intent, -/// accuracy, output_names, child)` — factored into its own alias per -/// `clippy::type_complexity`, not a semantic distinction. -type BindableAccuracyAggregate<'a> = ( - &'a Reduction, - &'a AggIntent, - AccuracyTarget, - &'a [String], - &'a Rc, -); - -/// The `(reduction, intent, accuracy, output_names, child)` shape this -/// module operates on: the same single-measure, no-`HAVING` bindable shape -/// [`crate::replacement::ASAPStrategies`] targets (see that -/// module's private `bindable_intent`), further narrowed to a measure whose -/// intent actually carries an [`AccuracyTarget`] -/// ([`crate::realization::accuracy_target`]'s own scope: `Count` / -/// `Quantile` / `Cardinality` / `TopK`). `None` for anything else, including -/// a multi-measure or `HAVING` aggregate, a non-`Aggregate` node, or an -/// accuracy-free intent (`Sum`, `Avg`, …). -fn bindable_accuracy_aggregate(node: &OperatorNode) -> Option> { - let Some(NonASAPOp::Aggregate { - reduction, - measures, - output_names, - filters, - having, - child, - }) = node.non_asap() - else { - return None; - }; - let ([intent], None) = (measures.as_slice(), having) else { - return None; - }; - if any_measure_filtered(filters) { - return None; - } - let accuracy = accuracy_target(intent)?; - Some((reduction, intent, accuracy, output_names.as_slice(), child)) -} - -/// Are `a` and `b` the identical [`AggIntent`] apart from `accuracy` — -/// same variant, same `col`/`q`/`k`/ranking basis? Only ever called with two -/// accuracy-bearing intents (both `bindable_accuracy_aggregate`-gated -/// first), but written as a full match rather than assuming that, the same -/// defensive-completeness style [`crate::replacement::describe_intent`] -/// uses for its own non-exhaustive `AggIntent` match. -fn same_intent_except_accuracy(a: &AggIntent, b: &AggIntent) -> bool { - match (a, b) { - (AggIntent::Count { .. }, AggIntent::Count { .. }) => true, - ( - AggIntent::Quantile { col: c1, q: q1, .. }, - AggIntent::Quantile { col: c2, q: q2, .. }, - ) => c1 == c2 && q1 == q2, - (AggIntent::TopK { k: k1, .. }, AggIntent::TopK { k: k2, .. }) => k1 == k2, - (AggIntent::Cardinality { cols: c1, .. }, AggIntent::Cardinality { cols: c2, .. }) => { - c1 == c2 - } - _ => false, - } -} - -/// Would a build sized to `tighter`'s accuracy requirement also satisfy -/// `looser`'s? See the module docs' "Safety of tightening" section for the -/// full argument; this is the Pareto check that argument reduces to: both -/// sides resolve through [`accuracy_budget`] to concrete `(eps, delta)` -/// numbers, and `tighter` dominates `looser` iff neither of its two numbers -/// is larger. -/// -/// `AccuracyTarget::exact()` on either side always returns `false` — never a -/// dominator, never dominated. Numerically, `accuracy_budget(Exact)` -/// resolves to a budget that would Pareto-dominate everything (zero error), -/// but `realizations_for_intent` realizes `Exact` through a wholly -/// different code path (`exact_realization`, never `sketch_realizations`) -/// — a different `Realization` family, not a point on the same sizing -/// curve — so the numeric comparison alone does not mean what it means for -/// two approximate targets. See the module docs for the full reasoning. -fn dominates(tighter: &AccuracyTarget, looser: &AccuracyTarget) -> bool { - if tighter.is_exact() || looser.is_exact() { - return false; - } - let (tighter_eps, tighter_delta) = accuracy_budget(tighter); - let (looser_eps, looser_delta) = accuracy_budget(looser); - tighter_eps <= looser_eps && tighter_delta <= looser_delta -} - -/// `dominates(a, b)` and the two resolved budgets aren't equal — the -/// **strict** ordering [`AccuracyReconciliationStrategy`] actually needs. -/// Without strictness, two `AccuracyTarget`s that resolve to the identical -/// `(eps, delta)` budget via different spellings (e.g. `Epsilon(0.01)` vs. -/// `EpsilonDelta { epsilon: 0.01, delta: DEFAULT_DELTA }`) would each -/// `dominates` the other, and this module would propose "read the sibling -/// instead" both ways for zero actual benefit — a degenerate tie, not a real -/// choice. Requiring strictness means only a build that is genuinely tighter -/// somewhere ever gets proposed as a replacement for a looser one. -fn strictly_tighter(a: &AccuracyTarget, b: &AccuracyTarget) -> bool { - dominates(a, b) && accuracy_budget(a) != accuracy_budget(b) -} - -/// Reconciles near-duplicate [`AggIntent`]s that differ only in their -/// [`AccuracyTarget`] (issue #273, part of #33): given a looser-accuracy -/// [`TargetSubDAG`], finds every already-known sibling `Aggregate` that -/// computes the identical intent over the identical input at a strictly -/// tighter accuracy, and proposes reading that sibling's own (to-be-built) -/// result instead of building an independent, looser copy — "build once at -/// the tightest of the group's accuracy requirements, all consumers read -/// from it," ranked by [`crate::cost_model::CostModel`] like any other -/// candidate, never forced. See the module docs for the full design. -/// -/// `siblings` is **caller-supplied, not discovered here** — the identical -/// "workload-wide discovery isn't this strategy's job" split -/// [`crate::rollup::RollupStrategy`] and [`crate::topk_reuse::TopKLimitReuseStrategy`] -/// already draw; [`crate::replacement::search_workload_with`] constructs -/// this strategy from the same post-CSE `Aggregate` sibling set it already -/// builds for `RollupStrategy`. -pub struct AccuracyReconciliationStrategy { - siblings: Vec>, -} - -impl AccuracyReconciliationStrategy { - /// A strategy that owns clones of every node in `siblings` and considers - /// each as a candidate tighter-accuracy source (or looser-accuracy - /// target) — typically the full set of `Aggregate` nodes a workload-wide - /// discovery pass already found. - pub fn new(siblings: &[Rc]) -> Self { - Self { - siblings: siblings.to_vec(), - } - } - - /// Every sibling that is a legal, strictly-tighter accuracy source for - /// `target` — shared between `matches` and `replacements` so the two can - /// never disagree about which siblings qualify. Sorted tightest-first - /// (arbitrary but deterministic order over the exhaustive candidate - /// list — this strategy makes no claim about which tighter source a - /// `CostModel` should prefer). - /// - /// Also requires the candidate's own *output* schema to carry a provable - /// unique key ([`Schema::has_unique_key`]) — the exact legality gate - /// `ir::cse::share_common_sub_dags` already applies to its own - /// sharing decisions, and [`crate::rollup::RollupStrategy`] already - /// reuses verbatim for the identical reason (see that module's - /// `is_legal_rollup_source` doc, point 4): a producer's output is only - /// safely reusable across a second, independent consumer when its row - /// identity is provably stable across reads. Without this, a global or - /// `without(...)`-grouped candidate (whose own `Reduction::by(vec![])` - /// reports no unique key — see `cse.rs`'s "Legality" section) would get - /// proposed for reconciliation even though nothing guarantees a second - /// read of it lines up row-for-row with the first. - fn tighter_sources<'a>(&'a self, target: &TargetSubDAG<'_>) -> Vec<&'a Rc> { - let Some((target_reduction, target_intent, target_accuracy, target_names, target_child)) = - bindable_accuracy_aggregate(target.root) - else { - return Vec::new(); - }; - - let mut sources: Vec<&Rc> = self - .siblings - .iter() - .filter(|candidate| { - if Rc::ptr_eq(candidate, target.root) { - return false; - } - let Some((reduction, intent, accuracy, names, child)) = - bindable_accuracy_aggregate(candidate) - else { - return false; - }; - reduction == target_reduction - && names == target_names - && (Rc::ptr_eq(child, target_child) || child == target_child) - && same_intent_except_accuracy(intent, target_intent) - && strictly_tighter(&accuracy, &target_accuracy) - && candidate.schema.has_unique_key() - }) - .collect(); - sources.sort_by(|a, b| { - let (.., a_accuracy, _, _) = - bindable_accuracy_aggregate(a).expect("filter above already confirmed this shape"); - let (.., b_accuracy, _, _) = - bindable_accuracy_aggregate(b).expect("filter above already confirmed this shape"); - accuracy_budget(&a_accuracy) - .partial_cmp(&accuracy_budget(&b_accuracy)) - .unwrap_or(Ordering::Equal) - }); - sources - } -} - -impl ReplacementStrategy for AccuracyReconciliationStrategy { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - !self.tighter_sources(target).is_empty() - } - - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - let Some((.., target_accuracy, _, _)) = bindable_accuracy_aggregate(target.root) else { - return Vec::new(); - }; - self.tighter_sources(target) - .into_iter() - .map(|source| { - let (.., source_accuracy, _, _) = bindable_accuracy_aggregate(source) - .expect("tighter_sources only returns bindable_accuracy_aggregate matches"); - ReplacementSubDAG { - strategy: self.name(), - replacement: Replacement::SubDAG(Rc::clone(source)), - provenance: ReplacementProvenance::AccuracyReconciliation, - rationale: format!( - "reuses a near-duplicate sibling aggregate — identical intent and grouping \ - over the same shared input, differing only in AccuracyTarget — built to a \ - strictly tighter accuracy ({source_accuracy:?} dominates this node's own \ - {target_accuracy:?} on both eps and delta) instead of an independent, \ - looser-accuracy copy; every shipped sketch sizing formula is monotonic in \ - (eps, delta), so a build sized to the tighter bound always satisfies this \ - consumer's own looser one too — see AccuracyReconciliationStrategy's module \ - docs for the full argument" - ), - } - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cost_model::{CostModel, DefaultCostModel}; - use asap_types::ir::cse::share_common_sub_dags; - use asap_types::ir::operator_properties::{GroupKeys, Source}; - use asap_types::post_asap::SketchAlgorithm; - use asap_types::pre_asap::schema::{ColumnId, DataType, Field, Schema}; - - /// `[ts(0), value(1), job(2)]`. - /// A unique-keyed scan (`[ts]`) so `share_common_sub_dags` is actually - /// willing to hoist it — see `Schema::has_unique_key`/`cse.rs`'s own - /// "Legality" section: a producer with no provable unique key is always - /// inserted fresh, never hoisted, regardless of structural equality. - fn metric_scan() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - Field::plain("job", DataType::Utf8, true), - ], - 0, - vec![vec![0]], - ), - })) - .unwrap() - } - - fn agg(by: Vec, intent: AggIntent, child: &Rc) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(by), - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(child), - })) - .unwrap() - } - - fn quantile(q: f64, accuracy: AccuracyTarget, child: &Rc) -> Rc { - agg( - vec![2], - AggIntent::Quantile { - col: None, - q, - accuracy, - }, - child, - ) - } - - /// A globally-grouped (`by(vec![])`) quantile — `aggregate_output_schema` - /// reports no unique key for an empty `by` (see `aggregate_schema.rs`'s own - /// `unique_keys = if by.is_empty() || has_count_values { vec![] } else - /// { .. }`). - fn ungrouped_quantile( - q: f64, - accuracy: AccuracyTarget, - child: &Rc, - ) -> Rc { - agg( - vec![], - AggIntent::Quantile { - col: None, - q, - accuracy, - }, - child, - ) - } - - /// A `without(...)`-grouped quantile — never carries a provable unique - /// key regardless of the excluded set (mirrors `rollup.rs`'s own - /// `without_agg` test helper). - fn without_quantile( - q: f64, - accuracy: AccuracyTarget, - excluded: Vec, - child: &Rc, - ) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(GroupKeys::without(excluded)), - measures: vec![AggIntent::Quantile { - col: None, - q, - accuracy, - }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(child), - })) - .unwrap() - } - - // ── dominates / strictly_tighter ───────────────────────────────────── - - #[test] - fn a_smaller_epsilon_dominates_a_larger_one() { - assert!(dominates( - &AccuracyTarget::max_error(0.01), - &AccuracyTarget::max_error(0.05) - )); - assert!(!dominates( - &AccuracyTarget::max_error(0.05), - &AccuracyTarget::max_error(0.01) - )); - } - - #[test] - fn epsilon_delta_needs_both_dimensions_at_least_as_tight() { - // Smaller epsilon but larger delta: neither Pareto-dominates. - let a = AccuracyTarget::max_error(0.01).failure_probability(0.1); - let b = AccuracyTarget::max_error(0.05).failure_probability(0.01); - assert!(!dominates(&a, &b)); - assert!(!dominates(&b, &a)); - } - - #[test] - fn exact_never_dominates_or_is_dominated() { - assert!(!dominates( - &AccuracyTarget::exact(), - &AccuracyTarget::max_error(0.5) - )); - assert!(!dominates( - &AccuracyTarget::max_error(0.5), - &AccuracyTarget::exact() - )); - assert!(!dominates( - &AccuracyTarget::exact(), - &AccuracyTarget::exact() - )); - } - - #[test] - fn equal_resolved_budgets_are_not_strictly_tighter_either_way() { - // Same numeric (eps, delta) budget, different AccuracyTarget spelling. - let epsilon_only = AccuracyTarget::max_error(0.01); - let equivalent_epsilon_delta = - AccuracyTarget::max_error(0.01).failure_probability(crate::realization::DEFAULT_DELTA); - assert!(dominates(&epsilon_only, &equivalent_epsilon_delta)); - assert!(dominates(&equivalent_epsilon_delta, &epsilon_only)); - assert!(!strictly_tighter(&epsilon_only, &equivalent_epsilon_delta)); - assert!(!strictly_tighter(&equivalent_epsilon_delta, &epsilon_only)); - } - - // ── the issue's own scenario: quantile(0.99, x) at eps=0.01 vs eps=0.05 ─ - - #[test] - fn looser_quantile_proposes_reading_the_tighter_sibling() { - let scan = metric_scan(); - let tight = quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - let loose = quantile(0.99, AccuracyTarget::max_error(0.05), &scan); - - let strategy = AccuracyReconciliationStrategy::new(&[Rc::clone(&tight), Rc::clone(&loose)]); - - assert!(strategy.matches(&TargetSubDAG::new(&loose))); - let replacements = strategy.replacements(&TargetSubDAG::new(&loose)); - assert_eq!(replacements.len(), 1); - let Replacement::SubDAG(rc) = &replacements[0].replacement else { - panic!("expected a Rewrite candidate"); - }; - assert!(Rc::ptr_eq(rc, &tight)); - assert_eq!( - replacements[0].provenance, - ReplacementProvenance::AccuracyReconciliation - ); - - // The tighter side has nothing looser to read from: no candidate. - assert!(!strategy.matches(&TargetSubDAG::new(&tight))); - assert!(strategy.replacements(&TargetSubDAG::new(&tight)).is_empty()); - } - - #[test] - fn end_to_end_search_workload_proposes_the_reconciliation_candidate() { - // The full search_workload_with entry point, not just the strategy in - // isolation — proves this is actually wired into the round loop - // alongside RollupStrategy/TopKLimitReuseStrategy. - let scan = metric_scan(); - let tight = quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - let loose = quantile(0.99, AccuracyTarget::max_error(0.05), &scan); - - let space = - crate::replacement::search_workload(vec![("tight", tight), ("loose", loose)]).unwrap(); - - let loose_root = &space.roots[1].1; - let loose_group = space - .candidates_for_target(loose_root) - .expect("loose consumer's own target has a group"); - assert!( - loose_group.candidates.iter().any(|candidate| { - candidate.strategy == "AccuracyReconciliationStrategy" - && matches!(candidate.replacement, Replacement::SubDAG(_)) - }), - "expected an AccuracyReconciliationStrategy candidate for the looser consumer, got: \ - {:?}", - loose_group - .candidates - .iter() - .map(|c| c.strategy) - .collect::>() - ); - } - - #[test] - fn different_columns_are_not_near_duplicates() { - let scan = metric_scan(); - let a = agg( - vec![2], - AggIntent::Quantile { - col: Some(1), - q: 0.99, - accuracy: AccuracyTarget::max_error(0.01), - }, - &scan, - ); - let b = agg( - vec![2], - AggIntent::Quantile { - col: Some(0), - q: 0.99, - accuracy: AccuracyTarget::max_error(0.05), - }, - &scan, - ); - let strategy = AccuracyReconciliationStrategy::new(&[Rc::clone(&a), Rc::clone(&b)]); - assert!(!strategy.matches(&TargetSubDAG::new(&b))); - } - - #[test] - fn topk_intents_differing_only_in_accuracy_are_near_duplicates() { - let tight = AggIntent::TopK { - k: 10, - accuracy: AccuracyTarget::max_error(0.01), - }; - let loose = AggIntent::TopK { - k: 10, - accuracy: AccuracyTarget::max_error(0.02), - }; - assert!(same_intent_except_accuracy(&tight, &loose)); - } - - #[test] - fn different_grouping_is_not_a_near_duplicate() { - let scan = metric_scan(); - let tight = quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - let loose = agg( - vec![], - AggIntent::Quantile { - col: None, - q: 0.99, - accuracy: AccuracyTarget::max_error(0.05), - }, - &scan, - ); - let strategy = AccuracyReconciliationStrategy::new(&[Rc::clone(&tight), Rc::clone(&loose)]); - assert!(!strategy.matches(&TargetSubDAG::new(&loose))); - } - - #[test] - fn exact_accuracy_is_never_reconciled() { - let scan = metric_scan(); - let exact = quantile(0.99, AccuracyTarget::exact(), &scan); - let approx = quantile(0.99, AccuracyTarget::max_error(0.05), &scan); - let strategy = - AccuracyReconciliationStrategy::new(&[Rc::clone(&exact), Rc::clone(&approx)]); - assert!(!strategy.matches(&TargetSubDAG::new(&approx))); - assert!(!strategy.matches(&TargetSubDAG::new(&exact))); - } - - // ── exact structural equality / share_common_sub_dags is unchanged ──── - - #[test] - fn share_common_subdags_still_never_merges_differing_accuracy() { - // The additive guarantee this issue explicitly must not violate: - // pre-ASAP CSE's own exact-equality merge stays exact. Two - // aggregates differing only in `accuracy` must come back as two - // distinct `Rc`s, not one shared `Rc` — AccuracyReconciliationStrategy - // is the *only* place cross-accuracy sharing gets proposed, never - // `share_common_sub_dags` itself. - let scan = metric_scan(); - let a = quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - let b = quantile(0.99, AccuracyTarget::max_error(0.05), &scan); - - let roots = share_common_sub_dags(vec![("a", a), ("b", b)]); - assert!( - !Rc::ptr_eq(&roots[0].1, &roots[1].1), - "share_common_sub_dags must not merge aggregates with different AccuracyTarget" - ); - assert_ne!( - roots[0].1, roots[1].1, - "the two aggregates really are structurally different (accuracy differs)" - ); - - // The identical scan child, though, is still shared exactly as - // before — this module changes nothing about that. - let Some(NonASAPOp::Aggregate { child: child_a, .. }) = roots[0].1.non_asap() else { - panic!("expected an Aggregate root"); - }; - let Some(NonASAPOp::Aggregate { child: child_b, .. }) = roots[1].1.non_asap() else { - panic!("expected an Aggregate root"); - }; - assert!(Rc::ptr_eq(child_a, child_b)); - } - - #[test] - fn identical_accuracy_still_merges_via_ordinary_cse() { - // Sanity check the fixture itself: truly identical aggregates - // (same accuracy too) still merge via share_common_sub_dags's own - // exact equality — unrelated to this module, but pins the contrast - // with the test above. - let scan = metric_scan(); - let a = quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - let b = quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - - let roots = share_common_sub_dags(vec![("a", a), ("b", b)]); - assert!(Rc::ptr_eq(&roots[0].1, &roots[1].1)); - } - - // ── legality gate: the tighter source needs a provable unique key ───── - - #[test] - fn a_globally_grouped_tighter_sibling_with_no_unique_key_is_not_a_source() { - // `by(vec![])` (global aggregation) reports no unique key - // (`aggregate_output_schema`'s own `unique_keys = if by.is_empty() .. - // { vec![] } ..`) — the same legality gate - // `share_common_sub_dags`/`RollupStrategy` apply, which this - // strategy must not bypass (module docs, point 5). - let scan = metric_scan(); - let tight = ungrouped_quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - let loose = ungrouped_quantile(0.99, AccuracyTarget::max_error(0.05), &scan); - - assert!( - !tight.schema.clone().has_unique_key(), - "fixture sanity: a globally-grouped aggregate has no provable unique key" - ); - - let strategy = AccuracyReconciliationStrategy::new(&[Rc::clone(&tight), Rc::clone(&loose)]); - assert!(!strategy.matches(&TargetSubDAG::new(&loose))); - assert!(strategy.replacements(&TargetSubDAG::new(&loose)).is_empty()); - } - - #[test] - fn a_without_grouped_tighter_sibling_with_no_unique_key_is_not_a_source() { - // Mirrors RollupStrategy's own - // `no_unique_key_on_the_finer_side_does_not_roll_up`: a - // `without(...)` grouping never carries a provable unique key, - // regardless of the excluded set. - let scan = metric_scan(); - let tight = without_quantile(0.99, AccuracyTarget::max_error(0.01), vec![2], &scan); - let loose = without_quantile(0.99, AccuracyTarget::max_error(0.05), vec![2], &scan); - - assert!( - !tight.schema.clone().has_unique_key(), - "fixture sanity: a without(...) aggregate has no provable unique key" - ); - - let strategy = AccuracyReconciliationStrategy::new(&[Rc::clone(&tight), Rc::clone(&loose)]); - assert!(!strategy.matches(&TargetSubDAG::new(&loose))); - assert!(strategy.replacements(&TargetSubDAG::new(&loose)).is_empty()); - } - - // ── cost: reading the sibling must not be priced like recomputing - // `target` independently per consumer ──────────────────────────────── - - #[test] - fn estimate_cost_does_not_scale_with_the_readers_own_consumer_count() { - // Regression guard for the review-reported sign inversion: pricing - // this candidate like `CseRecompute` ("rebuild `target`, once per - // consumer") made it artificially *more* expensive exactly as more - // of `target`'s own consumers stood to benefit from reading the - // already-necessary tighter sibling instead — the literal opposite - // of the intended incentive. The real cost is "one more read against - // `rc`'s own build," which must not scale with `target`'s own - // `consumer_count`. - let scan = metric_scan(); - let tight = quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - let loose = quantile(0.99, AccuracyTarget::max_error(0.05), &scan); - let strategy = AccuracyReconciliationStrategy::new(&[Rc::clone(&tight), Rc::clone(&loose)]); - let candidate = strategy - .replacements(&TargetSubDAG::new(&loose)) - .into_iter() - .next() - .expect("loose has a reconciliation candidate reading the tight sibling"); - - let cost_model = DefaultCostModel; - let single_consumer = TargetSubDAG::with_consumer_count(&loose, 1); - let many_consumers = TargetSubDAG::with_consumer_count(&loose, 5); - - let cost_single = cost_model.estimate_cost(&candidate, &single_consumer); - let cost_many = cost_model.estimate_cost(&candidate, &many_consumers); - - assert!( - cost_single.is_finite(), - "expected a real cost, not the NaN placeholder: {cost_single}" - ); - assert_eq!( - cost_single, cost_many, - "AccuracyReconciliation's estimate_cost must price 'read the sibling', not scale \ - with the reader's own consumer_count the way CseRecompute's 'rebuild independently \ - per consumer' formula does (single-consumer: {cost_single}, 5 consumers: \ - {cost_many})" - ); - } - - // ── cost_sorted / global_selection: single-consumer and shared-consumer ─ - - #[test] - fn cost_sorted_and_global_selection_handle_a_single_consumer_looser_target() { - let scan = metric_scan(); - let tight = quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - let loose = quantile(0.99, AccuracyTarget::max_error(0.05), &scan); - let space = - crate::replacement::search_workload(vec![("tight", tight), ("loose", loose)]).unwrap(); - let loose_root = &space.roots[1].1; - - let cost_model = DefaultCostModel; - let ranked = space.cost_sorted(&cost_model); - let loose_ranked = ranked - .iter() - .find(|group| Rc::ptr_eq(group.target, loose_root)) - .expect("loose has its own ranked group"); - assert!( - loose_ranked.costs.iter().all(|cost| cost.is_finite()), - "no candidate should cost NaN under DefaultCostModel: {:?}", - loose_ranked.costs - ); - assert!( - loose_ranked - .candidates - .iter() - .any(|c| c.strategy == "AccuracyReconciliationStrategy"), - "the reconciliation candidate must still be present, ranked, not filtered" - ); - - let selected = space.global_selection(&cost_model); - let chosen = selected - .for_target(loose_root) - .and_then(|group| group.chosen); - assert!( - chosen.is_some(), - "global_selection must commit to some candidate for a single-consumer looser target" - ); - // With no recompute term at all (it never rebuilds `target`), this - // candidate strictly undercuts every ASAPStrategies - // candidate (which each pay a recompute term on top of their own - // maintenance term) under DefaultCostModel's numbers — the sane - // direction: reading an already-necessary sibling should be able to - // win on its own merit, not just fail to lose as badly as before. - assert_eq!( - chosen.map(|c| c.provenance), - Some(crate::replacement::ReplacementProvenance::AccuracyReconciliation) - ); - } - - #[test] - fn cost_sorted_and_global_selection_handle_a_shared_looser_target() { - // The loose accuracy target itself has 2 direct consumers (two - // independently-built but structurally identical loose queries - // merge onto one Rc via ordinary CSE), *and* a separate, - // single-consumer tight sibling exists over the same input — the - // scenario the issue itself targets: `SharedSubDAGStrategy`'s own - // CseShare/CseRecompute pair is on the table for the loose target's - // own 2 consumers at the same time as this strategy's "read the - // tight sibling instead" candidate. - let scan = metric_scan(); - let loose_a = (*quantile(0.99, AccuracyTarget::max_error(0.05), &scan)).clone(); - let loose_b = (*quantile(0.99, AccuracyTarget::max_error(0.05), &scan)).clone(); - let tight = (*quantile(0.99, AccuracyTarget::max_error(0.01), &scan)).clone(); - - let space = crate::replacement::search_workload(vec![ - ("loose_a", Rc::new(loose_a)), - ("loose_b", Rc::new(loose_b)), - ("tight", Rc::new(tight)), - ]) - .unwrap(); - - // Fixture sanity: the two loose roots really did merge onto one Rc. - assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); - let loose_group = space - .candidates_for_target(&space.roots[0].1) - .expect("the merged loose target has a group"); - assert_eq!(loose_group.consumer_count, 2); - assert!( - loose_group - .candidates - .iter() - .any(|c| c.strategy == "AccuracyReconciliationStrategy"), - "the reconciliation candidate must still be proposed alongside the CSE share/recompute \ - pair, not crowded out: {:?}", - loose_group - .candidates - .iter() - .map(|c| (c.strategy, c.provenance)) - .collect::>() - ); - - let cost_model = DefaultCostModel; - let ranked = space.cost_sorted(&cost_model); - let loose_ranked = ranked - .iter() - .find(|group| Rc::ptr_eq(group.target, &space.roots[0].1)) - .expect("loose has its own ranked group"); - assert!( - loose_ranked.costs.iter().all(|cost| cost.is_finite()), - "no candidate should cost NaN under DefaultCostModel, shared or not: {:?}", - loose_ranked.costs - ); - - let selected = space.global_selection(&cost_model); - let chosen = selected - .for_target(&space.roots[0].1) - .and_then(|group| group.chosen); - assert!( - chosen.is_some(), - "global_selection must commit to some candidate for the shared looser target" - ); - // Under `DefaultCostModel`'s numbers, `CseShare` (flat maintenance, - // no recompute term) and this strategy's own candidate (also a - // flat, non-scaling read cost after the fix) land tied, and - // `global_selection` breaks ties in `CseShare`'s favor (it only ever - // overrides the CSE choice on a *strict* `<`, not `<=`) — a sane, - // deliberate tie-break, not the "reconciliation always loses to - // CseShare regardless of its own real merit" bug this test guards - // against (see `estimate_cost_does_not_scale_with_the_readers_own_consumer_count` - // for the direct regression check that the old `* consumer_count` - // scaling — which made this an unfair, ever-widening loss instead - // of a tie — is gone). - assert_eq!( - chosen.map(|c| c.provenance), - Some(crate::replacement::ReplacementProvenance::CseShare) - ); - } - - #[test] - fn global_selection_propagates_reconciled_consumers_to_the_tighter_group() { - struct PreferReconciliation; - - impl CostModel for PreferReconciliation { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn estimate_cost( - &self, - candidate: &ReplacementSubDAG, - _target: &TargetSubDAG<'_>, - ) -> f64 { - if candidate.provenance == ReplacementProvenance::AccuracyReconciliation { - 0.0 - } else { - 100.0 - } - } - } - - let scan = metric_scan(); - let tight = quantile(0.99, AccuracyTarget::max_error(0.01), &scan); - let loose = quantile(0.99, AccuracyTarget::max_error(0.05), &scan); - let space = crate::replacement::search_workload(vec![ - ("tight", Rc::clone(&tight)), - ("loose", Rc::clone(&loose)), - ]) - .unwrap(); - - let selected = space.global_selection(&PreferReconciliation); - let tight_root = &space.roots[0].1; - let loose_root = &space.roots[1].1; - assert_eq!( - selected - .for_target(loose_root) - .and_then(|group| group.chosen) - .map(|candidate| candidate.provenance), - Some(ReplacementProvenance::AccuracyReconciliation), - "fixture must select the cross-sibling rewrite" - ); - assert_eq!( - selected - .for_target(tight_root) - .expect("the tighter sibling is a discovered memo group") - .effective_consumer_count, - 2, - "the tighter build serves its original root and the reconciled looser root" - ); - } -} diff --git a/crates/asap-aware-mapping/src/asap_aware_cse.rs b/crates/asap-aware-mapping/src/asap_aware_cse.rs index 4cb67d5a..11f2305f 100644 --- a/crates/asap-aware-mapping/src/asap_aware_cse.rs +++ b/crates/asap-aware-mapping/src/asap_aware_cse.rs @@ -1983,6 +1983,19 @@ mod tests { ); } + /// Quantiles over other groupings share no capability build. + #[test] + fn different_groupings_are_not_capability_sharing_candidates() { + let groupings = chose( + &[ + ("quantile by (job) (0.5, lat)", 0.01), + ("quantile (0.99, lat)", 0.001), + ], + SketchAlgorithm::Kll, + ); + assert!(capability_sharing_candidates(&groupings).is_empty()); + } + /// Each candidate's label (the groups it shares) and its roots agree: /// applying the label gives the same roots, sharing exactly the /// labelled groups. @@ -2282,6 +2295,9 @@ mod tests { "sum by (job) (sum_over_time(m[5m]))", "sum by (job, instance) (sum_over_time(m[1m]))", ], + &["sum by (job) (m)", "sum by (job) (m)"], + &["sum by (job) (m)", "sum without (instance) (m)"], + &["sum without (job) (m)", "sum by (job, instance) (m)"], ] { assert!(rollup_candidates(&exact(queries)).is_empty(), "{queries:?}"); } @@ -2395,6 +2411,10 @@ mod tests { ][..], &["topk(5, sum by (job) (m))", "topk(10, sum by (job) (n))"], &["topk(5, sum by (job) (m))", "topk(5, max by (job) (m))"], + &[ + "topk(5, sum by (job) (m))", + "topk by (job) (10, sum by (job) (m))", + ], ] { assert!( topk_reuse_candidates(&exact(queries)).is_empty(), diff --git a/crates/asap-aware-mapping/src/cost_model.rs b/crates/asap-aware-mapping/src/cost_model.rs deleted file mode 100644 index 51fbc86e..00000000 --- a/crates/asap-aware-mapping/src/cost_model.rs +++ /dev/null @@ -1,1556 +0,0 @@ -//! Cost model interface (issues #6, #33). -//! -//! `asap-plan` deliberately has no cost model *implementation* of its own — -//! ranking candidate summaries by real cost (bandwidth budget, memory -//! footprint, site count, observed drift, workload-level CSE credit, …) -//! 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`. -//! -//! 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 -//! sibling families for sampling-based, wavelet-transform, and fitted -//! statistical-model summaries -//! ([`asap_types::post_asap::SamplingKind`]/…/[`asap_types::post_asap::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. -//! -//! ## CSE sharing (issue #237, #223 stage 4) -//! -//! [`CseCandidate`]/[`ShareDecision`]/[`CostModel::cse_share_decision`] below -//! decide whether a CSE-detected shared sub-DAG -//! ([`asap_types::ir::cse::share_common_sub_dags`], issue #223 stages -//! 1-2, PR #235) is actually worth sharing, via a real Volcano/Cascades-style -//! cost comparison rather than a fixed rule. See -//! `docs/design_docs/cse-cost-model-decision.md` for the full design discussion (why -//! cost-based, why not a full plan-search engine, the layering constraint -//! that forces detection to stay cost-agnostic). -//! [`CandidateLogicalASAPDAGs::cost_sorted`](crate::replacement::CandidateLogicalASAPDAGs::cost_sorted) -//! (via [`crate::replacement`]'s own `cse_preference`) and -//! [`DefaultCostModel::estimate_cost`] are this crate's own callers. - -use std::rc::Rc; - -use crate::exact_composition::ExactOperation; -use asap_types::ir::{ASAPOp, Operator, OperatorNode}; -use asap_types::post_asap::{ - FieldDataType, GroupingStrategy, HydraParams, SketchAlgorithm, SketchParams, SketchStatistic, -}; -use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::pre_asap::expr_ir::ColumnRef; - -use crate::exact_composition::{ExactComposition, OperationPlacement}; -use crate::recurrence::{ - self, CostRate, EvaluationRate, Horizon, RecurrenceCostExplanation, RecurrenceError, - RecurrenceProfile, -}; -use crate::replacement::{ - realize_child, Realization, Replacement, ReplacementProvenance, ReplacementSubDAG, TargetSubDAG, -}; - -// ── Recurring-cost vocabulary for mixed exact/summary plans (issue #171) ── - -/// The unit a cost is expressed in. Costs in different units are never -/// compared or aggregated. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub enum CostUnit { - /// A recurring cost expressed per second (`cost_units / s`); it requires - /// an explicit recurrence interval or rate. - CostUnitsPerSecond, - /// A finite-run or one-shot total at some horizon. - CostUnits, -} - -impl CostUnit { - /// Stable machine-readable name. - pub fn as_str(self) -> &'static str { - match self { - Self::CostUnitsPerSecond => "cost_units_per_second", - Self::CostUnits => "cost_units", - } - } -} - -impl std::fmt::Display for CostUnit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CostUnit::CostUnitsPerSecond => write!(f, "cost units/s"), - CostUnit::CostUnits => write!(f, "cost units"), - } - } -} - -/// Who produced a set of [`ExactCompositionCostInputs`], and under which -/// model version — carried into every composed decision's explanation and -/// DAG export so a reviewer can tell a deployment's measured numbers from -/// a placeholder. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CostProvenance { - /// The cost model's own name (e.g. `"DefaultCostModel"`). - pub model: String, - /// The model's own version string, whatever scheme it uses. - pub version: String, -} - -/// Which mixed-execution shapes the downstream runtime can actually -/// execute (issue #171). [`crate::exact_composition::ExactCompositionStrategy`] -/// proposes an `ValueOperationAtQueryTime` candidate only when -/// `query_time` is set, and an `ValueOperationAtIngestionTime` candidate only -/// when `ingestion_time` is — a runtime that cannot run an exact -/// operator on the update path must never be handed one. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct ValueOperationCapabilities { - /// The runtime can apply an exact operator to summary evaluations at - /// query evaluation time. - pub query_time: bool, - /// The runtime can apply an exact row transform on the update path, - /// feeding its output into maintained summary state. - pub ingestion_time: bool, -} - -impl ValueOperationCapabilities { - /// Neither shape supported. - pub const NONE: Self = Self { - query_time: false, - ingestion_time: false, - }; - /// Both shapes supported. - pub const ALL: Self = Self { - query_time: true, - ingestion_time: true, - }; - - pub fn supports(self, placement: OperationPlacement) -> bool { - match placement { - OperationPlacement::Read => self.query_time, - OperationPlacement::Maintenance => self.ingestion_time, - } - } -} - -/// What [`CostModel::exact_composition_cost_inputs`] is asked about: one -/// composed alternative at one site, paired with the concrete summary it -/// composes with. -#[derive(Debug, Clone, Copy)] -pub struct ExactCompositionCostRequest<'a> { - /// The target the composed candidate replaces. - pub target: &'a OperatorNode, - /// The composition itself — placement, operator, child target. - pub composition: &'a ExactComposition, - /// For [`OperationPlacement::Read`]: the child target's *selected* - /// summary evaluation candidate the exact operator consumes. For - /// [`OperationPlacement::Maintenance`]: the maintained summary *above* the - /// transform that consumes its output (the `SummaryAgg` this transform - /// feeds). Either way, the summary whose maintenance/read cost the - /// formula charges. - pub summary: &'a OperatorNode, - /// How many times this site actually runs once ancestors' own choices - /// are accounted for (see `CandidateLogicalASAPDAGs::global_selection`). - pub effective_consumer_count: usize, -} - -/// Every input the issue #171 cost formulas need, each individually -/// optional: **an unknown stays `None` — never a zero** — so a formula -/// with a missing input yields no rate at all rather than a spuriously -/// cheap one, and global selection then keeps the conservative -/// keep-as-is behavior. A deployment model that wants defaults supplies -/// them explicitly by overriding [`CostModel::exact_composition_cost_inputs`]. -#[derive(Debug, Clone, PartialEq)] -pub struct ExactCompositionCostInputs { - /// Exact operator cost per row it processes — per evaluation row for a - /// read-time operation, per input row for an maintenance-time operation. - pub exact_cost_per_row: Option, - /// Rows the exact operator consumes per evaluation (read-time operation) or - /// per update (transform). - pub expected_input_rows: Option, - /// Rows the exact operator emits per evaluation/update. - pub expected_output_rows: Option, - /// Cost of one update to the composed-with summary's maintained state. - pub summary_maintenance_cost_per_update: Option, - /// Cost of one evaluation of that summary at evaluation time. - pub summary_read_cost: Option, - /// Update (ingest) events per second reaching this site. - pub update_rate: Option, - /// Evaluations per second across every consumer of this site. - pub evaluation_rate: Option, - /// Cost of one full raw recompute of the target from pre-ASAP data — - /// the kept-query baseline's per-evaluation cost. - pub raw_recompute_cost: Option, - /// Recurring formulas require `CostUnitsPerSecond`; totals yield no rate. - pub unit: CostUnit, - pub provenance: CostProvenance, -} - -impl ExactCompositionCostInputs { - /// Every input unknown, attributed to `provenance` — what a model that - /// has no statistics for a site returns. - pub fn unknown(provenance: CostProvenance) -> Self { - Self { - exact_cost_per_row: None, - expected_input_rows: None, - expected_output_rows: None, - summary_maintenance_cost_per_update: None, - summary_read_cost: None, - update_rate: None, - evaluation_rate: None, - raw_recompute_cost: None, - unit: CostUnit::CostUnitsPerSecond, - provenance, - } - } - - /// The rate for whichever composition placement is requested — - /// [`read_operation_plan_cost_rate`] or [`maintenance_operation_plan_cost_rate`]. - pub fn composed_plan_cost_rate(&self, placement: OperationPlacement) -> Option { - match placement { - OperationPlacement::Read => read_operation_plan_cost_rate(self), - OperationPlacement::Maintenance => maintenance_operation_plan_cost_rate(self), - } - } -} - -/// Outer exact read-time operation over a maintained summary: -/// -/// ```text -/// read_operation_plan_cost_rate = -/// update_rate * summary_maintenance_cost_per_update -/// + evaluation_rate * (summary_read_cost -/// + output_rows_per_eval * exact_read-time operation_cost_per_row) -/// ``` -/// -/// `None` if any input is unknown — see [`ExactCompositionCostInputs`]. -pub fn read_operation_plan_cost_rate(inputs: &ExactCompositionCostInputs) -> Option { - if inputs.unit != CostUnit::CostUnitsPerSecond { - return None; - } - let maintenance = inputs.update_rate? * inputs.summary_maintenance_cost_per_update?; - let per_eval = - inputs.summary_read_cost? + inputs.expected_output_rows? * inputs.exact_cost_per_row?; - let evaluation = inputs.evaluation_rate?.0 * per_eval; - finite_rate(maintenance + evaluation) -} - -/// Outer maintained summary over an exact maintenance-time operation: -/// -/// ```text -/// maintenance_operation_plan_cost_rate = -/// update_rate * (exact_function_cost_per_input_row -/// + summary_maintenance_cost_per_update) -/// + evaluation_rate * summary_read_cost -/// ``` -/// -/// `None` if any input is unknown — see [`ExactCompositionCostInputs`]. -pub fn maintenance_operation_plan_cost_rate( - inputs: &ExactCompositionCostInputs, -) -> Option { - if inputs.unit != CostUnit::CostUnitsPerSecond { - return None; - } - let per_update = inputs.exact_cost_per_row? + inputs.summary_maintenance_cost_per_update?; - let maintenance = inputs.update_rate? * per_update; - let evaluation = inputs.evaluation_rate?.0 * inputs.summary_read_cost?; - finite_rate(maintenance + evaluation) -} - -/// The raw/pre-ASAP fallback baseline: -/// -/// ```text -/// raw_recompute_cost_rate = evaluation_rate * raw_recompute_cost -/// ``` -/// -/// `None` if either input is unknown — see [`ExactCompositionCostInputs`]. -pub fn raw_recompute_cost_rate(inputs: &ExactCompositionCostInputs) -> Option { - if inputs.unit != CostUnit::CostUnitsPerSecond { - return None; - } - finite_rate(inputs.evaluation_rate?.0 * inputs.raw_recompute_cost?) -} - -fn finite_rate(units_per_second: f64) -> Option { - units_per_second - .is_finite() - .then_some(CostRate(units_per_second)) -} - -/// A CSE-detected, legality-gated shared sub-DAG with two or more consumers -/// — the unit [`CostModel::cse_share_decision`] decides over. Built by -/// [`CandidateLogicalASAPDAGs::cost_sorted`](crate::replacement::CandidateLogicalASAPDAGs::cost_sorted) -/// (via [`crate::replacement`]'s own `cse_preference`) the first time it -/// needs a representative bound node for a sub-DAG that -/// [`asap_types::ir::cse::share_common_sub_dags`] already collapsed -/// onto one `Rc` for two or more workload roots. See -/// `docs/design_docs/cse-cost-model-decision.md`. -pub struct CseCandidate<'a> { - /// The shared sub-DAG itself. - pub sub_dag: &'a Rc, - /// The node this sub-DAG bound to — gives the cost model the - /// concrete `FieldDataType`/`(kind, params)` actually at stake, not - /// just the logical shape. - pub bound_summary: &'a OperatorNode, - /// How many workload roots reference this exact shared sub-DAG, counted - /// once up front over the whole workload (always >= 2 — a candidate is - /// only ever constructed for an actually-shared sub-DAG). - pub consumer_count: usize, -} - -/// A cost estimate produced by a [`CostModel`] hook. A newtype around `f64` -/// rather than a bare `f64` return type, so a future cost dimension (e.g. -/// separate CPU/memory/network estimates, once a deployment actually needs -/// to compare along more than one axis) can be added as a field here -/// without changing every hook's signature a second time. Today it's still -/// a single unitless scalar — the same magnitude convention -/// [`default_cse_recompute_cost`]/[`default_cse_shared_maintenance_cost`] -/// already used as bare `f64`s, just wrapped. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct Cost(pub f64); - -impl Cost { - /// The cost of an operation that costs nothing at all. - pub const ZERO: Cost = Cost(0.0); -} - -impl std::fmt::Display for Cost { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl std::ops::Add for Cost { - type Output = Cost; - fn add(self, rhs: Cost) -> Cost { - Cost(self.0 + rhs.0) - } -} - -impl std::ops::Mul for Cost { - type Output = Cost; - fn mul(self, rhs: usize) -> Cost { - Cost(self.0 * rhs as f64) - } -} - -/// The decision [`CostModel::cse_share_decision`] returns for one -/// [`CseCandidate`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ShareDecision { - /// Reuse one bound node across every consumer. - Share, - /// Bind each occurrence independently — the shared-maintenance cost - /// isn't worth it for this candidate. - RecomputeIndependently, -} - -/// Default [`CostModel::cse_recompute_cost`]: a structural-size proxy — the -/// number of *unique* nodes in `sub-DAG`'s DAG -/// ([`asap_types::ir::cse::dag_node_count`], the same module this -/// candidate's sharing was detected in). Deliberately **not** a raw -/// `serde_json` serialization length: after CSE, `sub-DAG` is generally a -/// DAG, not a tree (a `CseCandidate` only exists because something got -/// shared), and a naive full serialization re-serializes — over-counts — -/// any descendant `sub-DAG` already shares internally, once per parent -/// that references it, instead of once for the whole DAG. `dag_node_count` -/// dedupes by `Rc` pointer identity, so it charges each unique node's -/// contribution exactly once regardless of how many places within -/// `sub-DAG` reference it. Cheap to compute (one pass, no serialization), -/// and still scales with real structural complexity — a genuinely tiny -/// leaf costs little to recompute, a deep multi-join sub-DAG costs a lot. -/// A deployment with real per-row/per-update cost knowledge should -/// override [`CostModel::cse_recompute_cost`] instead of relying on this. -pub fn default_cse_recompute_cost(sub_dag: &Rc) -> Cost { - Cost(asap_types::ir::cse::dag_node_count(sub_dag) as f64) -} - -/// Default [`CostModel::cse_shared_maintenance_cost`]: a small -/// per-[`FieldDataType`] weight, scaled to the same order of magnitude -/// as [`default_cse_recompute_cost`]'s typical output (a small node -/// count, not a byte length), reflecting that families differ in how -/// expensive they are to keep *continuously updated* for the life of a -/// workload — an exact accumulator is the cheapest (an O(1) merge), -/// sketches/samples cost more (a whole data structure to update per new -/// row), wavelets/fitted models cost the most (coefficient/parameter -/// maintenance). These weights are illustrative, not measured — a -/// deployment with real memory/update-cost numbers should override -/// [`CostModel::cse_shared_maintenance_cost`] instead of relying on this -/// table. -pub fn default_cse_shared_maintenance_cost(family: &FieldDataType) -> Cost { - const UNIT: f64 = 1.0; - let weight = match family { - FieldDataType::Plain(_) => 1.0, - FieldDataType::ExactAggregate(..) => 1.0, - FieldDataType::Sketch(..) => 3.0, - FieldDataType::Sample(..) => 3.0, - FieldDataType::Wavelet(..) => 5.0, - FieldDataType::StatModel(..) => 6.0, - }; - Cost(weight * UNIT) -} - -/// Ranks the candidate sketch algorithms for one [`AggIntent`], best choice -/// first. -/// -/// [`realization::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. -pub trait CostModel { - /// Whether [`Self::candidate_cost`] prices a complete physical - /// alternative, including its raw baseline, rather than a local - /// heuristic for one memo-group node. - /// - /// Complete-plan models make every CSE alternative available to final - /// ranking. Choosing a share/recompute arm first through the legacy - /// structural hooks would discard a physical alternative before its - /// evidence-backed cost was compared. - fn candidate_cost_covers_complete_plan(&self) -> bool { - false - } - - /// Opt into historical qualitative ranking/CSE decisions when no numeric - /// candidate cost exists. The safe default leaves an uncosted candidate - /// unselected; a model with an intentional non-numeric policy overrides - /// this to `true`. - fn allow_uncosted_legacy_selection(&self) -> bool { - false - } - - /// Candidate-level cost availability for final selection. A non-finite or - /// negative estimate is unknown/invalid, never an available cost. - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - target: &TargetSubDAG<'_>, - ) -> Option { - let value = self.estimate_cost(candidate, target); - (value.is_finite() && value >= 0.0).then_some(Cost(value)) - } - - /// Rank `candidates` (as returned by - /// [`summary_candidates`](crate::realization::summary_candidates)) for - /// `intent`, best choice first. - /// - /// Implementations MAY reorder freely, but MUST return exactly the input - /// candidates: no additions, removals, or duplicates. Semantic legality - /// belongs to replacement generation; cost availability is reported - /// separately by `candidate_cost`. Letting this hook filter would violate - /// [`ReplacementStrategy`]'s exhaustive, never-prune contract. This - /// invariant is checked at every production call site. - /// - /// [`ReplacementStrategy`]: crate::replacement::ReplacementStrategy - fn rank_candidates( - &self, - intent: &AggIntent, - 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: - /// [`realization::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::realization::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. - fn estimated_subpopulation_count(&self, _target: &OperatorNode) -> Option { - None - } - - /// Comparable memory-state cost for a sketch grouping candidate. The - /// default compares `N` independent inner sketches against the complete - /// shared Hydra grid, using [`Self::estimated_subpopulation_count`]. - fn grouping_state_cost( - &self, - candidate: &ReplacementSubDAG, - target: &TargetSubDAG<'_>, - ) -> Option { - let Replacement::SubDAG(node) = &candidate.replacement else { - return None; - }; - let (kind, grouping) = sketch_state(node)?; - let inner = sketch_state_units(kind.params()); - let units = match grouping { - GroupingStrategy::PerSubpopulationInstance => { - inner * self.estimated_subpopulation_count(target.root)? as f64 - } - GroupingStrategy::SharedMultiSubpopulation { params, .. } => { - inner * hydra_grid_cells(params) - } - }; - 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 - /// `docs/design_docs/cse-cost-model-decision.md`. - fn cse_recompute_cost(&self, candidate: &CseCandidate) -> Cost { - default_cse_recompute_cost(candidate.sub_dag) - } - - /// Estimate the cost of maintaining `candidate.bound_summary` as one - /// continuously-updated shared summary for the life of the workload. - /// Default: [`default_cse_shared_maintenance_cost`] (a per-family - /// weight table), applied to whichever field of - /// `candidate.bound_summary`'s output schema actually carries summary - /// state (falls back to the cheapest, `Plain`, weight if none does — - /// e.g. `bound_summary` is a kept non-ASAP sub-DAG with nothing - /// summary-shaped to maintain). See `docs/design_docs/cse-cost-model-decision.md`. - fn cse_shared_maintenance_cost(&self, candidate: &CseCandidate) -> Cost { - let family = candidate - .bound_summary - .schema - .fields - .iter() - .map(|f| &f.dtype) - .find(|dtype| !matches!(dtype, FieldDataType::Plain(_))) - .cloned() - .unwrap_or(FieldDataType::Plain( - asap_types::pre_asap::DataType::Float64, - )); - default_cse_shared_maintenance_cost(&family) - } - - /// Decide whether to reuse one shared node across every - /// consumer of `candidate`, or bind each occurrence independently — a - /// Volcano/Cascades-style cost comparison (issue #237, #223 stage 4; see - /// `docs/design_docs/cse-cost-model-decision.md`): share iff the estimated cost of - /// maintaining one shared summary is no greater than the estimated total - /// cost of recomputing it independently everywhere it's used. - /// - /// The default body composes [`cse_recompute_cost`](Self::cse_recompute_cost) - /// and [`cse_shared_maintenance_cost`](Self::cse_shared_maintenance_cost) - /// — a deployment with real cost knowledge should override those two - /// (keeping this comparison), or override this method directly for a - /// wholly different policy. - fn cse_share_decision(&self, candidate: &CseCandidate) -> ShareDecision { - let recompute_total = self.cse_recompute_cost(candidate) * candidate.consumer_count; - let shared = self.cse_shared_maintenance_cost(candidate); - if shared <= recompute_total { - ShareDecision::Share - } else { - ShareDecision::RecomputeIndependently - } - } - - // ── Recurrence-aware costing (issue #287) ─────────────────────────── - // - // See `crate::recurrence`'s module docs for the full cost model - // (`maintained_cost_rate`/`recompute_cost_rate` formulas, units, - // provenance of every new input). The three hooks below are the - // per-update-event/per-read/per-recomputation cost primitives that - // formula is built from; `cse_share_decision_with_recurrence` is the - // composed decision, mirroring how `cse_share_decision` above composes - // `cse_recompute_cost`/`cse_shared_maintenance_cost`. - - /// Cost of maintaining `candidate`'s bound summary for a single ingest - /// update event. Units: cost units per update — the - /// `maintenance_cost_per_update` term of `maintained_cost_rate` - /// (`crate::recurrence`), where it is multiplied by an `UpdateRate` in - /// **Hz** (`update_rate * maintenance_cost_per_update`). - /// - /// Default: a small nominal constant, `Cost(0.01)` — deliberately - /// **not** derived from - /// [`cse_shared_maintenance_cost`](Self::cse_shared_maintenance_cost)'s - /// per-family weight table. That table's values (~1-6) are calibrated - /// against [`cse_recompute_cost`](Self::cse_recompute_cost)'s - /// structural-size proxy for a *life-of-the-workload*, one-time - /// maintenance magnitude — multiplying them by a real ingest rate (even - /// a modest one, e.g. 100 events/s) inflates `maintained_cost_rate` far - /// past any realistic `recompute_cost_rate`, making `Share` - /// unreachable regardless of how infrequently the summary is actually - /// read (issue #287 review). `Cost(0.01)` — one order of magnitude - /// below [`summary_read_cost`](Self::summary_read_cost)'s own nominal - /// default — reflects only that an incremental per-event update is - /// normally far cheaper than a full read or recompute, not a measured - /// ratio; a deployment with a real per-update cost (e.g. observed - /// sketch-insert latency) should override this instead of relying on - /// this placeholder. - fn maintenance_cost_per_update(&self, _candidate: &CseCandidate) -> Cost { - Cost(0.01) - } - - /// Cost of one read against `candidate`'s already-maintained summary. - /// Units: cost units per read — the `summary_read_cost` term of - /// `maintained_cost_rate`. Default: `Cost(1.0)`, a nominal unit read — - /// illustrative, like every other numeric default in this trait; a - /// deployment with a real read-path cost should override this. - fn summary_read_cost(&self, _candidate: &CseCandidate) -> Cost { - Cost(1.0) - } - - /// Cost of recomputing `candidate.sub-DAG` once, from the pre-ASAP/raw - /// path. Units: cost units per recomputation — the `raw_recompute_cost` - /// term of `recompute_cost_rate`. Default: delegates to - /// [`cse_recompute_cost`](Self::cse_recompute_cost) (the same - /// structural-size proxy `cse_share_decision` already uses). - fn raw_recompute_cost(&self, candidate: &CseCandidate) -> Cost { - self.cse_recompute_cost(candidate) - } - - /// The one-time cost of materializing `candidate`'s bound summary for - /// the *first* time — before any read or ingest-driven update charges - /// anything. Units: cost units (a one-time [`Cost`], not a rate). - /// - /// This is what makes a purely (or mostly) one-shot comparison - /// economically sound: without a build cost, "maintained" looked free - /// to construct, so `Share` won unconditionally for any number of - /// one-shot consumers, no matter how few (issue #287 review, bug 1). - /// With it, a single one-shot consumer never benefits from sharing - /// (build + one read costs more than one direct recompute), while many - /// one-shot consumers still amortize the fixed build cost across their - /// reads, same as before. - /// - /// Default: delegates to - /// [`raw_recompute_cost`](Self::raw_recompute_cost) — materializing a - /// summary for the first time costs about as much as computing its - /// answer once from raw, since there's no delta history yet to apply - /// incrementally. A deployment with a distinct measured "cold build" - /// cost should override this instead. - fn summary_build_cost(&self, candidate: &CseCandidate) -> Cost { - self.raw_recompute_cost(candidate) - } - - /// The recurrence-aware counterpart to - /// [`cse_share_decision`](Self::cse_share_decision): the same - /// `Share`/`RecomputeIndependently` choice, weighted by how *often* - /// `candidate`'s consumers actually run (`recurrence`) instead of only - /// how many structurally exist (`candidate.consumer_count`). See - /// `crate::recurrence`'s module docs for the full design. - /// - /// - `recurrence.is_empty()` (no [`RepeatingEntry`]/[`DataWorkload`]-derived - /// metadata available): delegates to - /// [`cse_share_decision`](Self::cse_share_decision), preserving - /// today's structural-consumer-count behavior exactly — issue #287's - /// "preserve existing behavior when recurrence metadata is - /// unavailable" requirement. - /// - Otherwise: compares `maintained_cost_rate` against - /// `recompute_cost_rate` (both cost units/second). If - /// `recurrence.one_shot_consumers > 0` alongside any recurring rate - /// (mixed one-shot + repeating work), `horizon` MUST be `Some` — - /// `Err(RecurrenceError::MissingHorizon)` otherwise, per "the cost - /// model must not silently combine rate-valued and one-shot costs". - /// With no one-shot consumers, `horizon` is optional (comparing bare - /// rates is equivalent to comparing `rate * H` for any fixed `H > 0`). - /// - /// [`RepeatingEntry`]: asap_types::workload::RepeatingEntry - /// [`DataWorkload`]: asap_types::workload::DataWorkload - fn cse_share_decision_with_recurrence( - &self, - candidate: &CseCandidate, - recurrence: &RecurrenceProfile, - horizon: Option, - ) -> Result { - recurrence::decide(self, candidate, recurrence, horizon) - } - - /// Estimate a comparable, numeric cost for one already-constructed - /// [`ReplacementSubDAG`] candidate at `target` — a real `f64`, not just a - /// relative rank, meant for a caller that wants to *display* "candidate A - /// costs ≈ X, candidate B costs ≈ Y" (e.g. a DAG-visualization view built - /// on [`CandidateLogicalASAPDAGs::cost_sorted`](crate::replacement::CandidateLogicalASAPDAGs::cost_sorted)), - /// not just order candidates against each other — that ordering job - /// already belongs to [`rank_candidates`](Self::rank_candidates) (for a - /// [`ASAPStrategies`](crate::replacement::ASAPStrategies) - /// group) and [`cse_share_decision`](Self::cse_share_decision) (for a - /// [`SharedSubDAGStrategy`](crate::replacement::SharedSubDAGStrategy) - /// group). - /// - /// One method covers both candidate shapes this crate ships: - /// `candidate.replacement`'s [`Replacement::SubDAG`] from a summary - /// realization (a `ASAPStrategies` candidate — the bound node is - /// right there, nothing to reconstruct) and the same arm from a rewrite - /// (a `SharedSubDAGStrategy` share-vs-recompute candidate — no bound - /// summary of its own, since sharing is a decision about a target - /// already bound some other way; a representative binding is recovered - /// from `target` itself). `target` is threaded through explicitly - /// (rather than only ever the target embedded in `candidate` — there - /// isn't one for a rewrite) so both arms have the `consumer_count` - /// context a cost estimate needs to be meaningful. - /// - /// Default: **not a real cost model** — always returns `f64::NAN`. - /// `f64::partial_cmp` against `NAN` is always `None`, so a caller that - /// forgot to check whether its `CostModel` actually overrides this can't - /// silently treat the placeholder as a real comparison. A deployment - /// that wants numeric costs exposed should override this method; - /// [`DefaultCostModel`] does, reusing - /// [`cse_recompute_cost`](Self::cse_recompute_cost)/ - /// [`cse_shared_maintenance_cost`](Self::cse_shared_maintenance_cost) — - /// the same arithmetic that already backs `cse_share_decision` — rather - /// than inventing a second, drifting cost formula. - fn estimate_cost(&self, candidate: &ReplacementSubDAG, target: &TargetSubDAG<'_>) -> f64 { - let _ = (candidate, target); - f64::NAN - } - - /// Which mixed exact/summary execution shapes the downstream runtime - /// advertises (issue #171). Gates candidate *generation* in - /// [`crate::exact_composition::ExactCompositionStrategy`]: a shape the - /// runtime can't execute is never proposed, so it can't be selected - /// either. - /// - /// Default: [`ValueOperationCapabilities::ALL`]. This describes shapes - /// worth exploring, not proof that a runtime implements them. The - /// [`Self::value_operation_support_evidence`] hook gates selection; - /// a deployment whose runtime lacks a shape can narrow this hook. - fn value_operation_capabilities(&self) -> ValueOperationCapabilities { - ValueOperationCapabilities::ALL - } - - /// Whether the runtime implements this concrete function at this - /// placement. Deployments override this definition-level hook when - /// support differs between functions; the default delegates to the - /// coarse placement capability for backward compatibility. - fn supports_value_operation( - &self, - _operation: &ExactOperation, - placement: OperationPlacement, - ) -> bool { - self.value_operation_capabilities().supports(placement) - } - - /// Runtime support evidence for a mixed exact/summary operation. `None` - /// means that the logical shape is possible but runtime support has not - /// been established. The legacy boolean hook still rules out explicit - /// `false`; implementations that can prove support override this method - /// with `Some(true)`. - fn value_operation_support_evidence( - &self, - operation: &ExactOperation, - placement: OperationPlacement, - ) -> Option { - (!self.supports_value_operation(operation, placement)).then_some(false) - } - - /// The statistics the issue #171 recurring-cost formulas need for one - /// composed alternative — see [`ExactCompositionCostInputs`] for each - /// input and [`read_operation_plan_cost_rate`]/ - /// [`maintenance_operation_plan_cost_rate`]/[`raw_recompute_cost_rate`] for how - /// they combine. One structured hook rather than eight scalar ones, so - /// a deployment answers them all from one place (and can attach its own - /// [`CostProvenance`]). - /// - /// Default: every input unknown ([`ExactCompositionCostInputs::unknown`]) - /// — unknown is never zero, and with no rate derivable - /// `CandidateLogicalASAPDAGs::global_selection` keeps the conservative keep-as-is - /// behavior for the site. A deployment that wants defaults must supply - /// them here explicitly. - fn exact_composition_cost_inputs( - &self, - request: &ExactCompositionCostRequest<'_>, - ) -> ExactCompositionCostInputs { - let _ = request; - ExactCompositionCostInputs::unknown(CostProvenance { - model: "CostModel::exact_composition_cost_inputs (default)".into(), - version: "unknown".into(), - }) - } -} - -fn sketch_state( - node: &OperatorNode, -) -> Option<(&asap_types::post_asap::SketchKind, &GroupingStrategy)> { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - sketch_state(summary_input) - } - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, grouping), - .. - }) => Some((kind, grouping)), - _ => None, - } -} - -fn sketch_state_units(params: &SketchParams) -> f64 { - match params { - SketchParams::Cms { width, depth } - | SketchParams::CountSketch { width, depth } - | SketchParams::CmsWithHeap { width, depth, .. } - | SketchParams::CountSketchWithHeap { width, depth, .. } => { - f64::from(*width) * f64::from(*depth) - } - _ => 1.0, - } -} - -fn hydra_grid_cells(params: &HydraParams) -> f64 { - match params { - HydraParams::HydraKll { shared_buckets, .. } => f64::from(*shared_buckets), - HydraParams::HydraCms { - shared_rows, - shared_columns, - .. - } - | HydraParams::HydraCountSketch { - shared_rows, - shared_columns, - .. - } => f64::from(*shared_rows) * f64::from(*shared_columns), - } -} - -/// Apply [`CostModel::rank_candidates`] and enforce its permutation-only -/// contract at the boundary where planner code consumes the result. -pub(crate) fn validated_candidate_ranking( - cost_model: &dyn CostModel, - intent: &AggIntent, - candidates: &[SketchAlgorithm], -) -> Vec { - let ranked = cost_model.rank_candidates(intent, candidates); - let mut expected = candidates.to_vec(); - let mut actual = ranked.clone(); - expected.sort(); - actual.sort(); - assert_eq!( - actual, expected, - "CostModel::rank_candidates must return a permutation of its input; candidate generation is exhaustive and cost models may not add, remove, or duplicate candidates" - ); - ranked -} - -/// The default cost model: preserves [`summary_candidates`]'s built-in static -/// order and [`realization::default_size_params`]'s built-in sizing unchanged. -/// -/// [`summary_candidates`]: crate::realization::summary_candidates -pub struct DefaultCostModel; - -impl CostModel for DefaultCostModel { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - /// Real numbers, reusing [`CostModel::cse_recompute_cost`]/ - /// [`CostModel::cse_shared_maintenance_cost`] — the same arithmetic - /// `cse_share_decision`'s default body already composes — rather than a - /// second formula: - /// - /// - A [`ReplacementProvenance::SummaryRealization`] candidate (a - /// `ASAPStrategies` binding): `cse_recompute_cost` (the one-time - /// structural cost of building `target` at all) plus - /// `cse_shared_maintenance_cost` of the candidate's own bound family - /// (a pricier family — a sketch over an exact accumulator, say — - /// costs more here, consistent with the per-family weighting - /// [`default_cse_shared_maintenance_cost`] already orders candidates - /// by). - /// - Any other [`Replacement::SubDAG`] (a logical rewrite or a CSE - /// share/recompute candidate): recovers one - /// representative bound node for `target` via `realize_child` (the same - /// rank-and-take-first helper `replacement::realize_child` reuses for the - /// identical need), then charges - /// `cse_shared_maintenance_cost` for the candidate that shares - /// `target`'s own `Rc` (`Rc::ptr_eq`), or `cse_recompute_cost * - /// consumer_count` for the one that doesn't — the same two terms - /// `cse_share_decision` already compares against each other. `NaN` - /// only if `target` itself can't be bound at all (schema derivation - /// failed) — never expected for a target that's already part of a - /// legitimate workload DAG. - /// - /// **Exception**: a [`ReplacementProvenance::AccuracyReconciliation`] - /// candidate (issue #273) never rebuilds `target` — it reads a - /// sibling `rc` that this crate builds regardless — so it gets its - /// own arm: `cse_shared_maintenance_cost` against `rc`'s **own** bound - /// summary (a "read", not a "rebuild `target` per consumer") instead - /// of the `cse_recompute_cost * consumer_count` formula the other - /// `Rewrite` shapes fall through to. See - /// `accuracy_reconciliation.rs`'s own "Costing this candidate shape" - /// module docs for why that formula would otherwise misprice it (in - /// the wrong direction, worse the more consumers would actually - /// benefit from sharing). - fn estimate_cost(&self, candidate: &ReplacementSubDAG, target: &TargetSubDAG<'_>) -> f64 { - let consumer_count = target.consumer_count.max(1); - match &candidate.replacement { - Replacement::SubDAG(node) - if candidate.provenance == ReplacementProvenance::SummaryRealization => - { - let cse = CseCandidate { - sub_dag: target.root, - bound_summary: node, - consumer_count, - }; - (self.cse_recompute_cost(&cse) + self.cse_shared_maintenance_cost(&cse)).0 - } - Replacement::SubDAG(rc) - if candidate.provenance == ReplacementProvenance::AccuracyReconciliation => - { - let Ok(sibling_bound) = realize_child(rc, self) else { - return f64::NAN; - }; - let cse = CseCandidate { - sub_dag: rc, - bound_summary: &sibling_bound, - // One additional reference into `rc`'s own (already - // necessary) build, from this one consumer's - // perspective — not `target`'s own `consumer_count`, - // which would conflate the reader-side multiplicity - // with a maintenance metric that's `rc`'s own group's - // concern, not this candidate's. - consumer_count: 1, - }; - self.cse_shared_maintenance_cost(&cse).0 - } - Replacement::SubDAG(rc) => { - let Ok(bound) = realize_child(target.root, self) else { - return f64::NAN; - }; - let cse = CseCandidate { - sub_dag: target.root, - bound_summary: &bound, - consumer_count, - }; - if Rc::ptr_eq(rc, target.root) { - self.cse_shared_maintenance_cost(&cse).0 - } else { - (self.cse_recompute_cost(&cse) * consumer_count).0 - } - } - // A composed candidate is costed in cost-units-per-second by - // `CandidateLogicalASAPDAGs::global_selection` against the child decision it - // is committed with — a different unit from this structural - // estimate, and unknowable here without that child. `NaN` - // keeps it from ever out-ranking a real estimate by accident. - Replacement::ExactComposition(_) => f64::NAN, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::realization::summary_candidates; - use asap_types::pre_asap::agg_intent::default_cardinality; - - #[test] - fn default_cost_model_preserves_static_order() { - let intent = default_cardinality(); - let candidates = summary_candidates(&intent); - assert_eq!( - DefaultCostModel.rank_candidates(&intent, candidates), - candidates.to_vec() - ); - } - - struct AlwaysPreferLast; - - impl CostModel for AlwaysPreferLast { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - v.reverse(); - v - } - } - - #[test] - fn custom_cost_model_can_reorder_candidates() { - let intent = default_cardinality(); - let candidates = summary_candidates(&intent); - let ranked = validated_candidate_ranking(&AlwaysPreferLast, &intent, candidates); - assert_eq!(ranked.first(), candidates.last()); - } - - struct DropsLast; - - impl CostModel for DropsLast { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates[..candidates.len() - 1].to_vec() - } - } - - #[test] - #[should_panic(expected = "must return a permutation of its input")] - fn candidate_ranking_rejects_filtering() { - let intent = default_cardinality(); - let candidates = summary_candidates(&intent); - validated_candidate_ranking(&DropsLast, &intent, candidates); - } - - struct DuplicatesFirst; - - impl CostModel for DuplicatesFirst { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut ranked = candidates.to_vec(); - ranked.push(candidates[0].clone()); - ranked - } - } - - #[test] - #[should_panic(expected = "must return a permutation of its input")] - fn candidate_ranking_rejects_additions_and_duplicates() { - let intent = default_cardinality(); - let candidates = summary_candidates(&intent); - 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::realization::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, - failure_probability: None, - } - } - other => crate::realization::default_size_params(other, intent, eps, delta), - } - } - } - - #[test] - fn custom_cost_model_can_override_sizing_independently_of_ranking() { - use asap_types::pre_asap::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, - failure_probability: None - }, - ); - // Untouched kinds still fall through to the default formula. - assert_eq!( - DiscreteKllRungs.size_params(SketchAlgorithm::Hll, &intent, 0.01, 0.01), - crate::realization::default_size_params(SketchAlgorithm::Hll, &intent, 0.01, 0.01), - ); - } - - // ── Recurring-cost formulas (issue #171) ───────────────────────────── - - fn known_inputs() -> ExactCompositionCostInputs { - ExactCompositionCostInputs { - exact_cost_per_row: Some(0.1), - expected_input_rows: Some(50.0), - expected_output_rows: Some(10.0), - summary_maintenance_cost_per_update: Some(0.01), - summary_read_cost: Some(1.0), - update_rate: Some(100.0), - evaluation_rate: Some(EvaluationRate(2.0)), - raw_recompute_cost: Some(100.0), - unit: CostUnit::CostUnitsPerSecond, - provenance: CostProvenance { - model: "test".into(), - version: "1".into(), - }, - } - } - - #[test] - fn composition_formulas_match_the_issue_definitions() { - let inputs = known_inputs(); - // 100 * 0.01 + 2 * (1 + 10 * 0.1) = 1 + 4 = 5 - assert_eq!(read_operation_plan_cost_rate(&inputs).unwrap().0, 5.0); - // 100 * (0.1 + 0.01) + 2 * 1 = 11 + 2 = 13 - assert!((maintenance_operation_plan_cost_rate(&inputs).unwrap().0 - 13.0).abs() < 1e-9); - // 2 * 100 - assert_eq!(raw_recompute_cost_rate(&inputs).unwrap().0, 200.0); - assert_eq!( - crate::recurrence::total_cost(CostRate(5.0), Horizon(10.0), Cost(3.0)), - Cost(53.0) - ); - } - - /// Consolidation preserves type identity and rejects totals in recurring formulas. - #[test] - fn recurring_formulas_require_the_shared_rate_unit() { - let mut inputs = known_inputs(); - let shared: CostUnit = inputs.unit; - assert_eq!(shared.as_str(), "cost_units_per_second"); - inputs.unit = CostUnit::CostUnits; - assert_eq!(read_operation_plan_cost_rate(&inputs), None); - assert_eq!(maintenance_operation_plan_cost_rate(&inputs), None); - assert_eq!(raw_recompute_cost_rate(&inputs), None); - } - - #[test] - fn a_missing_input_yields_no_rate_not_zero() { - let mut inputs = known_inputs(); - inputs.summary_maintenance_cost_per_update = None; - assert_eq!(read_operation_plan_cost_rate(&inputs), None); - assert_eq!(maintenance_operation_plan_cost_rate(&inputs), None); - // The baseline doesn't need maintenance and is still known. - assert!(raw_recompute_cost_rate(&inputs).is_some()); - let unknown = ExactCompositionCostInputs::unknown(known_inputs().provenance); - assert_eq!(raw_recompute_cost_rate(&unknown), None); - } - - #[test] - fn default_model_has_potential_shapes_but_unknown_runtime_support() { - assert_eq!( - DefaultCostModel.value_operation_capabilities(), - ValueOperationCapabilities::ALL - ); - assert_eq!( - DefaultCostModel.value_operation_support_evidence( - &ExactOperation::Aggregate { - reduction: asap_types::ir::operator_properties::Reduction::by(vec![]), - measures: vec![AggIntent::Max { col: None }], - output_names: vec![], - filters: vec![], - having: None, - }, - OperationPlacement::Read, - ), - None - ); - assert!(ValueOperationCapabilities::NONE - .supports(OperationPlacement::Read) - .not()); - } - - trait Not { - fn not(self) -> bool; - } - impl Not for bool { - fn not(self) -> bool { - !self - } - } - - // ── CSE sharing (issue #237, #223 stage 4) ────────────────────────── - - use asap_types::ir::operator_properties::Source; - use asap_types::ir::{NonASAPOp, Predicate, ScalarExpr}; - use asap_types::post_asap::{ - ExactKind, ExactParams, Field, GroupingStrategy, Schema, SketchKind, - }; - use asap_types::pre_asap::schema::DataType; - - fn scan() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - ], - 0, - vec![], - ), - })) - .unwrap() - } - - /// A `SummaryAgg` directly over the kept `scan()` sub-DAG. - fn summary_node(family: FieldDataType) -> Rc { - std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child: scan(), - family: family.clone(), - input: asap_types::post_asap::SummaryUpdate::column( - asap_types::pre_asap::expr_ir::ColumnRef::Named("value".into()), - ), - reduction: asap_types::ir::operator_properties::Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - }), - Schema::lifted(vec![Field::new("state", family, false)], None), - ) - .with_guarantee(None), - ) - } - - #[test] - fn default_recompute_cost_is_positive_and_grows_with_structural_size() { - let leaf = scan(); - let nested = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols: vec![0], - child: Rc::clone(&leaf), - })) - .unwrap(); - assert!(default_cse_recompute_cost(&leaf) > Cost::ZERO); - assert!(default_cse_recompute_cost(&nested) > default_cse_recompute_cost(&leaf)); - } - - /// The DAG-awareness this proxy exists for: a sub-DAG that internally - /// re-references one shared descendant (e.g. after single-query CSE, - /// `x op x` collapsing both branches onto one `Rc`) must cost the same - /// as if that descendant only appeared once — not double, the way a - /// naive per-path size measure (a full serialization, or an - /// identity-blind recursive walk) would count it. - #[test] - fn default_recompute_cost_does_not_double_count_an_internally_shared_descendant() { - use asap_types::ir::operator_properties::JoinKind; - use asap_types::pre_asap::expr_ir::ScalarValue; - - let true_pred = || Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))); - let shared_leaf = scan(); - let no_sharing = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { - kind: JoinKind::Inner, - pred: true_pred(), - left: scan(), - right: scan(), - })) - .unwrap(); - let with_sharing = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { - kind: JoinKind::Inner, - pred: true_pred(), - left: Rc::clone(&shared_leaf), - right: Rc::clone(&shared_leaf), - })) - .unwrap(); - assert_eq!( - default_cse_recompute_cost(&no_sharing), - Cost(3.0), - "no sharing: Join + 2 independent Scans = 3 unique nodes" - ); - assert_eq!( - default_cse_recompute_cost(&with_sharing), - Cost(2.0), - "internal sharing: Join + 1 shared Scan (referenced twice) = \ - 2 unique nodes, not 3 — a per-path size measure would \ - wrongly charge for the shared Scan twice" - ); - } - - #[test] - fn default_shared_maintenance_cost_orders_families_cheapest_to_priciest() { - let exact = default_cse_shared_maintenance_cost(&FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let sketch = default_cse_shared_maintenance_cost(&FieldDataType::Sketch( - SketchKind::new( - SketchAlgorithm::Hll, - SketchParams::Hll { - precision: 12, - relative_error: None, - }, - ), - GroupingStrategy::default(), - )); - assert!( - exact < sketch, - "an exact accumulator should be cheaper to keep continuously updated \ - than a sketch: exact={exact}, sketch={sketch}" - ); - } - - #[test] - fn cse_share_decision_shares_when_recompute_dominates_maintenance() { - let candidate = CseCandidate { - sub_dag: &scan(), - bound_summary: &summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )), - // Many consumers of a cheap accumulator: recompute_total should - // dominate the fixed maintenance cost. - consumer_count: 1000, - }; - assert_eq!( - DefaultCostModel.cse_share_decision(&candidate), - ShareDecision::Share - ); - } - - #[test] - fn cse_share_decision_recomputes_when_maintenance_dominates_recompute() { - let candidate = CseCandidate { - sub_dag: &scan(), - bound_summary: &summary_node(FieldDataType::StatModel( - asap_types::post_asap::StatModelKind::Parametric, - asap_types::post_asap::StatModelParams::Parametric { - family: "gaussian_mixture".into(), - }, - )), - // A single, cheap-to-recompute leaf (scan() alone is 1 DAG - // node, recompute_total = 1) against an expensive-to-maintain - // family (StatModel, maintenance cost 6.0): maintenance should - // dominate. - consumer_count: 1, - }; - assert_eq!( - DefaultCostModel.cse_share_decision(&candidate), - ShareDecision::RecomputeIndependently - ); - } - - #[test] - fn cse_share_decision_default_body_composes_the_two_cost_hooks() { - struct AlwaysExpensiveToRecompute; - impl CostModel for AlwaysExpensiveToRecompute { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn cse_recompute_cost(&self, _candidate: &CseCandidate) -> Cost { - Cost(1e9) - } - } - - // Even the priciest family should lose to an overridden recompute - // cost this large, confirming `cse_share_decision`'s default body - // actually calls through to the overridable hooks rather than - // hardcoding a comparison against its own defaults. - let candidate = CseCandidate { - sub_dag: &scan(), - bound_summary: &summary_node(FieldDataType::StatModel( - asap_types::post_asap::StatModelKind::Parametric, - asap_types::post_asap::StatModelParams::Parametric { - family: "gaussian_mixture".into(), - }, - )), - consumer_count: 2, - }; - assert_eq!( - AlwaysExpensiveToRecompute.cse_share_decision(&candidate), - ShareDecision::Share - ); - } - - // ── estimate_cost ──────────────────────────────────────────────────── - - /// The trait's default `estimate_cost` body is an explicit placeholder, - /// not a real cost model — a `CostModel` that only overrides - /// `rank_candidates` (the minimum required to implement the trait) must - /// still get `f64::NAN` back, never a value that looks like a real - /// estimate. - #[test] - fn estimate_cost_default_body_is_a_nan_placeholder() { - struct RankOnly; - impl CostModel for RankOnly { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - } - - let root = scan(); - let target = TargetSubDAG::new(&root); - let candidate = ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG(summary_node(FieldDataType::Plain( - asap_types::pre_asap::DataType::Float64, - ))), - provenance: crate::replacement::ReplacementProvenance::SummaryRealization, - rationale: "whatever".into(), - }; - assert!(RankOnly.estimate_cost(&candidate, &target).is_nan()); - } - - /// `DefaultCostModel::estimate_cost` for a summary-rooted [`Replacement::SubDAG`] - /// candidate reuses [`default_cse_shared_maintenance_cost`]'s own - /// per-family ordering: a candidate bound to a cheap-to-maintain family - /// (an exact accumulator) must cost less than one bound to an - /// expensive-to-maintain family (a fitted statistical model), same - /// target either way — consistent with - /// `default_shared_maintenance_cost_orders_families_cheapest_to_priciest` - /// above. - #[test] - fn estimate_cost_for_summary_orders_candidates_by_family_cheapest_to_priciest() { - let root = scan(); - let target = TargetSubDAG::new(&root); - - let cheap = ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG(summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - ))), - provenance: crate::replacement::ReplacementProvenance::SummaryRealization, - rationale: "exact accumulator".into(), - }; - let pricey = ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG(summary_node(FieldDataType::StatModel( - asap_types::post_asap::StatModelKind::Parametric, - asap_types::post_asap::StatModelParams::Parametric { - family: "gaussian_mixture".into(), - }, - ))), - provenance: crate::replacement::ReplacementProvenance::SummaryRealization, - rationale: "fitted statistical model".into(), - }; - - let cheap_cost = DefaultCostModel.estimate_cost(&cheap, &target); - let pricey_cost = DefaultCostModel.estimate_cost(&pricey, &target); - assert!( - cheap_cost.is_finite() && pricey_cost.is_finite(), - "cheap={cheap_cost}, pricey={pricey_cost}" - ); - assert!( - cheap_cost < pricey_cost, - "an ExactAggregate candidate should cost less than a StatModel one: \ - exact={cheap_cost}, stat_model={pricey_cost}" - ); - } - - /// `DefaultCostModel::estimate_cost` for a relational [`Replacement::SubDAG`] pair - /// (the `SharedSubDAGStrategy` share-vs-recompute shape) agrees with - /// what `cse_share_decision` would already pick for the same target: with - /// many consumers of a cheap-to-recompute leaf, the "share" candidate - /// (the target's own `Rc`) must cost less than the "recompute - /// independently" one (a fresh `Rc`) — mirrors - /// `cse_share_decision_shares_when_recompute_dominates_maintenance` - /// above, through `estimate_cost` instead of `cse_share_decision` - /// directly. - #[test] - fn estimate_cost_for_rewrite_prefers_sharing_when_recompute_dominates_maintenance() { - let target_root = scan(); - let target = TargetSubDAG::with_consumer_count(&target_root, 20); - - let share = ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG(Rc::clone(&target_root)), - provenance: crate::replacement::ReplacementProvenance::CseShare, - rationale: "build once and share".into(), - }; - let recompute = ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG(Rc::new((*target_root).clone())), - provenance: crate::replacement::ReplacementProvenance::CseRecompute, - rationale: "build independently".into(), - }; - - let share_cost = DefaultCostModel.estimate_cost(&share, &target); - let recompute_cost = DefaultCostModel.estimate_cost(&recompute, &target); - assert!( - share_cost.is_finite() && recompute_cost.is_finite(), - "share={share_cost}, recompute={recompute_cost}" - ); - assert!( - share_cost < recompute_cost, - "with 20 consumers of a cheap-to-recompute leaf, sharing should cost less: \ - share={share_cost}, recompute={recompute_cost}" - ); - } -} diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs deleted file mode 100644 index d001cdb8..00000000 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ /dev/null @@ -1,745 +0,0 @@ -//! [`ExactCompositionStrategy`] composes an exact function with a summary -//! plan across an explicit maintenance/read-time boundary (issue #171). -//! -//! `construct_summary_agg` already nests accumulator realizations, such as -//! KLL over exact `Sum` state or a quantile over `Rate` state. This strategy -//! covers the more general cases where an exact function must consume a -//! summary evaluation, or where a maintained summary consumes the values of an -//! exact function that has no accumulator realization. -//! -//! Both cases use an ordinary `NonASAPOp::Aggregate` node over the child -//! plan. The node carries no timing: it runs when its consumer runs, so the -//! same operator serves both placements and adding a function does not -//! require adding a new physical node type. [`OperationPlacement`] is the -//! search-time placement choice. -//! -//! ## Reference, don't select -//! -//! A composed candidate needs a child plan to compose *with* — the inner -//! quantile's own summary evaluation, say. This strategy deliberately does -//! **not** pick that child itself (the way `construct_summary_agg`'s -//! `realize_child` takes the head of the child's own ranking): a -//! [`Replacement::ExactComposition`] carries only the child *target* -//! (`ExactComposition::child_target`, the same `Rc` whose -//! `TargetSubDAGCandidates` in `CandidateLogicalASAPDAGs` already holds every candidate for it). It is -//! [`CandidateLogicalASAPDAGs::global_selection`](crate::replacement::CandidateLogicalASAPDAGs::global_selection) -//! that commits the compatible parent/child pair — so the child's own -//! cost-model ranking, workload-wide effective consumer count, and shared -//! `Rc` identity (one inner summary serving two outer folds) all stay -//! correct, and a child that is also shared by an unrelated consumer is -//! maintained exactly once. `GlobalSelection::assemble_selected_query` then links the -//! committed pair into one validated post-ASAP DAG. -//! -//! ## Proposal conditions -//! -//! A candidate is proposed only when all of these hold: -//! -//! - the target is a single-measure, `HAVING`-free exact aggregate; -//! - read-time operation: the child is a bindable aggregate that has at least one -//! evaluation-producing summary implementation (a sketch/sample/wavelet/ -//! model — the shapes a maintained accumulator can't sit above), and the -//! target's grouping keys resolve in the child's output schema; -//! 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. -//! -//! `avg` gets a read-time operation candidate *and* keeps -//! [`crate::rewrite::AvgToSumOverCountStrategy`]'s rewrite in the same -//! group; the cost model picks between them, nothing here hard-codes one. -//! -//! ## What this strategy never does -//! -//! - Propose an `ExactRead` for a position beneath a maintained -//! summary — data_state validation at composition rejects it as a typed -//! `RealizationError` regardless. -//! - Decide whether a composition is *worth it*: that is -//! `global_selection`'s job, using the issue's cost-units-per-second -//! formulas (see `crate::cost_model::read_operation_plan_cost_rate` and -//! siblings). Missing statistics keep the conservative kept sub-DAG. - -use asap_types::ir::non_asap::any_measure_filtered; -use std::rc::Rc; - -use asap_types::ir::aggregate_schema::aggregate_output_schema; -use asap_types::ir::operator_properties::Reduction; -use asap_types::ir::timing::{planned_data_state, validate_at_ingestion}; -use asap_types::ir::{NonASAPOp, Operator, OperatorNode, Predicate}; -use asap_types::post_asap::execution_data_state::lift_plain; -use asap_types::post_asap::{ - AccuracyError, AccuracyGuarantee, ExactOperationSchemaError, ExecutionDataState, - ExecutionDataStateError, Schema, -}; -use asap_types::pre_asap::agg_intent::AggIntent; - -use crate::cost_model::CostModel; -use crate::replacement::{ - bindable_intent, describe_intent, realizations_for_intent, Realization, RealizationError, - Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, -}; -use crate::{AccuracyModel, DefaultAccuracyModel, PropagationStats}; - -#[cfg(test)] -use asap_types::post_asap::ExecutionTiming; - -/// Which side of the maintenance/read boundary an [`ExactComposition`]'s -/// exact function executes on. -/// The exact function an [`ExactComposition`] applies: the parameters of -/// the `NonASAPOp::Aggregate` node the composition builds over its child. -#[derive(Debug, Clone, PartialEq)] -pub enum ExactOperation { - Aggregate { - reduction: Reduction, - measures: Vec, - output_names: Vec, - filters: Vec>, - having: Option, - }, -} - -impl ExactOperation { - /// Output schema of this operation over a child whose edge carries - /// `input` — the same canonical derivation the pre-ASAP `Aggregate` - /// node uses. `Err` when the child carries non-plain state the operator - /// cannot read. - pub fn output_schema(&self, input: &Schema) -> Result { - if !input.is_all_plain() { - return Err(ExactOperationSchemaError::NonPlainInput); - } - let plain = lift_plain(input); - let ExactOperation::Aggregate { - reduction, - measures, - output_names, - .. - } = self; - let out = aggregate_output_schema(&plain, reduction, measures, output_names)?; - Ok(lift_plain(&out)) - } - - fn into_op(self, child: Rc) -> NonASAPOp { - let ExactOperation::Aggregate { - reduction, - measures, - output_names, - filters, - having, - } = self; - NonASAPOp::Aggregate { - reduction, - measures, - output_names, - filters, - having, - child, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum OperationPlacement { - /// After the child's summary evaluation. - Read, - /// On the maintenance path, feeding - /// maintained state above. - Maintenance, -} - -impl OperationPlacement { - /// The availability the composed operator consumes and produces. - pub fn data_state(self) -> ExecutionDataState { - match self { - Self::Read => ExecutionDataState::QUERY_ROWS, - Self::Maintenance => ExecutionDataState::INGESTION_ROWS, - } - } - - pub fn provenance(self) -> ReplacementProvenance { - match self { - Self::Read => ReplacementProvenance::ValueOperationAtQueryTime, - Self::Maintenance => ReplacementProvenance::ValueOperationAtIngestionTime, - } - } -} - -/// The payload of a [`Replacement::ExactComposition`] candidate: an exact -/// operator, the placement it runs at, and a *reference* to the child target -/// it composes over — never an already-selected child plan (see the module -/// docs' "Reference, don't select"). -#[derive(Debug, Clone)] -pub struct ExactComposition { - pub placement: OperationPlacement, - pub op: ExactOperation, - /// The pre-ASAP child the operator consumes; its `TargetSubDAGCandidates` holds the - /// candidates `global_selection` may commit this composition with. - pub child_target: Rc, - /// The composed node's output schema — the target's own pre-ASAP - /// output schema, lifted with every column `Plain` (an exact operator - /// only ever produces plain values). - pub schema: Schema, -} - -impl ExactComposition { - /// The data state `child` produces when this operation (its consumer) - /// runs at the placement's timing. - fn child_data_state(&self, child: &Rc) -> ExecutionDataState { - planned_data_state(child, self.placement.data_state().timing) - } - - /// Can `child` legally be this composition's input? Phase legality - /// (the child's produced data_state — a kept pre-ASAP sub-DAG takes the - /// phase this edge assigns) plus the plain-operand rule, checked - /// through the same schema derivation [`Self::compose`] uses. - pub fn accepts_child(&self, child: &Rc) -> bool { - self.child_data_state(child) == self.placement.data_state() - && self.op.output_schema(&child.schema).is_ok() - } - - /// Build the composed, data_state-validated node over `child`. Every edge of - /// the result (including everything beneath `child`) is checked by - /// `asap_types::ir::timing::validate_at_ingestion`; an illegal - /// placement is a typed [`RealizationError::ExecutionDataState`], never deferred to a - /// runtime. - pub fn compose(&self, child: Rc) -> Result, RealizationError> { - self.compose_with_accuracy(child, &DefaultAccuracyModel) - } - - /// Compose using the caller's accuracy algebra. Exact operators do not - /// erase an approximate child's error: supported folds propagate it; - /// unsupported folds fail closed with a typed accuracy error. - pub fn compose_with_accuracy( - &self, - child: Rc, - accuracy_model: &dyn AccuracyModel, - ) -> Result, RealizationError> { - let produced = self.child_data_state(&child); - if produced != self.placement.data_state() { - let edge = match self.placement { - OperationPlacement::Maintenance => "exact operation child (maintenance time)", - OperationPlacement::Read => "exact operation child (read time)", - }; - return Err(RealizationError::ExecutionDataState( - ExecutionDataStateError::IllegalChildDataState { - edge, - child: produced, - }, - )); - } - let schema = self.op.output_schema(&child.schema)?; - let guarantee = match &child.guarantee { - None => None, - Some(input) if input.is_exact() => Some(AccuracyGuarantee::exact(format!( - "exact function {:?} over exact input", - self.op - ))), - Some(input) => match accuracy_model.exact_operation_rule(&self.op) { - Some(operator) => match accuracy_model.propagate( - &operator, - std::slice::from_ref(input), - None, - &PropagationStats::default(), - ) { - Ok(guarantee) => Some(guarantee), - // The plan remains executable without a declared accuracy - // target, but an unknown guarantee cannot satisfy a later - // target check. Never replace this with an exact/default - // bound. - Err(AccuracyError::UnsupportedComposition { .. }) => None, - Err(error) => return Err(RealizationError::Accuracy(error)), - }, - // No definition-registered rule: preserve "unknown". This is - // the fail-closed value used by accuracy-target filtering. - None => None, - }, - }; - let node = Rc::new( - OperatorNode::with_schema(Operator::NonASAP(self.op.clone().into_op(child)), schema) - .with_guarantee(guarantee), - ); - validate_at_ingestion(&node, self.placement.data_state().timing)?; - Ok(node) - } - - /// Structural identity for `TargetSubDAGCandidates` dedup: same placement, same - /// operator, same child `Rc`. - pub(crate) fn same_as(&self, other: &Self) -> bool { - self.placement == other.placement - && self.op == other.op - && Rc::ptr_eq(&self.child_target, &other.child_target) - } -} - -/// Which exact reducers may run as a query-time fold over evaluation rows. -/// `Count` only at `Exact` accuracy (an approximate count is a sketch -/// target, not an exact fold). -fn is_query_time_reducer(intent: &AggIntent) -> bool { - match intent { - AggIntent::Sum { .. } - | AggIntent::Min { .. } - | AggIntent::Max { .. } - | AggIntent::Avg { .. } - | AggIntent::StdDev { .. } - | AggIntent::Variance { .. } => true, - AggIntent::Count { accuracy } => accuracy.is_exact(), - _ => false, - } -} - -/// Does `implementation` need a `SummaryEstimate` evaluation to yield a value -/// — i.e. is it a shape a maintained accumulator can't legally sit above? -fn needs_evaluation(implementation: &Realization) -> bool { - matches!( - implementation, - Realization::Sketch(_) - | Realization::Sample { .. } - | Realization::Wavelet { .. } - | Realization::StatModel { .. } - ) -} - -/// 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)> { - let Some(NonASAPOp::Aggregate { - reduction, - measures, - output_names, - filters, - having: None, - child, - }) = root.non_asap() - else { - return None; - }; - if any_measure_filtered(filters) { - return None; - } - let Reduction::Reduce(by) = reduction else { - return None; - }; - if by.is_without() { - return None; - } - let [intent] = measures.as_slice() else { - return None; - }; - if !is_query_time_reducer(intent) { - return None; - } - let child_intent = bindable_intent(child)?; - if !realizations_for_intent(child_intent, cost_model) - .iter() - .any(needs_evaluation) - { - return None; - } - // Grouping keys must resolve in the child's output schema — the same - // derivation the composed node's own schema will use. - Some(( - ExactOperation::Aggregate { - reduction: reduction.clone(), - measures: measures.clone(), - output_names: output_names.clone(), - filters: filters.clone(), - having: None, - }, - Rc::clone(child), - intent.clone(), - )) -} - -/// The `(op, child)` of a function-shaped target — a per-entity exact -/// 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, - measures, - output_names, - filters, - having: None, - child, - }) = root.non_asap() - else { - return None; - }; - if any_measure_filtered(filters) { - return None; - } - let [intent] = measures.as_slice() else { - return None; - }; - if !intent.is_per_series() { - return None; - } - // 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) - .iter() - .any(|i| *i != Realization::PassThrough) - { - return None; - } - Some(( - ExactOperation::Aggregate { - reduction: Reduction::PerEntity, - measures: measures.clone(), - output_names: output_names.clone(), - filters: filters.clone(), - having: None, - }, - Rc::clone(child), - intent.clone(), - )) -} - -/// 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 } - } - - 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)) = 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) - ), - }); - } - } - out - } -} - -impl ReplacementStrategy for ExactCompositionStrategy<'_> { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - !self.candidates(target).is_empty() - } - - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - self.candidates(target) - } -} - -#[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::ASAPOp; - use asap_types::post_asap::{ExecutionDataStateError, FieldDataType, SketchAlgorithm}; - use asap_types::pre_asap::agg_intent::default_quantile; - - /// `max by (zone) (quantile by (zone, host) (m))`. - fn max_over_quantile() -> Rc { - let inner = agg( - vec![2, 3], - default_quantile(0.99), - metric_scan(&["zone", "host"]), - ); - agg(vec![0], AggIntent::Max { col: None }, inner) - } - - #[test] - 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(); - assert!(strategy.matches(&target)); - let candidates = strategy.replacements(&target); - assert_eq!(candidates.len(), 1); - let Replacement::ExactComposition(comp) = &candidates[0].replacement else { - panic!( - "expected a composition, got {:?}", - candidates[0].replacement - ); - }; - assert_eq!(comp.placement, OperationPlacement::Read); - assert_eq!( - candidates[0].provenance, - ReplacementProvenance::ValueOperationAtQueryTime - ); - let Some(NonASAPOp::Aggregate { child, .. }) = root.non_asap() else { - unreachable!() - }; - assert!( - Rc::ptr_eq(&comp.child_target, child), - "the candidate references the child target's own Rc — nothing selected" - ); - let names: Vec<_> = comp.schema.fields.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, vec!["zone", "max"]); - } - - #[test] - fn proposes_query_time_operation_for_avg_over_quantile_alongside_the_rewrite() { - 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 - ); - // `avg` competes with AvgToSumOverCountStrategy in the same group. - assert!(crate::rewrite::AvgToSumOverCountStrategy.matches(&target)); - } - - #[test] - 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); - assert_eq!(candidates.len(), 1); - assert_eq!( - candidates[0].provenance, - ReplacementProvenance::ValueOperationAtIngestionTime - ); - } - - #[test] - fn does_not_propose_for_shapes_already_covered_by_accumulators() { - // sum by (zone) over an exact Sum child: the child has no evaluation, - // so SummaryAgg(Sum) over SummaryAgg(Sum) is already legal. - let inner = agg( - vec![2, 3], - AggIntent::Sum { col: None }, - metric_scan(&["zone", "host"]), - ); - let root = agg(vec![0], AggIntent::Sum { col: None }, inner); - assert!(!ExactCompositionStrategy::default_cost_model().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))); - // 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))); - } - - #[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 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 Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &state_child.operator - else { - panic!("expected the child to realize to a evaluation"); - }; - assert!(!comp.accepts_child(summary_input)); - assert!(matches!( - comp.compose(Rc::clone(summary_input)), - Err(RealizationError::ExecutionDataState( - ExecutionDataStateError::IllegalChildDataState { .. } - )) - )); - // The evaluation itself is accepted and composes to a plain schema. - assert!(comp.accepts_child(&state_child)); - let composed = comp.compose(state_child).unwrap(); - assert!( - composed.guarantee.is_none(), - "rank error has no registered conversion through max" - ); - assert!(matches!( - composed.operator, - Operator::NonASAP(NonASAPOp::Aggregate { .. }) - )); - // Timing is no longer stored by composition: under the default - // materialization assignment the composed read-time operation runs at - // query time. - assert_eq!(timed(&composed).timing, Some(ExecutionTiming::QueryTime)); - assert!(composed - .schema - .fields - .iter() - .all(|f| matches!(f.dtype, FieldDataType::Plain(_)))); - } - - #[test] - 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 Replacement::ExactComposition(comp) = &candidates[0].replacement else { - unreachable!() - }; - let evaluation = - crate::replacement::realize_child(&comp.child_target, &DefaultCostModel).unwrap(); - assert!(!comp.accepts_child(&evaluation)); - assert!(matches!( - comp.compose(evaluation), - Err(RealizationError::ExecutionDataState( - ExecutionDataStateError::IllegalChildDataState { .. } - )) - )); - // Raw update input is fine. - let raw = retain_exact(&comp.child_target).unwrap(); - assert!(comp.accepts_child(&raw)); - // Timing is no longer stored by composition: the composition's - // placement is maintenance time, the composed exact operation is a - // plain Aggregate over the raw rows, and it is legal (and planned to - // run) at ingestion time. - assert_eq!(comp.placement, OperationPlacement::Maintenance); - let composed = comp.compose(raw).unwrap(); - assert!(matches!( - composed.operator, - Operator::NonASAP(NonASAPOp::Aggregate { .. }) - )); - validate_at_ingestion(&composed, ExecutionTiming::IngestionTime).unwrap(); - assert_eq!( - planned_data_state(&composed, ExecutionTiming::IngestionTime).timing, - ExecutionTiming::IngestionTime - ); - } - - fn max_op(by: Vec) -> ExactOperation { - ExactOperation::Aggregate { - reduction: Reduction::by(by), - measures: vec![AggIntent::Max { col: None }], - output_names: vec![], - filters: vec![], - having: None, - } - } - - #[test] - fn exact_operator_schema_matches_pre_asap_aggregate_derivation() { - let child = lift_plain(&metric_scan(&["zone"]).schema); - let out = max_op(vec![2]).output_schema(&child).unwrap(); - let names: Vec<_> = out.fields.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, vec!["zone", "max"]); - assert!(out.is_all_plain()); - } - - #[test] - fn exact_operator_rejects_non_plain_input() { - let state = Schema::lifted( - vec![asap_types::pre_asap::Field::new( - "state", - FieldDataType::ExactAggregate( - asap_types::post_asap::ExactKind::Sum, - asap_types::post_asap::ExactParams::Sum, - ), - false, - )], - None, - ); - assert!(matches!( - max_op(vec![]).output_schema(&state), - Err(ExactOperationSchemaError::NonPlainInput) - )); - } -} diff --git a/crates/asap-aware-mapping/src/explanation.rs b/crates/asap-aware-mapping/src/explanation.rs deleted file mode 100644 index ec1227e4..00000000 --- a/crates/asap-aware-mapping/src/explanation.rs +++ /dev/null @@ -1,880 +0,0 @@ -//! This crate's **explanation of a replacement**: for a `TargetSubDAG` that -//! [`crate::replacement::search_workload`] found something to say about, why -//! does that candidate exist? (issue #33: "Add logic to detect which -//! optimizations are applicable to a query workload"; this module: issue -//! #257.) -//! -//! This module does not answer "is optimization X applicable here, yes or -//! no" — that framing implies a classifier deciding admissibility from -//! scratch. What it actually does is narrower and more mechanical: reuse a -//! matching candidate's own [`crate::replacement::ReplacementSubDAG::rationale`] -//! to explain, in the candidate's own words, why a [`Replacement`] exists at -//! a given target. No new prose is invented here; see "The reframing" below -//! for exactly what's being reused and why. -//! -//! ## The reframing: an explanation *is* "this `TargetSubDAG`'s candidate -//! list is non-trivial" -//! -//! Earlier (PR #247, superseded by this module — see "What this replaces" -//! below), "is optimization X applicable here?" was a yes/no fact each rule -//! re-derived by walking the DAG itself. That made sense before there was -//! any other structure to consult. But [`crate::replacement::search_workload`] -//! (issue #252) now *already* computes, for every -//! [`TargetSubDAG`](crate::replacement::TargetSubDAG) in the workload, every -//! semantically valid [`crate::replacement::ReplacementSubDAG`] a registered -//! [`ReplacementStrategy`] can propose — a [`CandidateLogicalASAPDAGs`] of [`TargetSubDAGCandidates`]s. A -//! rule re-deriving the same yes/no fact from scratch would be answering a -//! question the search already answered, via a second, independently -//! maintained traversal that has to keep agreeing with the first one. -//! -//! Once that candidate space exists, "which optimizations are applicable" -//! collapses into a single question this module asks of *that* data instead: -//! **for a given `TargetSubDAG`, does its candidate list contain anything -//! other than the trivial, no-op realization?** A `TargetSubDAG` whose only -//! candidate is "the one thing `ASAPStrategies` would have committed -//! to anyway, with no alternative" has no optimization to report — that -//! candidate isn't an *opportunity*, it's just the target's existing shape -//! reflected back. A `TargetSubDAG` with more than one candidate (several -//! sketch families to choose between), or one candidate that is itself a -//! genuine alternative to the status quo (share this already-shared sub-DAG -//! instead of recomputing it at every consumer), *is* an applicability -//! finding — [`explain_replacements`] and -//! [`explain_replacements_with`] just translate [`CandidateLogicalASAPDAGs`]'s -//! [`TargetSubDAGCandidates`]s into that shape: -//! -//! - [`ExplanationKind::SketchApproximation`] — the `TargetSubDAG`'s -//! candidate list contains at least one summary-realization [`Replacement::SubDAG`] that -//! actually realizes a sketch family (`FieldDataType::Sketch`), i.e. -//! [`ASAPStrategies`] found something to offer beyond whatever -//! exact/pass-through candidate [`crate::replacement`]'s own -//! `realizations_for_intent` would have committed to on its own. -//! - [`ExplanationKind::CommonSubexpressionReuse`] — the `TargetSubDAG` -//! has two or more consumers *and* its candidate list contains the -//! [`SharedSubDAGStrategy`] "build once and share" candidate (the one -//! whose `Rc` is the group's own `target`) — i.e. sharing this sub-DAG -//! instead of recomputing it independently is a real, reported choice, not -//! just an accident of how the workload happened to be built. -//! -//! Each finding's `reason` is literally the matching candidate's own -//! [`crate::replacement::ReplacementSubDAG::rationale`] (joined, if more than one candidate -//! qualifies) — this module invents no new prose to explain *why* a -//! candidate is valid; that explanation already exists on the candidate a -//! [`ReplacementStrategy`] produced, and repeating it here (rather than -//! re-describing the same fact in different words) keeps exactly one place -//! that has to be right about "why is this a valid alternative". -//! -//! ## What this replaces, and what carries over unmodified -//! -//! [`ReplacementExplanation`] and [`ExplanationKind`] keep PR #247's original -//! shape and contract — a struct/enum pair meant for a downstream -//! DAG-visualization consumer, `#[non_exhaustive]` discipline (only an -//! optimization backed by a real, registered [`ReplacementStrategy`] gets a -//! variant; see the catalog table below for everything still deliberately -//! unrepresented). So do the two top-level entry points, -//! [`explain_replacements`] and [`explain_replacements_with`] -//! — same "workload roots in, findings out" contract, mirroring -//! [`crate::replacement::search_workload`]/[`crate::replacement::search_workload_with`]'s -//! own signature shape. Only the *data source* changed: this module now -//! calls those two functions and translates the result, rather than running -//! its own rules and their supporting traversal over the DAG a second time. -//! All of that old traversal is deleted, not kept alongside the new -//! implementation — see "Two guarantees the old traversal made, re-verified" -//! below for the two properties it's important that deletion didn't quietly -//! lose. -//! -//! ## Why a second, applicability-specific rule trait doesn't exist here -//! -//! PR #247 gave this module its own extension-point trait, `ApplicabilityRule` -//! (`fn optimization(&self) -> ExplanationKind` + `fn evaluate(&self, roots) -//! -> Vec`), the same shape [`crate::cost_model::CostModel`] -//! and [`crate::replacement::Matcher`] use elsewhere in this crate. Once -//! findings are a *view* over [`CandidateLogicalASAPDAGs`] rather than an independent -//! computation, that trait would be a second extension point answering a -//! question [`ReplacementStrategy`] (issue #251) already answers: "does this -//! `TargetSubDAG` have an alternative worth reporting, and why". A caller who -//! wants a new optimization represented as a finding needs a new -//! `impl ReplacementStrategy` wired into -//! [`crate::replacement::search_workload_with`]'s strategy set *regardless* -//! (that's the only way its candidates end up in the [`CandidateLogicalASAPDAGs`] this -//! module reads) — adding an `ApplicabilityRule` too would mean maintaining -//! two extension points for the same new capability, one of which (the rule) -//! would just be re-describing candidates the other (the strategy) already -//! 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 -//! [`crate::replacement::search_workload_with`] itself exposes. -//! -//! ## Two guarantees the old traversal made, re-verified against the new one -//! -//! 1. **A finding is reported at the maximal `TargetSubDAG`, never once more -//! per subsumed descendant.** [`crate::replacement`]'s own -//! `discover_targets` (used by [`crate::replacement::search_workload_with`], -//! and so by this module) walks every workload root's whole DAG but only -//! *recurses into a node's children the first time that node's `Rc` is -//! seen*; every subsequent occurrence still counts towards -//! `consumer_count`, but never triggers a second descent. A node nested -//! under an already-discovered shared ancestor therefore only becomes its -//! own `TargetSubDAG` if something *outside* that ancestor also -//! references it — identical to PR #247's own discovery pass, which -//! reported "the highest point sharing starts," not a finding at every -//! subsumed level below it. Same guarantee, same mechanism, just living in -//! [`crate::replacement`] now instead of here. -//! 2. **A node reachable via more than one path is one finding, not one per -//! path.** [`TargetSubDAGCandidates`]s are keyed by `Rc` pointer identity in -//! [`CandidateLogicalASAPDAGs`]'s internal map — there is exactly one group per distinct -//! `Rc`, full stop, so a shared `Aggregate` reached via two different -//! `BinaryOp` branches (or two different workload roots) is exactly one -//! group, hence at most one [`ExplanationKind::SketchApproximation`] -//! finding, no matter how many paths reach it. -//! [`tests::a_shared_sketchable_aggregate_is_reported_only_once`] pins -//! this directly. -//! -//! ## One thing [`CandidateLogicalASAPDAGs`] doesn't carry that this module still needs: -//! human-readable `location` text -//! -//! [`TargetSubDAGCandidates`]/[`CandidateLogicalASAPDAGs`] deliberately track only `Rc` -//! pointer identity — the currency the search itself needs — not -//! caller-facing prose. [`ReplacementExplanation::location`] is prose (a -//! breadcrumb like `root "dash_a" > lhs`), so this module keeps one small, -//! self-contained walk of its own, [`collect_locations`], whose *only* job -//! is turning "this `Rc`" into "the human-readable place(s) it occurs" for a -//! finding already decided by [`CandidateLogicalASAPDAGs`]. This is not a reincarnation of -//! the deleted rule traversal: it makes no applicability decision (it runs -//! the same regardless of what any strategy found), and duplicating this -//! small, self-contained shape rather than threading location strings -//! through [`crate::replacement`]'s own `discover_targets` matches the same -//! call that module's own docs already make for its (test-only) -//! `count_consumers` counterpart — see [`crate::replacement`]'s "Where -//! `TargetSubDAG` discovery comes from" section. -//! -//! ## Catalog primitives deliberately left as future work -//! -//! The internal catalog -//! (`ProjectASAP/internal-docs/catalog_of_optimizations.md`) lists several -//! primitives with **no [`ReplacementStrategy`] implementation anywhere in -//! this codebase today**. Faking a variant for one of them would report a -//! finding this codebase cannot back with a real candidate, so none of the -//! below get an [`ExplanationKind`] variant yet — each gets one once a real -//! strategy exists and is wired into [`crate::replacement::default_strategies`]: -//! -//! | Catalog entry | Status | Where a future `ExplanationKind` would come from | -//! |---|---|---| -//! | 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` | -//! | 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::post_asap` 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 | -//! | Function decomposition | No representation anywhere | No hook point identified yet | -//! | Continuous distributed monitoring | No representation — `RepeatingEntry`/`RepetitionInterval` in `asap_types::workload` describe *that* a query repeats, not any monitoring-specific decomposition | Would likely key off `RepeatingEntry` once such logic exists | -//! | Incremental computation across time | No representation — nothing carries state across repeated evaluations of a `RepeatingEntry` today | Would key off `RepeatingEntry` + `TimeShift`/`TimeRange` once incremental state-carry exists | -//! | Delta encoding | No representation — `AggIntent::Delta`/`IDelta` are PromQL *value*-difference semantics, not a wire/storage delta-encoding optimization | Would plug into a future deployment-side wire/storage encoding decision (post-ASAP), not this crate's IR-level dispatch | -//! -//! [`ReplacementStrategy`]: crate::replacement::ReplacementStrategy -//! [`ReplacementSubDAG`]: crate::replacement::ReplacementSubDAG -//! [`Replacement`]: crate::replacement::Replacement -//! [`Replacement::SubDAG`]: crate::replacement::Replacement::SubDAG -//! [`ASAPStrategies`]: crate::replacement::ASAPStrategies -//! [`SharedSubDAGStrategy`]: crate::replacement::SharedSubDAGStrategy -//! [`CandidateLogicalASAPDAGs`]: crate::replacement::CandidateLogicalASAPDAGs -//! [`TargetSubDAGCandidates`]: crate::replacement::TargetSubDAGCandidates - -use std::collections::HashMap; -use std::fmt::Display; -use std::rc::Rc; - -use asap_types::ir::cse::{structural_hash, HashCache}; -use asap_types::ir::{ASAPOp, Operator, OperatorNode}; -use asap_types::post_asap::FieldDataType; - -use crate::replacement::{ - self, CandidateLogicalASAPDAGs, Replacement, ReplacementStrategy, TargetSubDAGCandidates, -}; - -/// Which kind of replacement a [`ReplacementExplanation`] is about. -/// -/// `#[non_exhaustive]`: only optimizations with a real [`ReplacementStrategy`] -/// behind them get a variant (see the module docs' "Catalog primitives -/// deliberately left as future work" table for everything else in the -/// catalog). -/// -/// [`ReplacementStrategy`]: crate::replacement::ReplacementStrategy -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum ExplanationKind { - /// A `TargetSubDAG`'s candidate list contains at least one - /// [`Replacement::SubDAG`] that realizes a sketch family — - /// [`crate::replacement::ASAPStrategies`] found a genuine sketch - /// alternative for this `Aggregate`, beyond whatever exact/pass-through - /// candidate `crate::replacement`'s own `realizations_for_intent` would - /// have committed to on its own. - SketchApproximation, - /// A `TargetSubDAG` has two or more consumers *and* its candidate list - /// contains [`crate::replacement::SharedSubDAGStrategy`]'s "build once - /// and share" candidate — the catalog's cross-statistic / cross-metrics / - /// cross-subpopulation reuse entries, all the same underlying structural - /// fact. - CommonSubexpressionReuse, - /// A `TargetSubDAG`'s candidate list contains at least one - /// [`Replacement::ExactComposition`] — - /// [`crate::exact_composition::ExactCompositionStrategy`] found an exact - /// operator that can be composed with a summary plan across an explicit - /// update/evaluation boundary instead of keeping the whole tree as it is - /// (issue #171). - ExactComposition, -} - -/// Why a [`Replacement`] of `kind` exists at `location` (a human-readable -/// breadcrumb into the workload — e.g. `root "dashboard_p99"` or -/// `root "ratio" > lhs`): `reason` (human-readable, meant for a report/log, -/// not machine parsing — literally the matching candidate's own -/// [`crate::replacement::ReplacementSubDAG::rationale`]). -/// -/// `node_hash` is [`structural_hash`](asap_types::ir::cse::structural_hash) -/// of the `TargetSubDAG`'s own `target` sub-DAG. A downstream consumer that -/// hashed the same node can match this explanation by first comparing hashes -/// and then confirming structural equality with -/// [`ReplacementExplanation::target`]. -#[derive(Debug, Clone, PartialEq)] -pub struct ReplacementExplanation { - pub kind: ExplanationKind, - pub location: String, - pub reason: String, - pub node_hash: u64, - /// The exact target expression the explanation describes. Reporting - /// integrations use this together with `node_hash`: the hash narrows the - /// search, and structural equality makes the final match collision-safe. - pub target: Rc, -} - -/// Explain every replacement [`crate::replacement::search_workload`] finds -/// across a workload's pre-ASAP query roots, using -/// [`crate::replacement::default_strategies`]. -/// -/// `roots` — like [`crate::replacement::search_workload`]'s own `Id` type -/// parameter — is caller-chosen: a `QueryWorkload` entry's own key, an index, -/// a query name. It only needs [`Display`], since a finding's `location` is -/// prose, not a structured key back to the caller. -/// -/// Internally runs [`crate::replacement::search_workload`] to build the -/// candidate-plan space, then reads findings off it — see the module docs' -/// "The reframing" section for what that translation actually checks. -pub fn explain_replacements( - roots: Vec<(Id, Rc)>, -) -> Result, replacement::SearchError> { - explain_replacements_with(roots, &replacement::default_strategies()) -} - -/// 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`]). -/// -/// [`ReplacementStrategy`]: crate::replacement::ReplacementStrategy -pub fn explain_replacements_with<'s, Id: Display>( - roots: Vec<(Id, Rc)>, - strategies: &[Box], -) -> Result, replacement::SearchError> { - let ided: Vec<(String, Rc)> = roots - .into_iter() - .map(|(id, expr)| (id.to_string(), expr)) - .collect(); - let space = replacement::search_workload_with(ided, strategies)?; - Ok(findings_from_candidate_logical_asap_dags(&space)) -} - -/// Translate every discovered [`TargetSubDAGCandidates`] in `space` into zero, one, or two -/// [`ReplacementExplanation`]s (a `TargetSubDAG` can be both sketch-approximable -/// *and* shared — the two optimizations are independent axes, not mutually -/// exclusive). -/// -/// `space`'s own `Id` is always `String` here: [`explain_replacements_with`] -/// already converted the caller's `Id: Display` into a `String` (via -/// `to_string()`) before calling [`crate::replacement::search_workload_with`], -/// so this function (and [`collect_locations`], which formats `id` with -/// [`std::fmt::Debug`] for the breadcrumb text) doesn't need its own generic -/// `Id` bound. -fn findings_from_candidate_logical_asap_dags( - space: &CandidateLogicalASAPDAGs, -) -> Vec { - let locations = collect_locations(&space.roots); - // One cache for the whole pass — this is a bottom-up pass over every discovered - // group, so amortizing the cache across groups (rather than resetting it - // per group) is real, not just a micro-optimization. - let mut hash_cache = HashCache::new(); - let mut findings = Vec::new(); - for group in space.target_subdag_candidates() { - let location = locations - .get(&Rc::as_ptr(&group.target)) - .map(|locs| locs.join(", ")) - .unwrap_or_default(); - let node_hash = structural_hash(&group.target, &mut hash_cache); - - if let Some(reason) = sketch_finding_reason(group) { - findings.push(ReplacementExplanation { - kind: ExplanationKind::SketchApproximation, - location: location.clone(), - reason, - node_hash, - target: Rc::clone(&group.target), - }); - } - if let Some(reason) = shared_subexpr_finding_reason(group) { - findings.push(ReplacementExplanation { - kind: ExplanationKind::CommonSubexpressionReuse, - location: location.clone(), - reason, - node_hash, - target: Rc::clone(&group.target), - }); - } - if let Some(reason) = composition_finding_reason(group) { - findings.push(ReplacementExplanation { - kind: ExplanationKind::ExactComposition, - location, - reason, - node_hash, - target: Rc::clone(&group.target), - }); - } - } - findings -} - -/// Does `group`'s candidate list contain an exact composition (issue -/// #171)? If so, the finding's `reason` is every such candidate's own -/// `rationale`, joined. -fn composition_finding_reason(group: &TargetSubDAGCandidates) -> Option { - let reasons: Vec<&str> = group - .candidates - .iter() - .filter(|c| matches!(c.replacement, Replacement::ExactComposition(_))) - .map(|c| c.rationale.as_str()) - .collect(); - if reasons.is_empty() { - None - } else { - Some(reasons.join("; ")) - } -} - -/// Does `group`'s candidate list contain a genuine sketch-family realization? -/// If so, the finding's `reason` is every such candidate's own `rationale`, -/// joined — this module does not invent new prose to restate why a candidate -/// is valid. -fn sketch_finding_reason(group: &TargetSubDAGCandidates) -> Option { - let reasons: Vec<&str> = group - .candidates - .iter() - .filter( - |c| matches!(&c.replacement, Replacement::SubDAG(node) if is_sketch_realization(node)), - ) - .map(|c| c.rationale.as_str()) - .collect(); - if reasons.is_empty() { - None - } else { - Some(reasons.join("; ")) - } -} - -/// Does `group` have two or more consumers *and* a "build once and share" -/// candidate (the [`Replacement::SubDAG`] whose `Rc` is the group's own -/// `target`) in its candidate list? If so, the finding's `reason` is that -/// candidate's own `rationale`. -fn shared_subexpr_finding_reason(group: &TargetSubDAGCandidates) -> Option { - if group.consumer_count < 2 { - return None; - } - group - .candidates - .iter() - .find( - |c| matches!(&c.replacement, Replacement::SubDAG(rc) if Rc::ptr_eq(rc, &group.target)), - ) - .map(|c| c.rationale.clone()) -} - -/// Does `node` (unwrapping any `SummaryEstimate` layer, the same shape -/// [`crate::replacement`]'s own private `sketch_kind_of` unwraps) ultimately -/// realize a [`FieldDataType::Sketch`] family? This module only needs the -/// yes/no fact (a candidate's own `rationale` already names the specific -/// `SketchKind`/`SketchAlgorithm` for a finding's `reason` text), so unlike -/// `replacement.rs`'s counterpart this returns `bool`, not the kind itself. -fn is_sketch_realization(node: &OperatorNode) -> bool { - if node - .guarantee - .as_ref() - .is_some_and(|guarantee| guarantee.is_exact()) - { - return false; - } - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - is_sketch_realization(summary_input) - } - Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) => { - matches!(family, FieldDataType::Sketch(..)) - } - _ => false, - } -} - -// ── location breadcrumbs ───────────────────────────────────────────────── - -/// Build `location` text for every distinct `TargetSubDAG` reachable from -/// `roots` — see the module docs' "One thing `CandidateLogicalASAPDAGs` doesn't carry" -/// section for why this module needs its own small walk for this. Returns -/// every breadcrumb path that reaches a given `Rc`, not just the first: a -/// shared node referenced from two workload roots (or two branches of one -/// root) needs both breadcrumbs in its finding's `location`, not just one. -fn collect_locations( - roots: &[(String, Rc)], -) -> HashMap<*const OperatorNode, Vec> { - let mut locations: HashMap<*const OperatorNode, Vec> = HashMap::new(); - for (id, root) in roots { - visit(root, format!("root {id:?}"), &mut locations); - } - locations -} - -/// Record `label` as one of `node`'s breadcrumbs, then propagate that path -/// through its children. A shared ancestor is intentionally traversed once -/// per incoming path so every descendant receives every valid breadcrumb. -fn visit( - node: &Rc, - label: String, - locations: &mut HashMap<*const OperatorNode, Vec>, -) { - let ptr = Rc::as_ptr(node); - locations.entry(ptr).or_default().push(label.clone()); - visit_children(node, &label, locations); -} - -/// `node`'s operator inputs, including the operator nodes its scalar -/// expressions read — the same scope `crate::replacement`'s own -/// target-discovery `walk_children` uses. Exhaustive over every -/// `NonASAPOp` variant: a new variant fails to compile here until this match -/// is extended too. An ASAP node never occurs in a workload root. -fn visit_children( - node: &OperatorNode, - label: &str, - locations: &mut HashMap<*const OperatorNode, Vec>, -) { - use asap_types::ir::{NonASAPOp::*, ScalarExpr}; - let Operator::NonASAP(op) = &node.operator else { - return; - }; - let mut inputs: Vec<(&Rc, String)> = Vec::new(); - match op { - Scan { .. } | Values { .. } => {} - PromqlVectorFromScalar(ScalarExpr::PromqlScalarFromVector(c)) => { - inputs.push((c, format!("{label} > child"))) - } - PromqlVectorFromScalar(_) => {} - PromqlRelabel { child, .. } - | PromqlInfoEnrich { child, .. } - | PromqlSeriesSample { child, .. } - | Filter { child, .. } - | Project { child, .. } - | Aggregate { child, .. } - | Dedup { child, .. } - | PromqlSubquery { child, .. } - | TimeRange { child, .. } - | TimeShift { child, .. } - | SQLWindowFunc { child, .. } - | Sort { child, .. } - | Limit { child, .. } => inputs.push((child, format!("{label} > child"))), - Concat { children, .. } => { - for (i, c) in children.iter().enumerate() { - inputs.push((c, format!("{label} > concat[{i}]"))); - } - } - Join { left, right, .. } | SetOp { left, right, .. } => { - inputs.push((left, format!("{label} > left"))); - inputs.push((right, format!("{label} > right"))); - } - BinaryOp { lhs, rhs, .. } => { - inputs.push((lhs, format!("{label} > lhs"))); - inputs.push((rhs, format!("{label} > rhs"))); - } - } - // Operator nodes read from this node's scalar expressions (`scalar(v)` - // in a predicate or projection, subqueries) are inputs too. - for input in node.children() { - if !inputs.iter().any(|(visited, _)| Rc::ptr_eq(visited, input)) { - inputs.push((input, format!("{label} > scalar"))); - } - } - for (input, path) in inputs { - visit(input, path, locations); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use asap_types::ir::operator_properties::{BinaryOpKind, Reduction, Source}; - use asap_types::ir::{BinaryOperator, NonASAPOp, OperatorNode, Predicate, ScalarExpr}; - use asap_types::pre_asap::agg_intent::{default_quantile, AggIntent}; - use asap_types::pre_asap::schema::{DataType, Field, Schema}; - - use asap_types::types::AccuracyTarget; - - fn metric_scan(labels: &[&str]) -> Rc { - let mut columns = vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - ]; - columns.extend( - labels - .iter() - .map(|n| Field::plain(*n, DataType::Utf8, true)), - ); - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index(columns, 0, vec![]), - })) - .unwrap() - } - - fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(by), - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child, - })) - .unwrap() - } - - fn binary( - kind: BinaryOpKind, - lhs: Rc, - rhs: Rc, - ) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::BinaryOp { - operator: BinaryOperator { - kind, - vector_match: None, - checked_relative_division: false, - checked_finite_division: false, - }, - return_bool: false, - lhs, - rhs, - })) - .unwrap() - } - - // ── SketchApproximation ────────────────────────────────────────────── - - #[test] - fn approximate_quantile_is_a_sketch_applicability_finding() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let findings = explain_replacements(vec![("dashboard_p99", q)]).unwrap(); - let sketch: Vec<_> = findings - .iter() - .filter(|f| f.kind == ExplanationKind::SketchApproximation) - .collect(); - assert_eq!( - sketch.len(), - 1, - "expected one sketch finding, got {findings:?}" - ); - assert!(sketch[0].location.contains("dashboard_p99")); - assert!(sketch[0].reason.to_lowercase().contains("kll")); - } - - /// A quantile read through `scalar(...)` in a predicate is located under - /// the node whose expression reads it. - #[test] - fn a_quantile_read_by_a_scalar_expression_is_located() { - use asap_types::ir::ExprSemantics; - use asap_types::pre_asap::CompareOpKind; - let q = agg(vec![], default_quantile(0.99), metric_scan(&["job"])); - let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(1)), - op: CompareOpKind::Gt, - right: Box::new(ScalarExpr::PromqlScalarFromVector(q)), - semantics: ExprSemantics::Promql, - }), - child: metric_scan(&["job"]), - })) - .unwrap(); - let findings = explain_replacements(vec![("alert", root)]).unwrap(); - let sketch = findings - .iter() - .find(|f| f.kind == ExplanationKind::SketchApproximation) - .unwrap_or_else(|| panic!("expected a sketch finding, got {findings:?}")); - assert!( - sketch.location.contains("root \"alert\" > scalar"), - "{}", - sketch.location - ); - } - - /// `node_hash` must be the literal `structural_hash` a downstream - /// consumer would compute over the *same* `OperatorNode` sub-DAG, with - /// no string-matching against `location` required. - #[test] - fn node_hash_is_the_structural_hash_of_the_sub_dag() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let expected_hash = structural_hash(&q, &mut HashCache::new()); - - let findings = explain_replacements(vec![("dashboard_p99", q)]).unwrap(); - let sketch = findings - .iter() - .find(|f| f.kind == ExplanationKind::SketchApproximation) - .expect("expected a sketch finding"); - assert_eq!(sketch.node_hash, expected_hash); - } - - #[test] - fn exact_quantile_is_not_a_sketch_applicability_finding() { - let q = agg( - vec![2], - AggIntent::Quantile { - col: None, - q: 0.99, - accuracy: AccuracyTarget::exact(), - }, - metric_scan(&["job"]), - ); - let findings = explain_replacements(vec![("exact_p99", q)]).unwrap(); - assert!( - findings - .iter() - .all(|f| f.kind != ExplanationKind::SketchApproximation), - "an Exact accuracy target must not report sketch-applicability, got {findings:?}" - ); - } - - #[test] - fn nested_aggregate_still_finds_the_inner_sketchable_node() { - // avg(quantile(0.9, sum by (job) (m))) shaped test isn't representable - // (avg is PassThrough, not a wrapper we recurse through structurally - // the way an Aggregate's own child is) — instead nest a sketchable - // quantile under an exact sum, the same nesting replacement.rs's own - // nested_aggregates_bind_per_node test uses. - let inner = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let outer = agg(vec![], default_quantile(0.9), inner); - let findings = explain_replacements(vec![("q", outer)]).unwrap(); - let sketch_count = findings - .iter() - .filter(|f| f.kind == ExplanationKind::SketchApproximation) - .count(); - assert_eq!( - sketch_count, 1, - "expected the outer quantile only, got {findings:?}" - ); - } - - #[test] - fn pass_through_intent_reports_no_sketch_finding() { - let q = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); - let findings = explain_replacements(vec![("avg_latency", q)]).unwrap(); - assert!(findings - .iter() - .all(|f| f.kind != ExplanationKind::SketchApproximation)); - } - - /// A sketch-applicable `Aggregate` reachable via two paths that CSE - /// collapses onto one `Rc` — the same `median(x) == median(x)` shape - /// `ir::cse`'s own `single_query_shares_its_own_repeated_sub_dag` - /// test uses — must be reported once, not once per path: it is exactly - /// one [`crate::replacement::TargetSubDAGCandidates`], keyed by `Rc` pointer identity, - /// not one per path that reaches it. - #[test] - fn a_shared_sketchable_aggregate_is_reported_only_once() { - let quantile = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let root = binary( - BinaryOpKind::Compare(asap_types::pre_asap::expr_ir::CompareOpKind::Eq), - Rc::clone(&quantile), - quantile, - ); - let findings = explain_replacements(vec![("ratio", root)]).unwrap(); - let sketch: Vec<_> = findings - .iter() - .filter(|f| f.kind == ExplanationKind::SketchApproximation) - .collect(); - assert_eq!( - sketch.len(), - 1, - "a single shared Aggregate must produce one finding, not one \ - per path that reaches it: got {findings:?}" - ); - } - - // ── CommonSubexpressionReuse ───────────────────────────────────────── - - #[test] - fn two_roots_with_the_same_grouped_aggregate_share_a_reuse_finding() { - // Grouped (`by (job)`), so the shared `Aggregate`'s output schema - // carries a provable unique key — share_common_sub_dags's legality - // gate — and identical across both roots, so it is shareable. - 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 findings = explain_replacements(vec![("dash_a", a), ("dash_b", b)]).unwrap(); - let reuse: Vec<_> = findings - .iter() - .filter(|f| f.kind == ExplanationKind::CommonSubexpressionReuse) - .collect(); - assert_eq!( - reuse.len(), - 1, - "expected one reuse finding, got {findings:?}" - ); - assert!(reuse[0].location.contains("dash_a")); - assert!(reuse[0].location.contains("dash_b")); - } - - #[test] - fn descendant_of_a_shared_root_keeps_every_root_breadcrumb() { - let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let outer = agg(vec![0], AggIntent::Sum { col: None }, inner); - let findings = - explain_replacements(vec![("dash_a", Rc::clone(&outer)), ("dash_b", outer)]).unwrap(); - let inner_sketch = findings - .iter() - .find(|f| { - f.kind == ExplanationKind::SketchApproximation && f.location.contains("child") - }) - .expect("expected the nested sketch explanation"); - assert!(inner_sketch.location.contains("dash_a")); - assert!(inner_sketch.location.contains("dash_b")); - } - - #[test] - fn distinct_queries_report_no_reuse_finding() { - let a = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let b = agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["route"]), - ); - let findings = explain_replacements(vec![("dash_a", a), ("dash_b", b)]).unwrap(); - assert!( - findings - .iter() - .all(|f| f.kind != ExplanationKind::CommonSubexpressionReuse), - "structurally different queries must not report reuse, got {findings:?}" - ); - } - - #[test] - fn ungrouped_identical_aggregates_are_not_shareable_so_no_finding() { - // Empty `by`: no provable unique key — share_common_sub_dags never - // hoists these, so consumer_count stays 1 for each and this module - // must not report a finding either. - let a = agg(vec![], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let b = agg(vec![], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let findings = explain_replacements(vec![("a", a), ("b", b)]).unwrap(); - assert!(findings - .iter() - .all(|f| f.kind != ExplanationKind::CommonSubexpressionReuse)); - } - - #[test] - fn single_query_repeated_subexpression_is_a_reuse_finding() { - // The same shared branch appearing twice within one query (an `a/a` - // shape) — single-query CSE. - let branch = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let q = binary( - BinaryOpKind::Arithmetic(asap_types::pre_asap::expr_ir::ArithmeticOpKind::Div), - Rc::clone(&branch), - branch, - ); - let findings = explain_replacements(vec![("ratio", q)]).unwrap(); - let reuse: Vec<_> = findings - .iter() - .filter(|f| f.kind == ExplanationKind::CommonSubexpressionReuse) - .collect(); - assert_eq!( - reuse.len(), - 1, - "expected one reuse finding, got {findings:?}" - ); - assert!(reuse[0].location.contains("lhs")); - assert!(reuse[0].location.contains("rhs")); - } - - /// A shared node nested three levels under two *different*, unshared - /// `Filter` parents (mirrors `crate::replacement::tests:: - /// nested_shared_sub-DAG_below_an_unshared_parent_is_still_discovered`) - /// must still be exactly one finding — the maximal-`TargetSubDAG` - /// guarantee the module docs describe, now provided by - /// `crate::replacement`'s own target discovery rather than this module's - /// (deleted) traversal. - #[test] - fn a_deeply_shared_sub_dag_under_different_parents_is_reported_once() { - use asap_types::pre_asap::expr_ir::ScalarValue; - - 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: 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: shared, - })) - .unwrap(); - let findings = explain_replacements(vec![("a", root_a), ("b", root_b)]).unwrap(); - let reuse: Vec<_> = findings - .iter() - .filter(|f| f.kind == ExplanationKind::CommonSubexpressionReuse) - .collect(); - assert_eq!( - reuse.len(), - 1, - "a node shared under two different parents must be one finding, got {findings:?}" - ); - 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::post_asap::SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v - .iter() - .position(|k| *k == asap_types::post_asap::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).unwrap(); - assert_eq!(findings.len(), 1); - assert!(findings[0].reason.to_lowercase().contains("ddsketch")); - } -} diff --git a/crates/asap-aware-mapping/src/function_rules.rs b/crates/asap-aware-mapping/src/function_rules.rs index 0b96e018..8c8d7e7e 100644 --- a/crates/asap-aware-mapping/src/function_rules.rs +++ b/crates/asap-aware-mapping/src/function_rules.rs @@ -1,62 +1,41 @@ -//! Function facts shared by value-operation propagation and accumulator realization. -//! Runtime support remains a deployment decision in `CostModel`. -use asap_types::post_asap::{CompositionOperator, ExactKind, ExactParams}; +//! The accuracy composition rule of each exact function applied to an +//! approximate input, shared by Pass 1's guarantee composition and the +//! accuracy algebra. +use asap_types::post_asap::CompositionOperator; use asap_types::pre_asap::AggIntent; -pub(crate) struct FunctionRules { - pub accuracy: CompositionOperator, - pub accumulator: Option<(ExactKind, ExactParams)>, -} - -/// Unregistered functions have no approximate-input propagation rule. -pub(crate) fn function_rules(intent: &AggIntent) -> Option { - let (accuracy, accumulator) = match intent { - AggIntent::Sum { .. } => ( - CompositionOperator::ExactSum, - Some((ExactKind::Sum, ExactParams::Sum)), - ), - AggIntent::Min { .. } => ( - CompositionOperator::ExactExtremum, - Some((ExactKind::Min, ExactParams::Min)), - ), - AggIntent::Max { .. } => ( - CompositionOperator::ExactExtremum, - Some((ExactKind::Max, ExactParams::Max)), - ), - AggIntent::Avg { .. } => (CompositionOperator::ExactAverage, None), - AggIntent::Rate => ( - CompositionOperator::CounterRate, - Some((ExactKind::Rate, ExactParams::Rate)), - ), - AggIntent::IRate => ( - CompositionOperator::InstantCounterRate, - Some((ExactKind::IRate, ExactParams::IRate)), - ), - AggIntent::Increase => ( - CompositionOperator::CounterIncrease, - Some((ExactKind::Increase, ExactParams::Increase)), - ), +/// The composition operator that propagates an approximate input's guarantee +/// through `intent`; `None` for a function with no registered rule over +/// approximate input, whose callers must fail closed. +pub(crate) fn approximate_input_composition(intent: &AggIntent) -> Option { + Some(match intent { + AggIntent::Sum { .. } => CompositionOperator::ExactSum, + AggIntent::Min { .. } | AggIntent::Max { .. } => CompositionOperator::ExactExtremum, + AggIntent::Avg { .. } => CompositionOperator::ExactAverage, + AggIntent::Rate => CompositionOperator::CounterRate, + AggIntent::IRate => CompositionOperator::InstantCounterRate, + AggIntent::Increase => CompositionOperator::CounterIncrease, _ => return None, - }; - Some(FunctionRules { - accuracy, - accumulator, }) } #[cfg(test)] mod tests { use super::*; - // The maintained extrema state must encode the direction independently of query text. + #[test] - fn minimum_and_maximum_have_distinct_accumulator_contracts() { - assert_ne!( - function_rules(&AggIntent::Min { col: None }) - .unwrap() - .accumulator, - function_rules(&AggIntent::Max { col: None }) - .unwrap() - .accumulator + fn counter_functions_have_distinct_definition_rules() { + assert_eq!( + approximate_input_composition(&AggIntent::Rate), + Some(CompositionOperator::CounterRate) + ); + assert_eq!( + approximate_input_composition(&AggIntent::IRate), + Some(CompositionOperator::InstantCounterRate) + ); + assert_eq!( + approximate_input_composition(&AggIntent::Increase), + Some(CompositionOperator::CounterIncrease) ); } } diff --git a/crates/asap-aware-mapping/src/grouping.rs b/crates/asap-aware-mapping/src/grouping.rs deleted file mode 100644 index d84d597e..00000000 --- a/crates/asap-aware-mapping/src/grouping.rs +++ /dev/null @@ -1,829 +0,0 @@ -//! `GroupingStrategy` (issue #256, part of #33): the axis deciding whether a -//! grouped aggregate's summary state is built as one independent instance -//! per `by` subpopulation (today's only, implicit behavior) or as one -//! shared Hydra-family structure serving all of them — orthogonal to -//! *which* summary family/kind answers the intent, the same way -//! [`asap_types::post_asap::GroupingStrategy`]'s own doc explains. -//! -//! ## Placement: planning metadata and edge-state type -//! -//! `ASAPOp::SummaryAgg` carries the grouping choice next to the -//! `Reduction` whose `by` keys determine legality. The same choice is also -//! committed to `FieldDataType::Sketch` on the aggregate's output edge. -//! That duplication is intentional: the node field makes the choice easy to -//! inspect during planning, while the edge type ensures an independent KLL/ -//! CMS state and a Hydra-backed state cannot be accepted as compatible inputs -//! to a downstream `SummaryMerge`. [`with_grouping`] updates both atomically. -//! -//! ## Legality vs. cost (same split [`crate::replacement::realizations_for_intent`] -//! already draws) -//! -//! This module only answers "is `SharedMultiSubpopulation` valid here at -//! all", never "is it worth it": -//! -//! - **Non-empty `by`** ([`has_subpopulations`]): an aggregate with no -//! subpopulation concept (a global reduction, or a per-entity reduction -//! with no grouping concept at all) has nothing for a -//! shared-multi-subpopulation structure to multiplex across. -//! - **The family has a Hydra variant** -//! ([`asap_types::post_asap::hydra_kind_for`]): `Cms` and `CountSketch` -//! have structural Hydra mappings. `HydraKll` remains an explicit -//! experimental IR value, but the paper excludes quantiles and search -//! therefore never emits it. The shared-grid term is represented -//! symbolically; missing statistics leave an uncertified candidate visible. -//! -//! Whether Hydra is *worth it* for a given estimated subpopulation -//! cardinality is a cost-model question, deliberately out of scope here — -//! candidates with missing error evidence remain visible for downstream review. -//! -//! ## No `ForceSketchKind`-style steering — bind one already-known candidate directly -//! -//! An earlier draft of this module (written against the very first draft of -//! #251) reused a `CostModel`-wrapping adapter that "steered" a -//! whole-recursive-bind decision procedure toward a specific `SketchKind`, -//! the same pattern [`crate::replacement::ASAPStrategies`]'s own module -//! docs explain was deliberately deleted from this crate as an anti-pattern: -//! forcing a choice via a whole-DAG `CostModel` adapter had a real bug where -//! the forced choice could leak into a target's own nested aggregates. This -//! module never needs that: [`crate::replacement::realizations_for_intent`] -//! already returns every ranked candidate `Realization` directly, so -//! [`build_candidate`](HydraGroupingStrategy::build_candidate) just finds the -//! one whose `Realization::Sketch(kind)` has `kind.algorithm()` matching -//! the Hydra-eligible `sketch_kind` it's building a candidate for, and -//! passes that exact, -//! already-decided `Realization` to -//! [`crate::replacement::construct_summary`] — the same first-class, -//! one-candidate-at-a-time primitive [`crate::replacement::ASAPStrategies`] -//! itself calls once per candidate. No adapter, no steering, no risk of a -//! forced choice leaking into nested aggregates. -//! -//! ## Cross-axis legality with roll-up (issue #254) -//! -//! Roll-up and Hydra currently operate on disjoint candidates. Roll-up -//! rewrites exact `Sum`/`Min`/`Max`/`Count` aggregates in the pre-ASAP DAG; -//! Hydra is offered only for approximate quantile/count intents (KLL, CMS, -//! Count-Sketch) and produces a terminal post-ASAP summary candidate. -//! Consequently neither strategy can -//! presently offer the other's candidate as a source. If roll-up support is -//! extended to mergeable sketches, that extension must consult -//! `rollup::is_legal_rollup_source` and add explicit Hydra merge semantics; -//! grouping alone must not imply that a sketch can be rolled up. - -use std::rc::Rc; - -use asap_types::ir::operator_properties::Reduction; -use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode}; -use asap_types::post_asap::{ - default_hydra_params, hydra_kind_for, AccuracyError, AccuracyGuarantee, BoundExpr, - CompositionOperator, FieldDataType, GroupingStrategy, GuaranteeSource, HydraKind, - ProbabilityExpr, SketchAlgorithm, SketchParams, -}; -use asap_types::pre_asap::agg_intent::AggIntent; - -use crate::accuracy::{AccuracyEvidenceProvider, AccuracyModel, PropagationStats}; -use crate::cost_model::{CostModel, DefaultCostModel}; -use crate::realization::{accuracy_target, summary_candidates}; -use crate::replacement::{ - bindable_intent, construct_summary_with, describe_intent, realizations_for_intent, - CandidatePlanningInputs, Proposals, Realization, RejectedCandidate, Replacement, - ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, -}; - -/// Whether `reduction` has a genuine subpopulation concept for -/// `GroupingStrategy::SharedMultiSubpopulation` to multiplex across — the -/// non-empty-`by` legality condition issue #256 requires. -/// -/// - [`Reduction::PerEntity`]: no grouping concept at all (never merges -/// across entities) — `false`. -/// - [`Reduction::Reduce`] with an empty, non-`without` `by`: a genuine full -/// reduction, one output row, no subpopulations — `false`. -/// - [`Reduction::Reduce`] with a non-empty `by`, or any `without(...)` -/// exclusion grouping (which groups by whatever labels remain, even -/// `without([])` — "group by every label"): a real subpopulation concept -/// — `true`. -pub fn has_subpopulations(reduction: &Reduction) -> bool { - match reduction.group_keys() { - None => false, - Some(keys) => keys.is_without() || !keys.is_empty(), - } -} - -/// 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 -/// `GroupingStrategy::SharedMultiSubpopulation` candidate wherever the -/// legality conditions in the module docs above hold — alongside, not -/// instead of, the per-subpopulation candidates `ASAPStrategies` -/// itself enumerates. The workload search composes both strategies over the -/// same target, so it sees every summary-family alternative *and* the Hydra -/// alternative; the built-in workload search registers both strategies, and -/// this strategy's own `replacements()` reports only the -/// latter, matching every other strategy in this crate's "one strategy, one -/// concern" shape. -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 { - Self { - planning_inputs: CandidatePlanningInputs::with_default_accuracy(&DEFAULT_COST_MODEL), - } - } -} - -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, - evidence: &'a dyn AccuracyEvidenceProvider, - ) -> Self { - Self { - planning_inputs: CandidatePlanningInputs { - cost: cost_model, - accuracy: accuracy_model, - evidence, - }, - } - } - - /// Every legal `SharedMultiSubpopulation` candidate for `target` — empty - /// when `target` isn't a bindable aggregate, has no subpopulation - /// concept, or its intent's candidate summary families have no Hydra - /// variant modeled. - fn hydra_proposals(&self, target: &TargetSubDAG<'_>) -> Proposals { - let mut proposals = Proposals::default(); - let Some(NonASAPOp::Aggregate { reduction, .. }) = target.root.non_asap() else { - return proposals; - }; - if !has_subpopulations(reduction) { - return proposals; - } - let Some(intent) = bindable_intent(target.root) else { - return proposals; - }; - for (sketch_kind, hydra_kind) in summary_candidates(intent) - .iter() - .filter_map(|kind| hydra_kind_for(kind).map(|hydra_kind| (kind.clone(), hydra_kind))) - { - if let Some(candidate) = self.build_candidate( - target.root, - intent, - sketch_kind, - hydra_kind, - &mut proposals.rejected, - ) { - proposals.candidates.push(candidate); - } - } - proposals - } - - /// Find the already-ranked candidate [`Realization::Sketch`] matching - /// `sketch_kind` among [`realizations_for_intent`]'s exhaustive list for - /// `intent`, bind `root` to that exact, already-decided candidate via - /// [`crate::replacement::construct_summary_with`] (no steering/forcing — see - /// the module docs' "No `ForceSketchKind`-style steering"), then swap the - /// resulting `SummaryAgg`'s `grouping` field from the default - /// `PerSubpopulationInstance` to - /// `SharedMultiSubpopulation { kind: hydra_kind, .. }` — reusing the - /// entire bind decision procedure (schema derivation, column resolution, - /// evaluation construction) unchanged, patching only the one field this - /// axis owns. - fn build_candidate( - &self, - root: &Rc, - intent: &AggIntent, - sketch_kind: SketchAlgorithm, - hydra_kind: HydraKind, - rejected: &mut Vec, - ) -> Option { - let realization = realizations_for_intent(intent, self.planning_inputs.cost) - .into_iter() - .find(|candidate| { - matches!(candidate, Realization::Sketch(kind) if *kind.algorithm() == sketch_kind) - })?; - let node = - construct_summary_with(root, intent, realization, self.planning_inputs, None, None) - .ok()?; - let per_subpopulation_params = per_subpopulation_sketch_params(&node)?; - let params = default_hydra_params(hydra_kind.clone(), &per_subpopulation_params)?; - let grouping = GroupingStrategy::SharedMultiSubpopulation { - kind: hydra_kind.clone(), - params, - }; - - let (family, query) = match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) => match &summary_input.operator { - Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) => (family, Some(query)), - _ => return None, - }, - _ => return None, - }; - let stats = self.planning_inputs.evidence.propagation_stats( - &CompositionOperator::ApproximateAggregate, - family, - query, - ); - if stats - .hydra_shared_grid_collision_bound - .is_some_and(|bound| !bound.is_finite() || bound < 0.0) - || stats - .hydra_shared_grid_failure_probability - .is_some_and(|probability| { - !probability.is_finite() || !(0.0..=1.0).contains(&probability) - }) - { - rejected.push(RejectedCandidate { - strategy: "HydraGroupingStrategy", - description: format!("{hydra_kind:?} over {sketch_kind:?}"), - error: AccuracyError::UnsupportedComposition { - operator: CompositionOperator::ApproximateAggregate, - input_metrics: node - .guarantee - .as_ref() - .map_or_else(Vec::new, |g| vec![g.spec.accuracy_metric]), - local_metric: None, - reason: "invalid Hydra shared-grid collision or failure-probability evidence" - .into(), - }, - }); - return None; - } - let patched = with_grouping(node, grouping, &stats); - if let (Some(target), Some(guarantee)) = (accuracy_target(intent), &patched.guarantee) { - if !self.planning_inputs.accuracy.satisfies( - &guarantee.optimistic_floor(), - &crate::accuracy::native_target(&target, guarantee), - ) { - rejected.push(RejectedCandidate { - strategy: "HydraGroupingStrategy", - description: format!("{hydra_kind:?} over {sketch_kind:?}"), - error: AccuracyError::TargetNotSatisfied { - metric: guarantee.spec.accuracy_metric, - bound: guarantee.spec.error.evaluate(), - failure_probability: guarantee.spec.failure_probability.evaluate(), - target: target.clone(), - }, - }); - return None; - } - } - Some(ReplacementSubDAG { - strategy: "HydraGroupingStrategy", - replacement: Replacement::SubDAG(patched), - provenance: crate::replacement::ReplacementProvenance::SummaryRealization, - rationale: format!( - "{} realizes as a shared {hydra_kind:?} structure over {sketch_kind:?} \ - serving every subpopulation of this grouped aggregate, instead of one \ - {sketch_kind:?} instance per distinct `by` key — legal because this \ - aggregate has a non-empty subpopulation concept and {sketch_kind:?} has a \ - modeled Hydra variant (asap_types::post_asap::hydra_kind_for); whether it's \ - *worth* the shared/independent trade-off for the actual subpopulation \ - cardinality is a CostModel's call, not this strategy's", - describe_intent(intent) - ), - }) - } -} - -impl ReplacementStrategy for HydraGroupingStrategy<'_> { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - let Some(NonASAPOp::Aggregate { reduction, .. }) = target.root.non_asap() else { - return false; - }; - if !has_subpopulations(reduction) { - return false; - } - let Some(intent) = bindable_intent(target.root) else { - return false; - }; - realizations_for_intent(intent, self.planning_inputs.cost) - .into_iter() - .any(|realization| { - matches!(realization, - Realization::Sketch(kind) if hydra_kind_for(kind.algorithm()).is_some()) - }) - } - - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - self.hydra_proposals(target).candidates - } - - fn propose(&self, target: &TargetSubDAG<'_>) -> Proposals { - self.hydra_proposals(target) - } -} - -/// The [`SketchParams`] a bound sketch candidate's `SummaryAgg` committed -/// to, if its family is `Sketch(_)` at all — `None` for any other bound -/// shape (an exact accumulator, a pass-through, or a family with no -/// `SketchParams`), never expected here in practice since `hydra_candidates` -/// only calls this for a `sketch_kind` it already confirmed has a -/// `HydraKind` via `hydra_kind_for`, but degrading to "no candidate" rather -/// than panicking keeps this as conservative as the rest of this module. -/// -/// Deliberately returns the *whole* [`SketchParams`], not one scalar field -/// pulled out of it (an earlier version of this function assumed -/// `SketchParams::Kll { k }` specifically and returned a bare `k: u32`). -/// This axis is a "sketch of sketches" framework: the inner sketch a Hydra -/// structure wraps isn't always KLL, and each [`HydraKind`] variant needs -/// its own inner sketch's own knobs — `HydraCms`/`HydraCountSketch` need -/// (`width`, `depth`), not a `k`. [`default_hydra_params`] is what actually -/// destructures the right variant for `kind`; this function's only job is -/// to find whatever `SketchParams` the bind decision already committed to -/// and hand the whole thing over unchanged. -fn per_subpopulation_sketch_params(node: &OperatorNode) -> Option { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - per_subpopulation_sketch_params(summary_input) - } - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) => Some(kind.params().clone()), - _ => None, - } -} - -/// Rebuild `node`, replacing its `SummaryAgg`'s `grouping` field with -/// `grouping` — patching the one field this axis owns onto an -/// already-correctly-bound node rather than re-deriving the rest of it. -/// Recurses through a `SummaryEstimate` evaluation wrapper (the shape every -/// sketch candidate this module builds actually has) to reach the -/// `SummaryAgg` underneath. -fn with_grouping( - node: Rc, - grouping: GroupingStrategy, - stats: &PropagationStats, -) -> Rc { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) => std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input: with_grouping(Rc::clone(summary_input), grouping, stats), - query: query.clone(), - }), - node.schema.clone(), - ) - .with_guarantee(node.guarantee.as_ref().map(|g| hydra_guarantee(g, stats))), - ), - Operator::ASAP(ASAPOp::SummaryAgg { - child, - family, - input, - reduction, - .. - }) => { - let grouped_family = match family { - FieldDataType::Sketch(kind, _) => { - FieldDataType::Sketch(kind.clone(), grouping.clone()) - } - _ => family.clone(), - }; - let mut grouped_schema = node.schema.clone(); - for field in &mut grouped_schema.fields { - if let FieldDataType::Sketch(kind, _) = &field.dtype { - field.dtype = FieldDataType::Sketch(kind.clone(), grouping.clone()); - } - } - std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child: Rc::clone(child), - family: grouped_family, - input: input.clone(), - reduction: reduction.clone(), - grouping, - filter: None, - }), - grouped_schema, - ) - .with_guarantee(None), - ) - } - // Never reached by this module's own callers (they only ever pass a - // node `construct_summary_with` just bound for a `Sketch` - // candidate, which is always `SummaryAgg` or - // `SummaryEstimate(SummaryAgg)`) — returning the node unchanged - // rather than panicking keeps this as conservative as the rest of - // the module if that ever stops holding. - _ => node, - } -} - -/// Compose the inner per-subpopulation guarantee with Hydra's outer shared -/// grid. The paper's collision term depends on deployment/data statistics; -/// keeping those leaves symbolic makes the formula explicit while ensuring -/// target satisfaction fails closed until a caller supplies them. -fn hydra_guarantee(inner: &AccuracyGuarantee, stats: &PropagationStats) -> AccuracyGuarantee { - let mut provenance = inner.provenance.clone(); - provenance.extend(stats.evidence_provenance.clone()); - provenance.push(GuaranteeSource::ChildGuarantee { - input_index: 0, - guarantee: Box::new(inner.clone()), - }); - if stats.hydra_shared_grid_collision_bound.is_none() { - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: "hydra_shared_grid_collision_bound".into(), - }); - } - if stats.hydra_shared_grid_failure_probability.is_none() { - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: "hydra_shared_grid_failure_probability".into(), - }); - } - provenance.push(GuaranteeSource::CompositionStep { - operator: CompositionOperator::ApproximateAggregate, - rule: "hydra_shared_grid_union_bound".into(), - }); - AccuracyGuarantee::new( - inner.spec.accuracy_metric, - BoundExpr::Sum { - terms: vec![ - inner.spec.error.clone(), - stats.hydra_shared_grid_collision_bound.map_or_else( - || BoundExpr::Unknown { - statistic: "hydra_shared_grid_collision_bound".into(), - }, - |value| BoundExpr::Constant { value }, - ), - ], - }, - ProbabilityExpr::UnionBound { - terms: vec![ - inner.spec.failure_probability.clone(), - stats.hydra_shared_grid_failure_probability.map_or_else( - || ProbabilityExpr::Unknown { - statistic: "hydra_shared_grid_failure_probability".into(), - }, - |value| ProbabilityExpr::Constant { value }, - ), - ], - }, - provenance, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::accuracy::DefaultAccuracyModel; - use crate::test_support::{agg, agg_per_entity, metric_scan}; - use asap_types::post_asap::AccuracyMetric; - use asap_types::pre_asap::agg_intent::{default_cardinality, default_quantile}; - use asap_types::types::AccuracyTarget; - - // ── has_subpopulations ──────────────────────────────────────────────── - - #[test] - fn per_entity_has_no_subpopulation_concept() { - assert!(!has_subpopulations(&Reduction::PerEntity)); - } - - #[test] - fn empty_by_reduction_has_no_subpopulation_concept() { - assert!(!has_subpopulations(&Reduction::by(vec![]))); - } - - #[test] - fn non_empty_by_reduction_has_a_subpopulation_concept() { - assert!(has_subpopulations(&Reduction::by(vec![2]))); - } - - #[test] - fn without_grouping_has_a_subpopulation_concept_even_when_empty() { - use asap_types::ir::operator_properties::GroupKeys; - // `without([])` groups by every remaining label — a real - // subpopulation concept, unlike `by([])`'s genuine full reduction. - assert!(has_subpopulations(&Reduction::Reduce(GroupKeys::without( - vec![] - )))); - } - - #[test] - fn hydra_composes_inner_and_shared_grid_error_symbolically() { - let inner = AccuracyGuarantee::new( - AccuracyMetric::ABSOLUTE_STREAM_L1, - BoundExpr::Constant { value: 0.01 }, - ProbabilityExpr::Constant { value: 0.02 }, - vec![], - ); - let composed = hydra_guarantee(&inner, &PropagationStats::default()); - - assert_eq!( - composed.spec.accuracy_metric, - AccuracyMetric::ABSOLUTE_STREAM_L1 - ); - assert!(matches!( - composed.spec.error, - BoundExpr::Sum { ref terms } - if matches!(terms.as_slice(), [ - BoundExpr::Constant { value }, - BoundExpr::Unknown { statistic }, - ] if *value == 0.01 && statistic == "hydra_shared_grid_collision_bound") - )); - assert!(matches!( - composed.spec.failure_probability, - ProbabilityExpr::UnionBound { ref terms } - if matches!(terms.as_slice(), [ - ProbabilityExpr::Constant { value }, - ProbabilityExpr::Unknown { statistic }, - ] if *value == 0.02 && statistic == "hydra_shared_grid_failure_probability") - )); - } - - // ── HydraGroupingStrategy ───────────────────────────────────────────── - - #[test] - fn matches_a_grouped_count_with_an_unprovable_accuracy_target() { - let intent = AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), - }; - let q = agg(vec![2], intent, metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - assert!(HydraGroupingStrategy::default_cost_model().matches(&target)); - } - - #[test] - fn does_not_match_an_empty_by_aggregate() { - // 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(); - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - } - - #[test] - 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(); - 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!(!HydraGroupingStrategy::default_cost_model().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); - assert!(replacements.is_empty(), "{replacements:?}"); - } - - #[test] - fn count_with_an_accuracy_target_keeps_uncertified_hydra_candidates() { - let intent = AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), - }; - let q = agg(vec![2], intent, metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - let replacements = HydraGroupingStrategy::default_cost_model().replacements(&target); - assert_eq!(replacements.len(), 2, "{replacements:?}"); - assert!(replacements.iter().all(|candidate| matches!( - &candidate.replacement, - Replacement::SubDAG(node) - if node.guarantee.as_ref().is_some_and(|guarantee| - guarantee.spec.error.evaluate().is_none() - && guarantee.spec.failure_probability.evaluate().is_none()) - ))); - } - - struct ZeroSharedGridEvidence; - - impl AccuracyEvidenceProvider for ZeroSharedGridEvidence { - fn propagation_stats( - &self, - _op: &CompositionOperator, - _family: &FieldDataType, - _query: Option<&asap_types::post_asap::SketchStatistic>, - ) -> PropagationStats { - PropagationStats { - hydra_shared_grid_collision_bound: Some(0.0), - hydra_shared_grid_failure_probability: Some(0.0), - ..Default::default() - } - } - } - - #[test] - fn hydra_shared_grid_evidence_is_consumed_by_candidate_construction() { - let intent = AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), - }; - let q = agg(vec![2], intent, metric_scan(&["job"])); - let strategy = HydraGroupingStrategy::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &ZeroSharedGridEvidence, - ); - let replacements = strategy.replacements(&TargetSubDAG::new(&q)); - assert_eq!(replacements.len(), 2, "{replacements:?}"); - assert!(replacements.iter().all(|candidate| matches!( - &candidate.replacement, - Replacement::SubDAG(node) - if node.guarantee.as_ref().is_some_and(|g| - g.spec.error.evaluate().is_some() - && g.spec.failure_probability.evaluate().is_some()) - ))); - } - - #[test] - fn invalid_partial_hydra_evidence_is_rejected_with_a_reason() { - struct InvalidEvidence; - impl AccuracyEvidenceProvider for InvalidEvidence { - fn propagation_stats( - &self, - _op: &CompositionOperator, - _family: &FieldDataType, - _query: Option<&asap_types::post_asap::SketchStatistic>, - ) -> PropagationStats { - PropagationStats { - hydra_shared_grid_failure_probability: Some(1.5), - ..Default::default() - } - } - } - let intent = AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), - }; - let q = agg(vec![2], intent, metric_scan(&["job"])); - let strategy = HydraGroupingStrategy::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &InvalidEvidence, - ); - let proposals = strategy.propose(&TargetSubDAG::new(&q)); - assert!(proposals.candidates.is_empty()); - assert_eq!(proposals.rejected.len(), 2); - assert!(proposals - .rejected - .iter() - .all(|r| matches!(r.error, AccuracyError::UnsupportedComposition { .. }))); - - let space = crate::replacement::search_workload_with( - vec![("q", Rc::clone(&q))], - &[Box::new(strategy)], - ) - .unwrap(); - let group = space.candidates_for_target(&space.roots[0].1).unwrap(); - assert!(group.candidates.is_empty()); - assert_eq!(group.rejected.len(), 2); - } - - #[test] - fn known_hydra_bound_over_target_is_rejected_despite_unknown_probability() { - struct ExcessiveCollision; - impl AccuracyEvidenceProvider for ExcessiveCollision { - fn propagation_stats( - &self, - _op: &CompositionOperator, - _family: &FieldDataType, - _query: Option<&asap_types::post_asap::SketchStatistic>, - ) -> PropagationStats { - PropagationStats { - hydra_shared_grid_collision_bound: Some(0.1), - ..Default::default() - } - } - } - let q = agg( - vec![2], - AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), - }, - metric_scan(&["job"]), - ); - let strategy = HydraGroupingStrategy::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &ExcessiveCollision, - ); - let proposals = strategy.propose(&TargetSubDAG::new(&q)); - assert!(proposals.candidates.is_empty()); - assert_eq!(proposals.rejected.len(), 2); - assert!(proposals - .rejected - .iter() - .all(|r| matches!(r.error, AccuracyError::TargetNotSatisfied { .. }))); - } - - #[test] - fn cardinality_has_no_hydra_candidate_yet() { - // summary_candidates(Cardinality) = [Hll, Kmv, UnivMon] — none have a - // modeled Hydra variant, so no candidate at all (not an error, just - // 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(); - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - } - - #[test] - fn exact_accuracy_target_has_no_hydra_candidate() { - // AccuracyTarget::exact() never binds a sketch at all — nothing for - // this axis to offer a shared-structure alternative to. - 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 strategy = HydraGroupingStrategy::default_cost_model(); - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - } - - #[test] - fn exact_mergeable_intent_has_no_hydra_candidate() { - // Sum's exact accumulator has no candidate summary families at all - // (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(); - 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 multi = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::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()); - } - - /// 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 4db6343a..8b8adefb 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -5,19 +5,10 @@ //! DAGs and makes the cost-aware decisions the pre-ASAP IR deliberately //! leaves open — which sketch (if any) realises each approximate intent. //! -//! **Common sub-expression elimination (CSE) is not this crate's job.** -//! Detection is a primary pass over the pre-ASAP operator IR itself -//! (`asap_types::ir::cse`, design tracked in issue #223), run before a -//! tree ever reaches [`replacement::ASAPStrategies`] — see issue #222 -//! for why (batch query optimization needs to see shared work across a -//! `QueryWorkload` before summary binding, not after). This crate may -//! eventually run a second, narrower CSE pass of its own over an -//! already-bound post-ASAP `OperatorNode` DAG, recognizing sharing that's invisible -//! at the pre-ASAP level by construction — e.g. `Quantile(x, 0.99)` and -//! `Quantile(x, 0.95)` are structurally distinct `AggIntent`s but can -//! still share one built sketch, read out twice. That post-ASAP pass is -//! secondary to, and downstream of, the primary pre-ASAP pass, not a -//! replacement for it. +//! Identical-sub-DAG sharing over the pre-ASAP IR (`asap_types::ir::cse`) +//! runs before the stages; the stages add the ASAP-aware sharing that is +//! invisible at the pre-ASAP level ([`asap_aware_cse`], Pass 2), e.g. +//! `Quantile(x, 0.99)` and `Quantile(x, 0.95)` reading one built sketch. //! //! It depends only on the IR crate, never on a front end — the layering //! invariant (arrows point up) holds here too. @@ -30,8 +21,8 @@ //! ## Planning workflows //! //! The planner runs Stage 1 to Stage 3 in order: Stage 1, logical ASAP-aware -//! optimization ([`per_computation_candidates`]: Pass 1 and composition); -//! Stage 2, physical ASAP-aware optimization +//! optimization ([`per_computation_candidates`]: Pass 1 and composition; +//! [`asap_aware_cse`]: Pass 2); Stage 2, physical ASAP-aware optimization //! ([`stage2_physical_asap_optimization`]); Stage 3, plan selection //! ([`stage3_plan_selection`]). //! @@ -40,11 +31,6 @@ //! - Call [`run_stage1_to_stage3`] over the frontends' roots to read every stage's //! candidates as well as the selected plan. //! -//! The planner no longer calls the legacy candidate search -//! ([`replacement::search_workload`] and -//! [`CandidateLogicalASAPDAGs::global_selection`]); it remains only until its -//! removal. -//! //! Models and evidence determine which choices the helpers can justify. //! Physical operator binding, placement, storage, deployment, and execution //! remain downstream responsibilities. Neither taking the first candidate nor @@ -52,161 +38,50 @@ //! //! ## Supporting components //! -//! - [`cost_model`] — the [`CostModel`](cost_model::CostModel) trait every -//! deployment's cost-based sketch selection plugs into (issues #6, #33). -//! `asap-plan` itself only ships [`DefaultCostModel`](cost_model::DefaultCostModel), -//! which preserves [`replacement`]'s built-in static preference order and -//! — via [`CostModel::estimate_cost`](cost_model::CostModel::estimate_cost) -//! — exposes an actual numeric cost per candidate, not just a relative -//! rank, for a caller (e.g. a DAG-visualization view) that wants to show -//! "candidate A costs ≈ X" next to "candidate B costs ≈ Y". -//! - [`explanation`] — this crate's explanation of a replacement: a -//! reporting *view* over [`replacement`]'s candidate-plan space (issue -//! #257, part of #33) that translates every discovered `TargetSubDAG` with -//! a non-trivial candidate list into an -//! [`explanation::ReplacementExplanation`] (why a replacement exists, -//! where, reusing the candidate's own rationale rather than inventing new -//! prose), meant for the same downstream consumer (e.g. a -//! DAG-visualization view) the crate doc's planning workflows section above -//! already names for [`replacement::CandidateLogicalASAPDAGs`] itself. Superseded PR -//! #247's own rule-based traversal, which re-walked the DAG once per -//! optimization before [`replacement::search_workload`] existed to read -//! from instead — see that module's docs for the full reframing. -//! - [`rollup`] — [`rollup::RollupStrategy`] wraps group-by-lattice roll-up -//! reuse (issue #254, part of #33) as a [`ReplacementStrategy`]: given a -//! coarser `Aggregate` target and a caller-supplied sibling set, proposes -//! re-deriving it from an already-computed, strictly finer sibling -//! `Aggregate` over identical child IR instead of an independent pass -//! over the raw source — the cross-aggregate sibling of -//! `ir::cse::share_common_sub_dags`'s identical-sub-DAG sharing. -//! [`rollup::is_legal_rollup_source`] is the standalone legality predicate -//! other axes (e.g. issue #256's `GroupingStrategy`) are expected to -//! consult directly, so it and this module's `RollupStrategy` can never -//! disagree about which siblings qualify. -//! - [`grouping`] — [`grouping::HydraGroupingStrategy`] (issue #256, part of -//! #33) is an additional `ReplacementStrategy`: the orthogonal -//! `GroupingStrategy` axis (one summary instance per `by` subpopulation -//! versus one shared Hydra-family structure serving all of them), offered -//! alongside the candidates [`replacement::ASAPStrategies`] -//! enumerates for the same target. -//! - [`rewrite`] — the "semantic-equivalent rewriting (e.g. `avg` → -//! `sum`/`count`) to increase how often the [sharing/sketch] optimizations -//! above apply" degree of freedom `docs/design_docs/asap_aware_mapping.md` -//! names (issue #253, part of #33): [`rewrite::AvgToSumOverCountStrategy`] -//! is a [`replacement::ReplacementStrategy`] that reshapes a bare `avg` -//! node — which [`replacement::realizations_for_intent`] can only -//! dispatch to `Realization::PassThrough`, so it can never be a -//! [`replacement::SharedSubDAGStrategy`] target — into a `sum`/`count` -//! pair under the same grouping, re-divided back by a wrapping `Project`, -//! so those *are* ordinary mergeable accumulators sharing/sketching can -//! reach. It only reshapes; [`replacement::search_workload`]'s cost-based -//! ranking (or a downstream consumer reading [`replacement::CandidateLogicalASAPDAGs`]) -//! is what decides whether the reshaped form is actually worth picking, -//! the same propose-don't-decide split every other strategy here keeps. -//! -//! ## Terminology -//! -//! Schema resolution, candidate realization, and runtime placement are distinct stages. -//! -//! | Term | Meaning | Entry point | -//! |---|---|---| -//! | Schema resolution | Derive input schemas and resolve column names to positions | `asap_frontend_common::schema_resolver::SchemaResolver::resolve_schema`, `asap_frontend_common::resolve::resolve_root` | -//! | Realization | Enumerate ranked physical forms for one aggregate intent | `replacement::realizations_for_intent` | -//! | Replacement | Construct each candidate summary sub-DAG | [`replacement::ASAPStrategies`] | -//! | Search | Enumerate and compare alternatives across a workload | [`replacement::search_workload`] | -//! | Runtime placement | Choose deployment locations and concrete executors | Downstream physical plan providers | -//! -//! A related question (tracked alongside issues #6/#33): whether this -//! crate should also own a **matching** predicate — "does an already -//! *available* `Realization` satisfy a *required* one" — the way a -//! database's materialized-view matching / "answering queries using -//! views" layer does. It owns the *question*, not an *answer*: -//! [`replacement::Matcher`] is a trait with no default implementation and -//! no shipped instance, the same shape as [`cost_model::CostModel`] and for -//! the same reason — which `Realization`s are actually *available* -//! anywhere is entirely a downstream deployment's concern (an inventory -//! this crate has no way to see), and even the pure sketch-algebra -//! compatibility rules (e.g. a heap-bearing top-k sketch also satisfying a -//! bare frequency point-query) turned out to have deployment-specific -//! competitors (e.g. single-vs-multi-population re-aggregation) that -//! don't reduce to a fact about a summary family's kind alone. `control_plane`'s own -//! `sketch_algebra::capability::Capability`/`is_satisfied_by` is the -//! reference downstream implementation. -//! +//! - [`realization`] — the summary families that can serve an aggregate +//! intent, the intent's accuracy target and `(ε, δ)` budget, and default +//! sketch sizing, shared by Pass 1 and Pass 2. //! - [`accuracy`] — the [`AccuracyModel`](accuracy::AccuracyModel) //! extension point (issue #172): the planning-time algebra that derives //! a machine-readable [`AccuracyGuarantee`](asap_types::post_asap::AccuracyGuarantee) -//! for every finalized post-ASAP value, propagates it through +//! for every finalized post-ASAP value and propagates it through //! approximate-over-approximate compositions under conservative rules -//! (no independence assumptions, unknown statistics stay unknown), and -//! rejects — before any `CostModel` ranks anything — every candidate with -//! no sound rule or one that misses the applicable `AccuracyTarget`. -//! Legality and cost are separate responsibilities; see that module's -//! docs for the pipeline order and the root-vs-per-node precedence rules. +//! (no independence assumptions, unknown statistics stay unknown). Plan +//! selection checks each candidate's guarantee against its query's +//! `AccuracyTarget` before pricing it. +//! - [`analytical_cost`] and [`physical_operator_statistics`] — the +//! analytical per-operator resource model Stage 3 prices with. +//! - [`maintained_population`] — builds a maintained-population sub-DAG for +//! an instant top-k/quantile/sum query; Pass 1 does not offer it yet (#681). pub mod accuracy; pub mod analytical_cost; -pub mod cost_model; /// The deployment's capabilities: supported summaries and what it retains. pub mod deployment_capabilities; -pub mod exact_composition; -pub mod explanation; mod function_rules; -pub mod grouping; pub mod pane_sharing; pub mod pass; pub mod physical_handoff_cost; pub mod physical_operator_statistics; pub mod query_physical_lowering; pub mod realization; -pub mod recurrence; -pub mod replacement; -pub mod rewrite; -pub mod rollup; pub mod storage_io; #[cfg(test)] mod test_support; -pub mod topk_reuse; -pub use accuracy::reconciliation::AccuracyReconciliationStrategy; pub use accuracy::{ AccuracyEvidenceProvider, AccuracyModel, DefaultAccuracyModel, NoAccuracyEvidence, PropagationStats, }; -pub use cost_model::{ - maintenance_operation_plan_cost_rate, raw_recompute_cost_rate, read_operation_plan_cost_rate, - CostModel, CostProvenance, CostUnit, DefaultCostModel, ExactCompositionCostInputs, - ExactCompositionCostRequest, ValueOperationCapabilities, -}; pub use deployment_capabilities::{ DeploymentCapabilities, DeploymentCapabilitiesError, SupportedSummary, }; -pub use exact_composition::{ExactComposition, ExactCompositionStrategy, OperationPlacement}; -pub use explanation::{ - explain_replacements, explain_replacements_with, ExplanationKind, ReplacementExplanation, -}; -pub use grouping::{has_subpopulations, HydraGroupingStrategy}; pub use pass::{ optimize, run_stage1_to_stage3, OptimizationInput, OptimizationInputError, OptimizationPass, OptimizeError, PassNameConflict, PassRegistry, PlanOutput, PlanningModels, QueryPlan, Stage1ToStage3Pass, Stage1ToStage3Run, }; pub use realization::summary_candidates; -pub use recurrence::{ - evaluation_rate_of, total_cost, update_rate_from_data_workload, CostRate, EvaluationRate, - Horizon, RecurrenceCostExplanation, RecurrenceError, RecurrenceProfile, RootRecurrence, - UpdateRate, -}; -pub use replacement::{ - default_strategies, default_strategies_with, search_workload, search_workload_with, - search_workload_with_targets, ASAPStrategies, CandidateLogicalASAPDAGs, CompositionDecision, - GlobalSelection, Matcher, Proposals, RankedTargetSubDAGCandidates, Realization, - RealizationError, RecurrenceProfileMap, RejectedCandidate, Replacement, ReplacementProvenance, - ReplacementStrategy, ReplacementSubDAG, SearchError, SharedSubDAGStrategy, TargetSubDAG, - TargetSubDAGCandidates, TargetSubDAGSelection, MAX_SEARCH_ITERATIONS, -}; -pub use rewrite::{AvgToSumOverCountStrategy, SemanticEquivalentRewriteStrategy}; -pub use topk_reuse::TopKLimitReuseStrategy; pub mod maintained_population; diff --git a/crates/asap-aware-mapping/src/maintained_population.rs b/crates/asap-aware-mapping/src/maintained_population.rs index c847e5d1..3c2f69a8 100644 --- a/crates/asap-aware-mapping/src/maintained_population.rs +++ b/crates/asap-aware-mapping/src/maintained_population.rs @@ -1,7 +1,4 @@ //! Shared maintained-population candidates over canonical relational IR. -use crate::replacement::{ - Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, -}; use asap_types::ir::non_asap::any_measure_filtered; use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; use asap_types::post_asap::{maintained_population::*, AccuracyGuarantee}; @@ -226,8 +223,8 @@ fn recognize( } /// Workload-aware rule: compatible evaluations share one retractable population. -/// Deployments opt in by registering this strategy when they can maintain complete -/// population updates and price the maintenance/evaluation boundary. +/// [`candidate`](Self::candidate) builds the maintained-population sub-DAG of one +/// root; Pass 1 does not offer it as a candidate yet (#681). /// The population is exact; max_k bounds the shared evaluation cache, not its members. pub struct MaintainedPopulationStrategy { roots: Vec>, @@ -311,25 +308,6 @@ impl MaintainedPopulationStrategy { )) } } -impl ReplacementStrategy for MaintainedPopulationStrategy { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - recognize(target.root).is_some() - } - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - self.candidate(target.root) - .map(|node| ReplacementSubDAG { - strategy: "MaintainedPopulationStrategy", - replacement: Replacement::SubDAG(node), - provenance: ReplacementProvenance::SummaryRealization, - rationale: - "share an exact maintained population across compatible aggregate evaluations" - .into(), - }) - .into_iter() - .collect() - } -} - #[cfg(test)] mod tests { use super::*; @@ -392,19 +370,6 @@ mod tests { .map(lower) .into(); let strategy = MaintainedPopulationStrategy::new(&roots); - let space = crate::search_workload_with( - roots - .iter() - .enumerate() - .map(|(i, r)| (i, Rc::clone(r))) - .collect(), - &[Box::new(MaintainedPopulationStrategy::new(&roots))], - ) - .unwrap(); - assert!(space - .target_subdag_candidates() - .flat_map(|g| &g.candidates) - .any(|c| c.strategy == "MaintainedPopulationStrategy")); let plans = share_common_sub_dags( roots .iter() diff --git a/crates/asap-aware-mapping/src/pass/mod.rs b/crates/asap-aware-mapping/src/pass/mod.rs index 66f1ab1c..e32e3bfd 100644 --- a/crates/asap-aware-mapping/src/pass/mod.rs +++ b/crates/asap-aware-mapping/src/pass/mod.rs @@ -2,8 +2,7 @@ //! //! An [`OptimizationPass`] is the whole optimization stage behind one //! signature: pre-ASAP IR in, post-ASAP DAG out. The trait deliberately names -//! none of this crate's two-phase vocabulary — no `CandidateLogicalASAPDAGs`, no -//! `TargetSubDAGCandidates`, no `ReplacementStrategy` — so an algorithm with no +//! none of the stages' candidate types, so an algorithm with no //! candidate-generation phase at all (a greedy MQO loop, say) can implement it //! without pretending to have phases it does not have. The shipped algorithm is //! one implementation, [`Stage1ToStage3Pass`]: the planning stages in order @@ -30,7 +29,6 @@ use asap_types::workload::WorkloadError; use crate::accuracy::{ AccuracyEvidenceProvider, AccuracyModel, DefaultAccuracyModel, NoAccuracyEvidence, }; -use crate::cost_model::{CostModel, DefaultCostModel}; use crate::deployment_capabilities::DeploymentCapabilities; use crate::per_computation_candidates::PerComputationCandidateError; use crate::stage3_plan_selection::{PlanSelectionSettings, Selection, SelectionError}; @@ -41,7 +39,6 @@ pub use stage1_to_stage3::{ Stage1Candidate, Stage1ToStage3Pass, Stage1ToStage3Run, }; -static DEFAULT_COST_MODEL: DefaultCostModel = DefaultCostModel; static DEFAULT_ACCURACY_MODEL: DefaultAccuracyModel = DefaultAccuracyModel; static NO_ACCURACY_EVIDENCE: NoAccuracyEvidence = NoAccuracyEvidence; static DEFAULT_PLAN_SELECTION: std::sync::LazyLock = @@ -55,15 +52,18 @@ static DEFAULT_DEPLOYMENT: std::sync::LazyLock = /// a built-in default, evidence about a particular deployment cannot. /// /// Plan selection reads `accuracy`, `evidence`, `plan_selection` and -/// `deployment_capabilities`. It measures candidates with its own -/// analytical model and compares them under `plan_selection`'s cost model, -/// so [`Stage1ToStage3Pass`] does not read `cost`. Stage 1 and Stage 2 read -/// `deployment_capabilities` too. +/// `deployment_capabilities`: it checks each candidate's guarantee with +/// `accuracy` and `evidence`, measures it with its own analytical model and +/// compares the candidates under `plan_selection`'s cost model. Stage 1 and +/// Stage 2 read `deployment_capabilities` too. #[derive(Clone, Copy)] #[non_exhaustive] pub struct PlanningModels<'a> { - pub cost: &'a dyn CostModel, + /// Decides whether a candidate's accuracy guarantee meets its query's + /// target; built-in [`DefaultAccuracyModel`] by default. pub accuracy: &'a dyn AccuracyModel, + /// Deployment facts the guarantees need (operand domains, observed + /// guarantees); [`NoAccuracyEvidence`] by default. pub evidence: &'a dyn AccuracyEvidenceProvider, /// The deployment's plan selection settings: the cost model (default /// cost model 1 with illustrative unit costs), the resource speed and @@ -77,13 +77,13 @@ pub struct PlanningModels<'a> { } impl<'a> PlanningModels<'a> { + /// These accuracy and evidence models with the default plan selection + /// settings. pub fn new( - cost: &'a dyn CostModel, accuracy: &'a dyn AccuracyModel, evidence: &'a dyn AccuracyEvidenceProvider, ) -> Self { Self { - cost, accuracy, evidence, plan_selection: &DEFAULT_PLAN_SELECTION, @@ -94,7 +94,6 @@ impl<'a> PlanningModels<'a> { /// The built-in models. pub fn builtin() -> PlanningModels<'static> { PlanningModels { - cost: &DEFAULT_COST_MODEL, accuracy: &DEFAULT_ACCURACY_MODEL, evidence: &NO_ACCURACY_EVIDENCE, plan_selection: &DEFAULT_PLAN_SELECTION, @@ -102,11 +101,6 @@ impl<'a> PlanningModels<'a> { } } - pub fn with_cost(mut self, cost: &'a dyn CostModel) -> Self { - self.cost = cost; - self - } - pub fn with_accuracy(mut self, accuracy: &'a dyn AccuracyModel) -> Self { self.accuracy = accuracy; self diff --git a/crates/asap-aware-mapping/src/per_computation_candidates.rs b/crates/asap-aware-mapping/src/per_computation_candidates.rs index 894cdb52..d95d30b9 100644 --- a/crates/asap-aware-mapping/src/per_computation_candidates.rs +++ b/crates/asap-aware-mapping/src/per_computation_candidates.rs @@ -120,7 +120,7 @@ use crate::accuracy::composition::{ use crate::accuracy::estimators::{hll, univmon}; use crate::accuracy::{per_input_target, AccuracyModel, DefaultAccuracyModel, PropagationStats}; use crate::deployment_capabilities::DeploymentCapabilities; -use crate::function_rules::function_rules; +use crate::function_rules::approximate_input_composition; use crate::realization::{ accuracy_budget, accuracy_target, default_size_params, summary_candidates, }; @@ -839,9 +839,7 @@ fn add_whole_expression_targets( fn whole_expression_input( target: &OperatorNode, ) -> Option<(Rc, SummaryUpdate, Reduction)> { - use crate::realization::{ - realize_keyed_additive_summary_input, PhysicalSummaryInputRuleResult, - }; + use crate::realization::realize_keyed_additive_summary_input; let Some(NonASAPOp::Aggregate { child, reduction, @@ -874,11 +872,7 @@ fn whole_expression_input( ), GroupingStrategy::default(), ); - let PhysicalSummaryInputRuleResult::Realized(realized) = - realize_keyed_additive_summary_input(intent, &family, reduction, child) - else { - return None; - }; + let realized = realize_keyed_additive_summary_input(intent, &family, reduction, child)?; let mut input = realized.input; let per_series = matches!( child.non_asap(), @@ -1361,7 +1355,7 @@ fn with_guarantees( /// inputs; a summary estimate has its sized family's bound when /// [`estimate_has_bound`], composed with its input's when that is /// approximate; an exact aggregation or accumulator over approximate values -/// uses its function's rule ([`function_rules`]); arithmetic composes as +/// uses its function's rule ([`approximate_input_composition`]); arithmetic composes as /// [`arithmetic_guarantee`]. A rule that needs a statistic leaves it /// symbolic (`BoundExpr::Unknown`), and plan selection (Stage 3) binds it. /// `None` where no rule applies: a comparison, a value `HAVING` or any other @@ -1495,7 +1489,7 @@ fn composed_guarantee(node: &OperatorNode) -> Option { match measures.as_slice() { [intent] => DefaultAccuracyModel .propagate( - &function_rules(intent)?.accuracy, + &approximate_input_composition(intent)?, &[operand_guarantee(child)?], None, &fold_stats(reduction, child)?, @@ -3091,9 +3085,9 @@ fn measure_input( })? .1, ), - _ => crate::realization::summarised_input(intent, schema).map_err(|_| { - PerComputationCandidateError::Unsupported("input column outside child schema") - })?, + _ => crate::realization::summarised_input(intent, schema).ok_or( + PerComputationCandidateError::Unsupported("input column outside child schema"), + )?, }; if let SummaryInputExpr::Column(column) = &read { resolve_column_ref(column, schema).map_err(|_| { @@ -4723,4 +4717,368 @@ mod tests { ); assert_eq!(none.combinations(), 1); } + + /// Mergeable intents give exactly their accumulator (`avg`: sum, count); other exact-only intents give `Exact`. + #[test] + fn intent_candidate_matrix() { + use asap_types::pre_asap::agg_intent::{MathFunc, TimeFunc}; + use AggIntent as A; + use ExactKind as E; + let accumulator = |kinds: Vec| ComputationCandidate::ExactAccumulator(kinds); + let exact_count = A::Count { + accuracy: AccuracyTarget::exact(), + }; + let mut matrix: Vec<(A, ComputationChoices)> = [ + (A::Min { col: None }, vec![E::Min]), + (A::Max { col: None }, vec![E::Max]), + (A::IRate, vec![E::IRate]), + (A::Increase, vec![E::Increase]), + (A::Sum { col: None }, vec![E::Sum]), + (A::Rate, vec![E::Rate]), + (A::Avg { col: None }, vec![E::Sum, E::Count]), + ] + .into_iter() + .map(|(intent, kinds)| { + ( + intent, + ComputationChoices { + candidates: vec![accumulator(kinds)], + pruned: vec![], + }, + ) + }) + .collect(); + // An exact count prunes its summaries for requiring an exact answer. + matrix.push(( + exact_count.clone(), + ComputationChoices { + candidates: vec![accumulator(vec![E::Count])], + pruned: summary_candidates(&exact_count) + .iter() + .map(|a| { + ( + ComputationCandidate::Summary(a.clone()), + PruneReason::RequiresExact, + ) + }) + .collect(), + }, + )); + let exact_only = [ + A::PearsonCorr { left: 0, right: 1 }, + A::Extension { + ext_kind: "frequency".into(), + payload: serde_json::json!({ "item": "checkout" }), + }, + A::HistogramQuantile { q: 0.99, le: 0 }, + A::Changes, + A::Delta, + A::IDelta, + A::Deriv, + A::Resets, + A::PredictLinear { seconds: 60.0 }, + A::DoubleExpSmoothing { + smoothing: 0.5, + trend: 0.5, + }, + A::HistogramCount, + A::HistogramSum, + A::HistogramAvg, + A::HistogramStdDev, + A::HistogramStdVar, + A::HistogramFraction { + lower: 0.0, + upper: 1.0, + }, + A::Math(MathFunc::Abs), + A::TimeFn(TimeFunc::Hour), + A::Absent, + A::AbsentOverTime, + A::PresentOverTime, + A::Group, + A::CountValues { label: "v".into() }, + A::LastOverTime, + A::FirstOverTime, + A::MadOverTime, + A::TsOfMinOverTime, + A::TsOfMaxOverTime, + A::TsOfFirstOverTime, + A::TsOfLastOverTime, + ]; + matrix.extend(exact_only.into_iter().map(|intent| { + ( + intent, + ComputationChoices { + candidates: vec![ComputationCandidate::Exact], + pruned: vec![], + }, + ) + })); + for (intent, expected) in &matrix { + assert_eq!( + &computation_candidates_for_intent(intent).unwrap(), + expected, + "{intent:?}" + ); + } + // Correlation never gets a single-input summary. + assert!(summary_candidates(&A::PearsonCorr { left: 0, right: 1 }).is_empty()); + } + + /// `avg_over_time` divides with a finite-division guard; a grouped `avg` does not. + #[test] + fn temporal_average_divides_with_a_finite_division_guard() { + let division_guard = |query: &str| { + let root = inventory_root(query); + let divisions: Vec = OperatorNode::reachable(&root) + .iter() + .filter_map(|node| match node.non_asap() { + Some(NonASAPOp::BinaryOp { operator, .. }) + if operator.kind == BinaryOpKind::Arithmetic(ArithmeticOpKind::Div) => + { + Some(operator.checked_finite_division) + } + _ => None, + }) + .collect(); + let [guarded] = divisions[..] else { + panic!("{query}: one sum / count division: {root:#?}") + }; + guarded + }; + assert!(division_guard("avg_over_time(a[5m])")); + assert!(!division_guard("avg by (job) (m)")); + } + + /// The candidates of `query` lowered at `accuracy`, over rows without the + /// series-identity column (open PromQL schemas): over identity-carrying + /// (closed) rows a grouped top-k gets no whole-expression heap. + fn inventory_at(query: &str, accuracy: AccuracyTarget) -> PerComputationCandidates { + let root = crate::test_support::lower_promql(query, accuracy); + generate_per_computation_candidates(vec![(0, QueryRoot::Operator(root))]).unwrap() + } + + fn open_inventory(query: &str) -> PerComputationCandidates { + inventory_at( + query, + AccuracyTarget::max_error(0.01).failure_probability(0.001), + ) + } + + /// The estimate and state of the top-k's fused whole-expression heap of + /// `algorithm`. + fn fused_heap( + inventory: &PerComputationCandidates, + algorithm: SketchAlgorithm, + ) -> (SketchStatistic, Rc) { + let topk = topk_target(inventory); + let own = inventory.targets[topk].choices.candidates.len(); + let (_, root) = composed_with(inventory, topk) + .into_iter() + .skip(own) + .find(|(candidate, _)| *candidate == ComputationCandidate::Summary(algorithm.clone())) + .unwrap_or_else(|| panic!("a fused {algorithm:?} candidate")); + let [state] = &heap_states(&root)[..] else { + panic!("one heap: {root:#?}") + }; + let estimate = OperatorNode::reachable(&root) + .into_iter() + .find_map(|node| match node.asap() { + Some(ASAPOp::SummaryEstimate { + summary_input, + query, + }) if Rc::ptr_eq(summary_input, state) => Some(query.clone()), + _ => None, + }) + .expect("the heap's estimate"); + (estimate, state.clone()) + } + + fn heap_update(state: &OperatorNode) -> &SummaryUpdate { + match state.asap() { + Some(ASAPOp::SummaryAgg { input, .. }) => input, + _ => unreachable!(), + } + } + + /// A top-k over a `count` fuses into one global unit-weight heap over the raw rows, ranking the label. + #[test] + fn count_ranked_topk_fuses_to_one_global_heap_sketch() { + let inventory = open_inventory("topk(10, count by (service) (m))"); + let topk = topk_target(&inventory); + let fused = inventory.targets[topk] + .fused + .as_ref() + .expect("a fused heap"); + assert!(fused + .candidates + .contains(&ComputationCandidate::Summary(SketchAlgorithm::CmsWithHeap))); + let (estimate, state) = fused_heap(&inventory, SketchAlgorithm::CmsWithHeap); + assert_eq!(estimate, SketchStatistic::TopK { k: 10 }); + let update = heap_update(&state); + assert_eq!( + update.item, + Some(SummaryInputExpr::Column(ColumnRef::Named("service".into()))) + ); + assert_eq!(update.weight, SummaryInputExpr::Constant(1.0)); + assert_eq!( + update.weight_domain, + WeightDomain::NonNegative { + proof: NonNegativeWeightProof::UnitCount, + } + ); + let Some(ASAPOp::SummaryAgg { child, .. }) = state.asap() else { + unreachable!() + }; + assert!(!child.contains_asap()); + } + + /// The ranked item over several group keys is their tuple, in the lowered (name-sorted) key order. + #[test] + fn multidimensional_ranked_item_is_a_structured_tuple() { + let inventory = open_inventory("topk(10, count by (service, region) (m))"); + let (_, state) = fused_heap(&inventory, SketchAlgorithm::CmsWithHeap); + let column = |name: &str| SummaryInputExpr::Column(ColumnRef::Named(name.into())); + assert_eq!( + heap_update(&state).item, + Some(SummaryInputExpr::Tuple(vec![ + column("region"), + column("service") + ])) + ); + } + + /// The top-k's own partition keys partition the heap and are left out of the ranked tuple. + #[test] + fn subpopulation_columns_are_not_part_of_the_ranked_item_tuple() { + let inventory = + open_inventory("topk by (region) (10, count by (service, method, region) (m))"); + let (_, state) = fused_heap(&inventory, SketchAlgorithm::CmsWithHeap); + let column = |name: &str| SummaryInputExpr::Column(ColumnRef::Named(name.into())); + assert_eq!( + heap_update(&state).item, + Some(SummaryInputExpr::Tuple(vec![ + column("method"), + column("service") + ])) + ); + let Some(ASAPOp::SummaryAgg { + child, reduction, .. + }) = state.asap() + else { + unreachable!() + }; + let keys: Vec<_> = reduction + .group_keys() + .expect("partitioned by region") + .keys() + .iter() + .map(|&k| child.schema.fields[k].name.as_str()) + .collect(); + assert_eq!(keys, ["region"]); + } + + /// A per-series top-k ranks series identity (column, else label set) weighted by sample value. + #[test] + fn temporal_per_entity_topk_uses_series_identity_and_sample_value() { + let query = "topk by (service) (5, sum_over_time(m[1m]))"; + let with_identity = inventory(query); + let without_identity = open_inventory(query); + for (inventory, item) in [ + ( + with_identity, + SummaryInputExpr::Column(ColumnRef::Named( + asap_types::pre_asap::schema::PROMQL_SERIES_IDENTITY.into(), + )), + ), + ( + without_identity, + SummaryInputExpr::EntityIdentity(EntityIdentity::PromqlLabelSet { + excluding: vec![ColumnRef::Named("service".into())], + }), + ), + ] { + let (_, state) = fused_heap(&inventory, SketchAlgorithm::CountSketchWithHeap); + let update = heap_update(&state); + assert_eq!(update.item, Some(item)); + assert_eq!( + update.weight, + SummaryInputExpr::Column(ColumnRef::SampleValue) + ); + } + } + + /// A KLL quantile over an exact sum accumulator composes per node, the sum adding no error. + #[test] + fn nested_quantile_over_an_exact_sum_composes_per_node() { + let sum = ComputationCandidate::ExactAccumulator(vec![ExactKind::Sum]); + let kll = ComputationCandidate::Summary(SketchAlgorithm::Kll); + let inventory = inventory_at( + "quantile(0.9, sum by (job) (m))", + AccuracyTarget::max_error(0.01).failure_probability(0.01), + ); + let choice: Vec = inventory + .targets + .iter() + .map(|target| { + let candidates = &target.choices.candidates; + if candidates.contains(&kll) { + position_of(candidates, kll.clone()) + } else { + position_of(candidates, sum.clone()) + } + }) + .collect(); + let composed = compose_logical_candidate(&inventory, &choice).unwrap(); + let QueryRoot::Operator(root) = &composed[0].1 else { + panic!("operator root") + }; + let estimate = OperatorNode::reachable(root) + .into_iter() + .find(|node| matches!(node.asap(), Some(ASAPOp::SummaryEstimate { .. }))) + .expect("a quantile estimate"); + let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = estimate.asap() else { + unreachable!() + }; + let Some(ASAPOp::SummaryAgg { child, family, .. }) = summary_input.asap() else { + panic!("a KLL state: {summary_input:#?}") + }; + let FieldDataType::Sketch(kind, _) = family else { + panic!("a sketch: {family:?}") + }; + assert_eq!(kind.algorithm(), &SketchAlgorithm::Kll); + assert_eq!( + kind.params(), + &SketchParams::Kll { + k: 269, + failure_probability: None + } + ); + let Some(ASAPOp::FinalizeExactAccumulator { child: sum_state }) = child.asap() else { + panic!("the finalized sum: {child:#?}") + }; + let Some(ASAPOp::SummaryAgg { + family: sum_family, + child: leaf, + .. + }) = sum_state.asap() + else { + panic!("the sum's accumulator: {sum_state:#?}") + }; + assert_eq!( + sum_family, + &FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) + ); + assert!(!leaf.contains_asap()); + assert!(child + .guarantee + .as_ref() + .is_some_and(AccuracyGuarantee::is_exact)); + let guarantee = root.guarantee.as_ref().expect("a rank bound"); + assert_eq!(guarantee.spec.accuracy_metric, AccuracyMetric::Rank); + assert_eq!( + guarantee.spec.error.evaluate(), + Some(crate::accuracy::estimators::kll::kll_rank_error_99(269)) + ); + } } diff --git a/crates/asap-aware-mapping/src/realization.rs b/crates/asap-aware-mapping/src/realization.rs index f2e6d52c..e7fb4f81 100644 --- a/crates/asap-aware-mapping/src/realization.rs +++ b/crates/asap-aware-mapping/src/realization.rs @@ -1,13 +1,10 @@ //! Summary realization helpers shared by Pass 1 (`per_computation_candidates`) -//! and the legacy replacement search: which sketch kinds can serve an intent, +//! and Pass 2 (`asap_aware_cse`): which sketch kinds can serve an intent, //! the intent's accuracy target and `(ε, δ)` budget, default sketch sizing, //! the column/tuple a summary consumes, the column a top-k ranks, and the //! keyed-additive rule for a heap sketch that reads a larger sub-DAG. -use asap_types::ir::{ - ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr, SchemaDerivationError, -}; -use asap_types::post_asap::{AccuracyError, ExactOperationSchemaError, ExecutionDataStateError}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; use asap_types::post_asap::{ EntityIdentity, Field, FieldDataType, NonNegativeWeightProof, Schema, SketchAlgorithm, SketchParams, SummaryInputExpr, SummaryUpdate, WeightDomain, @@ -19,7 +16,6 @@ use asap_types::pre_asap::schema::ColumnId; use asap_types::pre_asap::Reduction; use asap_types::types::{AccuracyMetric, AccuracyTarget}; use std::rc::Rc; -use thiserror::Error; /// Confidence δ assumed when the target carries only an ε /// (`AccuracyTarget::epsilon`): the (ε, δ)-parameterised sketches (CMS) need @@ -27,9 +23,8 @@ use thiserror::Error; pub const DEFAULT_DELTA: f64 = 0.01; /// The sketch kinds that can serve an intent, most-preferred first. -/// This is the `AggIntent → SketchAlgorithm` map of issue #98; -/// [`realizations_for_intent`] sizes and ranks every entry via `cost_model`. -/// Listed here so the candidate set has one home. +/// This is the `AggIntent → SketchAlgorithm` map of issue #98, read by +/// Pass 1's summary choices. Listed here so the candidate set has one home. pub fn summary_candidates(intent: &AggIntent) -> &'static [SketchAlgorithm] { match intent { AggIntent::Quantile { .. } => &[SketchAlgorithm::Kll, SketchAlgorithm::DDSketch], @@ -68,8 +63,7 @@ pub fn summary_candidates(intent: &AggIntent) -> &'static [SketchAlgorithm] { /// The [`AccuracyTarget`] an approximate-capable intent's summary is sized /// and checked against, or `None` for every other intent (no sketch -/// candidate applies — [`realizations_for_intent`]'s own match routes those -/// elsewhere). It is the query's target restated in the intent's own metric +/// candidate applies). It is the query's target restated in the intent's own metric /// (#690 §4): an L1-scaled absolute error for counts and top-k frequencies, /// a relative error for distinct counts and L2 frequency moments, an /// absolute error for entropy, and for a quantile the query's metric or a @@ -103,13 +97,11 @@ pub(crate) fn target_in(target: &AccuracyTarget, metric: AccuracyMetric) -> Accu } /// Resolve an [`AccuracyTarget`] into the `(eps, delta)` budget -/// [`CostModel::size_params`] needs. Shared by [`sketch_realizations`] and -/// this crate's own sizing — one place this resolution happens, so nothing -/// can drift apart on it. +/// [`default_size_params`] needs — one place this resolution happens, so +/// nothing can drift apart on it. /// -/// `Exact` is unreachable via [`realizations_for_intent`] (which routes -/// `Exact` to [`exact_realization`] instead); degrades to the tightest -/// parameters for a caller that resolves it directly anyway. +/// An exact target degrades to the tightest parameters for a caller that +/// resolves it anyway. pub fn accuracy_budget(accuracy: &AccuracyTarget) -> (f64, f64) { if accuracy.is_exact() { return (f64::MIN_POSITIVE, DEFAULT_DELTA); @@ -121,10 +113,7 @@ pub fn accuracy_budget(accuracy: &AccuracyTarget) -> (f64, f64) { } /// `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 @@ -226,14 +215,14 @@ pub(crate) fn column_ref(column: &Field) -> ColumnRef { /// /// A tuple leg outside the child schema is an error rather than /// [`summarised_column`]'s sample-value fallback: a leg has no sample-value -/// reading, and silently dropping one would under-count. +/// reading, and silently dropping one would under-count: `None` then. pub(crate) fn summarised_input( intent: &AggIntent, child_schema: &Schema, -) -> Result { +) -> Option { let cols = intent.input_cols(); if cols.len() < 2 { - return Ok(SummaryInputExpr::Column(summarised_column( + return Some(SummaryInputExpr::Column(summarised_column( intent, child_schema, ))); @@ -241,50 +230,12 @@ pub(crate) fn summarised_input( let legs = cols .iter() .map(|id| child_schema.fields.get(*id).map(column_ref)) - .collect::>>() - .ok_or(RealizationError::PhysicalRealization( - "a tuple column is outside the input schema", - ))?; - Ok(SummaryInputExpr::Tuple( + .collect::>>()?; + Some(SummaryInputExpr::Tuple( legs.into_iter().map(SummaryInputExpr::Column).collect(), )) } -/// Errors from the pre-ASAP → post-ASAP replacement/construction path -/// (`realize_child` and `retain_exact`). Moved here from the former -/// `bind.rs` (issue #251): this is what a `ReplacementStrategy` -/// implementor's own construction path can realistically fail with — -/// schema derivation over a pre-ASAP [`OperatorNode`] sub-DAG — not -/// something specific to workload-wide orchestration. -#[derive(Debug, Error)] -pub enum RealizationError { - /// Schema derivation failed while lifting an edge to `Schema`. - #[error("schema derivation failed during pre-ASAP → post-ASAP binding: {0}")] - Schema(#[from] SchemaDerivationError), - /// The candidate is accuracy-illegal (issue #172): its composed - /// guarantee has no sound propagation rule, or misses the applicable - /// `AccuracyTarget`. Fail-closed — the candidate is never constructed - /// with the child "treated as exact". [`ASAPStrategies::propose`] - /// records it as a [`RejectedCandidate`] instead of a candidate. - #[error("accuracy-illegal candidate: {0}")] - Accuracy(#[from] AccuracyError), - /// The selected summary family has a physical realization rule for this - /// logical shape, but the rule cannot represent the complete input. The - /// candidate must not fall back to ordinary one-node binding because that - /// would change its semantics. - #[error("unsupported physical summary realization: {0}")] - PhysicalRealization(&'static str), - /// A constructed plan violates the update/evaluation phase contract - /// (issue #171) — e.g. a summary evaluation placed beneath a maintained - /// `SummaryAgg`. Detected at construction, never at runtime. - #[error("execution-data_state violation in post-ASAP plan: {0}")] - ExecutionDataState(#[from] ExecutionDataStateError), - /// An `ExactOperator`'s output schema could not be derived over its - /// child — the child carries summary state the operator can't read. - #[error("exact operator schema derivation failed: {0}")] - ExactOperationSchema(#[from] ExactOperationSchemaError), -} - /// The physical input consumed by one summary realization. Most summaries /// consume the logical aggregate's immediate child and summarize its declared /// input value. Composite realizations can instead consume a larger @@ -294,33 +245,29 @@ pub(crate) struct PhysicalSummaryInput { pub(crate) input: SummaryUpdate, } -pub(crate) enum PhysicalSummaryInputRuleResult { - NotApplicable, - Realized(PhysicalSummaryInput), - Unsupported(&'static str), -} - /// Realize the composite heavy-hitter realization for /// `TopK(Count GROUP BY key)`. The heap sketch consumes the raw keyed stream; -/// it does not consume an independently materialized Count result. +/// it does not consume an independently materialized Count result. `None` +/// when the rule does not apply or the shape is unsupported (each reason is +/// noted where it returns). pub(crate) fn realize_keyed_additive_summary_input( intent: &AggIntent, family: &FieldDataType, output_reduction: &Reduction, child: &Rc, -) -> PhysicalSummaryInputRuleResult { +) -> Option { if !matches!(intent, AggIntent::TopK { .. }) { - return PhysicalSummaryInputRuleResult::NotApplicable; + return None; } let FieldDataType::Sketch(kind, _) = family else { - return PhysicalSummaryInputRuleResult::NotApplicable; + return None; }; let heap_algorithm = kind.algorithm(); if !matches!( heap_algorithm, SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap ) { - return PhysicalSummaryInputRuleResult::NotApplicable; + return None; } let Some(NonASAPOp::Aggregate { reduction, @@ -331,12 +278,12 @@ pub(crate) fn realize_keyed_additive_summary_input( .. }) = child.non_asap() else { - return PhysicalSummaryInputRuleResult::NotApplicable; + return None; }; // The heap sketch consumes every raw row; a filtered measure counts only // some of them (#466). if asap_types::ir::non_asap::any_measure_filtered(filters) { - return PhysicalSummaryInputRuleResult::NotApplicable; + return None; } let counter_input = matches!(measures.as_slice(), [AggIntent::Sum { .. }]) && matches!(raw_child.non_asap(), Some(NonASAPOp::Aggregate { measures, .. }) @@ -351,13 +298,12 @@ pub(crate) fn realize_keyed_additive_summary_input( Some(index) => match schema_column_ref(raw_child, *index) { Some(column) => column, None => { - return PhysicalSummaryInputRuleResult::Unsupported( - "sum-ranked Top-K value column is outside the raw input schema", - ) + // Unsupported: sum-ranked Top-K value column is outside the raw input schema. + return None; } }, }), - _ => return PhysicalSummaryInputRuleResult::NotApplicable, + _ => return None, }; let weight_domain = match measures.as_slice() { [AggIntent::Count { .. }] => WeightDomain::NonNegative { @@ -371,9 +317,8 @@ pub(crate) fn realize_keyed_additive_summary_input( if matches!(heap_algorithm, SketchAlgorithm::CmsWithHeap) && !matches!(weight_domain, WeightDomain::NonNegative { .. }) { - return PhysicalSummaryInputRuleResult::Unsupported( - "value-weighted CMS requires non-negative update evidence; use CountSketch for arbitrary values", - ); + // Unsupported: value-weighted CMS requires non-negative update evidence; use CountSketch for arbitrary values. + return None; } let subpopulation_columns = match output_reduction { Reduction::PerEntity => vec![], @@ -392,9 +337,8 @@ pub(crate) fn realize_keyed_additive_summary_input( .map(|index| schema_column_ref(raw_child, *index)) .collect::>>() else { - return PhysicalSummaryInputRuleResult::Unsupported( - "ranked item column is outside the raw input schema", - ); + // Unsupported: ranked item column is outside the raw input schema. + return None; }; let item_columns: Vec<_> = columns .into_iter() @@ -402,9 +346,8 @@ pub(crate) fn realize_keyed_additive_summary_input( .collect(); match item_columns.as_slice() { [] => { - return PhysicalSummaryInputRuleResult::Unsupported( - "subpopulation columns consume the complete ranked item identity", - ) + // Unsupported: subpopulation columns consume the complete ranked item identity. + return None; } [column] => SummaryInputExpr::Column(column.clone()), _ => SummaryInputExpr::Tuple( @@ -416,12 +359,11 @@ pub(crate) fn realize_keyed_additive_summary_input( } } Reduction::Reduce(_) => { - return PhysicalSummaryInputRuleResult::Unsupported( - "an empty or without grouping does not identify ranked items", - ) + // Unsupported: an empty or without grouping does not identify ranked items. + return None; } }; - PhysicalSummaryInputRuleResult::Realized(PhysicalSummaryInput { + Some(PhysicalSummaryInput { child: Rc::clone(raw_child), input: SummaryUpdate { item: Some(item), @@ -441,3 +383,96 @@ pub(crate) fn schema_column_ref(child: &OperatorNode, index: usize) -> Option ColumnRef::Named(column.name.clone()), }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::metric_scan; + use asap_types::ir::{ExprSemantics, Predicate}; + use asap_types::post_asap::{GroupingStrategy, SketchKind}; + use asap_types::pre_asap::{CompareOpKind, ScalarValue}; + + /// A heap sketch consumes every raw row, so the keyed input rule refuses + /// a TopK over a filtered count. + #[test] + fn keyed_heap_input_refuses_a_filtered_count() { + let count_by_job = |filter: Option| { + OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![2]), + measures: vec![AggIntent::Count { + accuracy: AccuracyTarget::exact(), + }], + output_names: vec![], + filters: vec![filter], + having: None, + child: metric_scan(&["job"]), + })) + .unwrap() + }; + let intent = AggIntent::TopK { + k: 5, + accuracy: AccuracyTarget::max_error(0.01), + }; + let family = FieldDataType::Sketch( + SketchKind::new( + SketchAlgorithm::CmsWithHeap, + SketchParams::CmsWithHeap { + width: 64, + depth: 4, + heap_size: 5, + }, + ), + GroupingStrategy::default(), + ); + let output = Reduction::Reduce(asap_types::ir::operator_properties::GroupKeys::none()); + assert!(realize_keyed_additive_summary_input( + &intent, + &family, + &output, + &count_by_job(None) + ) + .is_some()); + let value_above_one = Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(1)), + op: CompareOpKind::Gt, + right: Box::new(ScalarExpr::Literal(ScalarValue::Float64(1.0))), + semantics: ExprSemantics::Promql, + }); + assert!(realize_keyed_additive_summary_input( + &intent, + &family, + &output, + &count_by_job(Some(value_above_one)) + ) + .is_none()); + } + + /// A summary reads the intent's own input columns: one column by name, + /// none as the sample value, several as one hashed tuple. + #[test] + fn summarised_input_reads_the_intents_columns() { + // `metric_scan(&["job"])` → columns [ts=0, value=1, job=2]. + let scan = metric_scan(&["job"]); + let column = |name: &str| SummaryInputExpr::Column(ColumnRef::Named(name.into())); + let cases = [ + (vec![2], column("job")), + (vec![1], column("value")), + (vec![], SummaryInputExpr::Column(ColumnRef::SampleValue)), + ( + vec![1, 2], + SummaryInputExpr::Tuple(vec![column("value"), column("job")]), + ), + ]; + for (cols, want) in cases { + let intent = AggIntent::Cardinality { + cols: cols.clone(), + accuracy: AccuracyTarget::max_error(0.01), + }; + assert_eq!( + summarised_input(&intent, &scan.schema), + Some(want), + "wrong summarised input for cols={cols:?}" + ); + } + } +} diff --git a/crates/asap-aware-mapping/src/recurrence.rs b/crates/asap-aware-mapping/src/recurrence.rs deleted file mode 100644 index 0cd36cd3..00000000 --- a/crates/asap-aware-mapping/src/recurrence.rs +++ /dev/null @@ -1,1525 +0,0 @@ -//! Recurrence-aware cost context (issue #287). -//! -//! ASAPPlanner already models recurring-workload metadata -//! ([`asap_types::workload::RepeatingEntry`]) and ingest-rate metadata -//! ([`asap_types::workload::DataWorkload`]), but until this module -//! neither reached [`CostModel`]'s CSE share-vs-recompute decision -//! ([`CostModel::cse_share_decision`]): that decision only ever compared a -//! *structural* consumer count (how many workload locations reference a -//! shared sub-DAG) against a flat per-family maintenance weight — it had no -//! notion of how *often* those consumers actually run. -//! -//! This module adds that notion as a generic cost context, not a scheduler: -//! no Prometheus rule-group semantics, no execution loop, no temporal-pane -//! boundary reasoning (see the module's own "Out of scope" list mirrored -//! from the issue). -//! -//! ## Units — every new cost input names its own unit explicitly -//! -//! | Type | Unit | Meaning | -//! |---|---|---| -//! | [`UpdateRate`] | Hz (updates/second) | how often the *raw* data underlying a maintained summary changes (ingest rate) | -//! | [`EvaluationRate`] | Hz (evaluations/second) | how often a target is *read* — `sum(1 / query_interval_i)` over every repeating consumer | -//! | [`CostRate`] | cost units / second | a steady-state cost rate — never comparable to a bare [`Cost`](crate::cost_model::Cost) without going through [`total_cost`] | -//! | [`Horizon`] | seconds | the explicit evaluation window a caller supplies to compare a rate-valued cost against a one-shot cost | -//! -//! `Cost` (bare, from [`crate::cost_model`]) stays a one-time, unitless -//! magnitude — exactly what it was before this module existed, preserved -//! for [`CostModel::cse_share_decision`] and everything else that already -//! uses it. `CostRate` is a *new*, distinct type specifically so a rate and -//! a one-shot cost can never be added directly (no `impl Add for -//! CostRate`, and vice versa) — the compiler enforces the issue's "must not -//! silently combine rate-valued and one-shot costs" requirement; [`total_cost`] -//! is the one sanctioned way to combine them, and it takes an explicit -//! [`Horizon`] to do it. -//! -//! ## Cost semantics (from the issue) -//! -//! For a maintained summary: -//! -//! ```text -//! maintained_cost_rate = -//! update_rate * maintenance_cost_per_update -//! + evaluation_rate * summary_read_cost -//! ``` -//! -//! For recomputation from the pre-ASAP/raw path: -//! -//! ```text -//! recompute_cost_rate = evaluation_rate * raw_recompute_cost -//! ``` -//! -//! For a summary shared by multiple repeating consumers with intervals -//! `t1..tn`: -//! -//! ```text -//! evaluation_rate = sum(1 / query_interval_i) -//! ``` -//! ([`evaluation_rate_of`].) -//! -//! Repetition amortizes a maintained summary across more reads; it does -//! *not* reduce the physical maintenance work caused by ingest updates — -//! that's why `update_rate` and `evaluation_rate` are two separate terms in -//! `maintained_cost_rate` above, never folded into one. -//! -//! One-shot consumers are represented separately from a steady-state rate -//! ([`RecurrenceProfile::one_shot_consumers`]). Comparing a mix of one-shot -//! and repeating work requires an explicit evaluation horizon `H`: -//! -//! ```text -//! total_cost(H) = recurring_cost_rate * H + one_shot_cost -//! ``` -//! ([`total_cost`].) -//! -//! ## Provenance of each new cost input -//! -//! - [`EvaluationRate`]: derived from [`asap_types::workload::RepeatingEntry::demand`] -//! values of every repeating consumer reaching a target (via -//! [`evaluation_rate_of`], or [`crate::replacement::CandidateLogicalASAPDAGs::recurrence_profiles`] -//! for a whole workload). A one-shot ([`asap_types::workload::BatchEntry`]) -//! consumer contributes to [`RecurrenceProfile::one_shot_consumers`] -//! instead, never to this rate. -//! - [`UpdateRate`]: read from the workload-level arrival's -//! [`asap_types::workload::DataArrival::rows_per_second`] via -//! [`update_rate_from_data_workload`]. Missing evidence remains unknown; -//! data at rest is not assigned a fabricated update rate. -//! - `maintenance_cost_per_update` / `summary_read_cost` / -//! `raw_recompute_cost`: [`CostModel`] hooks (defaults documented on the -//! trait itself, in `cost_model.rs`) — illustrative placeholders, like -//! every other default in that trait; a deployment with real numbers -//! overrides them. -//! -//! ## Preserving existing behavior when recurrence metadata is unavailable -//! -//! [`RecurrenceProfile::is_empty`] is `true` exactly when a caller supplied -//! no [`RepeatingEntry`](asap_types::workload::RepeatingEntry)/ -//! [`DataWorkload`](asap_types::workload::DataWorkload)-derived -//! information at all (no evaluation rate, no update rate, zero recorded -//! one-shot consumers — [`RecurrenceProfile::EMPTY`], its `Default`). -//! [`CostModel::cse_share_decision_with_recurrence`]'s default body checks -//! this first and, when true, delegates to -//! [`CostModel::cse_share_decision`] byte-for-byte — the existing, -//! structural-consumer-count decision this module never has to touch or -//! second-guess when there's nothing new to feed it. - -use std::fmt; - -use asap_types::workload::{DataWorkload, RepetitionInterval}; - -use crate::cost_model::{Cost, CostModel, CseCandidate, ShareDecision}; - -// ── Units ──────────────────────────────────────────────────────────────── - -/// How often the *raw* data underlying a maintained summary changes — -/// ingest/update events per second (Hz). See the module docs' provenance -/// table: normally read from the arrival's `rows_per_second` via -/// [`update_rate_from_data_workload`]. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct UpdateRate(pub f64); - -/// How often a target is *evaluated* by its consumers — evaluations per -/// second (Hz). For a summary shared by repeating consumers with intervals -/// `t1..tn`, `sum(1 / t_i)` ([`evaluation_rate_of`]); a one-shot consumer -/// never contributes to this rate (see [`RecurrenceProfile::one_shot_consumers`]). -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct EvaluationRate(pub f64); - -/// A steady-state cost rate: cost units per second. Deliberately a -/// different type from [`Cost`] (a one-time, unitless magnitude) — there is -/// no `impl Add for CostRate` on purpose, so a rate and a one-shot -/// cost can never be combined except explicitly, through [`total_cost`]. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct CostRate(pub f64); - -impl CostRate { - /// A cost rate of exactly zero (no ongoing cost at all). - pub const ZERO: CostRate = CostRate(0.0); -} - -impl fmt::Display for CostRate { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}/s", self.0) - } -} - -impl std::ops::Add for CostRate { - type Output = CostRate; - fn add(self, rhs: CostRate) -> CostRate { - CostRate(self.0 + rhs.0) - } -} - -/// An explicit evaluation horizon, in seconds — the only input that lets a -/// [`CostRate`] be combined with a one-shot [`Cost`] (via [`total_cost`]). -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct Horizon(pub f64); - -/// The one sanctioned way to combine a steady-state [`CostRate`] with a -/// one-shot [`Cost`]: `rate * horizon + one_shot`. There is no other path -/// in this module (or in [`CostModel`]) that adds a `CostRate` to a `Cost` -/// — every other cost value stays in exactly one of the two units, -/// enforced by the type system, satisfying issue #287's "the cost model -/// must not silently combine rate-valued and one-shot costs" requirement. -pub fn total_cost(rate: CostRate, horizon: Horizon, one_shot: Cost) -> Cost { - Cost(rate.0 * horizon.0 + one_shot.0) -} - -// ── Errors ─────────────────────────────────────────────────────────────── - -/// Errors from building or applying recurrence-aware cost inputs. -#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)] -pub enum RecurrenceError { - /// A [`RepetitionInterval`] of zero (or, in principle, any non-positive - /// value — `RepetitionInterval` is `u32`-backed so only zero is - /// representable) was supplied. A zero interval has no finite rate - /// (`1 / 0`), so it cannot contribute to an [`EvaluationRate`]. - #[error( - "invalid RepetitionInterval({0:?}ms): a repeating query's interval must be > 0 to \ - contribute a finite evaluation rate" - )] - InvalidInterval(RepetitionInterval), - /// A comparison mixed one-shot and repeating work - /// ([`RecurrenceProfile::one_shot_consumers`] > 0 alongside a non-empty - /// [`RecurrenceProfile::evaluation_rate`] or [`RecurrenceProfile::update_rate`]) - /// without an explicit [`Horizon`] to combine them — see [`total_cost`] - /// and the module docs' "Cost semantics" section. - #[error( - "comparison mixes one-shot and repeating work but no evaluation horizon was supplied; \ - an explicit Horizon is required to combine a CostRate with a one-shot Cost (see \ - recurrence::total_cost)" - )] - MissingHorizon, - /// An [`UpdateRate`] that isn't finite and non-negative (NaN, infinite, - /// or negative) was supplied — such a value would silently corrupt - /// every downstream comparison (a NaN rate makes every `<=`/`>` - /// comparison `false`, which [`decide`] would otherwise read as "always - /// recompute" with no diagnostic at all). Validated the same way - /// [`evaluation_rate_of`] validates a zero [`RepetitionInterval`]. - #[error( - "invalid UpdateRate({0:?}Hz): an update rate must be finite and >= 0 to contribute a \ - well-defined maintained_cost_rate" - )] - InvalidUpdateRate(UpdateRate), - #[error("invalid EvaluationRate({0:?}Hz): an evaluation rate must be finite and >= 0")] - InvalidEvaluationRate(EvaluationRate), - #[error(transparent)] - InvalidWorkload(#[from] asap_types::workload::WorkloadError), - #[error("workload entry index {index} is out of bounds for {entry_count} entries")] - InvalidWorkloadEntry { index: usize, entry_count: usize }, - /// A [`Horizon`] that isn't finite and strictly positive (NaN, - /// infinite, zero, or negative) was supplied — a non-positive or - /// infinite horizon would silently drop or invert the recurring - /// `CostRate` term in [`total_cost`], exactly the "silently combine" - /// outcome [`MissingHorizon`](Self::MissingHorizon) exists to prevent. - #[error( - "invalid Horizon({0:?}s): an evaluation horizon must be finite and > 0 to combine a \ - CostRate with a one-shot Cost without distorting the comparison" - )] - InvalidHorizon(Horizon), - /// [`crate::replacement::CandidateLogicalASAPDAGs::recurrence_profiles`] was called - /// with a `root_recurrence` slice whose length doesn't match the - /// `CandidateLogicalASAPDAGs`'s own root count — a caller error, but recoverable - /// (this method's whole signature promises a `Result`, so this is - /// reported the same way every other input-validation failure is, - /// never a panic). - #[error( - "recurrence_profiles: root_recurrence must have one entry per root, in the same order \ - CandidateLogicalASAPDAGs::roots is in (got {got} entries for {expected} roots)" - )] - RootCountMismatch { - /// `CandidateLogicalASAPDAGs::roots.len()`. - expected: usize, - /// `root_recurrence.len()`. - got: usize, - }, -} - -/// Reject a non-finite or negative [`UpdateRate`] — the same validation -/// discipline [`evaluation_rate_of`] applies to each [`RepetitionInterval`], -/// applied at every point an `UpdateRate` enters a [`RecurrenceProfile`] -/// ([`RecurrenceProfile::with_update_rate`], -/// [`update_rate_from_data_workload`], -/// [`crate::replacement::CandidateLogicalASAPDAGs::recurrence_profiles`]'s own parameter) -/// *and*, as a backstop that can't be bypassed by constructing a -/// `RecurrenceProfile` via its public fields directly, inside [`decide`] -/// itself before any comparison uses it. -pub fn validate_update_rate(rate: UpdateRate) -> Result { - if rate.0.is_finite() && rate.0 >= 0.0 { - Ok(rate) - } else { - Err(RecurrenceError::InvalidUpdateRate(rate)) - } -} - -/// Reject a non-finite or non-positive [`Horizon`] — validated inside -/// [`decide`] wherever a caller-supplied `horizon` is used, so `Horizon(0.0)` -/// or a negative horizon can't silently zero out or invert the recurring -/// `CostRate` term in [`total_cost`]. -pub fn validate_horizon(horizon: Horizon) -> Result { - if horizon.0.is_finite() && horizon.0 > 0.0 { - Ok(horizon) - } else { - Err(RecurrenceError::InvalidHorizon(horizon)) - } -} - -// ── Aggregation ────────────────────────────────────────────────────────── - -/// `sum(1 / interval_i)`, converted from milliseconds -/// ([`RepetitionInterval`]'s own unit) to Hz, over every repeating -/// consumer's interval. `Ok(None)` when `intervals` is empty — "no -/// repeating consumers observed", distinct from "observed consumers whose -/// rate happens to be zero" (impossible: every valid interval contributes a -/// strictly positive rate). `Err` on the first zero interval encountered. -pub fn evaluation_rate_of(intervals: I) -> Result, RecurrenceError> -where - I: IntoIterator, -{ - let mut total_hz = 0.0; - let mut any = false; - for interval in intervals { - if interval.0 == 0 { - return Err(RecurrenceError::InvalidInterval(interval)); - } - any = true; - // RepetitionInterval is in milliseconds; Hz = 1000 / ms. - total_hz += 1000.0 / f64::from(interval.0); - } - Ok(any.then_some(EvaluationRate(total_hz))) -} - -/// Read an [`UpdateRate`] from workload-level [`DataWorkload`] evidence. -/// Missing evidence remains `None`; a present non-finite or negative rate is -/// rejected rather than propagated into a [`RecurrenceProfile`]. -pub fn update_rate_from_data_workload( - workload: &DataWorkload, -) -> Result, RecurrenceError> { - workload - .arrival - .rows_per_second() - .map(|rate| validate_update_rate(UpdateRate(rate))) - .transpose() -} - -// ── RecurrenceProfile ──────────────────────────────────────────────────── - -/// Aggregated recurrence context for one [`CseCandidate`]'s shared target: -/// how fast it's evaluated, how many one-shot consumers reference it -/// separately, and how fast its underlying raw data updates. -/// -/// [`RecurrenceProfile::EMPTY`] (also its `Default`) represents "no -/// recurrence metadata available at all" — the case -/// [`CostModel::cse_share_decision_with_recurrence`]'s default body -/// recognizes via [`is_empty`](Self::is_empty) and treats as "preserve -/// existing (structural) behavior". -#[derive(Debug, Clone, Copy, PartialEq, Default)] -pub struct RecurrenceProfile { - /// `sum(1 / interval_i)` over every repeating consumer of this target, - /// in Hz. `None` when no repeating consumer references it. - pub evaluation_rate: Option, - /// How many one-shot (batch) consumers reference this target, - /// independent of `evaluation_rate` — see the module docs' "Cost - /// semantics" section on why these can't be merged into one rate. - pub one_shot_consumers: usize, - /// The ingest/update rate of the raw data this target (if maintained) - /// would be kept up to date against. `None` when no - /// arrival rate (`DataArrival::rows_per_second`) was declared. - pub update_rate: Option, -} - -impl RecurrenceProfile { - /// No recurrence metadata at all: no evaluation rate, no one-shot - /// consumers, no update rate. - pub const EMPTY: RecurrenceProfile = RecurrenceProfile { - evaluation_rate: None, - one_shot_consumers: 0, - update_rate: None, - }; - - /// Whether this profile carries no recurrence information at all — the - /// "missing metadata" case [`CostModel::cse_share_decision_with_recurrence`] - /// falls back on. - pub fn is_empty(&self) -> bool { - self.evaluation_rate.is_none() && self.one_shot_consumers == 0 && self.update_rate.is_none() - } - - /// Build a profile purely from a set of repeating consumers' intervals - /// (no one-shot consumers, no update rate — attach those with - /// [`with_one_shot_consumers`](Self::with_one_shot_consumers)/ - /// [`with_update_rate`](Self::with_update_rate)). - pub fn from_repeating_intervals( - intervals: impl IntoIterator, - ) -> Result { - Ok(Self { - evaluation_rate: evaluation_rate_of(intervals)?, - ..Self::EMPTY - }) - } - - /// Attach a count of one-shot consumers. - pub fn with_one_shot_consumers(mut self, one_shot_consumers: usize) -> Self { - self.one_shot_consumers = one_shot_consumers; - self - } - - /// Attach an ingest/update rate (from [`update_rate_from_data_workload`] - /// or a deployment-specific measurement). Validated via - /// [`validate_update_rate`] — rejects a NaN, infinite, or negative rate - /// rather than silently storing it. - pub fn with_update_rate(mut self, update_rate: UpdateRate) -> Result { - self.update_rate = Some(validate_update_rate(update_rate)?); - Ok(self) - } -} - -/// How one workload root recurs — the opaque per-root tag -/// [`crate::replacement::CandidateLogicalASAPDAGs::recurrence_profiles`] threads down to -/// every target reachable from that root. Mirrors -/// [`asap_types::workload::QueryWorkload`]'s own `query_batch` (one-shot) -/// vs. `repeating_queries` (an interval each) split, but at the -/// already-opaque `Id` granularity `search_workload`'s callers already use -/// — this crate needs no more of a caller's own query identity than "which -/// of these two recurrence kinds is this root". One-time roots always use a -/// count, so there is no second spelling for the common count-of-one case. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum RootRecurrence { - /// A declared number of one-time invocations for this root. - OneShotCount(usize), - /// A repeated root normalized to evaluations per second. - Repeating(EvaluationRate), - /// No reliable recurrence evidence was supplied. It contributes no read - /// count or evaluation rate, but remains distinct from zero demand. - Unknown, -} - -// ── Explanation ────────────────────────────────────────────────────────── - -/// The full evaluation [`CostModel::cse_share_decision_with_recurrence`] -/// returns: which alternative was selected, both compared cost rates -/// (and, when a [`Horizon`] was supplied, both compared totals), every -/// input that went into them, their units, and provenance — meant to be -/// both machine-consumable (e.g. by issue #286's DAG-viewer cost/benefit -/// annotations) and human-readable (via its [`fmt::Display`] impl). -#[derive(Debug, Clone, PartialEq)] -pub struct RecurrenceCostExplanation { - /// The selected alternative. - pub decision: ShareDecision, - /// `maintained_cost_rate` — cost units/second — as defined in the - /// module docs' "Cost semantics" section. `None` on the structural - /// fallback path (`RecurrenceProfile::is_empty()`): no rate was - /// computed at all there (the decision came from - /// [`CostModel::cse_share_decision`] instead), so this is "not - /// computed", deliberately distinct from a real, computed rate of - /// exactly zero. - pub maintained_cost_rate: Option, - /// `recompute_cost_rate` — cost units/second. `None` on the same - /// structural fallback path, for the same reason. - pub recompute_cost_rate: Option, - /// `total_cost(horizon)` for the maintained alternative, when `horizon` - /// is `Some`. - pub maintained_total: Option, - /// `total_cost(horizon)` for the recompute alternative, when `horizon` - /// is `Some`. - pub recompute_total: Option, - /// The horizon the totals above were computed over, if any. - pub horizon: Option, - /// The [`UpdateRate`] input used, if any. - pub update_rate: Option, - /// The [`EvaluationRate`] input used, if any. - pub evaluation_rate: Option, - /// The one-shot consumer count input used. - pub one_shot_consumers: usize, - /// `maintenance_cost_per_update` — cost units/update. `None` on the - /// structural fallback path (not computed there). - pub maintenance_cost_per_update: Option, - /// `summary_read_cost` — cost units/read. `None` on the structural - /// fallback path. - pub summary_read_cost: Option, - /// `raw_recompute_cost` — cost units/recomputation. `None` on the - /// structural fallback path. - pub raw_recompute_cost: Option, - /// `summary_build_cost` — one-time cost of materializing the maintained - /// summary. `None` on the structural fallback path. - pub summary_build_cost: Option, - /// Human-readable provenance: which model/path produced this - /// explanation (e.g. "recurrence-aware: " or the - /// structural fallback note when `RecurrenceProfile::is_empty()`). - pub provenance: String, -} - -impl fmt::Display for RecurrenceCostExplanation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "decision: {:?} ({})", self.decision, self.provenance)?; - match (self.maintained_cost_rate, self.recompute_cost_rate) { - (Some(maintained), Some(recompute)) => { - writeln!( - f, - " maintained_cost_rate = {} (update_rate={:?}Hz * \ - maintenance_cost_per_update={:?} + evaluation_rate={:?}Hz * \ - summary_read_cost={:?})", - maintained, - self.update_rate.map(|r| r.0), - self.maintenance_cost_per_update, - self.evaluation_rate.map(|r| r.0), - self.summary_read_cost, - )?; - writeln!( - f, - " recompute_cost_rate = {} (evaluation_rate={:?}Hz * \ - raw_recompute_cost={:?})", - recompute, - self.evaluation_rate.map(|r| r.0), - self.raw_recompute_cost, - )?; - } - _ => { - writeln!( - f, - " maintained_cost_rate / recompute_cost_rate: not computed (structural \ - fallback — no recurrence metadata was supplied)" - )?; - } - } - writeln!(f, " one_shot_consumers = {}", self.one_shot_consumers)?; - if let Some(h) = self.horizon { - writeln!( - f, - " horizon = {}s; summary_build_cost = {:?}; maintained_total = {:?}, \ - recompute_total = {:?}", - h.0, self.summary_build_cost, self.maintained_total, self.recompute_total - )?; - } - Ok(()) - } -} - -/// [`CostModel::cse_share_decision_with_recurrence`]'s default body — see -/// that method's own doc for the decision rule; kept as a free function so -/// the logic exists exactly once regardless of how many `CostModel` -/// implementors inherit the default. -pub(crate) fn decide( - cost_model: &C, - candidate: &CseCandidate, - recurrence: &RecurrenceProfile, - horizon: Option, -) -> Result { - // Backstop validation: a `RecurrenceProfile`/`Horizon` may have reached - // here via a direct struct literal (bypassing `with_update_rate`'s own - // check) or a caller-supplied `horizon` argument — this is the one - // choke point every path funnels through before a value actually enters - // a comparison, so it's validated here regardless of how it arrived. - if let Some(rate) = recurrence.update_rate { - validate_update_rate(rate)?; - } - if let Some(h) = horizon { - validate_horizon(h)?; - } - - if recurrence.is_empty() { - let decision = cost_model.cse_share_decision(candidate); - return Ok(RecurrenceCostExplanation { - decision, - maintained_cost_rate: None, - recompute_cost_rate: None, - maintained_total: None, - recompute_total: None, - horizon: None, - update_rate: None, - evaluation_rate: None, - one_shot_consumers: 0, - maintenance_cost_per_update: None, - summary_read_cost: None, - raw_recompute_cost: None, - summary_build_cost: None, - provenance: "structural fallback: no recurrence metadata supplied — delegated to \ - CostModel::cse_share_decision (consumer_count-based), preserving \ - pre-#287 behavior exactly" - .to_string(), - }); - } - - let has_recurring = recurrence.evaluation_rate.is_some() || recurrence.update_rate.is_some(); - if recurrence.one_shot_consumers > 0 && has_recurring && horizon.is_none() { - return Err(RecurrenceError::MissingHorizon); - } - - let update_rate = recurrence.update_rate.map_or(0.0, |r| r.0); - let evaluation_rate = recurrence.evaluation_rate.map_or(0.0, |r| r.0); - - let maintenance_cost_per_update = cost_model.maintenance_cost_per_update(candidate); - let summary_read_cost = cost_model.summary_read_cost(candidate); - let raw_recompute_cost = cost_model.raw_recompute_cost(candidate); - // The one-time cost of materializing the shared summary at all, before - // any read or update — see `CostModel::summary_build_cost`'s own doc. - // Without this term, a purely (or mostly) one-shot comparison modeled - // "maintained" as free to construct, so `Share` won unconditionally - // for *any* number of one-shot consumers (issue #287 review, bug 1) — - // this term is what makes materializing-and-reading actually cost more - // than a single direct recompute for a lone consumer, while still - // amortizing correctly across many. - let summary_build_cost = cost_model.summary_build_cost(candidate); - - let maintained_cost_rate = CostRate( - update_rate * maintenance_cost_per_update.0 + evaluation_rate * summary_read_cost.0, - ); - let recompute_cost_rate = CostRate(evaluation_rate * raw_recompute_cost.0); - - // A pure one-shot comparison (no recurring rate at all — `has_recurring` - // is false, so the `MissingHorizon` gate above never fired even though - // `one_shot_consumers > 0`) still needs *some* horizon to run - // `total_cost` through, or its one-shot costs would never enter the - // decision at all. Any positive horizon gives the same ordering here, - // since `maintained_cost_rate`/`recompute_cost_rate` are both exactly - // zero in this case (`rate * H` contributes nothing regardless of `H`) - // — so an implicit `Horizon(1.0)` is exact, not approximate, and this - // is never reached for the genuinely mixed case (that already required - // an explicit `horizon` above). - let effective_horizon = horizon - .or_else(|| (recurrence.one_shot_consumers > 0 && !has_recurring).then_some(Horizon(1.0))); - - let (maintained_total, recompute_total, decision) = if let Some(h) = effective_horizon { - // `summary_build_cost` is paid exactly once — whether the summary - // is ever read again by a repeating consumer or not — never scaled - // by `one_shot_consumers`. - let one_shot_maintained = - Cost(summary_read_cost.0 * recurrence.one_shot_consumers as f64) + summary_build_cost; - let one_shot_recompute = Cost(raw_recompute_cost.0 * recurrence.one_shot_consumers as f64); - let maintained = total_cost(maintained_cost_rate, h, one_shot_maintained); - let recompute = total_cost(recompute_cost_rate, h, one_shot_recompute); - let decision = if maintained.0 <= recompute.0 { - ShareDecision::Share - } else { - ShareDecision::RecomputeIndependently - }; - (Some(maintained), Some(recompute), decision) - } else { - // Pure recurring, no one-shot consumers at all: comparing the bare - // rates is exactly equivalent to comparing `rate * H` for any fixed - // `H > 0` in the limit of a long-lived, continuously-maintained - // summary, so no horizon is needed to get a correct decision — the - // one-time `summary_build_cost` is asymptotically negligible next - // to an ongoing rate term and is deliberately not charged here (it - // only enters the comparison when a caller actually needs an - // absolute total over a finite horizon, via the branch above). - let decision = if maintained_cost_rate.0 <= recompute_cost_rate.0 { - ShareDecision::Share - } else { - ShareDecision::RecomputeIndependently - }; - (None, None, decision) - }; - - Ok(RecurrenceCostExplanation { - decision, - maintained_cost_rate: Some(maintained_cost_rate), - recompute_cost_rate: Some(recompute_cost_rate), - maintained_total, - recompute_total, - horizon: effective_horizon, - update_rate: recurrence.update_rate, - evaluation_rate: recurrence.evaluation_rate, - one_shot_consumers: recurrence.one_shot_consumers, - maintenance_cost_per_update: Some(maintenance_cost_per_update), - summary_read_cost: Some(summary_read_cost), - raw_recompute_cost: Some(raw_recompute_cost), - summary_build_cost: Some(summary_build_cost), - provenance: "recurrence-aware: maintained_cost_rate vs recompute_cost_rate (cost \ - units/second), per issue #287" - .to_string(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cost_model::DefaultCostModel; - - fn interval(ms: u32) -> RepetitionInterval { - RepetitionInterval(ms) - } - - fn repeating(ms: u32) -> RootRecurrence { - RootRecurrence::Repeating(evaluation_rate_of([interval(ms)]).unwrap().unwrap()) - } - - // ── evaluation_rate_of ────────────────────────────────────────────── - - #[test] - fn evaluation_rate_of_empty_is_none() { - assert_eq!(evaluation_rate_of(vec![]).unwrap(), None); - } - - #[test] - fn evaluation_rate_of_single_interval() { - // 1000ms interval => 1 Hz. - let rate = evaluation_rate_of(vec![interval(1000)]).unwrap().unwrap(); - assert!((rate.0 - 1.0).abs() < 1e-9); - } - - #[test] - fn evaluation_rate_of_mixed_intervals_sums_reciprocals() { - // 1s, 10s, 100s intervals => 1 + 0.1 + 0.01 Hz. - let rate = evaluation_rate_of(vec![interval(1_000), interval(10_000), interval(100_000)]) - .unwrap() - .unwrap(); - assert!((rate.0 - 1.11).abs() < 1e-9, "rate={}", rate.0); - } - - #[test] - fn evaluation_rate_of_rejects_zero_interval() { - let err = evaluation_rate_of(vec![interval(1000), interval(0)]).unwrap_err(); - assert_eq!(err, RecurrenceError::InvalidInterval(interval(0))); - } - - // ── update_rate_from_data_workload ─────────────────────────────────── - - #[test] - fn update_rate_from_data_workload_reads_the_arrival_rate() { - let workload = DataWorkload { - arrival: asap_types::workload::DataArrival::Ingesting { - rows_per_second: 100.0, - }, - ..Default::default() - }; - let rate = update_rate_from_data_workload(&workload).unwrap().unwrap(); - assert!((rate.0 - 100.0).abs() < 1e-9); - } - - #[test] - fn update_rate_from_data_workload_rejects_a_negative_rate() { - let workload = DataWorkload { - arrival: asap_types::workload::DataArrival::Mixed { - rows_per_second: -0.1, - }, - ..Default::default() - }; - let err = update_rate_from_data_workload(&workload).unwrap_err(); - assert!(matches!(err, RecurrenceError::InvalidUpdateRate(_))); - } - - #[test] - fn update_rate_from_data_workload_preserves_missing_evidence() { - assert_eq!( - update_rate_from_data_workload(&DataWorkload::default()).unwrap(), - None - ); - } - - // ── RecurrenceProfile ───────────────────────────────────────────────── - - #[test] - fn empty_profile_is_empty() { - assert!(RecurrenceProfile::EMPTY.is_empty()); - assert!(RecurrenceProfile::default().is_empty()); - } - - #[test] - fn profile_with_any_field_set_is_not_empty() { - assert!(!RecurrenceProfile::EMPTY - .with_one_shot_consumers(1) - .is_empty()); - assert!(!RecurrenceProfile::EMPTY - .with_update_rate(UpdateRate(1.0)) - .unwrap() - .is_empty()); - assert!( - !RecurrenceProfile::from_repeating_intervals(vec![interval(1000)]) - .unwrap() - .is_empty() - ); - } - - #[test] - fn with_update_rate_rejects_nan_infinite_and_negative() { - for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] { - let err = RecurrenceProfile::EMPTY - .with_update_rate(UpdateRate(bad)) - .unwrap_err(); - assert!( - matches!(err, RecurrenceError::InvalidUpdateRate(_)), - "bad={bad}, err={err:?}" - ); - } - // Zero is a legitimate (if unusual) update rate: no ingest at all. - assert!(RecurrenceProfile::EMPTY - .with_update_rate(UpdateRate(0.0)) - .is_ok()); - } - - #[test] - fn from_repeating_intervals_rejects_zero_interval() { - let err = RecurrenceProfile::from_repeating_intervals(vec![interval(0)]).unwrap_err(); - assert_eq!(err, RecurrenceError::InvalidInterval(interval(0))); - } - - // ── total_cost / unit consistency ──────────────────────────────────── - - #[test] - fn total_cost_combines_rate_and_one_shot_explicitly() { - let rate = CostRate(2.0); - let horizon = Horizon(10.0); - let one_shot = Cost(5.0); - assert_eq!(total_cost(rate, horizon, one_shot), Cost(25.0)); - } - - #[test] - fn cost_rate_and_cost_are_distinct_types() { - // Compile-time unit-consistency check: `CostRate` has no `Add` - // or `From` — the only way shown here to combine them is - // `total_cost`, which forces an explicit `Horizon`. (This test's - // real assertion is that the crate compiles at all with no such - // impls; the runtime assertion below just exercises the intended - // combination path.) - let combined = total_cost(CostRate(1.0), Horizon(1.0), Cost(1.0)); - assert_eq!(combined, Cost(2.0)); - } - - // ── decide (structural fallback) ───────────────────────────────────── - - use crate::cost_model::CseCandidate; - use asap_types::ir::operator_properties::{Reduction, Source}; - use asap_types::ir::{ - ASAPOp, BinaryOperator, ExprSemantics, NonASAPOp, OperatorNode, Predicate, ScalarExpr, - }; - use asap_types::post_asap::{ - AccuracyGuarantee, ExactKind, ExactParams, Field, FieldDataType, GroupingStrategy, Schema, - }; - use asap_types::pre_asap::expr_ir::ColumnRef; - use asap_types::pre_asap::schema::DataType; - - use std::rc::Rc; - - fn scan() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - ], - 0, - vec![], - ), - })) - .unwrap() - } - - /// A summary of `family` over the kept pre-ASAP scan. - fn summary_node(family: FieldDataType) -> Rc { - let kept = Rc::new( - scan() - .as_ref() - .clone() - .with_guarantee(Some(AccuracyGuarantee::exact("RetainedExact"))), - ); - std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child: kept, - family: family.clone(), - input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Named( - "value".into(), - )), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - }), - Schema::lifted(vec![Field::new("state", family, false)], None), - ) - .with_guarantee(None), - ) - } - - #[test] - fn decide_falls_back_to_structural_decision_when_profile_is_empty() { - let sub_dag = scan(); - let bound = summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let candidate = CseCandidate { - sub_dag: &sub_dag, - bound_summary: &bound, - consumer_count: 1000, - }; - let explanation = decide( - &DefaultCostModel, - &candidate, - &RecurrenceProfile::EMPTY, - None, - ) - .unwrap(); - assert_eq!( - explanation.decision, - DefaultCostModel.cse_share_decision(&candidate) - ); - assert!(explanation.provenance.contains("structural fallback")); - } - - #[test] - fn decide_rejects_mixed_one_shot_and_repeating_without_horizon() { - let sub_dag = scan(); - let bound = summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let candidate = CseCandidate { - sub_dag: &sub_dag, - bound_summary: &bound, - consumer_count: 2, - }; - let profile = RecurrenceProfile::from_repeating_intervals(vec![interval(1000)]) - .unwrap() - .with_one_shot_consumers(1); - let err = decide(&DefaultCostModel, &candidate, &profile, None).unwrap_err(); - assert_eq!(err, RecurrenceError::MissingHorizon); - } - - #[test] - fn decide_accepts_mixed_one_shot_and_repeating_with_an_explicit_horizon() { - let sub_dag = scan(); - let bound = summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let candidate = CseCandidate { - sub_dag: &sub_dag, - bound_summary: &bound, - consumer_count: 2, - }; - let profile = RecurrenceProfile::from_repeating_intervals(vec![interval(1000)]) - .unwrap() - .with_one_shot_consumers(1); - let explanation = decide( - &DefaultCostModel, - &candidate, - &profile, - Some(Horizon(3600.0)), - ) - .unwrap(); - assert!(explanation.maintained_total.is_some()); - assert!(explanation.recompute_total.is_some()); - assert!(explanation.summary_build_cost.is_some()); - assert_eq!(explanation.horizon, Some(Horizon(3600.0))); - } - - // ── A deterministic cost model exercising the trait hooks directly ── - - struct DeterministicUnitCostModel; - impl CostModel for DeterministicUnitCostModel { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } - - fn rank_candidates( - &self, - _intent: &asap_types::pre_asap::agg_intent::AggIntent, - candidates: &[asap_types::post_asap::SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn maintenance_cost_per_update(&self, _candidate: &CseCandidate) -> Cost { - Cost(1.0) - } - fn summary_read_cost(&self, _candidate: &CseCandidate) -> Cost { - Cost(1.0) - } - fn raw_recompute_cost(&self, _candidate: &CseCandidate) -> Cost { - Cost(50.0) - } - } - - /// Issue #287's headline acceptance criterion: with a deterministic test - /// cost model and identical IR, a high evaluation frequency selects - /// maintained/shared state while a sufficiently infrequent workload - /// selects recomputation — driven purely by `evaluation_rate`, with a - /// fixed, nonzero `update_rate` representing continuous ingest that - /// keeps a maintained summary's floor cost independent of how often - /// it's read. - #[test] - fn high_frequency_selects_maintained_low_frequency_selects_recompute() { - let sub_dag = scan(); - let bound = summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let candidate = CseCandidate { - sub_dag: &sub_dag, - bound_summary: &bound, - consumer_count: 1, - }; - - // A steady 10Hz ingest rate underlies both scenarios — the physical - // maintenance cost is unaffected by how often the summary is read - // (issue #287: "repetition ... does not reduce the physical - // maintenance work caused by ingest updates"). - let update_rate = UpdateRate(10.0); - - // High frequency: a consumer firing every 10ms => 100Hz. - let frequent = RecurrenceProfile::from_repeating_intervals(vec![interval(10)]) - .unwrap() - .with_update_rate(update_rate) - .unwrap(); - let frequent_explanation = - decide(&DeterministicUnitCostModel, &candidate, &frequent, None).unwrap(); - assert_eq!(frequent_explanation.decision, ShareDecision::Share); - assert!( - frequent_explanation.maintained_cost_rate.unwrap().0 - < frequent_explanation.recompute_cost_rate.unwrap().0 - ); - - // Low frequency: a consumer firing every 100s => 0.01Hz. - let infrequent = RecurrenceProfile::from_repeating_intervals(vec![interval(100_000)]) - .unwrap() - .with_update_rate(update_rate) - .unwrap(); - let infrequent_explanation = - decide(&DeterministicUnitCostModel, &candidate, &infrequent, None).unwrap(); - assert_eq!( - infrequent_explanation.decision, - ShareDecision::RecomputeIndependently - ); - assert!( - infrequent_explanation.maintained_cost_rate.unwrap().0 - > infrequent_explanation.recompute_cost_rate.unwrap().0 - ); - } - - /// Update rate feeds only `maintained_cost_rate` (via - /// `maintenance_cost_per_update`), never `recompute_cost_rate` — and - /// evaluation rate feeds both `summary_read_cost` (maintained) and - /// `raw_recompute_cost` (recompute), never bypassing either. Pins the - /// issue's "update rate affects maintained-summary cost but not read - /// frequency; evaluation rate affects summary-read and recomputation - /// cost" acceptance criterion directly against the trait hooks. - #[test] - fn update_rate_only_affects_maintained_cost_evaluation_rate_affects_both() { - let sub_dag = scan(); - let bound = summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let candidate = CseCandidate { - sub_dag: &sub_dag, - bound_summary: &bound, - consumer_count: 1, - }; - - let base = RecurrenceProfile::from_repeating_intervals(vec![interval(1000)]).unwrap(); - let with_update = base.with_update_rate(UpdateRate(1000.0)).unwrap(); - - let base_explanation = - decide(&DeterministicUnitCostModel, &candidate, &base, None).unwrap(); - let with_update_explanation = - decide(&DeterministicUnitCostModel, &candidate, &with_update, None).unwrap(); - - // Bumping update_rate alone raises maintained_cost_rate... - assert!( - with_update_explanation.maintained_cost_rate.unwrap().0 - > base_explanation.maintained_cost_rate.unwrap().0 - ); - // ...but leaves recompute_cost_rate (a pure function of - // evaluation_rate, unchanged between the two profiles) untouched. - assert_eq!( - with_update_explanation.recompute_cost_rate, - base_explanation.recompute_cost_rate - ); - } - - /// One-shot consumers alone (no repeating consumer, no update rate) — - /// still produce a real decision with no explicit `Horizon` required, - /// by comparing the one-shot costs directly (see `decide`'s - /// `effective_horizon` fallback): with enough one-shot consumers, the - /// fixed `summary_build_cost` amortizes and sharing wins even though - /// `raw_recompute_cost` is expensive. - #[test] - fn one_shot_only_consumer_decides_without_an_explicit_horizon() { - let sub_dag = scan(); - let bound = summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let candidate = CseCandidate { - sub_dag: &sub_dag, - bound_summary: &bound, - consumer_count: 1, - }; - let profile = RecurrenceProfile::EMPTY.with_one_shot_consumers(3); - - // raw_recompute_cost=50 >> summary_read_cost=1: sharing wins even - // for purely one-shot consumers, since materializing once (paying - // summary_build_cost=50 exactly once — DeterministicUnitCostModel's - // default delegates build cost to raw_recompute_cost) and reading it - // 3 times (3 * 1 = 3) totals 53, cheaper than recomputing - // independently 3 times (50 * 3 = 150). - let explanation = decide(&DeterministicUnitCostModel, &candidate, &profile, None).unwrap(); - assert_eq!(explanation.decision, ShareDecision::Share); - assert_eq!(explanation.maintained_total, Some(Cost(53.0))); - assert_eq!(explanation.recompute_total, Some(Cost(150.0))); - } - - /// Regression for issue #287 review bug 1: without a `summary_build_cost` - /// term, a purely one-shot comparison modeled "maintained" as free to - /// construct, so `Share` won unconditionally for *any* number of - /// one-shot consumers — including exactly one, where sharing can never - /// make sense (you always pay at least as much to build-then-read once - /// as you would to just recompute once directly). With the fix, a - /// single one-shot consumer strictly prefers `RecomputeIndependently`. - #[test] - fn one_shot_only_single_consumer_does_not_unconditionally_prefer_share() { - let sub_dag = scan(); - let bound = summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let candidate = CseCandidate { - sub_dag: &sub_dag, - bound_summary: &bound, - consumer_count: 1, - }; - let profile = RecurrenceProfile::EMPTY.with_one_shot_consumers(1); - - let explanation = decide(&DeterministicUnitCostModel, &candidate, &profile, None).unwrap(); - // build(50) + read(1) = 51 > recompute(50) * 1 = 50. - assert_eq!(explanation.decision, ShareDecision::RecomputeIndependently); - assert_eq!(explanation.maintained_total, Some(Cost(51.0))); - assert_eq!(explanation.recompute_total, Some(Cost(50.0))); - } - - /// A batch-only workload (no `DataWorkload`, only one-shot - /// consumers) with the *default* `DefaultCostModel` must not - /// unconditionally prefer `Share` regardless of how many one-shot - /// consumers there are — issue #287 review bug 1's original repro, - /// pinned against the real default cost model rather than the test's - /// own `DeterministicUnitCostModel`. - #[test] - fn batch_only_workload_does_not_unconditionally_prefer_share_under_default_cost_model() { - let sub_dag = scan(); - let bound = summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let candidate = CseCandidate { - sub_dag: &sub_dag, - bound_summary: &bound, - consumer_count: 1, - }; - // `DefaultCostModel`'s `raw_recompute_cost`/`summary_build_cost` - // both delegate to the same structural-size proxy - // (`cse_recompute_cost`), and `summary_read_cost` defaults to a - // nominal `1.0` — build and recompute cost the same, so reading a - // materialized copy even once more than a bare recompute can never - // pay off: for every one-shot-only consumer count, the maintained - // total (`build + read * n`) must be strictly greater than the - // recompute total (`recompute * n`), i.e. `Share` must never win. - for n in [1usize, 2, 10, 1_000] { - let profile = RecurrenceProfile::EMPTY.with_one_shot_consumers(n); - let explanation = decide(&DefaultCostModel, &candidate, &profile, None).unwrap(); - assert_eq!( - explanation.decision, - ShareDecision::RecomputeIndependently, - "n={n}, explanation={explanation:?}" - ); - } - } - - // ── multiple roots sharing a sub-DAG, via CandidateLogicalASAPDAGs ────────────────── - - use crate::replacement::search_workload; - use asap_types::ir::operator_properties::Reduction as QueryReduction; - use asap_types::pre_asap::agg_intent::AggIntent; - use asap_types::pre_asap::expr_ir::{CompareOpKind, ScalarValue}; - - /// Like `scan()`, plus a "job" label column to group by — CSE's - /// sharing legality gate requires a provable unique key - /// (`Schema::has_unique_key`), and an *ungrouped* aggregate's empty - /// `by` reports none (see `asap_types::ir::cse`'s own "Legality" - /// module docs); grouping by a label column gives `sum_agg()` below a - /// real one, matching the pattern - /// `replacement.rs`'s own CSE fixtures already use (`metric_scan`/`agg` - /// grouped by a label column). - fn labeled_scan() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - Field::plain("job", DataType::Utf8, true), - ], - 0, - vec![], - ), - })) - .unwrap() - } - - fn sum_agg() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: QueryReduction::by(vec![2]), - measures: vec![AggIntent::Sum { col: Some(1) }], - output_names: vec![], - filters: vec![], - having: None, - child: labeled_scan(), - })) - .unwrap() - } - - /// A root wrapping a fresh, independently-built (but structurally - /// identical to every other call's) `sum_agg()` in a `Filter` whose - /// literal predicate is unique per root — keeps the three roots - /// themselves structurally distinct (so they don't collapse into one - /// root the way whole-root-identical fixtures do — see - /// `shared_aggregate_across_two_roots_gets_both_strategies_candidates`'s - /// own doc) while letting `share_common_sub_dags` unify their - /// identical `sum_agg()` children onto one shared `Rc`. - fn filtered_root(distinguishing_literal: i64) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(1)), - op: CompareOpKind::Gt, - right: Box::new(ScalarExpr::Literal(ScalarValue::Int64( - distinguishing_literal, - ))), - semantics: ExprSemantics::Sql, - }), - child: sum_agg(), - })) - .unwrap() - } - - /// Three workload roots share one underlying `sum_agg()` sub-DAG: two - /// repeating consumers with different intervals, one one-shot batch - /// consumer. `CandidateLogicalASAPDAGs::recurrence_profiles` must aggregate all three - /// onto the shared sub-DAG's own profile: `evaluation_rate = 1/t1 + - /// 1/t2`, `one_shot_consumers = 1` — issue #287's "support a shared - /// sub-DAG consumed by queries with different intervals" and "multiple - /// roots sharing a sub-DAG" acceptance criteria. - #[test] - fn recurrence_profiles_aggregates_mixed_intervals_across_roots_sharing_a_subdag() { - let roots: Vec<(&str, Rc)> = vec![ - ("root_a", filtered_root(1)), - ("root_b", filtered_root(2)), - ("root_c", filtered_root(3)), - ]; - let space = search_workload(roots).unwrap(); - - // Fixture sanity: the three roots stayed distinct (different - // literal predicates), but their `sum_agg()` children merged onto - // one shared `Rc` (consumer_count 3) — this is the "shared sub-DAG" - // under test. Its own child Scan collapses along with it (all 3 - // Filters' aggregates now point at the *same* Aggregate Rc, so - // there is only ever one Scan Rc underneath, directly referenced - // from exactly one place — the shared Aggregate's own `child`). - assert_eq!( - space.len(), - 5, - "3 distinct Filters + 1 shared Aggregate + 1 Scan underneath it" - ); - let shared_group = space - .target_subdag_candidates() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .expect("the shared sum_agg() is a discovered target"); - assert_eq!(shared_group.consumer_count, 3, "shared by all 3 roots"); - - let root_recurrence = vec![ - repeating(1_000), // 1 Hz - repeating(10_000), // 0.1 Hz - RootRecurrence::OneShotCount(1), - ]; - let profiles = space - .recurrence_profiles(&root_recurrence, Some(UpdateRate(5.0))) - .unwrap(); - - let profile = profiles.for_target(&shared_group.target); - let expected_rate = 1.0 / 1.0 + 1.0 / 10.0; // Hz - assert!( - (profile.evaluation_rate.unwrap().0 - expected_rate).abs() < 1e-9, - "evaluation_rate={:?}", - profile.evaluation_rate - ); - assert_eq!(profile.one_shot_consumers, 1); - assert_eq!(profile.update_rate, Some(UpdateRate(5.0))); - - // Each root's own unshared Filter node sees only its own - // contribution — no cross-contamination between sibling roots: the - // 1Hz root's own Filter carries only that 1Hz, not the combined - // rate the shared Aggregate beneath all three carries. - let root_a_profile = profiles.for_target(&space.roots[0].1); - assert!( - (root_a_profile.evaluation_rate.unwrap().0 - 1.0).abs() < 1e-9, - "root_a's own Filter should see only its own 1Hz, not the combined rate: {:?}", - root_a_profile.evaluation_rate - ); - assert_eq!(root_a_profile.one_shot_consumers, 0); - - // The one-shot root's own Filter sees only its one-shot - // contribution, no evaluation rate at all. - let root_c_profile = profiles.for_target(&space.roots[2].1); - assert_eq!(root_c_profile.evaluation_rate, None); - assert_eq!(root_c_profile.one_shot_consumers, 1); - } - - #[test] - fn plan_selection_uses_recurrence_profiles_for_cse_choices() { - let roots = vec![("a", filtered_root(1)), ("b", filtered_root(2))]; - let space = search_workload(roots).unwrap(); - let shared = space - .target_subdag_candidates() - .find(|group| matches!(group.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .expect("the aggregate is shared by both roots"); - let update_rate = Some(UpdateRate(10.0)); - - let frequent = space - .recurrence_profiles(&[repeating(10), repeating(10)], update_rate) - .unwrap(); - let infrequent = space - .recurrence_profiles(&[repeating(100_000), repeating(100_000)], update_rate) - .unwrap(); - - let frequent_ranked = space - .cost_sorted_with_recurrence(&DeterministicUnitCostModel, &frequent, None) - .unwrap(); - let infrequent_ranked = space - .cost_sorted_with_recurrence(&DeterministicUnitCostModel, &infrequent, None) - .unwrap(); - let first_provenance = - |ranked: &[crate::replacement::RankedTargetSubDAGCandidates<'_>]| { - ranked - .iter() - .find(|group| Rc::ptr_eq(group.target, &shared.target)) - .and_then(|group| group.candidates.first()) - .map(|candidate| candidate.provenance) - }; - assert_eq!( - first_provenance(&frequent_ranked), - Some(crate::replacement::ReplacementProvenance::CseShare) - ); - assert_eq!( - first_provenance(&infrequent_ranked), - Some(crate::replacement::ReplacementProvenance::CseRecompute) - ); - - let frequent_selected = space - .global_selection_with_recurrence(&DeterministicUnitCostModel, &frequent, None) - .unwrap(); - let infrequent_selected = space - .global_selection_with_recurrence(&DeterministicUnitCostModel, &infrequent, None) - .unwrap(); - assert_eq!( - frequent_selected - .for_target(&shared.target) - .and_then(|group| group.chosen) - .map(|candidate| candidate.provenance), - Some(crate::replacement::ReplacementProvenance::CseShare) - ); - assert_eq!( - infrequent_selected - .for_target(&shared.target) - .and_then(|group| group.chosen) - .map(|candidate| candidate.provenance), - Some(crate::replacement::ReplacementProvenance::CseRecompute) - ); - } - - #[test] - fn recurrence_profiles_rejects_an_invalid_evaluation_rate() { - let root = scan(); - let roots: Vec<(&str, Rc)> = vec![("only", root)]; - let space = search_workload(roots).unwrap(); - let err = space - .recurrence_profiles(&[RootRecurrence::Repeating(EvaluationRate(f64::NAN))], None) - .unwrap_err(); - assert!(matches!(err, RecurrenceError::InvalidEvaluationRate(_))); - } - - /// Issue #287 review bug 6: a length mismatch is a recoverable - /// `RecurrenceError`, not a panic — `recurrence_profiles`'s whole - /// signature promises a `Result`. - #[test] - fn recurrence_profiles_reports_a_root_count_mismatch_as_an_error_not_a_panic() { - let root = scan(); - let roots: Vec<(&str, Rc)> = vec![("only", root)]; - let space = search_workload(roots).unwrap(); - let err = space.recurrence_profiles(&[], None).unwrap_err(); - assert_eq!( - err, - RecurrenceError::RootCountMismatch { - expected: 1, - got: 0, - } - ); - } - - #[test] - fn recurrence_profiles_rejects_an_invalid_update_rate() { - let root = scan(); - let roots: Vec<(&str, Rc)> = vec![("only", root)]; - let space = search_workload(roots).unwrap(); - let err = space - .recurrence_profiles( - &[RootRecurrence::OneShotCount(1)], - Some(UpdateRate(f64::NAN)), - ) - .unwrap_err(); - assert!(matches!(err, RecurrenceError::InvalidUpdateRate(_))); - } - - /// Issue #287 review bug 2: a site no root's own structural DAG - /// actually reaches must not have the caller-supplied `update_rate` - /// stamped onto it. `AvgToSumOverCountStrategy` (part of - /// `default_strategies`, so included by `search_workload`) is a real, - /// already-shipped source of exactly this shape: it rewrites a bare - /// `avg` `Aggregate` into a *brand new* `Project(sum, count)` sub-DAG — - /// `sum`/`count` are genuinely new `Rc`s, discovered via - /// `discover_new_descendant_targets` from the *candidate's* own - /// children, never reachable by walking the original `avg` root's own - /// structural children (which is just the raw scan). Before the fix, - /// this `count` site would get `{evaluation_rate: None, - /// one_shot_consumers: 0, update_rate: Some(rate)}` — `maintained_cost_rate - /// > 0` against a `recompute_cost_rate` of exactly `0` — unconditionally - /// `RecomputeIndependently`, regardless of the site's own real - /// `consumer_count`. - #[test] - fn recurrence_profiles_does_not_stamp_update_rate_on_a_site_unreachable_from_any_root() { - let avg_root = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: QueryReduction::by(vec![]), - measures: vec![AggIntent::Avg { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: scan(), - })) - .unwrap(); - let roots: Vec<(&str, Rc)> = vec![("q", avg_root)]; - let space = search_workload(roots).unwrap(); - - let count_group = space - .target_subdag_candidates() - .find(|g| { - matches!( - g.target.non_asap(), - Some(NonASAPOp::Aggregate { measures, .. }) - if measures.iter().any(|m| matches!(m, AggIntent::Count { .. })) - ) - }) - .expect( - "AvgToSumOverCountStrategy should have introduced a new Count aggregate \ - target, unreachable from the original avg root's own structural children", - ); - - let root_recurrence = vec![repeating(1_000)]; - let profiles = space - .recurrence_profiles(&root_recurrence, Some(UpdateRate(5.0))) - .unwrap(); - - let count_profile = profiles.for_target(&count_group.target); - assert_eq!( - count_profile, - RecurrenceProfile::EMPTY, - "a site unreachable from any root's own structural DAG must fall back to \ - RecurrenceProfile::EMPTY (no update_rate, no evaluation_rate, no one-shot \ - consumers), not just an evaluation-rate-free profile that still carries the \ - caller's update_rate" - ); - - // The root itself (and the raw scan directly beneath it, which the - // walk *does* reach) still get the real update_rate. - let root_profile = profiles.for_target(&space.roots[0].1); - assert_eq!(root_profile.update_rate, Some(UpdateRate(5.0))); - } - - /// Issue #287 review (lower-priority item): a parent referencing the - /// same shared child twice (`BinaryOp{lhs: X, rhs: X}`, the same shape - /// `ir::cse`'s own within-one-query sharing collapses onto one - /// `Rc`) must credit that child with 2 contributions per repeating - /// root, matching how `TargetSubDAGCandidates::consumer_count` already counts that - /// exact structural occurrence twice — not 1, which a plain - /// reachability-set walk would (wrongly) collapse it to. - #[test] - fn recurrence_profiles_credits_a_direct_repeated_reference_by_its_multiplicity() { - let root = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::BinaryOp { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: asap_types::ir::operator_properties::BinaryOpKind::Compare( - CompareOpKind::Eq, - ), - vector_match: None, - }, - return_bool: false, - lhs: sum_agg(), - rhs: sum_agg(), - })) - .unwrap(); - let space = search_workload(vec![("q", root)]).unwrap(); - - let shared_group = space - .target_subdag_candidates() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .expect("sum_agg() should merge onto one shared Rc, referenced twice from BinaryOp"); - assert_eq!( - shared_group.consumer_count, 2, - "fixture sanity: referenced twice from the same BinaryOp parent" - ); - - let root_recurrence = vec![repeating(1_000)]; // 1 Hz - let profiles = space.recurrence_profiles(&root_recurrence, None).unwrap(); - let profile = profiles.for_target(&shared_group.target); - - // Referenced twice from the one root: evaluation_rate should be - // 2 * 1Hz = 2Hz, matching consumer_count's own multiplicity — not - // 1Hz, which would undercount by treating "reachable at all" as - // the whole story. - assert!( - (profile.evaluation_rate.unwrap().0 - 2.0).abs() < 1e-9, - "evaluation_rate={:?}", - profile.evaluation_rate - ); - - let scan_group = space - .target_subdag_candidates() - .find(|group| matches!(group.target.non_asap(), Some(NonASAPOp::Scan { .. }))) - .expect("the shared aggregate has a scan descendant"); - assert_eq!( - profiles - .for_target(&scan_group.target) - .evaluation_rate - .unwrap(), - EvaluationRate(2.0), - "ancestor multiplicity must propagate transitively to descendants" - ); - } - - // ── Horizon validation ──────────────────────────────────────────────── - - #[test] - fn decide_rejects_a_zero_or_negative_horizon() { - let sub_dag = scan(); - let bound = summary_node(FieldDataType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )); - let candidate = CseCandidate { - sub_dag: &sub_dag, - bound_summary: &bound, - consumer_count: 2, - }; - let profile = RecurrenceProfile::from_repeating_intervals(vec![interval(1000)]) - .unwrap() - .with_one_shot_consumers(1); - for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] { - let err = - decide(&DefaultCostModel, &candidate, &profile, Some(Horizon(bad))).unwrap_err(); - assert!( - matches!(err, RecurrenceError::InvalidHorizon(_)), - "bad={bad}, err={err:?}" - ); - } - } -} diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs deleted file mode 100644 index 3c437024..00000000 --- a/crates/asap-aware-mapping/src/replacement.rs +++ /dev/null @@ -1,11044 +0,0 @@ -//! `TargetSubDAG` / `ReplacementSubDAG` / `ReplacementStrategy` — the -//! candidate-replacement vocabulary `docs/design_docs/asap_aware_mapping.md` stubs out -//! under "Key concepts (not yet implemented)", implemented for real (issue -//! #251, part of #33). -//! -//! ## One step, not two: `ASAPStrategies::replacements()` decides *and* builds -//! -//! For a bindable `Aggregate`, `ASAPStrategies::replacements()` is the -//! single place this crate both decides what an `AggIntent` may become and -//! turns each of those candidates into a real, executable -//! [`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). -//! 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 -//! column, builds the evaluation query, recurses into the child (via -//! [`realize_child`], so a nested aggregate gets its own -//! independent enumeration, never the outer target's forced choice), and -//! assembles the `SummaryAgg`/`SummaryEstimate` node. -//! -//! There is no separate decision step and construction step living in -//! different modules bridged by a named "given a `Realization`, bind it" -//! function — step 2 is *not* a second decision (nothing about which -//! candidate to prefer happens there), it is mechanical construction that -//! has to run regardless of how `(kind, params)` were chosen, so it lives -//! directly inside the one method that needs it. -//! -//! - [`TargetSubDAG`] — a reference to a pre-ASAP [`OperatorNode`] that is a -//! candidate for replacement, plus how many places in the workload already -//! reference it (its `consumer_count`) — the one piece of cross-node -//! context [`SharedSubDAGStrategy`] needs that a bare node reference alone -//! doesn't carry. -//! - [`ReplacementSubDAG`] — one candidate replacement for a `TargetSubDAG`: -//! either a fully bound summary sub-DAG or a pre-ASAP logical rewrite -//! (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 -//! 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 -//! candidate" is core's job; picking the best one is left to the caller. -//! [`crate::explanation`] (issue #257) is this trait's own downstream -//! consumer, not a second extension point: it explains why a replacement -//! exists as a pure view over the candidates strategies registered here -//! already produced, rather than re-deriving that explanation with a rule -//! of its own. -//! -//! A caller may inspect local replacements, but taking the first candidate -//! does not establish a compatible workload plan or physical deployability. -//! For Planner-owned logical selection, call [`CandidateLogicalASAPDAGs::global_selection`] -//! once and [`GlobalSelection::assemble_selected_query`] for each wanted query -//! root. Physical binding, deployment, and execution remain downstream. -//! -//! Internally, [`realize_child`] and [`realize_one`] may take a preferred local -//! realization while constructing or costing a candidate. That local operation -//! is not the public workload-selection workflow and does not create runtime state. -//! -//! This means an ordinary single-target bind sizes and fully constructs -//! *every* sketch candidate at every sketch-capable node (not just the one a -//! caller keeps) — a deliberate tradeoff, made so there is exactly one place -//! in this crate that decides what an `AggIntent` may become, at the cost of -//! extra work per bind proportional to each node's own candidate count. -//! -//! ## The two strategies, and why these two -//! -//! - [`ASAPStrategies`] wraps [`realizations_for_intent`]'s exhaustive, -//! ranked list directly: for the same bindable-`Aggregate` shape this crate -//! binds (single intent, no `HAVING`), every entry becomes its own bound -//! candidate. -//! - [`SharedSubDAGStrategy`] wraps -//! `asap_types::ir::cse::share_common_sub_dags`'s sharing decision. -//! Wherever a [`TargetSubDAG`] already has two or more consumers (i.e. -//! `share_common_sub_dags` already collapsed two or more workload -//! locations onto the same `Rc` — [`discover_targets`] below -//! does the identical workload-wide discovery for [`search_workload_with`]; -//! this module's own tests reuse the same dedup logic to build realistic -//! fixtures), it reports the two-way candidate CSE's own detection pass -//! deliberately declines to pick between on its own: build once and share -//! the already-interned sub-DAG, or build it independently at each -//! consumer. [`crate::cost_model::CostModel::cse_share_decision`] is where -//! that choice actually gets made *today* (a fixed comparison, not a -//! search) — this strategy exposes the same two-way choice as an explicit, -//! inspectable pair of candidates instead of a cost model's already-decided -//! boolean. -//! -//! ## Non-goals (tracked separately, not attempted here) -//! -//! - **[`realizations_for_intent`]'s own outward-facing behavior is -//! unchanged.** Same inputs still produce the same exhaustive, ranked -//! list — only its home moved (from a separate `implementation` module -//! into this one) and its own visibility dropped to module-private, since -//! [`ASAPStrategies`] is now its only caller. -//! -//! ## Workload-wide search — merged in from the former `search.rs` (issue #252, part of #33) -//! -//! This section used to carry two more "non-goals" bullets here — "no -//! search/selection-across-a-whole-plan logic" and "no workload-wide -//! `TargetSubDAG` discovery pass" — describing work deliberately left for a -//! future Cascades/Volcano-style search engine (PR #263, -//! `feat/cascades-search-252`, over the [`ReplacementStrategy`] extension -//! point above). That engine is [`CandidateLogicalASAPDAGs`]/[`TargetSubDAGCandidates`]/ -//! [`search_workload`]/[`search_workload_with`] below, merged into this -//! module rather than kept as a separate `search` module — the same "one -//! module, one step" reasoning the top of this file already uses for -//! decide-and-build: searching *across* a whole workload's worth of -//! [`TargetSubDAG`]s is a natural continuation of deciding and building -//! replacements *for* one, not a different concern that deserves its own -//! file. What follows (through "Cost-based final selection" below) is that -//! engine's own design documentation, preserved from `search.rs`. -//! -//! ### The pseudocode, and the two things it deliberately leaves open -//! -//! ```text -//! candidate_plans = { input_workload_plan } -//! loop: -//! new_plans = {} -//! for plan in candidate_plans: -//! for site in plan.bindable_sites(): -//! for strategy in registered_strategies: -//! if strategy.matches(site): -//! for replacement in strategy.replacements(site): -//! new_plans += substitute(plan, site, replacement) -//! new_plans -= candidate_plans -//! candidate_plans += new_plans -//! until new_plans is empty -//! return candidate_plans.sorted_by(cost_model) -//! ``` -//! -//! Read literally, this enumerates whole *plans* — full copies of the -//! workload's DAG, one per combination of per-target choices. A workload -//! with `N` independently-choosable targets would produce up to `2^N` flat -//! plans, each one duplicating every untouched sibling sub-DAG. This module -//! does not do that: -//! -//! 1. **Per-target candidates, not flat plans.** [`TargetSubDAGCandidates`] -//! stores the alternatives for one distinct [`TargetSubDAG`] (identified by -//! its own `Rc` pointer identity — the same currency -//! [`asap_types::ir::cse::share_common_sub_dags`] already -//! established across the workload) holding every -//! [`ReplacementSubDAG`] alternative discovered for it. [`CandidateLogicalASAPDAGs`] is -//! a collection of these groups, keyed by `TargetSubDAG` — a candidate -//! "plan" is never materialized as a distinct top-level `Rc` -//! at all; two logically-different overall choices at two different -//! targets are just two different entries in two different groups, -//! sharing every other node in the workload by construction (they *are* -//! the same `Rc`s — nothing was copied to make a second "plan"). -//! 2. **Dedup by structural hash + `PartialEq`, reusing `ir::cse`'s own -//! discipline.** [`asap_types::ir::cse::structural_hash`] (made -//! `pub` for exactly this reuse) is only ever a candidate-narrowing -//! filter; [`TargetSubDAGCandidates::add_candidate`]'s actual duplicate check is -//! `OperatorNode`'s derived `PartialEq` — the same "hash is a filter, -//! `PartialEq` is the decision, no exceptions" rule `cse.rs`'s own -//! "Correctness" section states and this module inherits rather than -//! reinvents. See [`is_duplicate_rewrite`] for the one deliberate -//! wrinkle this reuse needs (a `Rc`-identity case pure value equality -//! would get wrong). -//! -//! ### Where `TargetSubDAG` discovery comes from -//! -//! [`discover_targets`] is the workload-wide `TargetSubDAG` discovery pass -//! this section used to flag as explicitly *not* implemented ("no -//! workload-wide `TargetSubDAG` discovery pass is shipped either... wiring -//! it up automatically belongs to the same future search engine, not this -//! issue") — this is that future engine, so it's this module's job now, and -//! it's what the quoted pseudocode's `for site in plan.bindable_sites()` -//! line above stands for: every `TargetSubDAG` this pass discovers is one -//! iteration of that loop. It walks every workload root's whole DAG (the -//! same **relational-skeleton** operator-child scope -//! `asap_types::ir::cse::share_common_sub_dags` itself uses — see -//! that module's "Algorithm" section), discovering one `TargetSubDAG` per -//! distinct `Rc` and a *real* `consumer_count`: how many operator-child -//! positions anywhere in the workload reference that exact `Rc`, not just -//! how many of the workload's own top-level roots happen to be it — a -//! `SharedSubDAGStrategy` candidate three levels under an unshared -//! `Filter` is exactly as real a target as a shared whole root, so this -//! module's discovery can't stop at the top level. -//! -//! `discover_targets` duplicates (rather than reuses) this module's own -//! `#[cfg(test)]`-only `count_consumers` traversal (in the test module -//! below), which mirrors this exact shape for this module's own test -//! fixtures — that copy is intentionally test-only, so it isn't reachable -//! from this module's production code without either moving it into -//! shared, non-test-gated code or duplicating the (small, self-contained) -//! traversal here. Duplicating was judged simpler than restructuring a test -//! helper into shared production code for one caller. -//! -//! ### Termination -//! -//! Every discovered target is asked *once* per registered strategy, never -//! re-asked — [`search_workload_with`]'s loop processes each round's -//! frontier of not-yet-visited targets exactly one time each, so there is -//! no scenario where the same `(target, strategy)` pair is queried twice -//! (the `new_plans -= candidate_plans` dedup step the module-level -//! pseudocode describes is therefore never asked to recognize "the same -//! candidate, proposed again" as a special case — see -//! [`TargetSubDAGCandidates::add_candidate`]'s own doc on why that distinction matters -//! for [`Replacement::Summary`] specifically, where no real equality check -//! exists to make it safely). -//! -//! What *can* grow the frontier is a candidate's own reachable structure: -//! after a target is processed, every [`Replacement::Rewrite`] candidate's -//! **children** (never the candidate's own top-level node — that value is -//! an alternative *for* the target just processed, not a new target of its -//! own; see [`discover_new_descendant_targets`]) are scanned for pointers -//! not already known, and any found become next round's frontier. Both shipped -//! strategies are idempotent in exactly this sense: [`ASAPStrategies`] -//! produces terminal bound-summary [`Replacement::SubDAG`] candidates (no -//! logical-rewrite children to scan at all), and [`SharedSubDAGStrategy`]'s -//! two logical-rewrite [`Replacement::SubDAG`] candidates both reuse the target's own -//! already-known child `Rc`s verbatim (`Rc::clone`/a shallow top-level -//! `.clone()` — see that strategy's own doc). So for both, the frontier is -//! always empty after round one: real workloads converge in exactly one -//! round, regardless of size. -//! -//! That said, a future strategy whose `Replacement::Rewrite` candidates -//! invent brand-new descendant structure every time they're computed (e.g. -//! internal state that fabricates a fresh child node on every call) could -//! in principle keep the frontier non-empty forever. Since this crate has -//! no principled bound on strategy-generated descendant sites, -//! [`search_workload_with`] enforces a generous, documented round cap -//! ([`MAX_SEARCH_ITERATIONS`]) instead: exceeding it panics with a clear -//! message naming the actual cause, rather than hanging silently — a test -//! ([`tests::a_pathologically_growing_strategy_trips_the_iteration_cap`]) -//! pins that this guard actually fires, by using exactly that shape of -//! pathological strategy. -//! -//! ### 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 -//! 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 -//! flagged that a real search engine — this module — is where that stops -//! being the whole story; it isn't a contradiction of #237, it's the scope -//! change #237 itself named). Concretely, per [`TargetSubDAGCandidates`]: -//! -//! - A group whose candidates are the [`SharedSubDAGStrategy`] -//! share-vs-recompute pair is ranked by calling -//! [`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 -//! `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 -//! invents a comparison `CostModel` doesn't already define. -//! -//! ## Whole-plan (cross-group) selection — issue #271 -//! -//! [`CandidateLogicalASAPDAGs::cost_sorted`] above ranks every group's candidates -//! independently: it never lets one group's choice influence how another -//! 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 -//! [`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 -//! raw [`TargetSubDAGCandidates::consumer_count`] (how many operator-child positions -//! directly reference it) whenever an ancestor on the path to it is -//! *itself* being recomputed independently rather than shared: recomputing -//! that ancestor independently at each of *its own* uses recomputes -//! everything underneath it that many times too, even though nothing -//! underneath gained a single new direct reference. `cost_sorted`'s -//! per-group ranking has no way to see this — it only ever looks at one -//! group's own `candidates`, in isolation. -//! -//! [`CandidateLogicalASAPDAGs::global_selection`] is that missing step: a single -//! **top-down dynamic-programming pass** over the discovered sites, -//! processed in the topological order [`topological_order`] computes over a -//! small [`ReferenceDAG`] built for exactly this purpose (parent before -//! every child, so a site's `effective_consumer_count` is always computed -//! from *already-decided* ancestors). For every site it computes the -//! **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 -//! 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 -//! 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 -//! execution backs every use of it); a group that chooses -//! `RecomputeIndependently` — or has no Share/Recompute decision of its own -//! at all, i.e. isn't itself a `SharedSubDAGStrategy` shape — passes its -//! *own* effective count straight through to whatever it references, -//! transitively composing contributions from every ancestor on the path, -//! not just the immediate parent. -//! -//! This is genuine dynamic programming in the classical sense: overlapping -//! subproblems (a site reachable through more than one parent path is -//! solved once, memoized in `effective_uses`, and reused for every path -//! into it) combined via a real recurrence — not just the MEMO-group -//! sharing [`CandidateLogicalASAPDAGs`] itself already does for *storing* candidates. That -//! distinction is exactly what issue #271 raised: this module already looks -//! like a Cascades/Volcano MEMO, but [`CandidateLogicalASAPDAGs::cost_sorted`] alone never -//! actually performed this composition step; `global_selection` is that -//! step, added alongside `cost_sorted` rather than replacing it (both stay -//! available — see [`RankedTargetSubDAGCandidates`] vs. [`TargetSubDAGSelection`]'s own docs for when -//! to reach for which). -//! -//! 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` -//! 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 -//! [`TargetSubDAGSelection::effective_consumer_count`] is computed and exposed -//! correctly regardless. Wiring sketch sizing/ranking to actually consume -//! it needs a `CostModel` interface change — out of scope here per this -//! issue's own "reuse `CostModel`, don't invent a new interface" ask; a -//! correct `effective_consumer_count` is the input such a future hook -//! 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`] -//! 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 -//! sibling `SharedSubDAGStrategy` groups that could trade off against -//! each other under some shared resource budget (memory, say) still -//! aren't jointly optimized here — this crate has no -//! cardinality/statistics estimation to bound a combinatorial search like -//! that with (the same constraint #237/#263 already navigated), so real -//! multi-group joint optimization beyond this per-site recurrence is left -//! for whenever that changes. - -use crate::accuracy::estimators::{ - cms::{cms_depth, cms_width}, - saturating_ceil, -}; -use crate::realization::{ - realize_keyed_additive_summary_input, schema_column_ref, PhysicalSummaryInput, - PhysicalSummaryInputRuleResult, -}; -use asap_types::ir::non_asap::any_measure_filtered; -use asap_types::pre_asap::resolve_column_ref; -use std::cell::RefCell; -use std::collections::{HashMap, HashSet, VecDeque}; - -use asap_types::ir::cse::{share_common_sub_dags, structural_hash, HashCache}; -use asap_types::ir::operator_properties::{BinaryOpKind, JoinKind, Reduction}; -use asap_types::ir::timing::validate_at_ingestion; -use asap_types::ir::{ - ASAPOp, BinaryOperator, NonASAPOp, Operator, OperatorNode, Predicate, ProjectItem, ScalarExpr, - SortKey, -}; -use asap_types::post_asap::{ - AccuracyError, AccuracyGuarantee, CompositionOperator, GuaranteeSource, -}; -use asap_types::post_asap::{ - EntityIdentity, ExactKind, ExactParams, ExecutionDataStateError, ExecutionTiming, Field, - FieldDataType, GroupingStrategy, NonNegativeWeightProof, SamplingKind, SamplingParams, Schema, - SketchAlgorithm, SketchKind, SketchParams, SketchStatistic as PostAsapSketchStatistic, - StatModelKind, StatModelParams, SummaryInputExpr, SummaryUpdate, WaveletKind, WaveletParams, - WeightDomain, -}; -use asap_types::pre_asap::agg_intent::{agg_is_mergeable, AggIntent}; -use asap_types::pre_asap::expr_ir::{ArithmeticOpKind, ColumnRef}; -use asap_types::pre_asap::schema::ColumnId; -use asap_types::types::AccuracyTarget; -use asap_types::workload::{DataWorkload, QueryRecurrence, QueryWorkload, RepeatedDemand}; -use std::rc::{Rc, Weak}; - -use crate::accuracy::reconciliation::AccuracyReconciliationStrategy; -use crate::accuracy::{ - per_input_target, AccuracyEvidenceProvider, AccuracyModel, DefaultAccuracyModel, - NoAccuracyEvidence, -}; -use crate::cost_model::{ - raw_recompute_cost_rate, CostModel, CseCandidate, DefaultCostModel, ExactCompositionCostInputs, - ExactCompositionCostRequest, ShareDecision, -}; -use crate::exact_composition::{ExactComposition, ExactCompositionStrategy, OperationPlacement}; -use crate::grouping::HydraGroupingStrategy; -use crate::realization::{ - accuracy_budget, accuracy_target, column_ref, default_size_params, summarised_column, - summarised_input, summary_candidates, DEFAULT_DELTA, -}; -use crate::recurrence::CostRate; -use crate::recurrence::{ - evaluation_rate_of, Horizon, RecurrenceError, RecurrenceProfile, RootRecurrence, UpdateRate, -}; -use crate::rollup::RollupStrategy; -use crate::topk_reuse::TopKLimitReuseStrategy; - -pub use crate::realization::RealizationError; - -/// A pre-ASAP sub-DAG a [`ReplacementStrategy`] knows how to replace. -/// -/// `root` is a reference into the workload's own [`OperatorNode`] DAG (an -/// `Rc`, the same currency [`search_workload`] and -/// `asap_types::ir::cse::share_common_sub_dags` already thread through -/// this crate's public API — not a bare `&OperatorNode` — so a strategy that -/// needs the node's own `Rc` identity, not just its shape, has it available -/// without the caller re-deriving it). -/// -/// `consumer_count` is how many locations across the workload reference this -/// exact `Rc` — 1 for an ordinary single-use node and 2+ for a shared sub-DAG. -/// [`search_workload_with`] computes the workload-wide value during target -/// discovery. [`TargetSubDAG::new`] defaults it to `1` for callers invoking a -/// strategy against one node in isolation. A strategy that only cares about -/// `root`'s shape (for example, [`ASAPStrategies`]) can ignore the -/// count; [`SharedSubDAGStrategy`] consults it directly. -/// -/// `strictest_sibling_accuracy` is the strictest accuracy among workload -/// siblings that read the same summary input as `root`, when stricter than -/// `root`'s own. [`search_workload_with`] sets it; [`ASAPStrategies`] -/// also sizes a candidate to it. -#[derive(Debug, Clone, Copy)] -pub struct TargetSubDAG<'a> { - pub root: &'a Rc, - pub consumer_count: usize, - pub strictest_sibling_accuracy: Option<&'a AccuracyTarget>, -} - -impl<'a> TargetSubDAG<'a> { - /// A target assumed to have exactly one consumer — the common case for a - /// caller that isn't already tracking cross-workload sharing. - pub fn new(root: &'a Rc) -> Self { - Self { - root, - consumer_count: 1, - strictest_sibling_accuracy: None, - } - } - - /// A target with an explicit `consumer_count`, used by workload discovery - /// and by callers that already know how many locations reference `root`. - pub fn with_consumer_count(root: &'a Rc, consumer_count: usize) -> Self { - Self { - root, - consumer_count, - strictest_sibling_accuracy: None, - } - } -} - -/// What a [`ReplacementSubDAG`] actually substitutes a [`TargetSubDAG`] with. -/// -/// Generalizes [`realizations_for_intent`]'s two possible *kinds* of answer -/// — a post-ASAP binding decision, or a still-pre-ASAP structural alternative -/// — into "one candidate among several", each with its own -/// [`ReplacementSubDAG`]. -#[derive(Debug, Clone)] -#[allow(clippy::large_enum_variant)] // Keep the public strategy API value-based. -pub enum Replacement { - /// A sub-DAG that replaces the target: either a bound summary decision - /// (a DAG containing ASAP operators, for one particular candidate - /// realization of the target) or a pre-ASAP rewrite (a logical sub-DAG - /// with no ASAP operator, structurally different from the target's own - /// `root` — e.g. sharing vs. not sharing a sub-DAG — but semantically - /// equivalent to it). [`ReplacementSubDAG::is_logical_rewrite`] tells the - /// two apart. - SubDAG(Rc), - /// An exact operator composed over another target's *own* selected - /// decision across an explicit update/evaluation boundary (issue #171): - /// `ValueOperationAtQueryTime` over a child's summary evaluation, or - /// `ValueOperationAtIngestionTime` feeding a maintained summary above. Carries only a - /// reference to the child target — [`CandidateLogicalASAPDAGs::global_selection`] - /// commits the compatible parent/child pair and - /// [`GlobalSelection::assemble_selected_dag`] links it into one validated - /// `OperatorNode` DAG. See [`crate::exact_composition`]. - ExactComposition(ExactComposition), -} - -impl ReplacementSubDAG { - /// Whether this candidate is a pure logical rewrite of its target rather - /// than a bound summary decision, as its [`ReplacementProvenance`] - /// records. - pub fn is_logical_rewrite(&self) -> bool { - matches!(self.replacement, Replacement::SubDAG(_)) && self.provenance.is_logical_rewrite() - } -} - -/// One candidate replacement for a [`TargetSubDAG`], plus a human-readable -/// `rationale` explaining why it's a valid candidate (meant for a -/// report/log/debugging a search engine's choices, not machine parsing — -/// [`crate::explanation::ReplacementExplanation::reason`] literally reuses -/// this same string rather than inventing new prose of its own. -#[derive(Debug, Clone)] -pub struct ReplacementSubDAG { - pub replacement: Replacement, - /// Name of the [`ReplacementStrategy`] that proposed this candidate. - /// Search fills this from `ReplacementStrategy::name`; consumers must not - /// infer it from the replacement's shape or provenance. - pub strategy: &'static str, - /// Machine-readable origin/role of this alternative. Selection uses this - /// instead of inferring strategy semantics from replacement shape or - /// pointer identity when several strategies contribute to one memo group. - pub provenance: ReplacementProvenance, - pub rationale: String, -} - -impl ReplacementSubDAG { - /// Whether this summary still needs accuracy/domain evidence about its - /// output before it can be selected automatically; it stays a candidate. - /// A kept pre-ASAP sub-DAG carries an explicit exact guarantee. - pub fn has_missing_accuracy_evidence(&self) -> bool { - matches!( - &self.replacement, - Replacement::SubDAG(node) if !self.is_logical_rewrite() && 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) - } - // Summary support is the deployment's capabilities, which the - // planner pipeline reads (`DeploymentCapabilities`); this legacy - // search defers it to physical compilation. - Replacement::SubDAG(_) if !self.is_logical_rewrite() => None, - Replacement::SubDAG(_) => Some(true), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReplacementProvenance { - SummaryRealization, - CseShare, - CseRecompute, - LogicalRewrite, - /// [`crate::accuracy::reconciliation::AccuracyReconciliationStrategy`]'s - /// "read a strictly-tighter sibling instead of building an independent, - /// looser copy" candidate (issue #273). Kept distinct from - /// `LogicalRewrite` — even though both are structurally-different, - /// semantically-equivalent rewrites — because - /// [`crate::cost_model::DefaultCostModel::estimate_cost`] needs to price - /// it differently: `LogicalRewrite` candidates (`RollupStrategy`, - /// `TopKLimitReuseStrategy`) still rebuild `target` itself from a - /// different source, so pricing them like an independent rebuild is - /// correct; this candidate never rebuilds `target` at all; it reads a - /// sibling that (per this strategy's own safety argument) is built - /// regardless, so pricing it like a full independent rebuild would be - /// the wrong shape of cost, not just the wrong number. - AccuracyReconciliation, - /// [`Replacement::ExactComposition`] with - /// [`OperationPlacement::Read`] (issue #171). - ValueOperationAtQueryTime, - /// [`Replacement::ExactComposition`] with - /// [`OperationPlacement::Maintenance`] (issue #171). - ValueOperationAtIngestionTime, - /// A finalized whole-query result over rows carrying the PromQL series - /// identity, which the logical root does not expose (see - /// [`ReplacementStrategy::propose_for_root`]). Default selection never - /// commits it, because its evaluation must be validated and priced by - /// deployment; otherwise it would silently replace the logical plan. - RootPhysicalRealization, -} - -impl ReplacementProvenance { - /// Whether a candidate of this origin is a pure logical rewrite of its - /// target (a structurally different, semantically equivalent sub-DAG - /// with no summary decision bound yet). - pub fn is_logical_rewrite(self) -> bool { - matches!( - self, - Self::LogicalRewrite - | Self::CseShare - | Self::CseRecompute - | Self::AccuracyReconciliation - ) - } -} - -/// A candidate a strategy considered for a target but refused to propose on -/// 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`]. -#[derive(Debug, Clone)] -pub struct RejectedCandidate { - /// Name of the [`ReplacementStrategy`] that considered it. - pub strategy: &'static str, - /// What the candidate would have been (the same prose a - /// [`ReplacementSubDAG::rationale`] would have carried). - pub description: String, - /// The typed reason it is illegal. - pub error: AccuracyError, -} - -/// Everything one [`ReplacementStrategy`] has to say about one target: the -/// legal candidates it proposes plus the accuracy-illegal ones it refused — -/// the output of [`ReplacementStrategy::propose`]. -#[derive(Debug, Clone, Default)] -pub struct Proposals { - pub candidates: Vec, - pub rejected: Vec, - domain_error: Option, -} - -/// A replacement strategy: given a [`TargetSubDAG`], does this strategy have -/// an opinion on it at all (`matches`), and if so, every semantically valid -/// replacement (`replacements`)? -/// -/// The extension point this module exists for — the same shape -/// [`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. -/// -/// `replacements` is only meaningful when `matches` would return `true` for -/// the same target; both [`ASAPStrategies`] and [`SharedSubDAGStrategy`] -/// return an empty `Vec` rather than panicking when called on a target they -/// don't match, so a caller that skips the `matches` check first still gets a -/// safe (merely uninformative) answer instead of a crash. -pub trait ReplacementStrategy { - /// Stable, human-readable strategy name carried into every proposed - /// candidate and ultimately into planner diagnostics/visualizations. - fn name(&self) -> &'static str { - let short = std::any::type_name::() - .rsplit("::") - .next() - .expect("a Rust type name always has a final segment"); - short.split_once('<').map_or(short, |(base, _)| base) - } - - /// Does this strategy have any replacement to offer for `target`? - fn matches(&self, target: &TargetSubDAG<'_>) -> bool; - - /// 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. - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec; - - /// [`replacements`](Self::replacements) plus the accuracy-illegal - /// candidates this strategy refused to propose (issue #172). Default: - /// every candidate from `replacements`, no rejections — a strategy that - /// never performs an accuracy check need not override this. - /// [`search_workload_with`] calls this (not `replacements`) so the - /// rejections land in [`TargetSubDAGCandidates::rejected`]. - fn propose(&self, target: &TargetSubDAG<'_>) -> Proposals { - Proposals { - candidates: self.replacements(target), - rejected: Vec::new(), - domain_error: None, - } - } - - /// Whole-query logical alternatives for a workload root under its - /// end-to-end `target`. These may need input rows the root does not expose - /// (for example, the PromQL series identity), so - /// [`search_workload_with_targets`] asks only workload roots, once each. - /// They decide what to compute, never placement. Default: none. - fn propose_for_root(&self, _root: &Rc, _target: &AccuracyTarget) -> Proposals { - Proposals::default() - } -} - -// ── Realization: how one AggIntent may be realised ─────────────────────── - -/// How an [`AggIntent`] may be realised at post-ASAP binding time (issue -/// #98): by an approximate summary (sketch, sample, wavelet, statistical -/// model, …), by an exact mergeable accumulator, or by an ordinary exact -/// operator (pass-through). This is a post-ASAP concern — the pre-ASAP IR -/// carries only the intent + accuracy target, never the realization — and -/// it's a per-node decision, made once per `AggIntent`, not a plan-wide one. -/// -/// [`realizations_for_intent`] is where every valid realization gets -/// enumerated, exhaustive and ranked (most-preferred first) — this crate has -/// no separate function that computes just "the one" `Realization` -/// independently of that list. [`ASAPStrategies`] is the sole -/// consumer: it wraps every entry of this list into its own bound -/// [`OperatorNode`] and returns all of them, ranked — a caller wanting a -/// single answer keeps the first one itself (see the module docs above). -#[derive(Debug, Clone, PartialEq)] -pub enum Realization { - /// An exact **mergeable** accumulator (partial state ≡ the value - /// itself: `Sum` / `Count` / `Min` / `Max` / `Rate` / `Increase`). The - /// built state *is* the answer already — no `SummaryEstimate` evaluation - /// step. - ExactAggregate { - kind: ExactKind, - params: ExactParams, - }, - /// An approximate sketch sized to the intent's [`AccuracyTarget`]. - /// Needs a `SummaryEstimate` evaluation to recover a value. Already - /// classified into its [`SketchKind`] category (`SketchKind::new` - /// having been called) — construction always goes through that - /// classifier, never this variant directly. - Sketch(SketchKind), - /// A sampling-based summary (a retained row subset). Needs a - /// `SummaryEstimate` evaluation. Not chosen by any core `AggIntent` - /// dispatch today — see the module docs. - Sample { - kind: SamplingKind, - params: SamplingParams, - }, - /// A wavelet-transform summary. Needs a `SummaryEstimate` evaluation. Not - /// chosen by any core `AggIntent` dispatch today — see the module docs. - Wavelet { - kind: WaveletKind, - params: WaveletParams, - }, - /// A fitted statistical/parametric-model summary. Needs a - /// `SummaryEstimate` evaluation. Not chosen by any core `AggIntent` - /// dispatch today — see the module docs. - StatModel { - kind: StatModelKind, - params: StatModelParams, - }, - /// No summary form — the node stays a logical pre-ASAP operator and is - /// executed exactly (per-series transforms, non-mergeable reducers, exact - /// quantile/top-k/cardinality, classic-bucket `HistogramQuantile`, …). - PassThrough, -} - -/// Does an already-**available** [`Realization`] — e.g. a summary -/// instance a downstream deployment already materialized somewhere, found -/// via whatever inventory/index that deployment keeps — satisfy a -/// **required** [`Realization`] (one of the candidates -/// [`realizations_for_intent`] produced for some [`AggIntent`])? -/// -/// This is the query-optimization-literature "materialized view matching" -/// / "answering queries using views" question, narrowed to this crate's -/// summary vocabulary: not "can I build this from scratch" (that's what -/// [`realizations_for_intent`] answers) but "does something that already -/// exists answer this". -/// -/// `asap-plan` deliberately ships no implementation of this trait and no -/// default method body — unlike [`realizations_for_intent`], which decision -/// an available `Realization` satisfies a required one is not a fact this -/// crate can settle on its own. Two real, reasonable answers already -/// diverge outside this crate: -/// -/// - A **pure sketch-algebra** answer would say a `Sketch{kind: Kll, ..}` -/// requirement is satisfied by an available `DDSketch` (both quantile -/// sketches), and that a heap-bearing top-k sketch also answers a bare -/// frequency point-query (the heap is additional info on the same -/// underlying matrix) — but not the reverse. -/// - A **deployment with its own storage-layout rules** may need more: -/// e.g. whether a multi-population accumulator can serve a -/// single-population query via re-aggregation is a fact about that -/// deployment's storage layout, not about any summary family's kind at -/// all — a family's own kind doesn't encode grouping (grouping lives on -/// the post-ASAP node's `by` instead), so there is nothing in this -/// crate's own vocabulary to subsume. -/// -/// Implementations are expected to consult `required`/`available`'s -/// `kind` (and whatever grouping/placement context the deployment tracks -/// alongside `Realization`, which this trait's signature doesn't carry -/// because this crate has no inventory concept to carry it in). -pub trait Matcher { - fn is_satisfied_by(&self, required: &Realization, available: &Realization) -> bool; -} - -/// Every valid [`Realization`] for `intent`, exhaustive and ranked -/// (most-preferred first via `cost_model`) — 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 -/// that wants a single executable answer takes the head of *that* strategy's -/// output itself. -/// -/// Exhaustive over the [`AggIntent`] vocabulary — adding a variant without an -/// explicit realization is a compile error, and the coverage-matrix test pins -/// each variant's category. -/// -/// `pub(crate)`: [`ASAPStrategies::replacements`] is this module's -/// own caller; `grouping::HydraGroupingStrategy` (issue #256) is the one -/// 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 { - match intent { - // ── Approximate-capable intents — the AccuracyTarget decides ──────── - AggIntent::Quantile { accuracy, .. } - | AggIntent::Cardinality { accuracy, .. } - | AggIntent::FrequencyL2 { accuracy, .. } - | AggIntent::FrequencyEntropy { accuracy, .. } - | AggIntent::Count { accuracy } - | AggIntent::TopK { accuracy, .. } => match accuracy { - accuracy if accuracy.is_exact() && matches!(intent, AggIntent::Count { .. }) => vec![ - exact_realization(intent), - Realization::Sketch(SketchKind::new( - SketchAlgorithm::UnivMon, - default_size_params(SketchAlgorithm::UnivMon, intent, 0.0, DEFAULT_DELTA), - )), - ], - accuracy if accuracy.is_exact() => vec![exact_realization(intent)], - _ if matches!(intent, AggIntent::Count { .. }) => { - let mut candidates = sketch_realizations(intent, accuracy, cost_model); - candidates.push(exact_realization(intent)); - candidates - } - _ => sketch_realizations(intent, accuracy, cost_model), - }, - - // ── Exact mergeable accumulators ───────────────────────────────────── - AggIntent::Sum { .. } - | AggIntent::Min { .. } - | AggIntent::Max { .. } - | AggIntent::Rate - | AggIntent::IRate - | AggIntent::Increase => { - let (kind, params) = crate::function_rules::function_rules(intent) - .and_then(|rules| rules.accumulator) - .expect("exact accumulator intents have registered realizations"); - vec![exact_accumulator(intent, kind, params)] - } - - // ── Exact, non-mergeable reducers — richer partial state than a - // single value (see `agg_is_mergeable`), so no accumulator form. - AggIntent::Avg { .. } - | AggIntent::StdDev { .. } - | AggIntent::Variance { .. } - | AggIntent::PearsonCorr { .. } => { - vec![Realization::PassThrough] - } - - // ── Classic-bucket histogram_quantile (#79): exact `le`-bucket - // interpolation over pre-aggregated counts — NOT re-sketchable. - // (The native/raw form lowers to the generic `Quantile` above.) - AggIntent::HistogramQuantile { .. } => vec![Realization::PassThrough], - - // ── Per-series transforms and reductions with no sketch realization: - // counter-derivatives (#44), math (#45), time/calendar (#46), - // presence (#47), native-histogram accessors (#43), and the - // `*OverTime` reducers (#51). All exact by construction. - AggIntent::Changes - | AggIntent::Delta - | AggIntent::IDelta - | AggIntent::Deriv - | AggIntent::Resets - | AggIntent::PredictLinear { .. } - | AggIntent::DoubleExpSmoothing { .. } - | AggIntent::HistogramCount - | AggIntent::HistogramSum - | AggIntent::HistogramAvg - | AggIntent::HistogramStdDev - | AggIntent::HistogramStdVar - | AggIntent::HistogramFraction { .. } - | AggIntent::Math(_) - | AggIntent::Absent - | AggIntent::AbsentOverTime - | AggIntent::PresentOverTime - | AggIntent::TimeFn(_) - | AggIntent::LastOverTime - | AggIntent::FirstOverTime - | AggIntent::MadOverTime - | AggIntent::TsOfMinOverTime - | AggIntent::TsOfMaxOverTime - | AggIntent::TsOfFirstOverTime - | AggIntent::TsOfLastOverTime => vec![Realization::PassThrough], - - // ── Group / count_values (#49): exact per `agg_is_exact`, but their - // output is structural (constant-1 / a synthesized label column), - // not a value a summary accumulator carries. - 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)] - } - } -} - -/// Exact realization of an approximate-capable intent whose target is -/// `AccuracyTarget::exact()`. `Count` has a mergeable exact accumulator; exact -/// quantile / top-k / cardinality have no single-value summary form (they -/// need the full multiset / heap / set) and pass through. -fn exact_realization(intent: &AggIntent) -> Realization { - match intent { - AggIntent::Count { .. } => exact_accumulator(intent, ExactKind::Count, ExactParams::Count), - _ => Realization::PassThrough, - } -} - -fn exact_accumulator(intent: &AggIntent, kind: ExactKind, params: ExactParams) -> Realization { - // An exact accumulator is only sound when partial states merge - // (`agg(A ∪ B) = combine(agg(A), agg(B))`). - debug_assert!( - agg_is_mergeable(intent), - "accumulator for non-mergeable {intent:?}" - ); - Realization::ExactAggregate { kind, params } -} - -/// 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 { - let (eps, delta) = accuracy_budget(accuracy); - let ranked = crate::cost_model::validated_candidate_ranking( - cost_model, - intent, - summary_candidates(intent), - ); - ranked - .into_iter() - .filter_map(|algorithm| { - let params = cost_model.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))) - }) - .collect() -} - -/// Fail-safe ceiling used when a deployment has not supplied a tighter -/// resource model. It applies to one physical keyed state; grouped instance -/// multiplicity must be charged separately by deployment-aware costing. -pub const DEFAULT_MAX_SKETCH_STATE_BYTES: u64 = 512 * 1024 * 1024; - -/// Conservative dense-counter allocation for CMS-family states. Returning -/// `None` leaves non-CMS families to their family-specific resource models. -pub fn sketch_state_bytes(params: &SketchParams) -> Option { - if let SketchParams::UnivMon { - heap_size, - sketch_rows, - sketch_cols, - layers, - .. - } = params - { - return u64::from(*sketch_rows) - .checked_mul(u64::from(*sketch_cols))? - .checked_mul(8)? - .checked_add(u64::from(*heap_size).checked_mul(64)?)? - .checked_mul(u64::from(*layers)); - } - let (width, depth, heap_size) = match params { - SketchParams::Cms { width, depth } | SketchParams::CountSketch { width, depth } => { - (*width, *depth, 0) - } - SketchParams::CmsWithHeap { - width, - depth, - heap_size, - } - | SketchParams::CountSketchWithHeap { - width, - depth, - heap_size, - } => (*width, *depth, *heap_size), - _ => return None, - }; - // Eight-byte counters plus a conservative 64 bytes for each heap entry. - u64::from(width) - .checked_mul(u64::from(depth))? - .checked_mul(8)? - .checked_add(u64::from(heap_size).checked_mul(64)?) -} - -/// A deployment's explicit bet about how "typical" (non-adversarial) its -/// workload's collision pattern is expected to be, consumed only by -/// [`posterior_aware_size_params`]. -/// -/// This is **not** derived from Chen et al.'s posterior-error-estimation -/// technique (issue #239, `asap_types::post_asap::query_time::error_estimation`) -/// — that technique computes a tighter bound *at query time* from a -/// sketch's real counter values, and this repo has no sketch runtime yet -/// for a real counter array to size against (see that module's docs, and -/// `asap_types::post_asap::query_time`'s module doc for why it's a -/// deliberately separate folder from this crate's own *plan-time* code). -/// This struct is this crate's own *plan-time* analogue of the same -/// underlying intuition — an expected-case (skewed / non-adversarial) -/// workload needs a smaller sketch than the adversarial worst case — -/// expressed as an explicit, caller-supplied assumption rather than -/// anything observed or proven. Issue #250 tracks actually connecting the -/// two: feeding query-time-observed posterior error back into a future -/// replan's `width_relaxation` instead of a bare caller guess. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ExpectedCaseSizing { - /// Fraction, in `(0, 1]`, of the traditional worst-case width - /// ([`cms_width`]) the caller is betting is enough. `1.0` (or any - /// value outside `(0, 1)`) reproduces the worst-case width exactly — - /// no risk taken. A smaller value shrinks the sketch proportionally, - /// at the cost documented on [`posterior_aware_size_params`]. - pub width_relaxation: f64, -} - -/// Opt-in alternative to [`default_size_params`] for the CMS-family kinds -/// (`Cms` / `CmsWithHeap` / `CountSketch` / `CountSketchWithHeap`): sizes -/// width to `assumption.width_relaxation` of the worst-case [`cms_width`], -/// trading the unconditional worst-case `(ε,δ)` guarantee for a smaller -/// sketch under an explicit, caller-stated non-adversarial-workload bet — -/// see [`ExpectedCaseSizing`]. -/// -/// **The tradeoff, spelled out:** [`default_size_params`]'s width guarantees -/// `Pr[error > ε·|F|₁] < δ` for *any* input, including an adversarial one -/// built to maximize collisions (§3.3 of the posterior-error-estimation -/// paper this issue is about — see -/// `asap_types::post_asap::query_time::error_estimation`'s module docs). -/// Shrinking -/// width below that only keeps the same `(ε,δ)` guarantee if the real -/// workload's collision load stays within `width_relaxation` of the -/// worst-case assumption — this function does not check that, cannot check -/// it (no data exists at plan time), and does not change the formal -/// guarantee's statement; it only changes how much hardware is spent -/// chasing it. Callers accept that gap explicitly by choosing -/// `width_relaxation < 1.0`. -/// -/// Depth ([`cms_depth`]) is left unchanged from [`default_size_params`]: -/// depth trades away confidence *exponentially* (`Pr[all r rows bad] = -/// p^r` — each extra row multiplies the failure probability down), a -/// differently-shaped and materially riskier tradeoff than width's linear -/// relaxation. Issue #239 asks for *a* tighter-sizing option under a -/// stated assumption, not a full redesign of the depth/width tradeoff -/// space, so depth relaxation is left as explicit future scope. -/// -/// For every `SketchAlgorithm` outside the CMS family, this is identical to -/// [`default_size_params`] — `width_relaxation` only ever touches the -/// [`cms_width`]-sized formulas this issue is about. -/// -/// [`default_size_params`]'s own behavior is completely unchanged by this -/// function's existence — this is a separate, additive entry point, never -/// called from [`default_size_params`] or [`realizations_for_intent`]. -pub fn posterior_aware_size_params( - kind: SketchAlgorithm, - intent: &AggIntent, - eps: f64, - delta: f64, - assumption: ExpectedCaseSizing, -) -> SketchParams { - let relaxed_width = |eps: f64| -> u32 { - let base = cms_width(eps); - let f = assumption.width_relaxation; - if !(f.is_finite() && f > 0.0 && f < 1.0) { - return base; // out-of-range bet: no relaxation, fall back to worst case - } - saturating_ceil(base as f64 * f, 2, base) - }; - match kind { - SketchAlgorithm::Cms => SketchParams::Cms { - width: relaxed_width(eps), - depth: cms_depth(delta), - }, - SketchAlgorithm::CmsWithHeap => { - let k = match intent { - AggIntent::TopK { k, .. } => *k, - _ => unreachable!("CmsWithHeap is only a TopK candidate"), - }; - SketchParams::CmsWithHeap { - width: relaxed_width(eps), - depth: cms_depth(delta), - heap_size: crate::accuracy::topk_capacity(k, eps), - } - } - SketchAlgorithm::CountSketch => { - // CMS's expected-L1 collision relaxation is not a CountSketch - // L2 theorem; retain the formal CountSketch sizing unchanged. - default_size_params(kind, intent, eps, delta) - } - SketchAlgorithm::CountSketchWithHeap => { - // As above, do not apply CMS's L1 relaxation to CountSketch. - default_size_params(kind, intent, eps, delta) - } - // Every other kind is untouched by this issue's CMS-specific - // relaxation — defer to the existing formula verbatim. Spelled out - // exhaustively, matching `default_size_params`'s own match, rather - // than a wildcard arm: a future `SketchAlgorithm` variant then fails to - // compile *here* too, instead of silently inheriting worst-case - // sizing with no signal that this function never considered it. - SketchAlgorithm::Kll => default_size_params(kind, intent, eps, delta), - SketchAlgorithm::Hll => default_size_params(kind, intent, eps, delta), - SketchAlgorithm::DDSketch => default_size_params(kind, intent, eps, delta), - SketchAlgorithm::Theta => default_size_params(kind, intent, eps, delta), - SketchAlgorithm::Kmv | SketchAlgorithm::UnivMon => { - default_size_params(kind, intent, eps, delta) - } - } -} - -// ── 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 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` decides -/// legality (issue #172) — see [`crate::accuracy`]'s module docs for why -/// those are separate from `cost` and run before it. -#[derive(Clone, Copy)] -pub(crate) struct CandidatePlanningInputs<'a> { - pub cost: &'a dyn CostModel, - pub accuracy: &'a dyn AccuracyModel, - pub evidence: &'a dyn AccuracyEvidenceProvider, -} - -impl<'a> CandidatePlanningInputs<'a> { - /// `cost` with the built-in [`DefaultAccuracyModel`]/ - /// equal budget split — what every entry point that only takes a - /// `CostModel` uses. - pub(crate) fn with_default_accuracy(cost: &'a dyn CostModel) -> Self { - Self { - cost, - accuracy: &DEFAULT_ACCURACY_MODEL, - evidence: &NO_ACCURACY_EVIDENCE, - } - } -} - -/// Proposes the supported ASAP realizations for a bindable aggregate, including -/// 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. -/// -/// 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 -/// 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 -/// [`ReplacementStrategy::propose`] as a [`RejectedCandidate`]. See -/// [`crate::accuracy`]'s module docs for the rules and the precedence -/// between root and per-node targets. -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 { - Self { - planning_inputs: CandidatePlanningInputs::with_default_accuracy(&DEFAULT_COST_MODEL), - } - } -} - -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`]. - 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. - pub fn new_with_planning_inputs( - cost_model: &'a dyn CostModel, - accuracy_model: &'a dyn AccuracyModel, - ) -> Self { - Self { - planning_inputs: CandidatePlanningInputs { - cost: cost_model, - accuracy: accuracy_model, - evidence: &NO_ACCURACY_EVIDENCE, - }, - } - } - - /// 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, - evidence: &'a dyn AccuracyEvidenceProvider, - ) -> Self { - Self { - planning_inputs: CandidatePlanningInputs { - cost: cost_model, - accuracy: accuracy_model, - evidence, - }, - } - } - - /// Preserve the canonical Sort/Limit representation while exploring heap - /// realizations of an instant-vector ranking under the caller's target. - /// The input must carry the complete dynamic series identity. This never - /// treats a range of historical samples as the instant vector. - pub fn current_series_topk_candidates( - &self, - root: &Rc, - accuracy: &AccuracyTarget, - ) -> Proposals { - let Some(NonASAPOp::Limit { - n: Some(n), - offset: 0, - partition_by: limit_partition, - child, - }) = root.non_asap() - else { - return Proposals::default(); - }; - let Some(NonASAPOp::Sort { - keys, - partition_by, - child, - }) = child.non_asap() - else { - return Proposals::default(); - }; - // The Limit keeps k rows per its own partition; only when that is the - // Sort's partition is the result the top k of each ranked group. - if limit_partition != partition_by { - return Proposals::default(); - } - let [key] = keys.as_slice() else { - return Proposals::default(); - }; - let ScalarExpr::Column(value) = key.expr else { - return Proposals::default(); - }; - let schema = &child.schema; - if key.ascending - || key.nulls_first - || partition_by.is_without() - || !schema.has_promql_series_identity() - || !schema - .fields - .get(value) - .is_some_and(|column| column.name == "value") - || !is_current_series_source(child) - { - return Proposals::default(); - } - let Ok(ranked) = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(partition_by.clone()), - measures: vec![AggIntent::TopK { - k: *n, - accuracy: accuracy.clone(), - }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(child), - })) - else { - return Proposals::default(); - }; - self.propose_with(&ranked, None, None) - } - - /// Fixed-window maintenance can finalize each series' counter state and - /// build a fresh heap or grouped Sum for that evaluation window. Deployment must provide - /// a complete, synchronized population and bind the matching window; this - /// candidate never incrementally adds one window's rates to another. - /// - pub fn fixed_window_rate_candidates(&self, root: &Rc) -> Proposals { - fn place(node: &Rc) -> Option> { - retime_rate_finalize(node, ExecutionTiming::IngestionTime, true) - } - // Legal only if the candidate stays executable with its states maintained. - let timed = |node: &Rc| { - asap_types::ir::timing::apply_materialization_timings( - node, - &asap_types::ir::timing::MaterializationAssignment::with_default( - asap_types::ir::Materialization::MatAtIngestion(asap_types::ir::Medium::Memory), - ), - &mut asap_types::ir::timing::TimingMemo::new(), - ) - .ok() - .and_then(|timed| { - asap_types::ir::physical_export::compile_physical_asap_dag(&timed).ok() - }) - }; - let mut proposals = self.propose_with(root, None, None); - proposals.candidates.retain_mut(|candidate| { - let Replacement::SubDAG(node) = &candidate.replacement else { - return false; - }; - let Some(dag) = timed(node) else { - return false; - }; - if !dag.nodes.iter().any(|node| match &node.payload { - asap_types::ir::physical_export::PhysicalASAPOperatorPayload::ASAP( - asap_types::ir::ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }, - ) => matches!( - kind.algorithm(), - SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap - ), - asap_types::ir::physical_export::PhysicalASAPOperatorPayload::ASAP( - asap_types::ir::ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Sum, _), - .. - }, - ) => true, - _ => false, - }) { - return false; - } - let Some(placed) = place(node) else { - return false; - }; - if timed(&placed).is_none() { - return false; - } - let Ok(placed) = finalize_query_candidate(placed, root) else { - return false; - }; - candidate.replacement = Replacement::SubDAG(placed); - candidate - .rationale - .push_str("; fixed-window precompute over complete per-series counter states"); - true - }); - proposals - } - - /// Retain grouped Sum after a per-series Rate evaluation as a query-time - /// candidate alongside its complete-window maintenance placement. - /// - pub fn query_time_rate_aggregation_candidates(&self, root: &Rc) -> Proposals { - let mut proposals = self.fixed_window_rate_candidates(root); - proposals.candidates.retain_mut(|candidate| { - let Replacement::SubDAG(node) = &candidate.replacement else { return false }; - if !matches!(&node.operator, Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) - if matches!(&child.operator, Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Sum, _), .. }))) { return false; } - let Some(query_time) = retime_rate_finalize(node, ExecutionTiming::QueryTime, false) else { return false }; - candidate.replacement = Replacement::SubDAG(query_time); - candidate.rationale = "query-time grouped Sum over complete per-series Rate evaluations".into(); - true - }); - proposals - } - - pub(crate) fn from_planning_inputs(planning_inputs: CandidatePlanningInputs<'a>) -> Self { - Self { planning_inputs } - } - - /// The whole enumeration for one target, with `intent_override` - /// substituting the target's own intent (only ever its `AccuracyTarget` - /// differs — see [`realize_child_with`]). `strictest_sibling` adds each - /// sketch resized to that stricter sibling accuracy (#509 summary - /// capability): alone it only costs more, but post-ASAP CSE shares it - /// with the sibling that needs it. - fn propose_with( - &self, - root: &Rc, - intent_override: Option<&AggIntent>, - strictest_sibling: Option<&AccuracyTarget>, - ) -> Proposals { - let mut proposals = Proposals::default(); - // A selected logical rewrite otherwise stays a kept pre-ASAP sub-DAG - // during DAG assembly. Also expose its concrete summary realization - // for selection. - if intent_override.is_none() { - if let Some(rewritten) = crate::rewrite::composed_aggregate_rewrite(root) { - if let Ok(node) = realize_child_with(&rewritten, self.planning_inputs, None) { - if node.contains_asap() { - proposals.candidates.push(ReplacementSubDAG { - replacement: Replacement::SubDAG(node), - strategy: "ASAPStrategies", - provenance: ReplacementProvenance::SummaryRealization, - rationale: "realize a schema-preserving composition of temporal and grouped accumulators".into(), - }); - } - } - } - } - if let Ok(Some(node)) = exact_topk_over_temporal_values(root, self.planning_inputs) { - proposals.candidates.push(ReplacementSubDAG { - replacement: Replacement::SubDAG(node), - strategy: "ASAPStrategies", - provenance: ReplacementProvenance::SummaryRealization, - rationale: "select exact Top-K from independently maintained temporal values" - .into(), - }); - } - if intent_override.is_none() { - if let Ok(Some(node)) = realize_temporal_average(root, self.planning_inputs, None) { - proposals.candidates.push(ReplacementSubDAG { - replacement: Replacement::SubDAG(node), - strategy: "ASAPStrategies", - provenance: ReplacementProvenance::SummaryRealization, - rationale: "read temporal average from sum/count only within the finite arithmetic domain; otherwise execute the original average".into(), - }); - } - } - if intent_override.is_none() && is_supported_exact_binary(root) { - if let Ok(Some(node)) = realize_binary(root, self.planning_inputs, None) { - let rationale = if node.guarantee.is_none() { - "DDSketch quantile ratio without a certified end-to-end accuracy guarantee" - } else { - "preserve exact PromQL arithmetic over independently realized summary operands" - }; - proposals.candidates.push(ReplacementSubDAG { - replacement: Replacement::SubDAG(node), - strategy: "ASAPStrategies", - provenance: ReplacementProvenance::SummaryRealization, - rationale: rationale.into(), - }); - } - return proposals; - } - let Some(declared) = bindable_intent(root) else { - return proposals; - }; - let intent = intent_override.unwrap_or(declared); - let planning_inputs = self.planning_inputs; - - // Is the child approximate? Probed once, up front: a candidate over - // an approximate child needs the end-to-end budget split across both - // layers, which changes which candidates exist at all. - let child_layers = aggregate_child(root) - .and_then(|child| realize_child_with(child, planning_inputs, None).ok()) - .and_then(|child| { - child - .guarantee - .as_ref() - .filter(|g| !g.is_exact()) - .map(AccuracyGuarantee::approximate_layer_count) - }); - - // `realizations_for_intent` is already exhaustive and ranked — no - // separate dispatch needed here. Only `Sketch` has more than one - // 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) { - let rationale = describe_realization(intent, &realization); - // The as-declared composition: every layer sized to its own - // declared `AccuracyTarget`. Legal iff the composed guarantee - // satisfies this node's target — a front end copying one target - // onto every node does not make that so. - proposals.record( - rationale.clone(), - construct_summary_with( - root, - intent, - realization.clone(), - planning_inputs, - None, - None, - ), - ); - - // Sized for the strictest sibling reading the same summary input, - // when that changes the parameters. - 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); - if params != *kind.params() { - proposals.record( - format!( - "{rationale}; sized for the strictest sibling consumer {stricter:?}" - ), - construct_summary_with( - root, - &override_accuracy(intent, stricter), - Realization::Sketch(SketchKind::new(algorithm, params)), - planning_inputs, - None, - None, - ), - ); - } - } - - // Budget-split alternatives (issue #172, PR 2): re-size this - // layer and the approximate child under each allocation of this - // node's target across every approximate layer. - let (Some(child_layers), Realization::Sketch(kind), Some(target)) = - (child_layers, &realization, accuracy_target(intent)) - else { - continue; - }; - let family = FieldDataType::Sketch(kind.clone(), GroupingStrategy::default()); - let Some(child) = aggregate_child(root) else { - continue; - }; - let Some(NonASAPOp::Aggregate { reduction, .. }) = root.non_asap() else { - continue; - }; - let Ok(input) = realize_physical_summary_input(intent, &family, reduction, child) - else { - continue; - }; - let evaluation_query = evaluation(intent, &input.input, planning_inputs.cost); - let Some(local) = planning_inputs - .accuracy - .local_guarantee(&family, &evaluation_query) - else { - continue; - }; - let (metric, layer_count) = (local.spec.accuracy_metric, 1 + child_layers); - let Some(outer_target) = per_input_target(&target, metric, layer_count) else { - proposals.rejected.push(RejectedCandidate { - strategy: "ASAPStrategies", - description: rationale.clone(), - error: AccuracyError::NoLegalAllocation { - target: target.clone(), - layer_count, - }, - }); - continue; - }; - let declared_child_target = aggregate_child(root) - .and_then(|child| bindable_intent(child)) - .and_then(accuracy_target); - let inner_target = (layer_count > 1) - .then(|| nested_layers_target(&target, metric, layer_count, layer_count - 1)) - .flatten(); - 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), - )); - // Identical to the as-declared composition already recorded - // above — nothing new to propose. - if resized == realization && inner_target == declared_child_target { - continue; - } - let note = GuaranteeSource::BudgetAllocation { - allocator: "equal_split".to_string(), - layer: 0, - layer_count, - local_target: outer_target.clone(), - end_to_end_target: target.clone(), - }; - proposals.record( - format!( - "{rationale}; sized under {} budget split of {target:?} across \ - {} approximate layers (this layer {outer_target:?}, child sub-DAG \ - {inner_target:?})", - "equal_split", layer_count - ), - construct_summary_with( - root, - intent, - resized, - planning_inputs, - inner_target.as_ref(), - Some(note), - ), - ); - } - if proposals.candidates.is_empty() { - if let Some(error) = &proposals.domain_error { - if let Ok(node) = retain_exact(root) { - proposals.candidates.push(ReplacementSubDAG { - strategy: "ASAPStrategies", - replacement: Replacement::SubDAG(node), - provenance: ReplacementProvenance::SummaryRealization, - rationale: format!( - "{} stays pre-ASAP because summary construction crosses an illegal \ - execution-data_state boundary ({error})", - describe_intent(intent) - ), - }); - } - } - } - proposals - } -} - -impl Proposals { - /// File one construction attempt: a legal node becomes a candidate, an - /// [`RealizationError::Accuracy`] becomes a [`RejectedCandidate`], and a - /// schema-derivation failure is skipped exactly as it always was. - fn record(&mut self, rationale: String, built: Result, RealizationError>) { - match built { - Ok(node) => self.candidates.push(ReplacementSubDAG { - strategy: "ASAPStrategies", - replacement: Replacement::SubDAG(node), - provenance: ReplacementProvenance::SummaryRealization, - rationale, - }), - Err(RealizationError::Accuracy(error)) => self.rejected.push(RejectedCandidate { - strategy: "ASAPStrategies", - description: rationale, - error, - }), - Err(RealizationError::ExecutionDataState(error)) => { - self.domain_error.get_or_insert(error); - } - Err( - RealizationError::Schema(_) - | RealizationError::ExactOperationSchema(_) - | RealizationError::PhysicalRealization(_), - ) => {} - } - } -} - -/// `target` over each value separately: this search sizes and checks every -/// node per value; a target over a whole result is checked only at a query -/// root, by plan selection. -fn per_value(target: &AccuracyTarget) -> AccuracyTarget { - target - .clone() - .accuracy_scope(asap_types::types::AccuracyScope::PerValue) -} - -/// The `child` of a [`bindable_intent`]-shaped `Aggregate`. -fn aggregate_child(node: &OperatorNode) -> Option<&Rc> { - match node.non_asap() { - Some(NonASAPOp::Aggregate { child, .. }) => Some(child), - _ => None, - } -} - -impl ReplacementStrategy for ASAPStrategies<'_> { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - bindable_intent(target.root).is_some() || is_supported_exact_binary(target.root) - } - - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - self.propose(target).candidates - } - - fn propose(&self, target: &TargetSubDAG<'_>) -> Proposals { - self.propose_with(target.root, None, target.strictest_sibling_accuracy) - } - - /// Heap realizations of an instant-vector ranking (current-series TopK). - /// They rank rows that carry the complete PromQL series identity, which - /// the logical root does not expose, so each is a finalized query result - /// for the identity-carrying root. Placement variants (for example, - /// fixed-window or query-time Rate aggregation) are not listed here: the - /// materialization assigns timing and the physical compiler reads it. - fn propose_for_root(&self, root: &Rc, target: &AccuracyTarget) -> Proposals { - let Ok(typed) = asap_types::ir::schema_support::with_promql_series_identity(root) else { - return Proposals::default(); - }; - - let mut proposals = self.current_series_topk_candidates(&typed, target); - for mut candidate in std::mem::take(&mut proposals.candidates) { - let Replacement::SubDAG(node) = candidate.replacement else { - continue; - }; - let Ok(node) = finalize_query_candidate(node, &typed) else { - continue; - }; - let duplicate = proposals.candidates.iter().any(|existing| { - matches!(&existing.replacement, Replacement::SubDAG(other) if *other == node) - }); - if !duplicate { - candidate.replacement = Replacement::SubDAG(node); - candidate.provenance = ReplacementProvenance::RootPhysicalRealization; - proposals.candidates.push(candidate); - } - } - proposals - } -} - -/// A human-readable rationale for one candidate `Realization`, for -/// [`ReplacementSubDAG::rationale`] text. -fn describe_realization(intent: &AggIntent, realization: &Realization) -> String { - match realization { - Realization::Sketch(kind) => format!( - "{} realizes as a {:?} sketch — one of summary_candidates' \ - candidates for this intent (asap_aware_mapping::replacement::realizations_for_intent)", - describe_intent(intent), - kind.algorithm() - ), - Realization::ExactAggregate { kind, .. } => format!( - "{} realizes as an exact {kind:?} accumulator — the only realization \ - realizations_for_intent produces for this intent (no approximate \ - candidate applies)", - describe_intent(intent) - ), - Realization::PassThrough => format!( - "{} has no summary realization and stays a logical pass-through — the \ - only realization realizations_for_intent produces for this intent", - describe_intent(intent) - ), - Realization::Sample { kind, .. } => format!( - "{} realizes as a {kind:?} sample — the only realization the plugged-in \ - CostModel 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", - describe_intent(intent) - ), - Realization::StatModel { kind, .. } => format!( - "{} realizes as a {kind:?} statistical model — the only realization the \ - plugged-in CostModel produced for this intent", - describe_intent(intent) - ), - } -} - -/// A short human-readable label for an `AggIntent`, for -/// [`ReplacementSubDAG::rationale`] text. Not exhaustive by design (unlike -/// this crate's other `AggIntent` matches, e.g. [`realizations_for_intent`]'s) -/// — this is prose for a rationale string, not a decision, so an unlisted -/// variant just falls back to its `Debug` tag rather than forcing every -/// future intent to be named here too. [`crate::explanation`] needs no -/// counterpart of its own: it reads a candidate's `rationale` — built from -/// this text — straight off [`ReplacementSubDAG`], rather than re-describing -/// the same intent a second time. -/// -/// `pub(crate)`: `grouping::HydraGroupingStrategy` (issue #256) reuses this -/// for its own rationale strings, for the same reason. -pub(crate) fn describe_intent(intent: &AggIntent) -> String { - match intent { - AggIntent::Quantile { q, .. } => format!("quantile(q={q})"), - AggIntent::Cardinality { cols, .. } if cols.len() > 1 => { - format!("cardinality (distinct count over {} columns)", cols.len()) - } - AggIntent::Cardinality { .. } => "cardinality (distinct count)".to_string(), - AggIntent::TopK { k, .. } => format!("top-{k} heavy-hitters"), - AggIntent::Count { .. } => "count".to_string(), - other => format!("{other:?}"), - } -} - -// ── realize_child / retain_exact: rank-and-take-first, and its fallback ── - -/// Rank-and-take-first selector for a single [`OperatorNode`]: enumerate -/// every candidate via [`ASAPStrategies::replacements`], keep the -/// `cost_model`-preferred (first) one, and fall back to [`retain_exact`] -/// when there's no candidate at all — **not** a general single-answer API -/// for a whole workload. Use [`CandidateLogicalASAPDAGs::global_selection`] and DAG assembly -/// for coordinated logical selection; physical deployment remains downstream. -/// `root` must already be the caller's own -/// `Rc`, never fabricated per call, so this never allocates beyond what the -/// caller already held. -/// -/// `pub(crate)`: reachable from this module's own construction helper -/// ([`construct_summary_agg`], so a nested aggregate gets its own -/// 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 -/// [`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, - ) -} - -/// [`realize_child`] with every model explicit, plus an optional -/// `end_to_end_target` for `root`'s own value (issue #172): when an -/// equal budget split ([`per_input_target`]) hands an approximate child a share of its -/// parent's budget, the child is re-enumerated with that share substituted -/// for its declared `AccuracyTarget` — sizing its sketch (and, recursively, -/// re-splitting for its own approximate children) under the allocated -/// budget. A child whose declared target is `Exact` keeps it: an allocation -/// never approximates something the caller declared exact. -fn exact_topk_over_temporal_values( - root: &Rc, - planning_inputs: CandidatePlanningInputs<'_>, -) -> Result>, RealizationError> { - let Some(NonASAPOp::Aggregate { - reduction, - measures, - output_names: _, - filters, - having: None, - child, - }) = root.non_asap() - else { - return Ok(None); - }; - if any_measure_filtered(filters) { - return Ok(None); - } - let [AggIntent::TopK { k, .. }] = measures.as_slice() else { - return Ok(None); - }; - let Some(NonASAPOp::Aggregate { - reduction: Reduction::PerEntity, - child: input, - .. - }) = child.non_asap() - else { - return Ok(None); - }; - if !matches!(input.non_asap(), Some(NonASAPOp::TimeRange { .. })) { - return Ok(None); - } - let values = realize_child_with(child, planning_inputs, Some(&AccuracyTarget::exact()))?; - if !values.contains_asap() - || !values - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact) - { - return Ok(None); - } - let values = finalize_query_candidate(values, child)?; - let partition_by = reduction - .group_keys() - .ok_or(RealizationError::PhysicalRealization( - "temporal ranking requires explicit grouping", - ))? - .clone(); - let score = ranking_score_index(child, &values.schema)?; - let guarantee = values.guarantee.clone(); - let schema = values.schema.clone(); - let sorted = Rc::new( - OperatorNode::with_schema( - Operator::NonASAP(NonASAPOp::Sort { - keys: vec![SortKey { - expr: ScalarExpr::Column(score), - ascending: false, - nulls_first: false, - }], - partition_by: partition_by.clone(), - child: values, - }), - schema.clone(), - ) - .with_guarantee(guarantee.clone()), - ); - let node = Rc::new( - OperatorNode::with_schema( - Operator::NonASAP(NonASAPOp::Limit { - n: Some(*k), - offset: 0, - partition_by, - child: sorted, - }), - schema, - ) - .with_guarantee(guarantee), - ); - validate_at_ingestion(&node, ExecutionTiming::QueryTime)?; - Ok(Some(node)) -} - -fn realize_temporal_average( - root: &Rc, - planning_inputs: CandidatePlanningInputs<'_>, - target: Option<&AccuracyTarget>, -) -> Result>, RealizationError> { - let Some(components) = crate::rewrite::temporal_average_components(root) else { - return Ok(None); - }; - let mut node = realize_child_with(&components, planning_inputs, target)?; - let Operator::NonASAP(NonASAPOp::BinaryOp { operator, .. }) = - &mut Rc::make_mut(&mut node).operator - else { - return Ok(None); - }; - operator.checked_finite_division = true; - validate_at_ingestion(&node, ExecutionTiming::QueryTime)?; - Ok(Some(node)) -} - -pub(crate) fn realize_child_with( - root: &Rc, - planning_inputs: CandidatePlanningInputs<'_>, - end_to_end_target: Option<&AccuracyTarget>, -) -> Result, RealizationError> { - if let Some(node) = realize_temporal_average(root, planning_inputs, end_to_end_target)? { - return Ok(node); - } - if let Some(composed) = realize_binary(root, planning_inputs, end_to_end_target)? { - return Ok(composed); - } - let overridden = end_to_end_target.and_then(|target| { - let declared = bindable_intent(root)?; - accuracy_target(declared) - .filter(|accuracy| !accuracy.is_exact()) - .map(|_| override_accuracy(declared, target)) - }); - match ASAPStrategies::from_planning_inputs(planning_inputs) - .propose_with(root, overridden.as_ref(), None) - .candidates - .into_iter() - .next() - { - Some(ReplacementSubDAG { - replacement: Replacement::SubDAG(node), - .. - }) => Ok(node), - Some(ReplacementSubDAG { - replacement: Replacement::ExactComposition(_), - .. - }) => { - unreachable!("ASAPStrategies never returns a composition candidate") - } - // No candidate at all: `root` isn't `bindable_intent` shape (or its - // intent has no realization `realizations_for_intent` can't - // produce — never happens, that match is exhaustive), or every - // candidate was accuracy-illegal — either way the same conservative - // fallback `ASAPStrategies::matches` uses: keep the - // pre-ASAP sub-DAG, executed exactly. - None => retain_exact(root), - } -} - -/// Preserve an exact arithmetic root while allowing each vector operand to -/// select its own summary realization. If either vector arm cannot be -/// accelerated, return `None` so the caller keeps the whole query exact; -/// mixed raw/summary snapshots are never constructed. -fn realize_binary( - root: &Rc, - planning_inputs: CandidatePlanningInputs<'_>, - end_to_end_target: Option<&AccuracyTarget>, -) -> Result>, RealizationError> { - let Some(NonASAPOp::BinaryOp { - operator, - return_bool, - lhs, - rhs, - }) = root.non_asap() - else { - return Ok(None); - }; - let (op, vector_match) = (&operator.kind, &operator.vector_match); - if !is_supported_exact_binary(root) { - return Ok(None); - } - - let mut lhs_node = realize_binary_operand(lhs, planning_inputs, None)?; - let mut rhs_node = realize_binary_operand(rhs, planning_inputs, None)?; - - let direct_ddsketch_ratio = matches!(op, BinaryOpKind::Arithmetic(ArithmeticOpKind::Div)) - && shared_quantile_target(lhs, rhs).is_some(); - let ratio_target = end_to_end_target - .cloned() - .or_else(|| shared_quantile_target(lhs, rhs)); - - let mut ratio_domains = None; - if direct_ddsketch_ratio { - if let Some(target) = ratio_target - .as_ref() - .and_then(ddsketch_ratio_operand_target) - { - let (alpha, _) = accuracy_budget(&target); - let lhs_domain = planning_inputs.evidence.operand_domain(lhs); - let rhs_domain = planning_inputs.evidence.operand_domain(rhs); - if [&lhs_domain, &rhs_domain] - .into_iter() - .flatten() - .any(|domain| !domain.supports_ddsketch(alpha)) - { - return Ok(None); - } - let domains = lhs_domain.zip(rhs_domain).map(|(lhs, rhs)| [lhs, rhs]); - let has_mean = [lhs, rhs] - .iter() - .any(|expr| matches!(bindable_intent(expr), Some(AggIntent::Avg { .. }))); - if has_mean - && domains.as_ref().is_none_or(|domains| { - domains.iter().any(|domain| { - !(domain.lower.abs().max(domain.upper.abs()) * domain.max_samples as f64) - .is_finite() - }) - }) - { - return Ok(None); - } - lhs_node = realize_ddsketch_quantile_operand(lhs, planning_inputs, &target)?; - rhs_node = realize_ddsketch_quantile_operand(rhs, planning_inputs, &target)?; - if let Some(domains) = domains.as_ref() { - for (domain, node) in domains.iter().zip([&lhs_node, &rhs_node]) { - if !ddsketch_quantile_alpha(node) - .or_else(|| { - node.guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact) - .then_some(alpha) - }) - .is_some_and(|alpha| domain.supports_ddsketch(alpha)) - { - return Ok(None); - } - } - } - ratio_domains = domains; - } - } else if let Some(target) = end_to_end_target { - let operand_guarantees = [lhs_node.guarantee.as_ref(), rhs_node.guarantee.as_ref()]; - if operand_guarantees.iter().any(Option::is_none) { - return Ok(None); - } - let approximate = operand_guarantees - .into_iter() - .enumerate() - .filter_map(|(index, guarantee)| { - guarantee - .filter(|guarantee| !guarantee.is_exact()) - .map(|guarantee| (index, guarantee.spec.accuracy_metric)) - }) - .collect::>(); - if !approximate.is_empty() { - let metric = approximate[0].1; - if approximate - .iter() - .any(|(_, candidate)| *candidate != metric) - { - return Ok(None); - } - let Some(local_target) = per_input_target(target, metric, approximate.len()) else { - return Ok(None); - }; - for (operand_index, _) in approximate { - if operand_index == 0 { - lhs_node = realize_binary_operand(lhs, planning_inputs, Some(&local_target))?; - } else { - rhs_node = realize_binary_operand(rhs, planning_inputs, Some(&local_target))?; - } - } - } - } - // Only direct quantile ratios may consume approximate division operands - // without domain proof; their root guarantee remains unknown. - if ratio_domains.is_none() - && !direct_ddsketch_ratio - && matches!(op, BinaryOpKind::Arithmetic(ArithmeticOpKind::Div)) - && [&lhs_node, &rhs_node].iter().any(|node| { - !node - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact) - }) - { - return Ok(None); - } - - let lhs_accelerated = lhs_node.contains_asap(); - let rhs_accelerated = rhs_node.contains_asap(); - if !lhs_accelerated || !rhs_accelerated { - return Ok(None); - } - - lhs_node = finalize_query_candidate(lhs_node, lhs)?; - rhs_node = finalize_query_candidate(rhs_node, rhs)?; - - let has_ratio_domains = ratio_domains.is_some(); - let guarantee = if matches!(op, BinaryOpKind::Arithmetic(ArithmeticOpKind::Div)) - && direct_ddsketch_ratio - && has_ratio_domains - && [&lhs_node, &rhs_node].iter().all(|node| { - ddsketch_quantile_alpha(node).is_some() - || node - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact) - }) { - [lhs_node.guarantee.clone(), rhs_node.guarantee.clone()] - .into_iter() - .collect::>>() - .and_then(|inputs| { - planning_inputs - .accuracy - .propagate( - &CompositionOperator::ExactDivision, - &inputs, - None, - &crate::accuracy::PropagationStats { - division_operand_domains: ratio_domains, - ..Default::default() - }, - ) - .ok() - }) - } else { - [lhs_node.guarantee.as_ref(), rhs_node.guarantee.as_ref()] - .into_iter() - .all(|guarantee| guarantee.is_some_and(AccuracyGuarantee::is_exact)) - .then(|| AccuracyGuarantee::exact("BinaryOp over exact operands")) - }; - - if direct_ddsketch_ratio && has_ratio_domains && guarantee.is_none() { - return Ok(None); - } - - Ok(Some(Rc::new( - OperatorNode::with_schema( - Operator::NonASAP(NonASAPOp::BinaryOp { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: op.clone(), - vector_match: vector_match.clone(), - }, - return_bool: *return_bool, - lhs: lhs_node, - rhs: rhs_node, - }), - root.schema.clone(), - ) - // Exact arithmetic does not erase approximation error. Until the - // accuracy algebra has an operator-specific rule (and any value-range - // evidence needed by multiplication/division), unknown stays unknown. - .with_guarantee(guarantee), - ))) -} - -/// Rebuild the summary chain above a per-series `Rate` accumulator with its -/// `FinalizeExactAccumulator` placed at `timing`. `strict` additionally -/// requires the fixed-window shape (a `PerEntity` Rate over a `TimeRange`); -/// `None` when no such boundary exists (strict only). -fn retime_rate_finalize( - node: &Rc, - timing: ExecutionTiming, - strict: bool, -) -> Option> { - let is_rate_boundary = |child: &OperatorNode| match &child.operator { - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Rate, _), - reduction, - child: source, - .. - }) => { - !strict - || (matches!(reduction, Reduction::PerEntity) - && matches!(source.non_asap(), Some(NonASAPOp::TimeRange { .. }))) - } - _ => false, - }; - let rebuilt = |operator: Operator, timing: Option| { - let mut copy = node.as_ref().clone(); - copy.operator = operator; - copy.timing = timing; - Rc::new(copy) - }; - match &node.operator { - Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) if is_rate_boundary(child) => { - Some(rebuilt(node.operator.clone(), Some(timing))) - } - Operator::ASAP( - ASAPOp::FinalizeExactAccumulator { child } - | ASAPOp::SummaryAgg { child, .. } - | ASAPOp::SummaryEstimate { - summary_input: child, - .. - }, - ) => { - let placed = match retime_rate_finalize(child, timing, strict) { - Some(placed) => placed, - None if strict => return None, - None => return Some(Rc::clone(node)), - }; - let operator = node.operator.map_children(|_| Rc::clone(&placed)); - Some(rebuilt(operator, node.timing)) - } - _ if strict => None, - _ => Some(Rc::clone(node)), - } -} - -/// Put an explicit read boundary between maintained exact state and a -/// query-time value consumer. Approximate summaries must already carry a -/// `SummaryEstimate`, so they deliberately do not pass this predicate. -pub fn finalize_query_candidate( - node: Rc, - logical_output: &OperatorNode, -) -> Result, RealizationError> { - finalize_exact_accumulator(node, logical_output, ExecutionTiming::QueryTime) -} - -/// The read boundary's placement is fixed here, where the candidate's -/// semantics decide it (a fresh query-time summary over this evaluation's -/// finalized values vs. finalized values feeding maintenance); the materialization -/// timing pass honors it. -fn finalize_exact_accumulator( - node: Rc, - logical_output: &OperatorNode, - placement: ExecutionTiming, -) -> Result, RealizationError> { - let is_exact_state = matches!( - node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(..), - .. - }) - ); - if !is_exact_state { - return Ok(node); - } - // The child edge carries accumulator state, while this explicit read - // boundary produces the logical operator's ordinary values. Preserve the - // canonical pre-ASAP output types instead of leaking ExactAggregate into - // query-time operators that follow this node. - let schema = logical_output.schema.clone(); - let guarantee = node.guarantee.clone(); - Ok(Rc::new( - OperatorNode::with_schema( - Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: node }), - schema, - ) - .with_guarantee(guarantee) - .with_timing(Some(placement)), - )) -} - -/// Arithmetic and comparisons over summary operands keep their candidates; -/// over approximate operands their combined guarantee is not composed (#669). -fn is_supported_exact_binary(root: &OperatorNode) -> bool { - matches!( - root.non_asap(), - Some(NonASAPOp::BinaryOp { - operator: BinaryOperator { - kind: BinaryOpKind::Arithmetic(_) - | BinaryOpKind::Compare(_) - | BinaryOpKind::CompareBool(_), - vector_match: None, - .. - }, - .. - }) - ) -} - -/// Quantile operands inherit one workload target. A temporal mean is exact -/// on its checked finite domain and needs no approximation budget. -fn shared_quantile_target(lhs: &OperatorNode, rhs: &OperatorNode) -> Option { - let quantile_target = |expr: &OperatorNode| match bindable_intent(expr) { - Some(AggIntent::Quantile { accuracy, q, .. }) - if q.is_finite() && (0.0..=1.0).contains(q) => - { - Some(accuracy.clone()) - } - _ => None, - }; - match (quantile_target(lhs), quantile_target(rhs)) { - (Some(lhs), Some(rhs)) => (lhs == rhs).then_some(lhs), - (Some(target), None) if matches!(bindable_intent(rhs), Some(AggIntent::Avg { .. })) => { - Some(target) - } - (None, Some(target)) if matches!(bindable_intent(lhs), Some(AggIntent::Avg { .. })) => { - Some(target) - } - _ => None, - } -} - -/// For `a / b`, two DDSketches with the same relative bound `alpha` produce -/// at most `2 * alpha / (1 - alpha)` relative error. Inverting that bound -/// gives `alpha = epsilon / (2 + epsilon)`. -fn ddsketch_ratio_operand_target(target: &AccuracyTarget) -> Option { - let tighten = - |epsilon: f64| (epsilon.is_finite() && epsilon > 0.0).then_some(epsilon / (2.0 + epsilon)); - if target.is_exact() { - return None; - } - tighten(target.error).map(|error| AccuracyTarget { - error, - ..target.clone() - }) -} - -fn ddsketch_quantile_alpha(node: &OperatorNode) -> Option { - let Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query: PostAsapSketchStatistic::Quantile { .. }, - }) = &node.operator - else { - return None; - }; - let Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) = &summary_input.operator - else { - return None; - }; - match (kind.algorithm(), kind.params()) { - (SketchAlgorithm::DDSketch, SketchParams::DDSketch { alpha }) => Some(*alpha), - _ => None, - } -} - -/// Whether nothing shows that the candidate's output meets its target. Only -/// the output's own guarantee (the composed one; realization already checked -/// it against the target) counts here; certified inputs do not, since a -/// combination of them (a difference, a ratio) can be unbounded. Such a -/// candidate is kept, not pruned: composition through operators (#669) or the -/// deployment's empirical output-level accuracy model (plan selection, #576) may -/// supply the evidence later. -fn has_missing_accuracy_evidence(node: &OperatorNode) -> bool { - node.guarantee - .as_ref() - .is_none_or(AccuracyGuarantee::has_unknown) -} - -/// A direct ratio has an operator-specific DDSketch proof, so it must select -/// DDSketch rather than the cost model's generally preferred KLL candidate. -fn realize_ddsketch_quantile_operand( - operand: &Rc, - planning_inputs: CandidatePlanningInputs<'_>, - target: &AccuracyTarget, -) -> Result, RealizationError> { - let intent = bindable_intent(operand).and_then(|intent| match intent { - AggIntent::Quantile { .. } => Some(override_accuracy(intent, target)), - _ => None, - }); - let Some(intent) = intent else { - return realize_binary_operand(operand, planning_inputs, Some(target)); - }; - let (epsilon, delta) = accuracy_budget(target); - let realization = Realization::Sketch(SketchKind::new( - SketchAlgorithm::DDSketch, - planning_inputs - .cost - .size_params(SketchAlgorithm::DDSketch, &intent, epsilon, delta), - )); - construct_summary_with(operand, &intent, realization, planning_inputs, None, None) -} - -fn realize_binary_operand( - operand: &Rc, - planning_inputs: CandidatePlanningInputs<'_>, - end_to_end_target: Option<&AccuracyTarget>, -) -> Result, RealizationError> { - realize_child_with(operand, planning_inputs, end_to_end_target) -} - -/// `intent` with its `AccuracyTarget` replaced by `target` — a no-op for an -/// intent that carries none (see [`accuracy_target`]). -fn override_accuracy(intent: &AggIntent, target: &AccuracyTarget) -> AggIntent { - let mut out = intent.clone(); - match &mut out { - AggIntent::Quantile { accuracy, .. } - | AggIntent::Cardinality { accuracy, .. } - | AggIntent::Count { accuracy } - | AggIntent::TopK { accuracy, .. } => *accuracy = target.clone(), - _ => {} - } - out -} - -/// Keep an unrewritten pre-ASAP sub-DAG as it is. There is no wrapper node: -/// the sub-DAG itself is the plan, carrying an exact guarantee. The same -/// `Rc` is returned when the node already has a guarantee; otherwise a copy -/// with `guarantee = exact("RetainedExact")` — only for a sub-DAG with no -/// ASAP operator (a sub-DAG containing one keeps whatever its construction -/// established). `pub` so a caller can fall back to this explicitly — e.g. -/// when `ASAPStrategies::replacements()` returns no candidate for a -/// target, or a deployment wants to force a node its own runtime can't -/// actually implement — through the same fallback this crate's own dispatch -/// uses. -pub fn retain_exact(expr: &Rc) -> Result, RealizationError> { - retain_exact_rc(Rc::clone(expr)) -} - -fn retain_exact_rc(expr: Rc) -> Result, RealizationError> { - if expr.guarantee.is_some() || expr.contains_asap() { - return Ok(expr); - } - // Keeping the same sub-DAG twice (e.g. one `Scan` read by an exact - // aggregate and by a sketch, or by two candidates) must yield one node: - // sharing is pointer identity. Memoize the kept copy per input node while - // both are alive; weak references keep the memo from extending lifetimes - // or matching a reused address. - type KeptMemo = HashMap<*const OperatorNode, (Weak, Weak)>; - thread_local! { - static KEPT: RefCell = RefCell::new(HashMap::new()); - } - let key = Rc::as_ptr(&expr); - if let Some(kept) = KEPT.with(|memo| { - memo.borrow().get(&key).and_then(|(input, kept)| { - input - .upgrade() - .filter(|input| Rc::ptr_eq(input, &expr)) - .and_then(|_| kept.upgrade()) - }) - }) { - return Ok(kept); - } - let kept = Rc::new( - expr.as_ref() - .clone() - // A kept pre-ASAP sub-DAG is executed exactly by the runtime - // (`Realization::PassThrough`'s contract) — zero error. - .with_guarantee(Some(AccuracyGuarantee::exact("RetainedExact"))), - ); - KEPT.with(|memo| { - let mut memo = memo.borrow_mut(); - if memo.len() > 4096 { - memo.retain(|_, (input, kept)| input.strong_count() > 0 && kept.strong_count() > 0); - } - memo.insert(key, (Rc::downgrade(&expr), Rc::downgrade(&kept))); - }); - Ok(kept) -} - -// ── Construction: turn one already-decided Realization into an OperatorNode ─ - -/// The bindable shape [`ASAPStrategies`] targets: a single intent, no -/// `HAVING`. A multi-intent node (SQL `SELECT SUM(a), AVG(b)`), or one with a -/// `HAVING` predicate (the filter would need the estimate first), stays -/// logical. Unsupported logical parents are conservatively kept as pre-ASAP -/// sub-DAGs ([`retain_exact`]). Relational operators are retained during -/// final DAG assembly so their independently planned children remain -/// visible. -pub fn bindable_intent(node: &OperatorNode) -> Option<&AggIntent> { - if let Some(NonASAPOp::Aggregate { - measures, - filters, - having, - .. - }) = node.non_asap() - { - if let ([intent], None) = (measures.as_slice(), having) { - if !any_measure_filtered(filters) { - return Some(intent); - } - } - } - None -} - -/// `expr` must still be the [`bindable_intent`] shape for `realization` to -/// have any effect; anything else falls back to [`retain_exact`]. -/// Only `expr`'s own top-level decision is forced — recursion into `expr`'s -/// child goes back through [`realize_child`] (fresh candidate -/// enumeration, not a forced pick), so choosing one candidate for a target -/// never leaks into that target's own nested aggregates. -/// -/// `pub(crate)`: `grouping::HydraGroupingStrategy` (issue #256) is the one -/// caller outside this module — the same first-class, -/// one-candidate-at-a-time primitive [`ASAPStrategies`] itself -/// calls once per candidate, reused rather than duplicated so a Hydra -/// candidate gets exactly the same schema derivation/column -/// resolution/evaluation construction as every other candidate, patching only -/// the `grouping` field this axis owns. -/// Construct a summary with every model explicit (issue #172). `intent` -/// is `expr`'s own [`bindable_intent`], or a copy of it with an allocated -/// `AccuracyTarget` substituted (see [`realize_child_with`]). -/// `child_target`, when set, is the end-to-end budget the child sub-DAG is -/// re-enumerated under; `allocation` is the provenance note recording the -/// split that produced both. `Err(RealizationError::Accuracy)` is the -/// fail-closed answer for a composition with no sound rule or one that -/// misses `intent`'s target. -pub(crate) fn construct_summary_with( - expr: &OperatorNode, - intent: &AggIntent, - realization: Realization, - planning_inputs: CandidatePlanningInputs<'_>, - child_target: Option<&AccuracyTarget>, - allocation: Option, -) -> Result, RealizationError> { - if let Some(NonASAPOp::Aggregate { - reduction, child, .. - }) = expr.non_asap() - { - // `bindable_intent` already established the shape: exactly one - // intent, no HAVING. (Multi-intent nodes and HAVING stay logical.) - if bindable_intent(expr).is_some() { - if let Some((family, estimate)) = summary_family(realization) { - let input = realize_physical_summary_input(intent, &family, reduction, child)?; - let candidate = construct_summary_agg( - expr, - reduction, - intent, - input, - family, - estimate, - planning_inputs, - child_target, - allocation, - )?; - if is_snapshot_weighted_topk(intent, child) { - return finish_weighted_topk(candidate, expr, intent); - } - return Ok(candidate); - } - } - } - retain_exact_rc(Rc::new(expr.clone())) -} - -fn finish_weighted_topk( - candidate: Rc, - logical: &OperatorNode, - intent: &AggIntent, -) -> Result, RealizationError> { - let AggIntent::TopK { k, .. } = intent else { - unreachable!() - }; - let Some(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(groups), - child, - .. - }) = logical.non_asap() - else { - return Err(RealizationError::PhysicalRealization( - "TopK requires explicit grouping", - )); - }; - let schema = child.schema.clone(); - let score = ranking_score_index(child, &schema)?; - let cols = schema - .fields - .iter() - .enumerate() - .map(|(i, field)| { - let source = if i == score { - candidate.schema.fields.len() - 1 - } else { - let matches = candidate - .schema - .fields - .iter() - .enumerate() - .filter(|(_, f)| f.name == field.name && f.dtype == field.dtype) - .map(|(i, _)| i) - .collect::>(); - match matches.as_slice() { - [i] => *i, - _ => { - return Err(RealizationError::PhysicalRealization( - "ambiguous TopK output identity", - )) - } - } - }; - Ok(ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(source), - }) - }) - .collect::, _>>()?; - let guarantee = candidate.guarantee.clone(); - let projected = Rc::new( - OperatorNode::with_schema( - Operator::NonASAP(NonASAPOp::Project { - cols, - qualifier: None, - child: candidate, - }), - schema.clone(), - ) - .with_guarantee(guarantee.clone()), - ); - let sorted = Rc::new( - OperatorNode::with_schema( - Operator::NonASAP(NonASAPOp::Sort { - keys: vec![SortKey { - expr: ScalarExpr::Column(score), - ascending: false, - nulls_first: false, - }], - partition_by: groups.clone(), - child: projected, - }), - schema.clone(), - ) - .with_guarantee(guarantee.clone()), - ); - let result = Rc::new( - OperatorNode::with_schema( - Operator::NonASAP(NonASAPOp::Limit { - n: Some(*k), - offset: 0, - partition_by: groups.clone(), - child: sorted, - }), - schema, - ) - .with_guarantee(guarantee), - ); - validate_at_ingestion(&result, ExecutionTiming::QueryTime)?; - Ok(result) -} - -fn is_current_series_source(child: &OperatorNode) -> bool { - let source = match child.non_asap() { - Some(NonASAPOp::TimeRange { child, .. }) => child.as_ref(), - _ => child, - }; - matches!(source.non_asap(), Some(NonASAPOp::Scan { - source: asap_types::pre_asap::Source::TimeSeries { .. }, schema, .. - }) if schema.has_promql_series_identity()) -} - -fn is_snapshot_weighted_topk(intent: &AggIntent, child: &OperatorNode) -> bool { - matches!(intent, AggIntent::TopK { .. }) - && (is_current_series_source(child) - || matches!(child.non_asap(), - Some(NonASAPOp::Aggregate { measures, child, .. }) - if matches!(measures.as_slice(), [AggIntent::Rate | AggIntent::Increase]) - || (matches!(measures.as_slice(), [AggIntent::Sum { .. }]) - && matches!(child.non_asap(), Some(NonASAPOp::Aggregate { measures, .. }) - if matches!(measures.as_slice(), [AggIntent::Rate | AggIntent::Increase]))))) -} - -/// Translate an [`Realization`] into the `(family, needs a -/// SummaryEstimate evaluation)` pair [`construct_summary_agg`] needs, or `None` -/// for `PassThrough` (the caller falls back to [`retain_exact`]). -/// -/// Every family's partial state needs a evaluation to recover a value, except -/// `ExactAggregate` — its partial state *is* the value already, so no -/// estimate step follows it. -fn summary_family(realization: Realization) -> Option<(FieldDataType, bool)> { - Some(match realization { - Realization::ExactAggregate { kind, params } => { - (FieldDataType::ExactAggregate(kind, params), false) - } - Realization::Sketch(kind) => ( - FieldDataType::Sketch(kind, GroupingStrategy::default()), - true, - ), - Realization::Sample { kind, params } => (FieldDataType::Sample(kind, params), true), - Realization::Wavelet { kind, params } => (FieldDataType::Wavelet(kind, params), true), - Realization::StatModel { kind, params } => (FieldDataType::StatModel(kind, params), true), - Realization::PassThrough => return None, - }) -} - -type PhysicalSummaryInputRule = - fn(&AggIntent, &FieldDataType, &Reduction, &Rc) -> PhysicalSummaryInputRuleResult; - -/// Ordered physical-realization rules for realizations that consume more -/// than the immediate logical input. New composite primitives add a rule here -/// instead of adding query- or algorithm-specific branches to -/// `construct_summary_agg`. -const PHYSICAL_SUMMARY_INPUT_RULES: &[PhysicalSummaryInputRule] = &[ - realize_value_frequency_summary_input, - realize_counter_value_summary_input, - realize_current_series_summary_input, - realize_keyed_additive_summary_input, -]; - -fn realize_value_frequency_summary_input( - intent: &AggIntent, - family: &FieldDataType, - _reduction: &Reduction, - child: &Rc, -) -> PhysicalSummaryInputRuleResult { - // Frequency counts hash sample values as items but add one per observation. - // Using the sample as a weight would turn counts into sums and admit signed CMS updates. - if !matches!(family, FieldDataType::Sketch(kind, _) - if kind.algorithm() == &SketchAlgorithm::UnivMon - || (matches!(intent, AggIntent::Count { .. }) - && matches!(kind.algorithm(), SketchAlgorithm::Cms | SketchAlgorithm::CountSketch))) - { - return PhysicalSummaryInputRuleResult::NotApplicable; - } - let schema = &child.schema; - // One item per observation is a single value stream. `summary_candidates` - // already withholds UnivMon from a distinct-tuple count; refused here too - // so the invariant does not rest on that table alone. - if intent.input_cols().len() > 1 { - return PhysicalSummaryInputRuleResult::Unsupported( - "a value-frequency summary reads a single column", - ); - } - PhysicalSummaryInputRuleResult::Realized(PhysicalSummaryInput { - child: Rc::clone(child), - input: SummaryUpdate { - item: Some(SummaryInputExpr::Column(summarised_column(intent, schema))), - weight: SummaryInputExpr::Constant(1.0), - weight_domain: WeightDomain::NonNegative { - proof: NonNegativeWeightProof::UnitCount, - }, - }, - }) -} - -fn realize_physical_summary_input( - intent: &AggIntent, - family: &FieldDataType, - reduction: &Reduction, - child: &Rc, -) -> Result { - for rule in PHYSICAL_SUMMARY_INPUT_RULES { - match rule(intent, family, reduction, child) { - PhysicalSummaryInputRuleResult::NotApplicable => {} - PhysicalSummaryInputRuleResult::Realized(input) => return Ok(input), - PhysicalSummaryInputRuleResult::Unsupported(reason) => { - return Err(RealizationError::PhysicalRealization(reason)); - } - } - } - - let child_schema = &child.schema; - if matches!(intent, AggIntent::TopK { .. }) { - return Err(RealizationError::PhysicalRealization( - "Top-K needs an explicit item identity and additive update input", - )); - } - Ok(PhysicalSummaryInput { - child: Rc::clone(child), - input: SummaryUpdate { - item: None, - weight: summarised_input(intent, child_schema)?, - weight_domain: WeightDomain::UnknownOrSigned, - }, - }) -} - -/// Emit `SummaryAgg` (recursively binding the child), plus the -/// `SummaryEstimate` evaluation when `estimate` is set. -// Retain the exact expression and schema while placing its value production -// on the update path (a node runs when its consumer runs, so beneath a -// maintained summary this value production is ingestion-time work). -// Read-time consumers keep their original shared nodes. -fn maintenance_exact_values(node: Rc) -> Option> { - let operator = match &node.operator { - // These guards can fall back at read time, but cannot recover a parent - // sketch after an invalid value has entered its maintained state. - Operator::NonASAP(NonASAPOp::BinaryOp { operator, .. }) - if operator.checked_finite_division || operator.checked_relative_division => - { - return None; - } - Operator::NonASAP(NonASAPOp::BinaryOp { - lhs, - rhs, - operator, - return_bool, - }) if operator.vector_match.is_none() - && matches!(operator.kind, BinaryOpKind::Arithmetic(_)) - && node - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact) => - { - Operator::NonASAP(NonASAPOp::BinaryOp { - lhs: maintenance_exact_values(lhs.clone())?, - rhs: maintenance_exact_values(rhs.clone())?, - operator: operator.clone(), - return_bool: *return_bool, - }) - } - Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) - if matches!( - child.operator, - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(..), - .. - }) - ) => - { - Operator::ASAP(ASAPOp::FinalizeExactAccumulator { - child: child.clone(), - }) - } - _ => return Some(node), - }; - Some(Rc::new( - OperatorNode::with_schema(operator, node.schema.clone()) - .with_guarantee(node.guarantee.clone()), - )) -} - -#[allow(clippy::too_many_arguments)] -fn construct_summary_agg( - node: &OperatorNode, - reduction: &Reduction, - intent: &AggIntent, - input: PhysicalSummaryInput, - family: FieldDataType, - estimate: bool, - planning_inputs: CandidatePlanningInputs<'_>, - child_target: Option<&AccuracyTarget>, - allocation: Option, -) -> Result, RealizationError> { - // The single canonical pre-ASAP derivation (per-series vs cross-series, - // name overrides) already computes the row shape; binding only retypes - // the summary state column. - let keyed_heap = input.input.item.is_some() - && matches!( - &family, - FieldDataType::Sketch(kind, _) - if matches!(kind.algorithm(), SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap) - ); - let snapshot_weighted = matches!(node.non_asap(), Some(NonASAPOp::Aggregate { child, .. }) - if is_snapshot_weighted_topk(intent, child)); - let mut family = family; - let score_population = if snapshot_weighted { - let bound = planning_inputs.evidence.topk_max_distinct_items(node); - if bound.is_some_and(|n| n == 0 || n > (1u64 << 53)) { - return Err(RealizationError::PhysicalRealization( - "invalid weighted TopK distinct-item bound", - )); - } - if let (Some(n), FieldDataType::Sketch(kind, grouping)) = (bound, &family) { - let (eps, delta) = accuracy_budget(&accuracy_target(intent).expect("TopK target")); - let params = default_size_params( - kind.algorithm().clone(), - intent, - eps, - delta / (2.0 * n as f64), - ); - family = FieldDataType::Sketch( - SketchKind::new(kind.algorithm().clone(), params), - grouping.clone(), - ); - } - bound - } else { - None - }; - let physical_reduction = if snapshot_weighted { - let Some(NonASAPOp::Aggregate { child, .. }) = node.non_asap() else { - unreachable!() - }; - let source = &input.child.schema; - let Reduction::Reduce(keys) = reduction else { - return Err(RealizationError::PhysicalRealization( - "TopK requires explicit partitions", - )); - }; - if keys.is_without() { - return Err(RealizationError::PhysicalRealization( - "TopK requires explicit partitions", - )); - } - let mapped = keys - .iter() - .map(|index| { - let reference = schema_column_ref(child, *index).ok_or( - RealizationError::PhysicalRealization("invalid TopK partition key"), - )?; - let matches = source - .fields - .iter() - .enumerate() - .filter(|(_, column)| column_ref(column) == reference) - .map(|(index, _)| index) - .collect::>(); - match matches.as_slice() { - [index] => Ok(*index), - _ => Err(RealizationError::PhysicalRealization( - "ambiguous TopK partition key", - )), - } - }) - .collect::, _>>()?; - Reduction::by(mapped) - } else if keyed_heap && matches!(reduction, Reduction::PerEntity) { - Reduction::by(vec![]) - } else { - reduction.clone() - }; - let out_schema = &node.schema; - let measures = match node.non_asap() { - Some(NonASAPOp::Aggregate { measures, .. }) => measures.len(), - _ => 1, - }; - let state_idx = summary_col_index(out_schema, reduction, measures); - - let evaluation_schema = if keyed_heap - && matches!(node.non_asap(), Some(NonASAPOp::Aggregate { child, .. }) if is_snapshot_weighted_topk(intent, child)) - { - keyed_heap_evaluation_schema(&input, node)? - } else { - out_schema.clone() - }; - - let summary_input = input.input; - let query = estimate.then(|| { - if snapshot_weighted { - if let FieldDataType::Sketch(kind, _) = &family { - let capacity = match kind.params() { - SketchParams::CmsWithHeap { heap_size, .. } - | SketchParams::CountSketchWithHeap { heap_size, .. } => *heap_size, - _ => unreachable!(), - }; - return PostAsapSketchStatistic::TopK { - k: capacity as usize, - }; - } - } - evaluation(intent, &summary_input, planning_inputs.cost) - }); - - let mut state_schema = out_schema.clone(); - if keyed_heap { - let mut state = state_schema.fields[state_idx].clone(); - state.dtype = family.clone(); - let mut fields = if snapshot_weighted { - evaluation_schema.fields[..reduction.group_keys().map_or(0, |keys| keys.len())].to_vec() - } else { - Vec::new() - }; - fields.push(state); - state_schema = Schema::lifted(fields, None); - } else if let Some(field) = state_schema.fields.get_mut(state_idx) { - field.dtype = family.clone(); - field.nullable = false; - if matches!(&family, FieldDataType::Sketch(kind, _) if kind.algorithm() == &SketchAlgorithm::UnivMon) - { - // State identity is independent of which statistic reads it. - field.name = "univmon".into(); - } else if let (AggIntent::Quantile { .. }, SummaryInputExpr::Column(col)) = - (intent, &summary_input.weight) - { - // The quantile is a evaluation parameter: name the state after the - // column it summarizes, not after the query's output column. - let child_schema = input.child.schema.clone(); - if let Ok(i) = resolve_column_ref(col, &child_schema) { - field.name = child_schema.fields[i].name.clone(); - } - } - } - - let exact = AccuracyTarget::exact(); - let bound_child = realize_child_with( - &input.child, - planning_inputs, - if snapshot_weighted { - Some(&exact) - } else { - child_target - }, - )?; - let bound_child = if snapshot_weighted && is_current_series_source(&input.child) { - // Explicit snapshot selection prevents historical observations from - // becoming repeated weights in an instant-vector heap. - let root = Rc::new(node.clone()); - let population = crate::maintained_population::MaintainedPopulationStrategy::new( - std::slice::from_ref(&root), - ) - .candidate(&root) - .ok_or(RealizationError::PhysicalRealization( - "snapshot ranking requires a supported current-series population", - ))?; - let Operator::ASAP(ASAPOp::EvaluatePopulation { child, .. }) = &population.operator else { - return Err(RealizationError::PhysicalRealization( - "missing population evaluation", - )); - }; - Rc::clone(child) - } else if snapshot_weighted { - // Each evaluation's finalized rates feed a fresh summary; rate snapshots - // must never accumulate across evaluations. Query time is only the - // initial layout; a maintained summary's materialization moves it to ingestion. - finalize_query_candidate(bound_child, &input.child)? - } else { - let child = - finalize_exact_accumulator(bound_child, &input.child, ExecutionTiming::IngestionTime)?; - maintenance_exact_values(child).unwrap_or(retain_exact(&input.child)?) - }; - - // ── Guarantee (issue #172) ────────────────────────────────────────── - // Derived *before* the node exists, so an illegal composition is never - // materialized: the local guarantee of this family's evaluation (or exact - // accumulator) composed over the child's, under the operator this - // family applies to the child's values. - let estimator = planning_inputs.accuracy; - let membership_query = if snapshot_weighted { - Some(evaluation(intent, &summary_input, planning_inputs.cost)) - } else { - query.clone() - }; - let mut guarantee = compose_guarantee( - &family, - membership_query.as_ref(), - &bound_child, - intent, - estimator, - planning_inputs.evidence, - allocation, - )?; - - if snapshot_weighted { - use asap_types::post_asap::{BoundExpr, ProbabilityExpr}; - let target = accuracy_target(intent).expect("TopK target"); - guarantee = if let Some(mut score) = - estimator.local_guarantee(&family, query.as_ref().unwrap()) - { - let count = match score_population { - Some(n) => BoundExpr::Constant { value: n as f64 }, - None => { - score - .provenance - .push(GuaranteeSource::UnavailableStatistic { - statistic: "topk_max_distinct_items".into(), - }); - BoundExpr::Unknown { - statistic: "topk_max_distinct_items".into(), - } - } - }; - score.provenance.push(GuaranteeSource::CompositionStep { - operator: CompositionOperator::ApproximateAggregate, - rule: "simultaneous_score_bounds_over_distinct_partition_item_identities".into(), - }); - score.spec.failure_probability = ProbabilityExpr::Scaled { - count, - inner: Box::new(score.spec.failure_probability), - }; - // #455: missing evidence preserves a logical candidate. Only known - // contributions that already violate the target reject it here. - if !estimator.satisfies( - &score.optimistic_floor(), - &crate::accuracy::native_target(&target, &score), - ) { - return Err(RealizationError::PhysicalRealization( - "weighted TopK scores miss accuracy target", - )); - } - let stats = planning_inputs.evidence.propagation_stats( - &CompositionOperator::TopKSelection, - &family, - membership_query.as_ref(), - ); - let mut joint = estimator.propagate( - &CompositionOperator::TopKSelection, - std::slice::from_ref(&score), - None, - &stats, - )?; - joint.spec.failure_probability = ProbabilityExpr::UnionBound { - terms: vec![ - joint.spec.failure_probability, - score.spec.failure_probability, - ], - }; - if !estimator.satisfies( - &joint.optimistic_floor(), - &crate::accuracy::native_target(&target, &joint), - ) { - return Err(RealizationError::PhysicalRealization( - "weighted TopK joint guarantee misses target", - )); - } - Some(joint) - } else { - None - }; - } - - // `reduction` is carried onto `SummaryAgg` verbatim — not flattened to a - // bare `Vec` — so `SummaryExecutor::find_candidates` can tell - // a genuine empty-`by` reduction apart from a per-entity shape with no - // grouping concept at all (issue #163). `construct_summary_agg` is the - // single place that decides this; nothing downstream re-derives it. - let agg = OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child: bound_child, - family, - input: summary_input, - reduction: physical_reduction, - grouping: GroupingStrategy::default(), - filter: None, - }), - state_schema, - ) - .with_guarantee( - // Summary *state* carries no caller-visible guarantee; only a - // finalized value does. An exact accumulator's state is its value. - if estimate { None } else { guarantee.clone() }, - ); - let agg = std::rc::Rc::new(agg); - match query { - // The evaluation: downstream of the estimate the schema is the plain - // pre-ASAP row shape again (the summary-state type does not - // propagate). - Some(query) => Ok(std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input: agg, - query, - }), - evaluation_schema, - ) - .with_guarantee(guarantee), - )), - None => Ok(agg), - } -} - -// Heap evaluation rows contain the encoded item identity, subpopulation keys, -// and an estimated score. They never inherit the exact-value producer's schema. -fn keyed_heap_evaluation_schema( - input: &PhysicalSummaryInput, - node: &OperatorNode, -) -> Result { - let source = &input.child.schema; - let mut refs = Vec::new(); - let Some(NonASAPOp::Aggregate { - reduction, child, .. - }) = node.non_asap() - else { - return Err(RealizationError::PhysicalRealization( - "heap evaluation requires an aggregate", - )); - }; - if let Reduction::Reduce(groups) = reduction { - if groups.is_without() { - return Err(RealizationError::PhysicalRealization( - "heap evaluation requires explicit grouping", - )); - } - for index in groups.iter() { - refs.push(schema_column_ref(child, *index).ok_or( - RealizationError::PhysicalRealization("invalid heap partition key"), - )?); - } - } - fn item_refs( - item: &SummaryInputExpr, - schema: &Schema, - refs: &mut Vec, - ) -> Result<(), RealizationError> { - match item { - SummaryInputExpr::Column(column) => refs.push(column.clone()), - SummaryInputExpr::Tuple(items) => { - for item in items { - item_refs(item, schema, refs)?; - } - } - SummaryInputExpr::EntityIdentity(EntityIdentity::PromqlLabelSet { excluding }) => { - if !schema.closed { - return Err(RealizationError::PhysicalRealization( - "dynamic label identity requires an explicit row representation", - )); - } - for (index, column) in schema.fields.iter().enumerate() { - if Some(index) != schema.time_index && column.name != "value" { - let reference = match &column.table { - Some(table) => ColumnRef::Qualified { - table: table.clone(), - name: column.name.clone(), - }, - None => ColumnRef::Named(column.name.clone()), - }; - if !excluding.contains(&reference) { - refs.push(reference); - } - } - } - } - _ => { - return Err(RealizationError::PhysicalRealization( - "unsupported heap item identity", - )) - } - } - Ok(()) - } - item_refs( - input - .input - .item - .as_ref() - .ok_or(RealizationError::PhysicalRealization( - "heap item identity is missing", - ))?, - source, - &mut refs, - )?; - let mut fields = Vec::::new(); - for reference in refs { - let matches: Vec<_> = source - .fields - .iter() - .filter(|column| match &reference { - ColumnRef::Named(name) => &column.name == name, - ColumnRef::Qualified { table, name } => { - column.table.as_ref() == Some(table) && &column.name == name - } - ColumnRef::SampleValue => column.name == "value", - ColumnRef::Wildcard => false, - }) - .collect(); - let [column] = matches.as_slice() else { - return Err(RealizationError::PhysicalRealization( - "heap key must resolve to exactly one source column", - )); - }; - if fields.iter().any(|field| field.name == column.name) || column.name == "__asap_estimate" - { - return Err(RealizationError::PhysicalRealization( - "heap keys must have distinct output names", - )); - } - fields.push(Field::new( - column.name.clone(), - column.dtype.clone(), - column.nullable, - )); - } - if fields.is_empty() { - return Err(RealizationError::PhysicalRealization( - "heap evaluation has no identity columns", - )); - } - fields.push(Field::new( - "__asap_estimate", - FieldDataType::Plain(asap_types::pre_asap::DataType::Float64), - false, - )); - Ok(Schema::lifted(fields, None)) -} - -fn ranking_score_index(logical: &OperatorNode, values: &Schema) -> Result { - if is_current_series_source(logical) { - return values - .fields - .iter() - .position(|field| { - field.name == "value" - && field.dtype == FieldDataType::Plain(asap_types::pre_asap::DataType::Float64) - }) - .ok_or(RealizationError::PhysicalRealization( - "snapshot ranking requires the sample value column", - )); - } - let Some(NonASAPOp::Aggregate { - reduction, - measures, - .. - }) = logical.non_asap() - else { - return Err(RealizationError::PhysicalRealization( - "ranking requires an explicit aggregate score", - )); - }; - if measures.len() != 1 { - return Err(RealizationError::PhysicalRealization( - "ranking requires exactly one score", - )); - } - let index = match reduction { - Reduction::Reduce(groups) if !groups.is_without() => groups.len(), - Reduction::PerEntity => values - .fields - .iter() - .position(|field| field.name == "value") - .ok_or(RealizationError::PhysicalRealization( - "ranking requires the sample value column", - ))?, - _ => { - return Err(RealizationError::PhysicalRealization( - "ranking requires explicit grouping", - )) - } - }; - if Some(index) == values.time_index - || !values.fields.get(index).is_some_and(|field| { - matches!( - field.dtype, - FieldDataType::Plain( - asap_types::pre_asap::DataType::Int64 | asap_types::pre_asap::DataType::Float64 - ) - ) - }) - { - return Err(RealizationError::PhysicalRealization( - "ranking score must be numeric", - )); - } - Ok(index) -} - -/// Rebuild a heap from this evaluation's finalized per-series counter values. -/// The rate window is preserved; raw counter samples never become CMS weights. -fn realize_counter_value_summary_input( - intent: &AggIntent, - family: &FieldDataType, - output_reduction: &Reduction, - child: &Rc, -) -> PhysicalSummaryInputRuleResult { - if !matches!(intent, AggIntent::TopK { .. }) - || !matches!(family, FieldDataType::Sketch(kind, _) if matches!(kind.algorithm(), SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap)) - || !matches!(child.non_asap(), Some(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures, .. }) if matches!(measures.as_slice(), [AggIntent::Rate | AggIntent::Increase])) - { - return PhysicalSummaryInputRuleResult::NotApplicable; - } - let schema = &child.schema; - if !schema.closed { - return PhysicalSummaryInputRuleResult::Unsupported( - "counter ranking needs the complete resolved series identity", - ); - } - let Reduction::Reduce(groups) = output_reduction else { - return PhysicalSummaryInputRuleResult::Unsupported( - "counter ranking requires explicit partitions", - ); - }; - if groups.is_without() { - return PhysicalSummaryInputRuleResult::Unsupported( - "counter ranking requires resolved partitions", - ); - } - // Retain the evaluation timestamp in each returned row. This sketch is a - // snapshot, not an additive history of successive rate evaluations. - let items = schema - .fields - .iter() - .enumerate() - .filter(|(index, column)| column.name != "value" && !groups.contains(index)) - .map(|(index, _)| schema_column_ref(child, index).map(SummaryInputExpr::Column)) - .collect::>>(); - let Some(items) = items.filter(|items| !items.is_empty()) else { - return PhysicalSummaryInputRuleResult::Unsupported("counter ranking has no item columns"); - }; - PhysicalSummaryInputRuleResult::Realized(PhysicalSummaryInput { - child: Rc::clone(child), - input: SummaryUpdate { - item: Some(SummaryInputExpr::Tuple(items)), - weight: SummaryInputExpr::Column(ColumnRef::SampleValue), - weight_domain: WeightDomain::NonNegative { - proof: NonNegativeWeightProof::ResetAwareCounterDerivative, - }, - }, - }) -} - -/// An instant-vector source has one current value per full series identity. -/// Rebuild the state for each evaluation; historical samples are not updates. -fn realize_current_series_summary_input( - intent: &AggIntent, - family: &FieldDataType, - output_reduction: &Reduction, - child: &Rc, -) -> PhysicalSummaryInputRuleResult { - if !matches!(intent, AggIntent::TopK { .. }) || !is_current_series_source(child) { - return PhysicalSummaryInputRuleResult::NotApplicable; - } - let FieldDataType::Sketch(kind, _) = family else { - return PhysicalSummaryInputRuleResult::NotApplicable; - }; - match kind.algorithm() { - SketchAlgorithm::CountSketchWithHeap => {} - SketchAlgorithm::CmsWithHeap => { - return PhysicalSummaryInputRuleResult::Unsupported( - "current sample values do not prove non-negative CMS weights", - ) - } - _ => return PhysicalSummaryInputRuleResult::NotApplicable, - } - let Reduction::Reduce(groups) = output_reduction else { - return PhysicalSummaryInputRuleResult::Unsupported( - "snapshot ranking requires explicit partitions", - ); - }; - if groups.is_without() { - return PhysicalSummaryInputRuleResult::Unsupported( - "snapshot ranking requires resolved partitions", - ); - } - let schema = &child.schema; - let items = schema - .fields - .iter() - .enumerate() - .filter(|(index, column)| column.name != "value" && !groups.contains(index)) - .map(|(index, _)| schema_column_ref(child, index).map(SummaryInputExpr::Column)) - .collect::>>(); - let Some(items) = items.filter(|items| !items.is_empty()) else { - return PhysicalSummaryInputRuleResult::Unsupported( - "snapshot ranking has no item identity", - ); - }; - PhysicalSummaryInputRuleResult::Realized(PhysicalSummaryInput { - child: Rc::clone(child), - input: SummaryUpdate { - item: Some(SummaryInputExpr::Tuple(items)), - weight: SummaryInputExpr::Column(ColumnRef::SampleValue), - weight_domain: WeightDomain::UnknownOrSigned, - }, - }) -} - -/// The guarantee of the value a `family` node produces over `child` — -/// [`AccuracyModel::propagate`] under the [`CompositionOperator`] this family -/// applies to its child's values — checked against `intent`'s own -/// `AccuracyTarget` whenever the child is approximate (an approximate -/// parent over an exact child is sized to that target by construction and -/// is not re-checked here, so single-layer behavior is unchanged; see -/// [`crate::accuracy`]'s precedence rules). `Ok(None)` is "no error model" -/// (an approximate family the model has no local guarantee for, over an -/// exact child) — unknown, never exact. -fn compose_guarantee( - family: &FieldDataType, - query: Option<&PostAsapSketchStatistic>, - child: &OperatorNode, - intent: &AggIntent, - accuracy: &dyn AccuracyModel, - evidence: &dyn AccuracyEvidenceProvider, - allocation: Option, -) -> Result, AccuracyError> { - let (op, local) = match (family, query) { - (FieldDataType::ExactAggregate(kind, _), _) => { - let op = match kind { - // A row count does not depend on the rows' values: exact - // regardless of the child's own error. - ExactKind::Count => { - return Ok(Some(AccuracyGuarantee::exact( - "ExactAggregate(Count): row count is independent of input values", - ))) - } - // Counter-reset detection over perturbed values has no finite - // Lipschitz constant — over an approximate child this is a - // deterministic transform with no registered rule. - _ => { - crate::function_rules::function_rules(intent) - .expect("exact accumulator intents have registered accuracy rules") - .accuracy - } - }; - ( - op, - Some(AccuracyGuarantee::exact(format!( - "ExactAggregate({kind:?})" - ))), - ) - } - (_, Some(query)) => ( - if matches!(query, PostAsapSketchStatistic::TopK { .. }) { - CompositionOperator::TopKSelection - } else { - CompositionOperator::ApproximateAggregate - }, - accuracy.local_guarantee(family, query), - ), - (_, None) => (CompositionOperator::ApproximateAggregate, None), - }; - let Some(input) = child.guarantee.clone() else { - // Shape is constructible, but the child has no accuracy certificate. - return Ok(None); - }; - if local.is_none() { - // No local error model: retain the candidate with unknown accuracy. - // Propagating the input alone would falsely certify the summary. - return Ok(None); - } - let stats = evidence.propagation_stats(&op, family, query); - let mut guarantee = - accuracy.propagate(&op, std::slice::from_ref(&input), local.as_ref(), &stats)?; - if let Some(note) = allocation { - guarantee.provenance.push(note); - } - if let Some(target) = accuracy_target(intent) { - guarantee.provenance.push(GuaranteeSource::AccuracyTarget { - target: target.clone(), - }); - // Check even over an exact input: parameter clamps or a conservative - // confidence conversion can make the tightest available sketch miss - // its requested target. - if !accuracy.satisfies( - &guarantee.optimistic_floor(), - &crate::accuracy::native_target(&target, &guarantee), - ) { - return Err(AccuracyError::TargetNotSatisfied { - metric: guarantee.spec.accuracy_metric, - bound: guarantee.spec.error.evaluate(), - failure_probability: guarantee.spec.failure_probability.evaluate(), - target: target.clone(), - }); - } - } - Ok(Some(guarantee)) -} - -/// Index of the summary-state column in the aggregate's output schema: -/// cross-series output is `by ++ [agg]` (the column after the keys); -/// a per-series reduction keeps every label and replaces the sample value -/// (named `value` — mirror `per_series_reduction_schema`'s fallback). -/// `reduction` is the caller's already-read `Reduction` (issue #165). -/// `without` output is `kept labels ++ measures`, and its keys are the -/// *excluded* labels, so the state column follows the kept labels instead. -fn summary_col_index(out_schema: &Schema, reduction: &Reduction, measures: usize) -> usize { - match reduction { - Reduction::PerEntity => out_schema - .column_id("value") - .or_else(|| (0..out_schema.fields.len()).find(|&i| Some(i) != out_schema.time_index)) - .unwrap_or(0), - Reduction::Reduce(keys) if keys.is_without() => { - out_schema.fields.len().saturating_sub(measures) - } - Reduction::Reduce(keys) => keys.len(), - } -} - -/// The `SummaryEstimate` evaluation for a summary-bound intent. -fn evaluation( - intent: &AggIntent, - input: &SummaryUpdate, - cost_model: &dyn CostModel, -) -> PostAsapSketchStatistic { - match intent { - AggIntent::Quantile { q, .. } => PostAsapSketchStatistic::Quantile { q: *q }, - AggIntent::Cardinality { .. } => PostAsapSketchStatistic::Cardinality, - AggIntent::FrequencyL2 { .. } => PostAsapSketchStatistic::FrequencyL2, - AggIntent::FrequencyEntropy { .. } => PostAsapSketchStatistic::FrequencyEntropy, - AggIntent::TopK { k, .. } => PostAsapSketchStatistic::TopK { k: *k }, - AggIntent::Count { .. } => PostAsapSketchStatistic::PointCount { - key: match &input.weight { - SummaryInputExpr::Column(col) => col.clone(), - SummaryInputExpr::Constant(1.0) => ColumnRef::SampleValue, - _ => unreachable!("point count requires one column"), - }, - 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)") - } - } -} - -// ── SharedSubDAGStrategy ──────────────────────────────────────────────── - -/// Wraps `asap_types::ir::cse::share_common_sub_dags`'s sharing -/// decision as an explicit candidate pair, wherever a [`TargetSubDAG`] -/// already has two or more consumers. -/// -/// This strategy does not decide sharing itself, nor does it discover which -/// nodes are shared — by the time a caller builds a `TargetSubDAG` with -/// `consumer_count >= 2`, `share_common_sub_dags` has already made that -/// (legality-gated, `PartialEq`-checked) call; [`discover_targets`] below -/// discovers real consumer counts across a workload the same way for -/// [`search_workload_with`] (this module's own tests reuse the identical -/// dedup logic to build realistic fixtures — see the module docs' -/// "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 -/// build independently at each consumer. -pub struct SharedSubDAGStrategy; - -impl ReplacementStrategy for SharedSubDAGStrategy { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - target.consumer_count >= 2 - } - - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - if target.consumer_count < 2 { - return Vec::new(); - } - let count = target.consumer_count; - vec![ - ReplacementSubDAG { - strategy: "SharedSubDAGStrategy", - // The already-interned `Rc` itself: reusing it verbatim *is* - // "build once and share" — no new node to construct. - replacement: Replacement::SubDAG(Rc::clone(target.root)), - provenance: ReplacementProvenance::CseShare, - rationale: format!( - "build once and share: share_common_sub_dags already interned this \ - sub_dag once and reused it across {count} consumers — one build can \ - answer all of them instead of computing it {count} times" - ), - }, - ReplacementSubDAG { - strategy: "SharedSubDAGStrategy", - // A structurally-identical but freshly-allocated `Rc`: same - // value (`PartialEq`), deliberately *not* the same pointer, - // representing "undo the sharing and recompute independently". - replacement: Replacement::SubDAG(Rc::new((**target.root).clone())), - provenance: ReplacementProvenance::CseRecompute, - rationale: format!( - "build independently: undo the sharing share_common_sub_dags found and \ - recompute this sub_dag separately at each of its {count} consumers — \ - worth it only when independence outweighs the shared-maintenance cost, \ - a CostModel's call (e.g. CostModel::cse_share_decision) and not this \ - strategy's" - ), - }, - ] - } -} - -// ── Workload-wide search: TargetSubDAGCandidates / CandidateLogicalASAPDAGs / search_workload ────────── -// -// Merged in from the former `search.rs` (issue #252, part of #33) — see this -// file's own top-level "Workload-wide search" doc section for the full -// design rationale. - -/// A generous, documented backstop against a hypothetically ill-behaved -/// future [`ReplacementStrategy`] (see the module docs' "Termination" -/// section) — not a bound either shipped strategy could ever approach. -/// [`ASAPStrategies`] and [`SharedSubDAGStrategy`] both converge in -/// exactly 2 passes over a fixed target set, regardless of workload size. -pub const MAX_SEARCH_ITERATIONS: usize = 1_000; - -// ── TargetSubDAGCandidates ────────────────────────────────────────────── - -/// Candidates for one distinct [`TargetSubDAG`] (its -/// own `target` `Rc`, keyed by pointer identity in -/// [`CandidateLogicalASAPDAGs`]'s internal map — never re-derived by value) plus every -/// [`ReplacementSubDAG`] alternative any registered [`ReplacementStrategy`] -/// proposed for it. -/// -/// `candidates` is deliberately *not* required to be non-empty — a -/// `TargetSubDAG` no registered strategy has an opinion on still gets a -/// group (with an empty candidate list), so [`CandidateLogicalASAPDAGs`] always has -/// exactly one group per discovered `TargetSubDAG`, not "one group per -/// `TargetSubDAG` something matched". -#[derive(Debug, Clone)] -pub struct TargetSubDAGCandidates { - /// The target sub-DAG this group is for. - pub target: Rc, - /// How many operator-child positions across the whole workload - /// reference this exact `Rc` — see [`discover_targets`]. - pub consumer_count: usize, - /// Every distinct alternative discovered for `target`, in discovery - /// order (not ranked — see [`CandidateLogicalASAPDAGs::cost_sorted`] for the ranked - /// view). - pub candidates: Vec, - /// Every candidate a strategy considered for `target` but refused on - /// 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. - pub rejected: Vec, -} - -impl TargetSubDAGCandidates { - fn new(target: Rc, consumer_count: usize) -> Self { - Self { - target, - consumer_count, - candidates: Vec::new(), - rejected: Vec::new(), - } - } - - /// Add `candidate` unless it's already present (see - /// [`is_duplicate_rewrite`]/[`is_duplicate_summary`] for what "already - /// present" means for each [`Replacement`] variant). Returns whether it - /// was actually added — [`search_workload_with`]'s fixpoint loop uses - /// this to detect when a pass made no progress. - fn add_candidate(&mut self, candidate: ReplacementSubDAG) -> bool { - let is_duplicate = self.candidates.iter().any(|existing| { - match (&existing.replacement, &candidate.replacement) { - (Replacement::SubDAG(existing_rc), Replacement::SubDAG(rc)) - if existing.is_logical_rewrite() && candidate.is_logical_rewrite() => - { - is_duplicate_rewrite(existing_rc, rc, &self.target) - } - (Replacement::SubDAG(existing_node), Replacement::SubDAG(node)) => { - is_duplicate_summary(existing_node, node) - } - ( - Replacement::ExactComposition(existing), - Replacement::ExactComposition(candidate), - ) => existing.same_as(candidate), - // Different `Replacement` variants are never the same - // candidate. - _ => false, - } - }); - if is_duplicate { - false - } else { - self.candidates.push(candidate); - true - } - } -} - -/// Are `existing` and `candidate` the same logical-rewrite -/// [`Replacement::SubDAG`] candidate for a group targeting `target`? -/// -/// Structural (`OperatorNode`) value equality alone is *not* enough here: -/// this module's one shipped multi-candidate logical-rewrite source, -/// [`SharedSubDAGStrategy`], deliberately returns **two** candidates that -/// are value-equal to each other (`build once and share` vs. `build -/// independently` — see that strategy's own doc) but represent genuinely -/// different physical choices, distinguished *only* by whether the -/// candidate's `Rc` is the group's own `target` `Rc` (share) or a freshly -/// allocated one (recompute independently) — this IR has no field that -/// records "materialized once and shared", so `Rc` identity against -/// `target` is the only signal that distinction exists in at all. Treating -/// those two as duplicates of each other via pure value equality would -/// silently collapse a real choice into one candidate — the "false-positive -/// dedup is a wrong answer, not a missed optimization" failure mode -/// `cse.rs`'s own "Correctness" section warns about, just one level up from -/// where that module states it. -/// -/// So: two candidates whose "is this the target's own `Rc`?" bit disagrees -/// are never duplicates of each other, full stop. Only when that bit -/// *agrees* does this fall through to the real dedup discipline — -/// [`structural_hash`] as a candidate-narrowing filter, `OperatorNode`'s -/// derived `PartialEq` as the actual decision — protecting against the -/// (currently hypothetical, since neither shipped strategy causes it) -/// case of the exact same alternative being proposed twice. A fresh -/// [`HashCache`] per call: this is a pairwise check between two candidates -/// for one group, not a bottom-up pass over a whole DAG, so there is no -/// wider traversal to amortize the cache across the way `InternTable`'s own -/// use of `structural_hash` does. -fn is_duplicate_rewrite( - existing: &Rc, - candidate: &Rc, - target: &Rc, -) -> bool { - let existing_is_target = Rc::ptr_eq(existing, target); - let candidate_is_target = Rc::ptr_eq(candidate, target); - if existing_is_target != candidate_is_target { - return false; - } - let mut cache = HashCache::new(); - structural_hash(existing, &mut cache) == structural_hash(candidate, &mut cache) - && existing == candidate -} - -/// Are `existing` and `candidate` the same bound-summary -/// [`Replacement::SubDAG`] candidate? -/// -/// A bound summary embeds `SketchParams`/`f64`-bearing accuracy targets and -/// guarantees, so value equality is not a dedup decision this module is -/// willing to make (see [`structural_hash`]'s own doc on `f64` hashing). -/// Per this module's inherited "hash is a filter, `PartialEq` is the -/// decision, no exceptions" rule, there is no real equality check to back a -/// dedup *decision* here — and skipping the check is the only choice that -/// rule permits: never merging two candidates -/// is harmless (at worst, a redundant entry in a group's candidate list), -/// while comparing by some proxy this module can't actually verify (e.g. -/// `Debug` text, or `ReplacementSubDAG::rationale` — documented elsewhere in -/// this crate as prose for a report, "not machine parsing") risks exactly -/// the false-positive merge the rule exists to prevent. Both strategies -/// shipped today already return a structurally distinct candidate for every -/// entry of one `replacements()` call, so this is future-proofing against a -/// hypothetical repeat call, not a gap either strategy's own tests exercise. -fn is_duplicate_summary(_existing: &Rc, _candidate: &Rc) -> bool { - false -} - -// ── CandidateLogicalASAPDAGs ──────────────────────────────────────────────────────────── - -/// The deduped candidate space [`search_workload`]/[`search_workload_with`] -/// discover: one [`TargetSubDAGCandidates`] per distinct `TargetSubDAG` in the -/// (already-CSE'd) workload, plus the workload's own post-CSE roots so a -/// caller can still map a `Root`'s `Id` back to the `Rc` whose -/// group holds its alternatives. Memos are keyed by `*const OperatorNode`. -pub struct CandidateLogicalASAPDAGs { - /// The workload's roots, after the one `share_common_sub_dags` pass - /// [`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>, - /// Discovery order — stable iteration for [`CandidateLogicalASAPDAGs::target_subdag_candidates`]/ - /// [`CandidateLogicalASAPDAGs::cost_sorted`], since `HashMap` iteration order isn't. - 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, -} - -struct PreparedComposition { - target: *const OperatorNode, - operation: ExactComposition, - child: Rc, - plan: Rc, -} - -impl CandidateLogicalASAPDAGs { - fn prepare_compositions( - &mut self, - accuracy: &dyn AccuracyModel, - targets: &HashMap<*const OperatorNode, Vec>, - ) { - self.composition_plans.clear(); - for group in self.groups.values() { - for candidate in &group.candidates { - let Replacement::ExactComposition(operation) = &candidate.replacement else { - continue; - }; - let children: Vec<_> = match operation.placement { - OperationPlacement::Read => self - .groups - .get(&Rc::as_ptr(&operation.child_target)) - .into_iter() - .flat_map(|g| &g.candidates) - .filter_map(|c| match &c.replacement { - Replacement::SubDAG(child) - if !c.is_logical_rewrite() && operation.accepts_child(child) => - { - Some(Rc::clone(child)) - } - _ => None, - }) - .collect(), - OperationPlacement::Maintenance => { - retain_exact(&operation.child_target).into_iter().collect() - } - }; - for child in children { - let Ok(plan) = operation.compose_with_accuracy(Rc::clone(&child), accuracy) - else { - continue; - }; - if let Some(requirements) = targets.get(&Rc::as_ptr(&group.target)) { - if operation.placement == OperationPlacement::Maintenance - || !requirements.iter().all(|target| { - plan.guarantee - .as_ref() - .is_some_and(|g| accuracy.satisfies(g, &per_value(target))) - }) - { - continue; - } - } - self.composition_plans.push(PreparedComposition { - target: Rc::as_ptr(&group.target), - operation: operation.clone(), - child, - plan, - }); - } - } - } - } -} - -/// DAG candidates assembled from an unpriced search space. -/// This is an internal planning stage: callers must still validate materialization -/// requirements and compile supported physical operators before deployment. -/// The caller supplies a finite expansion budget; exceeding it is an error, -/// never a silently truncated inventory presented as exhaustive. -#[derive(Debug)] -pub struct CandidateDAGInventory { - pub candidates: Vec)>>, - pub rejected_assemblies: Vec, -} - -type CandidateDAGChoice<'a> = (Option<&'a ReplacementSubDAG>, Option>); - -impl CandidateLogicalASAPDAGs { - pub fn enumerate_candidate_dags( - &self, - expansion_limit: usize, - ) -> Result, RealizationError> { - self.enumerate_candidate_roots(&self.roots, expansion_limit) - } - - /// Enumerate one workload root without expanding independent roots' choices. - /// Discovery and composition proofs still come from the shared workload - /// space. Deployment may price combinations lazily; this API does not rank - /// candidates or claim that independently cheapest roots minimize shared cost. - pub fn enumerate_candidate_dags_for_root( - &self, - id: &Id, - expansion_limit: usize, - ) -> Result, RealizationError> { - let roots = self - .roots - .iter() - .filter(|(candidate, _)| candidate == id) - .cloned() - .collect::>(); - if roots.len() != 1 { - return Err(RealizationError::PhysicalRealization( - "candidate enumeration requires one uniquely identified workload root", - )); - } - self.enumerate_candidate_roots(&roots, expansion_limit) - } - - fn enumerate_candidate_roots( - &self, - roots: &[(Id, Rc)], - expansion_limit: usize, - ) -> Result, RealizationError> { - let mut reachable = Vec::new(); - let mut nodes = HashMap::new(); - let mut counts = HashMap::new(); - for (_, root) in roots { - walk(root, &mut reachable, &mut nodes, &mut counts); - } - // Rewrites may introduce descendants absent from the original root. - let mut cursor = 0; - while cursor < reachable.len() { - let ptr = reachable[cursor]; - cursor += 1; - if let Some(group) = self.groups.get(&ptr) { - for candidate in &group.candidates { - if let Replacement::SubDAG(rewritten) = &candidate.replacement { - if candidate.is_logical_rewrite() { - walk(rewritten, &mut reachable, &mut nodes, &mut counts); - } - } - } - } - } - let order = self - .order - .iter() - .copied() - .filter(|ptr| counts.contains_key(ptr)) - .collect::>(); - // Composition plans carry the proofs established during discovery. - // No cost ranking is consulted while expanding these choices. - let options: Vec>> = order - .iter() - .map(|ptr| { - let group = &self.groups[ptr]; - let mut choices = vec![(None, None)]; - for candidate in &group.candidates { - match &candidate.replacement { - Replacement::ExactComposition(operation) => { - for prepared in &self.composition_plans { - if prepared.target == *ptr - && prepared.operation.placement == operation.placement - && prepared.operation.op == operation.op - && Rc::ptr_eq( - &prepared.operation.child_target, - &operation.child_target, - ) - { - choices - .push((Some(candidate), Some(Rc::clone(&prepared.plan)))); - } - } - } - _ => choices.push((Some(candidate), None)), - } - } - choices - }) - .collect(); - let combinations = options - .iter() - .try_fold(1usize, |n, choices| n.checked_mul(choices.len())) - .filter(|n| *n <= expansion_limit) - .ok_or(RealizationError::PhysicalRealization( - "candidate expansion budget exceeded; no partial inventory returned", - ))?; - let mut inventory = CandidateDAGInventory { - candidates: Vec::new(), - rejected_assemblies: Vec::new(), - }; - // Hash buckets avoid quadratic comparisons across a large workload - // inventory. Equality still decides deduplication, including collisions. - let mut seen = HashMap::>::new(); - for mut ordinal in 0..combinations { - let mut groups = HashMap::new(); - let mut assembled_nodes = HashMap::new(); - for (ptr, choices) in order.iter().zip(&options) { - let (chosen, prepared) = &choices[ordinal % choices.len()]; - ordinal /= choices.len(); - let group = &self.groups[ptr]; - if let Some(node) = prepared { - assembled_nodes.insert(*ptr, Rc::clone(node)); - } - groups.insert( - *ptr, - TargetSubDAGSelection { - target: &group.target, - consumer_count: group.consumer_count, - effective_consumer_count: group.consumer_count, - chosen: *chosen, - composition: None, - }, - ); - } - let assembly = GlobalSelection { - order: order.clone(), - groups, - assembled_nodes: RefCell::new(assembled_nodes), - }; - let roots = roots - .iter() - .map(|(id, root)| { - assembly - .assemble_target(root) - // Exposed query candidates return values. Internal assembly - // still retains accumulator states for sharing and storage. - .and_then(|node| finalize_query_candidate(node, root)) - .map(|node| (id.clone(), node)) - }) - .collect::, _>>(); - match roots { - Ok(roots) => { - let roots = share_common_sub_dags(roots); - use std::hash::{Hash, Hasher}; - let mut hash = std::collections::hash_map::DefaultHasher::new(); - let mut cache = HashCache::new(); - for (_, node) in &roots { - structural_hash(node, &mut cache).hash(&mut hash); - } - let bucket = seen.entry(hash.finish()).or_default(); - if !bucket - .iter() - .any(|&index| inventory.candidates[index] == roots) - { - bucket.push(inventory.candidates.len()); - inventory.candidates.push(roots); - } - } - Err(error) => { - let reason = error.to_string(); - if !inventory.rejected_assemblies.contains(&reason) { - inventory.rejected_assemblies.push(reason); - } - } - } - } - Ok(inventory) - } -} - -impl CandidateLogicalASAPDAGs { - /// One candidate set per discovered target sub-DAG, in discovery order. - pub fn target_subdag_candidates(&self) -> impl Iterator { - self.order.iter().map(move |ptr| &self.groups[ptr]) - } - - /// How many distinct targets were discovered. - pub fn len(&self) -> usize { - self.groups.len() - } - - /// Whether no targets were discovered at all (an empty workload, or one - /// with no `OperatorNode`s reachable from any root — never true for a - /// non-empty `roots`, since every root is itself a target). - pub fn is_empty(&self) -> bool { - self.groups.is_empty() - } - - /// The candidate set for `target`, if `target`'s own `Rc` is a discovered - /// `TargetSubDAG` (i.e. `Rc::ptr_eq` to some node reachable from - /// `roots`). - pub fn candidates_for_target( - &self, - target: &Rc, - ) -> 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, - )); - } - } - } - - 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 }) - } - - /// 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)); - } - } - 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); - } - let update_rate = data_workload - .and_then(|data| data.arrival.rows_per_second()) - .map(UpdateRate); - 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 => {} - } -} - -/// 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`]. -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> { - 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::pre_asap::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::pre_asap::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. An exact-state root comes out as - /// its bare state; query results go through - /// [`Self::assemble_selected_query`]. - 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() - } - - 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(AccuracyGuarantee::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(AccuracyGuarantee::is_exact) - }) => - { - Some(AccuracyGuarantee::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_at_ingestion(&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); - } - let rebuilt = std::rc::Rc::new( - 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_at_ingestion(&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 child_candidate.is_logical_rewrite() || !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 { - Some( - candidate @ ReplacementSubDAG { - replacement: Replacement::SubDAG(rewrite), - .. - }, - ) if candidate.is_logical_rewrite() => rewrite, - _ => &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, - } -} - -/// 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; - } - } - _ => {} - } - } - 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, - }; - 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 !candidate.is_logical_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); - } -} - -/// 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)), - } - } - - 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; - } - 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); - } - - 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 -} - -// ── default_strategies ────────────────────────────────────────────────── - -/// 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, - evidence, - )), - Box::new( - HydraGroupingStrategy::new_with_planning_inputs_and_evidence( - cost_model, - &DEFAULT_ACCURACY_MODEL, - 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)>, -) -> Result, SearchError> { - search_workload_with(roots, &default_strategies()) -} - -/// Why [`search_workload`] could not search a workload. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum SearchError { - /// Replacement search takes the front end's pre-ASAP DAG only. - #[error( - "a workload root already contains an ASAP operator ({0}); replacement search \ - takes the front end's pre-ASAP DAG only" - )] - AsapInput(&'static str), -} - -/// 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], -) -> Result, SearchError> { - let mut space = search_cse_workload_with(cse_workload(roots), strategies)?; - space.prepare_compositions(&DefaultAccuracyModel, &HashMap::new()); - Ok(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, -) -> Result, SearchError> { - 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(_) if candidate.is_logical_rewrite() => true, - Replacement::SubDAG(node) => node.guarantee.as_ref().map_or_else( - || !target.is_exact(), - |g| accuracy_model.satisfies(&g.optimistic_floor(), &per_value(&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.spec.accuracy_metric, - g.spec.error.evaluate(), - g.spec.failure_probability.evaluate(), - ) - }) - .unwrap_or((asap_types::post_asap::AccuracyMetric::ABSOLUTE, None, None)), - Replacement::ExactComposition(_) => { - (asap_types::post_asap::AccuracyMetric::ABSOLUTE, 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); - Ok(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 { - accuracy_target(intent).filter(|accuracy| !accuracy.is_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) == override_accuracy(other, &accuracy), - }; - 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::max_error(eps)) - } else { - Some(AccuracyTarget::max_error(eps).failure_probability(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], -) -> Result, SearchError> { - if let Some((_, root)) = cse_roots.iter().find(|(_, root)| root.contains_asap()) { - return Err(SearchError::AsapInput(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 candidate.is_logical_rewrite() { - 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); - - Ok(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 !candidate.is_logical_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. A `scalar(v)` read -/// by a Project/Filter expression is planned here as an interior site and -/// keeps its summary candidates; a combination's guarantee is not composed -/// in this pass (#669). -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); - } -} - -/// `shares` of `of` equal shares of `target`, in `metric`'s composition: -/// the target of a nested sub-DAG holding `shares` of a node's `of` -/// approximate layers ([`per_input_target`] is one share). `None` for an -/// exact target or an ε that is not positive and finite. -fn nested_layers_target( - target: &AccuracyTarget, - metric: asap_types::types::AccuracyMetric, - of: usize, - shares: usize, -) -> Option { - if target.is_exact() || !(target.error.is_finite() && target.error > 0.0) { - return None; - } - let fraction = shares.min(of.max(1)) as f64 / of.max(1) as f64; - let error = match metric { - asap_types::types::AccuracyMetric::Relative => (1.0 + target.error).powf(fraction) - 1.0, - _ => target.error * fraction, - }; - Some(AccuracyTarget { - error, - failure_probability: target.failure_probability.map(|delta| delta * fraction), - ..target.clone() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Siblings that differ only in their written targets share the - /// strictest one: two `count by (job)` (or distinct-count) siblings at - /// ε = 0.05 and 0.01, with no metric set, size the looser one for 0.01. - #[test] - fn identical_count_and_distinct_siblings_share_the_strictest_target() { - let scan = crate::test_support::metric_scan(&["job"]); - for intent in [ - |accuracy| AggIntent::Count { accuracy }, - |accuracy| AggIntent::Cardinality { - cols: vec![], - accuracy, - }, - ] { - let sibling = |error| { - crate::test_support::agg( - vec![2], - intent(AccuracyTarget::max_error(error)), - scan.clone(), - ) - }; - let (loose, strict) = (sibling(0.05), sibling(0.01)); - assert_eq!( - strictest_sibling_accuracy(&loose, &[loose.clone(), strict.clone()]), - Some(AccuracyTarget::max_error(0.01)) - ); - assert_eq!( - strictest_sibling_accuracy(&strict, &[loose, strict.clone()]), - None - ); - } - } - - /// A nested sub-DAG holding `shares` of `of` approximate layers gets - /// that fraction of an additive target's ε and δ. - #[test] - fn a_nested_sub_dag_gets_its_layers_share_of_the_target() { - let target = AccuracyTarget::max_error(0.1).failure_probability(0.02); - let rest = nested_layers_target(&target, asap_types::types::AccuracyMetric::ABSOLUTE, 3, 2) - .unwrap(); - assert!((rest.error - 0.1 * 2.0 / 3.0).abs() < 1e-12); - assert!((rest.failure_probability.unwrap() - 0.02 * 2.0 / 3.0).abs() < 1e-12); - } - use crate::accuracy::PropagationStats; - use crate::cost_model::Cost; - use crate::test_support::{ - agg, agg_per_entity, at_ingestion, lower_promql, metric_scan, timed, - }; - use asap_types::ir::operator_properties::{Reduction as ReductionTy, Source}; - use asap_types::ir::TimeRangeKind; - use asap_types::pre_asap::agg_intent::{ - agg_is_exact, default_cardinality, default_quantile, MathFunc, TimeFunc, - }; - use asap_types::pre_asap::schema::{DataType, Field, Schema as SchemaTy}; - use asap_types::types::AccuracyTarget; - - use std::collections::HashMap; - - /// A query result reads an exact state through its finalize; any other - /// selected root is returned as assembled. - #[test] - fn assemble_selected_query_finalizes_only_an_exact_state_root() { - let assemble = |query: &str, accuracy: AccuracyTarget| { - let space = search_workload(vec![("q", lower_promql(query, accuracy))]).unwrap(); - let selection = space.global_selection(&DefaultCostModel); - let root = &space.roots[0].1; - ( - selection.assemble_selected_dag(root).unwrap().unwrap(), - selection.assemble_selected_query(root).unwrap().unwrap(), - ) - }; - let (state, query) = assemble("sum_over_time(m[1m])", AccuracyTarget::exact()); - assert!(matches!( - state.asap(), - Some(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(..), - .. - }) - )); - let Some(ASAPOp::FinalizeExactAccumulator { child }) = query.asap() else { - panic!("expected a finalized exact state, got {query:#?}"); - }; - assert!(Rc::ptr_eq(child, &state)); - assert!(query.schema.fields.iter().all(|field| field.is_plain())); - - let (estimate, query) = assemble( - "quantile_over_time(0.99, m[1m])", - AccuracyTarget::max_error(0.01).accuracy_metric(AccuracyMetric::Rank), - ); - assert!(estimate.contains_asap()); - assert!(Rc::ptr_eq(&estimate, &query)); - } - - /// A logical rewrite is told apart by provenance, not shape: a summary - /// realization with no guarantee and no ASAP operator is still a bound - /// summary decision. - #[test] - fn logical_rewrite_follows_provenance_not_shape() { - let node = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - assert!(node.guarantee.is_none() && !node.contains_asap()); - let candidate = |provenance| ReplacementSubDAG { - replacement: Replacement::SubDAG(Rc::clone(&node)), - strategy: "test", - provenance, - rationale: String::new(), - }; - let realization = candidate(ReplacementProvenance::SummaryRealization); - assert!(!realization.is_logical_rewrite()); - assert!(realization.has_missing_accuracy_evidence()); - for provenance in [ - ReplacementProvenance::LogicalRewrite, - ReplacementProvenance::CseShare, - ReplacementProvenance::CseRecompute, - ReplacementProvenance::AccuracyReconciliation, - ] { - assert!(candidate(provenance).is_logical_rewrite(), "{provenance:?}"); - } - } - - /// A heap sketch consumes every raw row, so the keyed input rule refuses - /// a TopK over a filtered count (#466) even when handed one directly. - #[test] - fn keyed_heap_input_refuses_a_filtered_count() { - use asap_types::ir::{ExprSemantics, Predicate}; - use asap_types::pre_asap::{CompareOpKind, ScalarValue}; - let count_by_job = |filter: Option| { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: ReductionTy::by(vec![2]), - measures: vec![AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }], - output_names: vec![], - filters: vec![filter], - having: None, - child: metric_scan(&["job"]), - })) - .unwrap() - }; - let intent = AggIntent::TopK { - k: 5, - accuracy: AccuracyTarget::max_error(0.01), - }; - let family = FieldDataType::Sketch( - SketchKind::new( - SketchAlgorithm::CmsWithHeap, - SketchParams::CmsWithHeap { - width: 64, - depth: 4, - heap_size: 5, - }, - ), - GroupingStrategy::default(), - ); - let output = ReductionTy::Reduce(asap_types::ir::operator_properties::GroupKeys::none()); - assert!(matches!( - realize_keyed_additive_summary_input(&intent, &family, &output, &count_by_job(None)), - PhysicalSummaryInputRuleResult::Realized(_) - )); - let value_above_one = Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(1)), - op: CompareOpKind::Gt, - right: Box::new(ScalarExpr::Literal(ScalarValue::Float64(1.0))), - semantics: ExprSemantics::Promql, - }); - assert!(matches!( - realize_keyed_additive_summary_input( - &intent, - &family, - &output, - &count_by_job(Some(value_above_one)) - ), - PhysicalSummaryInputRuleResult::NotApplicable - )); - } - - /// Search takes the front end's pre-ASAP DAG: a root that already holds - /// an ASAP operator is an error, not a panic. - #[test] - fn search_rejects_a_root_with_an_asap_operator() { - let root = lower_promql( - "quantile_over_time(0.99, m[1m])", - AccuracyTarget::max_error(0.01), - ); - let space = search_workload(vec![("q", root)]).unwrap(); - let summary = space - .target_subdag_candidates() - .flat_map(|group| &group.candidates) - .find_map(|candidate| match &candidate.replacement { - Replacement::SubDAG(node) if node.contains_asap() => Some(Rc::clone(node)), - _ => None, - }) - .expect("a summary candidate"); - assert!(matches!( - search_workload(vec![("q", summary)]), - Err(SearchError::AsapInput(_)) - )); - } - - // 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::physical_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::max_error(0.1), - ), - ] { - let root = lower_promql(query, accuracy); - let inventory = search_workload(vec![(0usize, root)]) - .unwrap() - .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)]) - .unwrap() - .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())]).unwrap(); - 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)]).unwrap(); - 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).unwrap(); - 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).unwrap(); - 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)]).unwrap(); - 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::pre_asap::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::max_error(0.01).failure_probability(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 operand_domain(&self, _: &OperatorNode) -> Option { - Some(crate::accuracy::OperandDomain { - lower: 1.0, - upper: 1000.0, - max_samples: 10000, - contract: "finite test population".into(), - }) - } - } - let requested = AccuracyTarget::max_error(0.01) - .failure_probability(0.01) - .accuracy_metric(AccuracyMetric::Relative); - let target = requested.clone().accuracy_metric(AccuracyMetric::Relative); - 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, requested.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 requested = AccuracyTarget::max_error(0.01).failure_probability(0.01); - let target = requested.clone().accuracy_metric(AccuracyMetric::Relative); - let root = lower_promql( - "quantile_over_time(0.5,a[5m]) / quantile_over_time(0.9,a[5m])", - requested.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])", - requested.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_properties::JoinKind::Inner, - pred: equi_pred(0, 2), - left, - right, - })) - .unwrap() - }; - let is_exact = |node: &OperatorNode| { - node.guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::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))]).unwrap(); - 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::max_error(e), - } - } - - fn eps(e: f64) -> AccuracyTarget { - AccuracyTarget::max_error(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, - failure_probability: None - }, - )) - ); - - 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, - failure_probability: None - }, - )) - ); - } - - /// HLL is sized from (ε, δ) (#694): ε = 0.008125 at δ = 0.01 needs - /// `m ≥ 1/(2ε²δ)`, past precision 18, the largest. - #[test] - fn default_cardinality_sizes_hll_from_epsilon_and_delta() { - assert_eq!( - preferred(&default_cardinality()), - Realization::Sketch(SketchKind::new( - SketchAlgorithm::Hll, - SketchParams::Hll { - precision: 18, - relative_error: Some(0.008125) - }, - )) - ); - } - - // Exact counting remains a legal candidate under an approximate target. - #[test] - fn approximate_count_includes_exact_accumulator_candidate() { - let intent = AggIntent::Count { - accuracy: eps(0.01), - }; - assert!(realizations_for_intent(&intent, &DefaultCostModel) - .iter() - .any(|candidate| matches!( - candidate, - Realization::ExactAggregate { - kind: ExactKind::Count, - .. - } - ))); - } - - #[test] - fn epsilon_delta_sizes_cms_depth() { - let intent = AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.001).failure_probability(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::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: 26_602, - failure_probability: None - }, - )) - ); - } - - // ── 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, - }, - ) => { - 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:?}"), - } - } - - #[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); - } - - #[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, - }, - ); - assert_eq!( - worst_case, relaxed, - "width_relaxation={bad} should fall back to the worst-case width" - ); - } - } - - #[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:?}"), - } - } - - #[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, - }; - 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::pre_asap::expr_ir::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" - ); - } - - /// A distinct count whose UnivMon fits the search's state cap: sized for - /// a distinct count, UnivMon keeps a heap of `⌈1.35/(δ ε²)⌉` keys on each - /// of its 19 layers, so `default_cardinality`'s ε ≈ 0.008 (a 2M-key heap, - /// about 2.5 GB) exceeds 512 MiB, while ε = 0.05 needs 54,000 keys. - fn fitting_cardinality() -> AggIntent { - AggIntent::Cardinality { - cols: vec![], - accuracy: AccuracyTarget::max_error(0.05), - } - } - - #[test] - fn cardinality_epsilon_delta_keeps_unknown_accuracy_candidates() { - let q = agg(vec![2], fitting_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::Kmv, - SketchAlgorithm::UnivMon, - ] - ); - - let q = agg( - vec![2], - AggIntent::Cardinality { - cols: vec![], - accuracy: AccuracyTarget::max_error(0.05).failure_probability(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::Kmv, - SketchAlgorithm::UnivMon, - ] - ); - } - - #[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")); - } - - #[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 { - 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 - /// 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) - .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), - ) - .unwrap(); - 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::post_asap::FieldDataType::Sketch(kind, _) => kind.algorithm().clone(), - other => panic!("expected a Sketch family, got {other:?}"), - }, - other => panic!("expected SummaryAgg/SummaryEstimate, got {other:?}"), - } - } - - // ── 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 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:?}"), - }; - 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 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 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); - - let target = TargetSubDAG::with_consumer_count(&roots[0], count); - assert!(SharedSubDAGStrategy.matches(&target)); - assert_eq!(SharedSubDAGStrategy.replacements(&target).len(), 2); - } - - // ── 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() { - let intent = AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), - }; - let root = agg(vec![2], intent, metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]).unwrap(); - - // One group for the Aggregate, one for its Scan child. - assert_eq!(space.len(), 2); - - 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!( - 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!( - 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" - ); - assert_eq!( - 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)); - - 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" - ); - } - - #[test] - fn cardinality_group_keeps_all_three_candidates() { - let root = agg(vec![2], fitting_cardinality(), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]).unwrap(); - let agg_group = space - .target_subdag_candidates() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .unwrap(); - assert_eq!(agg_group.candidates.len(), 3); - 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], fitting_cardinality(), metric_scan(&["job"])); - let targeted = search_workload_with_targets( - vec![( - "q", - root, - Some(AccuracyTarget::max_error(0.01).failure_probability(0.01)), - )], - &default_strategies(), - &DefaultAccuracyModel, - ) - .unwrap(); - 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, - ) - .unwrap(); - assert!(exact_target - .candidates_for_target(&exact_target.roots[0].1) - .unwrap() - .candidates - .iter() - .all(|candidate| !candidate.has_missing_accuracy_evidence())); - } - - #[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)]).unwrap(); - - // 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); - assert_eq!( - group.candidates.len(), - 3, - "1 ExactAggregate Summary + 2 Rewrite (share/recompute): {:?}", - group.candidates - ); - - // 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); - - // 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); - } - - #[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::Predicate; - use asap_types::pre_asap::expr_ir::ScalarValue; - - 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)]).unwrap(); - assert_eq!( - space.len(), - 4, - "2 distinct Filters + 1 shared Aggregate + 1 shared Scan" - ); - - // `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 - )) - ); - } - - // ── 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; - } - } - 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; - } - } - 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); - } - assert_eq!( - group.candidates.len(), - 4, - "Summary candidates are never deduped by this module — see is_duplicate_summary" - ); - } - - #[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)); - assert_eq!( - *share, *recompute, - "fixture sanity: same value, different Rc" - ); - assert!(!is_duplicate_rewrite(&share, &recompute, &target)); - assert!(!is_duplicate_rewrite(&recompute, &share, &target)); - } - - #[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 - )); - } - - // ── 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).unwrap(); - 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)]).unwrap(); - 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::max_error(0.01).failure_probability(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).unwrap(); - 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)]).unwrap(); - 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)]).unwrap(); - 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)]).unwrap(); - - 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)]).unwrap(); - 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)]).unwrap(); - 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::Predicate; - use asap_types::pre_asap::expr_ir::ScalarValue; - - 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())]).unwrap(); - - // 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)), - ]) - .unwrap(); - 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::Predicate; - use asap_types::pre_asap::expr_ir::ScalarValue; - - 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())]).unwrap(); - 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::Predicate; - use asap_types::pre_asap::expr_ir::ScalarValue; - - 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())]).unwrap(); - 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::Predicate; - use asap_types::pre_asap::expr_ir::ScalarValue; - - 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).unwrap(); - 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 - ); - } - - // 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())]).unwrap(); - 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)]).unwrap(); - 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)]).unwrap(); - 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::Predicate; - use asap_types::pre_asap::expr_ir::ScalarValue; - - 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" - ); - } - - // ── termination ────────────────────────────────────────────────────── - - #[test] - fn default_strategies_converge_without_hitting_the_iteration_cap() { - // A workload exercising both strategies at once; if this test - // completes at all, the fixpoint converged well under - // MAX_SEARCH_ITERATIONS (both strategies are idempotent — see the - // module docs — so this always converges in exactly 2 passes). - let a = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let b = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("a", a), ("b", b)]).unwrap(); - assert!(!space.is_empty()); - } - - /// A deliberately ill-behaved [`ReplacementStrategy`]: every call to - /// `replacements` wraps `target` in two `Filter` layers — the outer one - /// (ignored by target discovery — see [`discover_new_descendant_targets`]) - /// and an inner one carrying a monotonically-increasing counter, so the - /// inner layer is a **brand-new, never-before-seen `Rc` every call**. - /// Each round, `search_workload_with` discovers that inner layer as a - /// new target, processes it next round (this strategy matches - /// everything), and gets handed *another* fresh inner layer — the - /// frontier never empties, exactly the failure mode - /// [`MAX_SEARCH_ITERATIONS`] exists to catch. - struct AlwaysGrowingStrategy { - next: std::cell::Cell, - } - - impl ReplacementStrategy for AlwaysGrowingStrategy { - fn matches(&self, _target: &TargetSubDAG<'_>) -> bool { - true - } - - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - let n = self.next.get(); - self.next.set(n + 1); - use asap_types::ir::Predicate; - use asap_types::pre_asap::expr_ir::ScalarValue; - let fresh_inner_layer = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(n))), - child: Rc::clone(target.root), - })) - .unwrap(); - let outer_wrapper = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), - child: fresh_inner_layer, - })) - .unwrap(); - vec![ReplacementSubDAG { - strategy: "AlwaysGrowingStrategy", - replacement: Replacement::SubDAG(outer_wrapper), - provenance: ReplacementProvenance::LogicalRewrite, - rationale: format!("pathological candidate #{n}"), - }] - } - } - - #[test] - #[should_panic(expected = "did not converge")] - fn a_pathologically_growing_strategy_trips_the_iteration_cap() { - let root = metric_scan(&["job"]); - let strategies: Vec> = vec![Box::new(AlwaysGrowingStrategy { - next: std::cell::Cell::new(0), - })]; - let _ = search_workload_with(vec![("q", root)], &strategies).unwrap(); - } - // ── realize_child / retain_exact: end-to-end single-target realization ── - // - // Moved from the former `bind.rs` (issue #251): `bind.rs`'s own - // workload-wide orchestration (`implement_workload`/ - // `implement_workload_with`) was deleted. Current whole-workload logical - // selection uses `CandidateLogicalASAPDAGs::global_selection`; these tests exercise - // `construct_summary_agg`'s schema derivation end to end through - // `realize_child` — production logic that still lives in this module — - // so they move here rather than disappear. Unlike `bind.rs` (an - // external caller that had to reconstruct the rank-and-take-first - // pattern by hand since `realize_child` is `pub(crate)`), these tests - // call `realize_child` directly. - - fn field<'a>(schema: &'a Schema, name: &str) -> &'a Field { - schema - .fields - .iter() - .find(|f| f.name == name) - .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) - } - - #[test] - fn quantile_realizes_kll_wrapped_in_estimate() { - // quantile by (job) (m) at ε=0.01 → Estimate(Quantile) over - // SummaryAgg(Kll{k:269}) over the kept Scan. job = col 2. - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let root = realize(&q).unwrap(); - - let Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) = &root.operator - else { - panic!("expected SummaryEstimate root, got {:?}", root.operator); - }; - assert!(matches!(query, PostAsapSketchStatistic::Quantile { q } if *q == 0.99)); - // Estimate edge: plain row shape — group key + Float64 answer. - assert_eq!( - field(&root.schema, "quantile_0_99").dtype, - FieldDataType::Plain(DataType::Float64) - ); - assert_eq!( - field(&root.schema, "job").dtype, - FieldDataType::Plain(DataType::Utf8) - ); - - let Operator::ASAP(ASAPOp::SummaryAgg { - child, - family, - input, - reduction, - .. - }) = &summary_input.operator - else { - panic!("expected SummaryAgg, got {:?}", summary_input.operator); - }; - assert_eq!( - family, - &FieldDataType::Sketch( - SketchKind::new( - SketchAlgorithm::Kll, - SketchParams::Kll { - k: 269, - failure_probability: None - } - ), - GroupingStrategy::default() - ) - ); - assert_eq!(input, &SummaryUpdate::column(ColumnRef::SampleValue)); - assert_eq!(reduction, &ReductionTy::by(vec![2])); - // SummaryAgg edge: the state column, named after its input, carries - // the committed family. - assert_eq!( - field(&summary_input.schema, "value").dtype, - FieldDataType::Sketch( - SketchKind::new( - SketchAlgorithm::Kll, - SketchParams::Kll { - k: 269, - failure_probability: None - } - ), - GroupingStrategy::default() - ) - ); - // The kept pre-ASAP leaf is the Scan node itself (no wrapper). - assert!(matches!(child.non_asap(), Some(NonASAPOp::Scan { .. }))); - 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. - 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(&q).unwrap(); - 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"])); - let root = realize(&q).unwrap(); - let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &root.operator else { - panic!( - "expected bare SummaryAgg (no estimate), got {:?}", - root.operator - ); - }; - assert_eq!( - family, - &FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) - ); - assert_eq!( - field(&root.schema, "sum").dtype, - FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) - ); - } - - #[test] - fn per_series_rate_keeps_labels_and_retypes_value() { - // rate(m[5m]) — per-series: every label survives; the sample value - // column becomes the Rate accumulator state. - use std::time::Duration; - let q = agg_per_entity( - AggIntent::Rate, - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { - kind: TimeRangeKind::Range, - range: Duration::from_secs(300), - child: metric_scan(&["job"]), - })) - .unwrap(), - ); - let root = realize(&q).unwrap(); - let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &root.operator else { - panic!("expected SummaryAgg, got {:?}", root.operator); - }; - assert_eq!( - family, - &FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate) - ); - assert_eq!( - root.schema - .fields - .iter() - .map(|f| f.name.as_str()) - .collect::>(), - vec!["ts", "value", "job"], - ); - assert_eq!( - field(&root.schema, "value").dtype, - FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate) - ); - assert_eq!(root.schema.time_index, Some(0)); - } - - /// Issue #163, case 1: a bare per-series range function (e.g. - /// `quantile_over_time(...)`) realizes to `SummaryAgg { reduction: - /// PerEntity, .. }` — proving the pre-ASAP `Reduction` this crate - /// already computes (issue #165) is carried onto the post-ASAP node - /// verbatim, not flattened back into an ambiguous bare `Vec`. - #[test] - fn bare_per_series_aggregate_realizes_summary_agg_with_per_entity_reduction() { - use std::time::Duration; - let q = agg_per_entity( - default_quantile(0.99), - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { - kind: TimeRangeKind::Range, - range: Duration::from_secs(10), - child: metric_scan(&["job"]), - })) - .unwrap(), - ); - let root = realize(&q).unwrap(); - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { - panic!("expected estimate root, got {:?}", root.operator); - }; - let Operator::ASAP(ASAPOp::SummaryAgg { reduction, .. }) = &summary_input.operator else { - panic!("expected SummaryAgg, got {:?}", summary_input.operator); - }; - assert_eq!(reduction, &ReductionTy::PerEntity); - } - - /// Issue #163, case 2: an aggregation operator explicitly invoked with - /// no grouping keys realizes to `SummaryAgg { - /// reduction: Reduce(vec![]), .. }` — byte-identical `by: []` to the - /// previous test at the old `Vec` shape; `reduction` is what - /// tells them apart now. - #[test] - fn explicit_empty_by_aggregate_realizes_summary_agg_with_reduce_reduction() { - let intent = AggIntent::Cardinality { - cols: vec![], - accuracy: AccuracyTarget::max_error(0.01), - }; - let q = agg(vec![], intent, metric_scan(&["job"])); - let root = realize(&q).unwrap(); - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { - panic!("expected estimate root, got {:?}", root.operator); - }; - let Operator::ASAP(ASAPOp::SummaryAgg { reduction, .. }) = &summary_input.operator else { - panic!("expected SummaryAgg, got {:?}", summary_input.operator); - }; - assert_eq!(reduction, &ReductionTy::by(vec![])); - } - - #[test] - fn nested_aggregates_realize_per_node() { - // quantile(0.9, sum by (job) (m)) — the realization decision - // fires per node over the nested DAG: KLL over an exact Sum - // accumulator. - let inner = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let outer = agg(vec![], default_quantile(0.9), inner); - // Timing is not stored during realization: time the candidate under - // a maintained materialization assignment to read the maintenance boundary. - let root = at_ingestion(&realize(&outer).unwrap()); - - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { - panic!("expected estimate root, got {:?}", root.operator); - }; - let Operator::ASAP(ASAPOp::SummaryAgg { child, family, .. }) = &summary_input.operator - else { - panic!( - "expected outer SummaryAgg, got {:?}", - summary_input.operator - ); - }; - assert!(matches!( - family, - FieldDataType::Sketch(kind, _) if kind.algorithm() == &SketchAlgorithm::Kll - )); - assert_eq!(child.timing, Some(ExecutionTiming::IngestionTime)); - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) = &child.operator else { - panic!("expected explicit maintenance evaluation"); - }; - let Operator::ASAP(ASAPOp::SummaryAgg { - family: inner_family, - child: leaf, - .. - }) = &child.operator - else { - panic!("expected inner SummaryAgg, got {:?}", child.operator); - }; - assert_eq!( - inner_family, - &FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) - ); - assert!(!leaf.contains_asap()); - } - - /// Issue #115: the summary is built over the intent's own input columns. - /// Before `Cardinality`/`Quantile` carried them, `summarised_input` always - /// fell through to `ColumnRef::SampleValue`, so an HLL was built over the - /// wrong column for every SQL `COUNT(DISTINCT c)`. A distinct-tuple count - /// hashes the whole tuple as one item — feeding the sketch a single leg - /// would report single-column cardinality instead. - #[test] - fn sketch_realizes_over_the_intents_input_columns() { - // `metric_scan(&["job"])` → columns [ts=0, value=1, job=2]. - let column = |name: &str| SummaryInputExpr::Column(ColumnRef::Named(name.into())); - let cases = [ - (vec![2], column("job")), - (vec![1], column("value")), - // PromQL convention: no column ⇒ the synthetic sample value. - (vec![], SummaryInputExpr::Column(ColumnRef::SampleValue)), - ( - vec![1, 2], - SummaryInputExpr::Tuple(vec![column("value"), column("job")]), - ), - ]; - for (cols, want) in cases { - let intent = AggIntent::Cardinality { - cols: cols.clone(), - accuracy: AccuracyTarget::max_error(0.01), - }; - let root = realize(&agg(vec![0], intent, metric_scan(&["job"]))).unwrap(); - let bound = find_summary_input(&root) - .unwrap_or_else(|| panic!("expected a SummaryAgg for cols={cols:?}")); - assert_eq!(bound, want, "wrong summarised input for cols={cols:?}"); - } - } - - /// The update expression of the first `SummaryAgg` in the tree. - fn find_summary_input(node: &OperatorNode) -> Option { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) if input.item.is_none() => { - Some(input.weight.clone()) - } - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - find_summary_input(summary_input) - } - _ => None, - } - } - - #[test] - fn pass_through_intents_stay_logical() { - // avg is exact but non-mergeable; histogram_quantile (classic - // buckets, #79) is never sketchable; exact quantile is exact by - // decree. All three stay whole logical sub-DAGs. - for intent in [ - AggIntent::Avg { col: None }, - AggIntent::HistogramQuantile { q: 0.99, le: 0 }, - AggIntent::Quantile { - col: None, - q: 0.99, - accuracy: AccuracyTarget::exact(), - }, - ] { - let q = agg(vec![2], intent.clone(), metric_scan(&["job"])); - let root = realize(&q).unwrap(); - // Kept pass-through: the pre-ASAP node itself, not a wrapper. - assert!( - !root.contains_asap() && root.operator == q.operator && root.schema == q.schema, - "expected kept pre-ASAP passthrough for {intent:?}" - ); - } - } - - #[test] - fn logical_parent_subsumes_bindable_child() { - // Filter over a bindable quantile: a kept non-ASAP sub-DAG has no - // summary children, so the conservative fallback keeps the whole sub-DAG - // logical. - use asap_types::ir::Predicate; - use asap_types::pre_asap::expr_ir::{CompareOpKind, ScalarValue}; - let q = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(0)), - op: CompareOpKind::Gt, - right: Box::new(ScalarExpr::Literal(ScalarValue::Float64(0.5))), - semantics: asap_types::ir::ExprSemantics::Promql, - }), - child: agg(vec![], default_quantile(0.99), metric_scan(&[])), - })) - .unwrap(); - let root = realize(&q).unwrap(); - assert!( - !root.contains_asap() && root.operator == q.operator && root.schema == q.schema, - "expected the whole Filter sub_dag kept pre-ASAP" - ); - } - - #[test] - fn having_and_multi_intent_stay_logical() { - use asap_types::ir::Predicate; - use asap_types::pre_asap::expr_ir::ScalarValue; - let q = crate::test_support::aggregate( - ReductionTy::by(vec![2]), - vec![default_quantile(0.99)], - vec![], - Some(Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true)))), - metric_scan(&["job"]), - ); - assert!(!realize(&q).unwrap().contains_asap()); - - 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!(!realize(&multi).unwrap().contains_asap()); - } - - // No binding rule applies a per-measure `FILTER` (#466), so the - // aggregate is retained exactly rather than bound to a summary. - #[test] - fn filtered_measure_stays_logical() { - use asap_types::pre_asap::expr_ir::ScalarValue; - let mut q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - if let Operator::NonASAP(NonASAPOp::Aggregate { filters, .. }) = - &mut Rc::make_mut(&mut q).operator - { - *filters = vec![Some(Predicate(ScalarExpr::Literal(ScalarValue::Boolean( - true, - ))))]; - } - assert!(bindable_intent(&q).is_none()); - assert!(!realize(&q).unwrap().contains_asap()); - } - - #[test] - fn unsupported_direct_topk_without_margin_evidence_falls_back_to_pre_asap() { - let q = agg( - vec![2], - AggIntent::TopK { - k: 5, - accuracy: AccuracyTarget::max_error(0.01), - }, - metric_scan(&["job"]), - ); - let root = realize(&q).unwrap(); - assert!(!root.contains_asap()); - } - - #[test] - fn count_ranked_topk_without_margin_evidence_keeps_an_uncertified_candidate() { - let inner = agg( - vec![2], - AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01), - }, - metric_scan(&["job"]), - ); - let root = agg( - vec![], - AggIntent::TopK { - k: 5, - accuracy: AccuracyTarget::max_error(0.01), - }, - inner, - ); - let proposals = - ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); - assert!(!proposals.is_empty()); - assert!(proposals.iter().any(|candidate| matches!( - &candidate.replacement, - Replacement::SubDAG(node) if node.guarantee.as_ref().is_some_and(|g| - g.spec.error.evaluate().is_none() - && g.spec.failure_probability.evaluate().is_none()) - ))); - } - - struct SeparatedTopKEvidence; - - impl AccuracyEvidenceProvider for SeparatedTopKEvidence { - fn propagation_stats( - &self, - op: &CompositionOperator, - _family: &FieldDataType, - _query: Option<&PostAsapSketchStatistic>, - ) -> PropagationStats { - if matches!(op, CompositionOperator::TopKSelection) { - PropagationStats { - topk_selected_lower_bound: Some(101.0), - topk_excluded_upper_bound: Some(100.0), - topk_interval_failure_probability: Some(0.005), - ..Default::default() - } - } else { - PropagationStats::default() - } - } - } - - #[test] - fn topk_margin_evidence_is_consumed_by_candidate_construction() { - let inner = agg( - vec![2], - AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), - }, - metric_scan(&["job"]), - ); - let q = agg( - vec![], - AggIntent::TopK { - k: 5, - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), - }, - inner, - ); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopKEvidence, - ); - let replacements = strategy.replacements(&TargetSubDAG::new(&q)); - assert!(!replacements.is_empty()); - assert!(replacements.iter().all(|candidate| matches!( - &candidate.replacement, - Replacement::SubDAG(node) - if node.guarantee.as_ref().is_some_and(|g| - g.spec.error.evaluate().is_some() - && g.spec.failure_probability.evaluate() == Some(0.005)) - ))); - } - - #[test] - fn count_ranked_topk_fuses_to_one_global_heap_sketch() { - let inner = agg( - vec![2], - AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01), - }, - metric_scan(&["service"]), - ); - let outer = agg( - vec![], - AggIntent::TopK { - k: 10, - accuracy: AccuracyTarget::max_error(0.01), - }, - inner, - ); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopKEvidence, - ); - let candidates = strategy.replacements(&TargetSubDAG::new(&outer)); - let node = candidates - .iter() - .find_map(|candidate| match &candidate.replacement { - Replacement::SubDAG(node) if candidate.rationale.contains("CmsWithHeap") => { - Some(node) - } - _ => None, - }) - .expect("CmsWithHeap candidate"); - let Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) = &node.operator - else { - panic!("expected Top-K evaluation") - }; - assert!(matches!(query, PostAsapSketchStatistic::TopK { k: 10 })); - let Operator::ASAP(ASAPOp::SummaryAgg { - child, - family, - input, - .. - }) = &summary_input.operator - else { - panic!("expected fused summary aggregation") - }; - assert!(matches!( - input.item.as_ref(), - Some(SummaryInputExpr::Column(ColumnRef::Named(name))) if name == "service" - )); - assert_eq!(input.weight, SummaryInputExpr::Constant(1.0)); - assert_eq!( - input.weight_domain, - WeightDomain::NonNegative { - proof: NonNegativeWeightProof::UnitCount, - } - ); - assert!(matches!( - family, - FieldDataType::Sketch(kind, _) - if kind.algorithm() == &SketchAlgorithm::CmsWithHeap - )); - assert!(!child.contains_asap()); - } - - #[test] - fn sum_ranked_topk_fuses_to_one_value_weighted_heap_sketch() { - let inner = agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["service"]), - ); - let outer = agg( - vec![], - AggIntent::TopK { - k: 5, - accuracy: AccuracyTarget::max_error(0.01), - }, - inner, - ); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopKEvidence, - ); - let candidates = strategy.replacements(&TargetSubDAG::new(&outer)); - assert!( - candidates.iter().all(|candidate| { - !candidate.rationale.contains("CmsWithHeap") - || candidate.rationale.contains("CountSketchWithHeap") - }), - "generic weighted Sum has no non-negative proof and must reject CMS" - ); - let node = candidates - .iter() - .find_map(|candidate| match &candidate.replacement { - Replacement::SubDAG(node) - if candidate.rationale.contains("CountSketchWithHeap") => - { - Some(node) - } - _ => None, - }) - .expect("CountSketchWithHeap candidate"); - let Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) = &node.operator - else { - panic!("expected Top-K evaluation") - }; - assert!(matches!(query, PostAsapSketchStatistic::TopK { k: 5 })); - let Operator::ASAP(ASAPOp::SummaryAgg { child, input, .. }) = &summary_input.operator - else { - panic!("expected fused summary aggregation") - }; - assert!(matches!( - input.item.as_ref(), - Some(SummaryInputExpr::Column(ColumnRef::Named(name))) if name == "service" - )); - assert_eq!( - input.weight, - SummaryInputExpr::Column(ColumnRef::SampleValue) - ); - assert!(!child.contains_asap()); - } - - #[test] - fn temporal_per_entity_topk_uses_series_identity_and_sample_value() { - let inner = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: ReductionTy::PerEntity, - measures: vec![AggIntent::Sum { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( - NonASAPOp::TimeRange { - kind: TimeRangeKind::Range, - range: std::time::Duration::from_secs(60), - child: metric_scan(&["service"]), - }, - )) - .unwrap(), - })) - .unwrap(); - let outer = agg( - vec![2], - AggIntent::TopK { - k: 5, - accuracy: AccuracyTarget::max_error(0.01), - }, - inner, - ); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopKEvidence, - ); - let candidates = strategy.replacements(&TargetSubDAG::new(&outer)); - let input = candidates.iter().find_map(|candidate| { - let Replacement::SubDAG(node) = &candidate.replacement else { - return None; - }; - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator - else { - return None; - }; - let Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) = &summary_input.operator else { - return None; - }; - input.item.is_some().then_some(input) - }); - let input = input.expect("keyed summary input"); - assert_eq!( - input.item.as_ref(), - Some(&SummaryInputExpr::EntityIdentity( - EntityIdentity::PromqlLabelSet { - excluding: vec![ColumnRef::Named("service".into())] - } - )) - ); - assert_eq!( - input.weight, - SummaryInputExpr::Column(ColumnRef::SampleValue) - ); - } - - #[test] - fn multidimensional_ranked_item_is_a_structured_tuple() { - let inner = agg( - vec![2, 3], - AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01), - }, - metric_scan(&["service", "region"]), - ); - let outer = agg( - vec![], - AggIntent::TopK { - k: 10, - accuracy: AccuracyTarget::max_error(0.01), - }, - inner, - ); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopKEvidence, - ); - - let update = strategy - .replacements(&TargetSubDAG::new(&outer)) - .into_iter() - .find_map(|candidate| match candidate.replacement { - Replacement::SubDAG(node) => match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - match &summary_input.operator { - Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) => Some(input.clone()), - _ => None, - } - } - _ => None, - }, - _ => None, - }) - .expect("tuple-keyed summary candidate"); - assert!(matches!( - update.item, - Some(SummaryInputExpr::Tuple(ref items)) - if items == &[ - SummaryInputExpr::Column(ColumnRef::Named("service".into())), - SummaryInputExpr::Column(ColumnRef::Named("region".into())), - ] - )); - assert_eq!(update.weight, SummaryInputExpr::Constant(1.0)); - } - - #[test] - fn subpopulation_columns_are_not_part_of_the_ranked_item_tuple() { - let inner = agg( - vec![2, 3, 4], - AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01), - }, - metric_scan(&["service", "method", "region"]), - ); - // The inner aggregate outputs its grouping keys first, so column 2 is - // `region`. Each region is a separate Top-K subpopulation. - let outer = agg( - vec![2], - AggIntent::TopK { - k: 10, - accuracy: AccuracyTarget::max_error(0.01), - }, - inner, - ); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopKEvidence, - ); - let (update, reduction) = strategy - .replacements(&TargetSubDAG::new(&outer)) - .into_iter() - .find_map(|candidate| match candidate.replacement { - Replacement::SubDAG(node) => match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - match &summary_input.operator { - Operator::ASAP(ASAPOp::SummaryAgg { - input, reduction, .. - }) => Some((input.clone(), reduction.clone())), - _ => None, - } - } - _ => None, - }, - _ => None, - }) - .expect("subpopulation-aware summary candidate"); - assert_eq!(reduction, ReductionTy::by(vec![2])); - assert!(matches!( - update.item, - Some(SummaryInputExpr::Tuple(ref items)) - if items == &[ - SummaryInputExpr::Column(ColumnRef::Named("service".into())), - SummaryInputExpr::Column(ColumnRef::Named("method".into())), - ] - )); - assert_eq!(update.weight, SummaryInputExpr::Constant(1.0)); - } - - #[test] - fn sql_reducer_resolves_named_input_column() { - // SUM(bytes) over a tabular scan: `col` resolves positionally to the - // named column, not the PromQL sample value. - let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::Table { - table_ref: "t".into(), - }, - predicates: vec![], - schema: SchemaTy { - fields: vec![ - Field::plain("host", DataType::Utf8, false), - Field::plain("bytes", DataType::Int64, false), - ], - time_index: None, - unique_keys: vec![], - closed: true, - }, - })) - .unwrap(); - let q = agg(vec![0], AggIntent::Sum { col: Some(1) }, scan); - let root = realize(&q).unwrap(); - let Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) = &root.operator else { - panic!("expected SummaryAgg, got {:?}", root.operator); - }; - let SummaryInputExpr::Column(col) = &input.weight else { - panic!("expected observation column") - }; - assert_eq!(col, &ColumnRef::Named("bytes".into())); - } - - // ── Accuracy guarantees and fail-closed composition (issue #172) ───── - - use asap_types::post_asap::AccuracyMetric; - - /// A test-only `AccuracyModel` that *registers* a rule the default - /// deliberately lacks — a sketch over rank-bounded inputs composes - /// additively, keeping the outer sketch's own metric — so the - /// composition/allocation machinery can be exercised end to end. - /// Everything else delegates to `DefaultAccuracyModel`. - struct RankAdditiveModel; - - impl AccuracyModel for RankAdditiveModel { - fn local_guarantee( - &self, - family: &FieldDataType, - query: &PostAsapSketchStatistic, - ) -> Option { - DefaultAccuracyModel.local_guarantee(family, query) - } - - fn propagate( - &self, - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], - local: Option<&AccuracyGuarantee>, - stats: &PropagationStats, - ) -> Result { - let rank = |g: &AccuracyGuarantee| { - g.is_exact() || g.spec.accuracy_metric == AccuracyMetric::Rank - }; - if let (CompositionOperator::ApproximateAggregate, true, Some(local)) = - (op, inputs.iter().all(rank), local) - { - let relabel = |g: &AccuracyGuarantee| { - let mut g = g.clone(); - g.spec.accuracy_metric = AccuracyMetric::ABSOLUTE; - g - }; - let inputs: Vec<_> = inputs.iter().map(relabel).collect(); - let mut out = - DefaultAccuracyModel.propagate(op, &inputs, Some(&relabel(local)), stats)?; - out.spec.accuracy_metric = local.spec.accuracy_metric; - return Ok(out); - } - DefaultAccuracyModel.propagate(op, inputs, local, stats) - } - - fn satisfies(&self, guarantee: &AccuracyGuarantee, target: &AccuracyTarget) -> bool { - DefaultAccuracyModel.satisfies(guarantee, target) - } - } - - fn quantile_eps(q: f64, eps: f64) -> AggIntent { - AggIntent::Quantile { - col: None, - q, - accuracy: AccuracyTarget::max_error(eps), - } - } - - fn summary_child(node: &OperatorNode) -> &Rc { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - summary_child(summary_input) - } - Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) => child, - other => panic!("expected a SummaryAgg, got {other:?}"), - } - } - - #[test] - fn approximate_over_approximate_is_rejected_by_default_not_treated_as_exact() { - // quantile(0.99, quantile by (job) (0.5, m)): rank over rank — no - // registered rule, so every outer sketch candidate is refused with a - // 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)); - assert!( - proposals.candidates.is_empty(), - "no outer sketch may be proposed over an approximate child without a rule: {:?}", - proposals.candidates - ); - // Every attempt — the as-declared composition and the equal-split - // re-sizing, for each of KLL/DDSketch — is refused for the same - // typed reason: no rule, whatever the budget. - assert_eq!(proposals.rejected.len(), 4, "{:?}", proposals.rejected); - for rejection in &proposals.rejected { - assert!( - matches!( - &rejection.error, - AccuracyError::UnsupportedComposition { input_metrics, .. } - if input_metrics == &vec![AccuracyMetric::Rank] - ), - "{:?}", - rejection.error - ); - } - // Fallback keeps the whole sub-DAG pre-ASAP — executed exactly. - let realized = realize_child(&outer, &DefaultCostModel).unwrap(); - assert!(!realized.contains_asap()); - assert!(realized - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact)); - - // A quantile over a distinct-count estimate: a rank error has no rule - // over a relative input, while DDSketch's relative error composes - // with it as with any relative value error (#690: a distinct count's - // error is a relative one). - 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)); - assert!(!proposals.rejected.is_empty()); - assert!(proposals.rejected.iter().all(|r| matches!( - &r.error, - AccuracyError::UnsupportedComposition { input_metrics, local_metric, .. } - if input_metrics == &vec![AccuracyMetric::Relative] - && *local_metric == Some(AccuracyMetric::Rank) - ))); - assert!(proposals.candidates.iter().all(|candidate| matches!( - &candidate.replacement, - Replacement::SubDAG(node) - if node.guarantee.as_ref().is_some_and(|g| g.spec.accuracy_metric == AccuracyMetric::Relative) - ))); - } - - #[test] - fn exact_child_contributes_zero_error() { - // quantile(0.9, sum by (job) (m)): KLL over an exact Sum accumulator - // — the evaluation's guarantee is exactly KLL's own local guarantee. - let inner = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let outer = agg(vec![], default_quantile(0.9), inner); - let root = realize(&outer).unwrap(); - let guarantee = root - .guarantee - .as_ref() - .expect("a evaluation carries a guarantee"); - assert_eq!(guarantee.spec.accuracy_metric, AccuracyMetric::Rank); - assert_eq!( - guarantee.spec.error.evaluate(), - Some(crate::accuracy::estimators::kll::kll_rank_error_99(269)) - ); - assert_eq!(guarantee.approximate_layer_count(), 1); - assert!(guarantee.provenance.iter().any(|s| matches!( - s, - GuaranteeSource::ChildGuarantee { guarantee, .. } if guarantee.is_exact() - ))); - assert!(guarantee.provenance.iter().any(|s| matches!( - s, - GuaranteeSource::CompositionStep { rule, .. } if rule == "exact_input" - ))); - // The sketch *state* node carries no guarantee; the exact - // accumulator's state is its value and does. - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { - panic!() - }; - assert!(summary_input.guarantee.is_none()); - assert!(summary_child(&root) - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact)); - } - - #[test] - fn exact_sum_can_consume_an_approximate_evaluation() { - // sum(count_distinct by (job) (m)) is an outer exact summary over - // the inner HLL evaluation. Both summary levels remain explicit. - let inner = agg(vec![2], default_cardinality(), metric_scan(&["job"])); - let outer = agg(vec![], AggIntent::Sum { col: None }, inner); - let root = realize(&outer).unwrap(); - let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &root.operator else { - panic!("outer exact sum should remain a SummaryAgg") - }; - assert!(matches!( - child.operator, - Operator::ASAP(ASAPOp::SummaryEstimate { .. }) - )); - assert!(root.guarantee.is_some()); - - // count(...) over the same child is exact: a row count does not - // depend on the rows' values. - let inner = agg(vec![2], default_cardinality(), metric_scan(&["job"])); - let outer = agg( - vec![], - AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }, - inner, - ); - let root = realize(&outer).unwrap(); - assert!(root - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact)); - } - - #[test] - fn equal_split_allocation_supports_nested_summary_evaluations() { - // A registered rank-additive rule and valid budget split make both - // 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); - let proposals = strategy.propose(&TargetSubDAG::new(&outer)); - - assert!(!proposals.candidates.is_empty()); - assert!(proposals.candidates.iter().all(|candidate| { - let Replacement::SubDAG(node) = &candidate.replacement else { - return false; - }; - matches!( - node.operator, - Operator::ASAP(ASAPOp::SummaryEstimate { .. }) - ) && node.guarantee.as_ref().is_some_and(|guarantee| { - DefaultAccuracyModel.satisfies( - guarantee, - &crate::accuracy::native_target(&AccuracyTarget::max_error(0.1), guarantee), - ) - }) - })); - } - - #[test] - fn global_selection_can_choose_nested_summaries() { - // The same nested summary remains available through workload search - // 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), - )]; - let space = search_workload_with(vec![("q", Rc::clone(&outer))], &strategies).unwrap(); - let root = &space.roots[0].1; - let group = space.candidates_for_target(root).unwrap(); - assert!(!group.rejected.is_empty()); - assert!(group.candidates.iter().all(|c| match &c.replacement { - // A summary candidate (old `Replacement::Summary`) contains an - // ASAP node; a logical rewrite (old `Replacement::Rewrite`) does not. - Replacement::SubDAG(node) if node.contains_asap() => { - node.guarantee.as_ref().is_some_and(|g| { - DefaultAccuracyModel.satisfies( - g, - &crate::accuracy::native_target(&AccuracyTarget::max_error(0.1), g), - ) - }) - } - Replacement::SubDAG(_) => false, - Replacement::ExactComposition(_) => false, - })); - let ranked = space.cost_sorted(&DefaultCostModel); - let root_ranked = ranked.iter().find(|g| Rc::ptr_eq(g.target, root)).unwrap(); - assert_eq!(root_ranked.candidates.len(), group.candidates.len()); - - let selection = space.global_selection(&DefaultCostModel); - let chosen = selection - .for_target(root) - .unwrap() - .chosen - .expect("a nested summary candidate wins"); - let Replacement::SubDAG(node) = &chosen.replacement else { - panic!() - }; - assert!(matches!( - node.operator, - Operator::ASAP(ASAPOp::SummaryEstimate { .. }) - )); - } - - #[test] - fn root_target_check_removes_candidates_before_cost_ranking() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - // A root target tighter than the node's own ε=0.01: every sketch - // candidate misses it and is moved to `rejected`; nothing is left - // for the cost model to rank. - let space = search_workload_with_targets( - vec![( - "q", - Rc::clone(&q), - Some(AccuracyTarget::max_error(0.001).accuracy_metric(AccuracyMetric::Rank)), - )], - &default_strategies(), - &DefaultAccuracyModel, - ) - .unwrap(); - let root = &space.roots[0].1; - let group = space.candidates_for_target(root).unwrap(); - assert!(group - .candidates - .iter() - .all(|c| matches!(&c.replacement, Replacement::SubDAG(n) if !n.contains_asap()))); - assert!(group.rejected.iter().all(|r| matches!( - r.error, - AccuracyError::TargetNotSatisfied { ref target, .. } if target.error == 0.001 - ))); - assert!(group.rejected.len() >= 2); - let selection = space.global_selection(&DefaultCostModel); - assert!(selection.for_target(root).unwrap().chosen.is_none()); - - // A root target the node's own sizing meets keeps every candidate. - let space = search_workload_with_targets( - vec![( - "q", - Rc::clone(&q), - Some(AccuracyTarget::max_error(0.01).accuracy_metric(AccuracyMetric::Rank)), - )], - &default_strategies(), - &DefaultAccuracyModel, - ) - .unwrap(); - let group = space.candidates_for_target(&space.roots[0].1).unwrap(); - assert!(group - .candidates - .iter() - .any(|c| matches!(&c.replacement, Replacement::SubDAG(n) if n.contains_asap()))); - - // An `Exact` root target admits only exact candidates. - let space = search_workload_with_targets( - vec![("q", Rc::clone(&q), Some(AccuracyTarget::exact()))], - &default_strategies(), - &DefaultAccuracyModel, - ) - .unwrap(); - let group = space.candidates_for_target(&space.roots[0].1).unwrap(); - assert!(group.candidates.iter().all(|c| match &c.replacement { - Replacement::SubDAG(node) if node.contains_asap() => node - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact), - Replacement::SubDAG(_) => true, - Replacement::ExactComposition(_) => false, - })); - } - - #[test] - fn topk_accuracy_target_keeps_uncertified_membership() { - let inner = agg( - vec![2], - AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01), - }, - metric_scan(&["job"]), - ); - let q = agg( - vec![], - AggIntent::TopK { - k: 10, - accuracy: AccuracyTarget::max_error(0.01), - }, - inner, - ); - let space = search_workload_with_targets( - vec![("q", Rc::clone(&q), Some(AccuracyTarget::max_error(0.01)))], - &default_strategies(), - &DefaultAccuracyModel, - ) - .unwrap(); - let group = space.candidates_for_target(&space.roots[0].1).unwrap(); - - assert!(group.candidates.iter().any(|candidate| matches!( - &candidate.replacement, - Replacement::SubDAG(node) if node.guarantee.as_ref().is_some_and(AccuracyGuarantee::has_unknown) - ))); - let candidate = group - .candidates - .iter() - .find(|candidate| candidate.has_missing_accuracy_evidence()) - .unwrap(); - let Replacement::SubDAG(node) = &candidate.replacement else { - unreachable!() - }; - assert!(node - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::has_unknown)); - 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!(selected - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .is_some()); - } - // 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, - ) - .unwrap(); - 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() { - let logical = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["id"])); - let mut values = logical.schema.clone(); - values.fields[0].dtype = FieldDataType::Plain(DataType::Int64); - assert_eq!(ranking_score_index(&logical, &values).unwrap(), 1); - } - // A heap's key schema is derived from its encoded item, not all label columns. - #[test] - fn heap_evaluation_preserves_numeric_item_identity() { - let mut schema = metric_scan(&["id", "description"]).schema.clone(); - schema.fields[2].dtype = FieldDataType::Plain(DataType::Int64); - let raw = crate::test_support::scan("m", schema); - let node = agg( - vec![], - AggIntent::TopK { - k: 2, - accuracy: AccuracyTarget::max_error(0.01), - }, - agg(vec![2], AggIntent::Sum { col: None }, raw.clone()), - ); - let input = PhysicalSummaryInput { - child: raw, - input: SummaryUpdate { - item: Some(SummaryInputExpr::Column(ColumnRef::Named("id".into()))), - weight: SummaryInputExpr::Constant(1.0), - weight_domain: WeightDomain::NonNegative { - proof: NonNegativeWeightProof::UnitCount, - }, - }, - }; - let schema = keyed_heap_evaluation_schema(&input, &node).unwrap(); - assert_eq!( - schema - .fields - .iter() - .map(|f| f.name.as_str()) - .collect::>(), - vec!["id", "__asap_estimate"] - ); - assert_eq!( - schema.fields[0].dtype, - FieldDataType::Plain(DataType::Int64) - ); - } - - // Every SummaryAgg a strategy proposes derives coverage of its whole - // source: an unrestricted selection over a definition reading that source. - #[test] - fn proposed_summary_states_cover_their_whole_source() { - let root = agg( - vec![], - AggIntent::Quantile { - q: 0.9, - col: None, - accuracy: AccuracyTarget::max_error(0.01), - }, - metric_scan(&["job"]), - ); - let source = Source::TimeSeries { metric: "m".into() }; - let proposals = ASAPStrategies::default_cost_model().propose(&TargetSubDAG::new(&root)); - let states: Vec<_> = proposals - .candidates - .iter() - .filter_map(|candidate| match &candidate.replacement { - Replacement::SubDAG(node) => Some(node), - _ => None, - }) - .flat_map(OperatorNode::reachable) - .filter(|node| matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. }))) - .collect(); - assert!(!states.is_empty()); - for state in states { - let coverage = state.coverage().expect("summary state has coverage"); - assert_eq!(coverage.selection, [Default::default()]); - let reads = OperatorNode::reachable(&coverage.definition) - .into_iter() - .filter_map(|node| match node.non_asap() { - Some(NonASAPOp::Scan { source, .. }) => Some(source.clone()), - _ => None, - }) - .collect::>(); - assert_eq!(reads, std::slice::from_ref(&source)); - } - } -} diff --git a/crates/asap-aware-mapping/src/rewrite.rs b/crates/asap-aware-mapping/src/rewrite.rs deleted file mode 100644 index 74b5b0eb..00000000 --- a/crates/asap-aware-mapping/src/rewrite.rs +++ /dev/null @@ -1,920 +0,0 @@ -//! [`SemanticEquivalentRewriteStrategy`] — algebraic, semantic-equivalent -//! query rewriting, -//! the third bullet in `docs/design_docs/asap_aware_mapping.md`'s "Degrees of freedom" -//! section: "Semantic-equivalent rewriting (e.g. `avg` → `sum`/`count`) to -//! increase how often the [sharing/sketch] optimizations above apply" -//! (issue #253, part of #33, per Peilin's #33 comment). -//! -//! ## Why `avg` needs this and `sum`/`count` don't -//! -//! [`replacement::realizations_for_intent`] dispatches `AggIntent::Avg` -//! straight to `Realization::PassThrough` — see that module's own -//! comment on why: `Avg`/`StdDev`/`Variance` "need richer partial state" -//! than a bare sketch/exact accumulator gives, so there is no summary -//! realization for a bare `avg` node to bind to at all. A logical `avg` -//! node therefore can never be a [`SharedSubDAGStrategy`] target either: -//! CSE-style sharing needs *some* mergeable accumulator underneath, and -//! `PassThrough` has none. -//! -//! `Sum` and `Count` are both ordinary mergeable accumulators -//! (`agg_is_mergeable`) — exactly the shape [`SharedSubDAGStrategy`] and a -//! future sketch-family search already know how to reuse across a -//! workload. Rewriting `Aggregate{ measures: [Avg{col}], .. }` into two -//! independent single-measure `Sum` and `Count` aggregates, divided with a -//! `BinaryOp`, computes the same result but *reshapes* it into targets other -//! strategies can bind and share independently. This module only performs -//! that reshaping — see "Non-goals" below for why it does not also decide -//! whether the reshaping is worth it. -//! -//! ## Scope -//! -//! Ordinary `by(...)` averages use a schema-preserving projection. Temporal -//! Float64 temporal averages require a typed finite-division guard; their -//! sum/count components are never exported as an unconditional logical rewrite. -//! -//! - **`without(...)` grouping** leaves an `Aggregate`'s own output schema -//! *open* (`closed: false`, see `without_output_schema`), while the -//! `Project` this strategy always wraps the rewrite in forces -//! `closed: true` (see `NonASAPOp::output_schema`'s `Project` arm). Under -//! `without(...)` the rewritten form's `closed` flag would silently flip -//! relative to the original — exactly the kind of schema drift this -//! module exists to avoid. -//! -//! Both are follow-ups (issue #253 itself scopes to "the concrete case in -//! Peilin's comment"), not correctness bugs in what ships here — a node -//! outside this scope simply doesn't `match`, the same "safe but -//! uninformative" fallback [`ASAPStrategies`]/[`SharedSubDAGStrategy`] -//! already use for shapes they don't have an opinion on. -//! -//! ## Non-goals (mirrors [`replacement`]'s own discipline) -//! -//! **No "is it worth it" heuristic.** An earlier draft of this idea needed a -//! manual before/after-CSE cost comparison to decide whether rewriting -//! helps. With `ReplacementStrategy`'s exhaustive-candidate shape in place, -//! that's unnecessary: this strategy just reports the rewritten form as one -//! more [`ReplacementSubDAG`] alongside whatever else applies to the same -//! target. A future cost-based search (issue #252) is what decides whether -//! the rewritten form is actually worth picking, by letting the original -//! and rewritten forms compete on cost — not this strategy. - -use asap_types::ir::non_asap::any_measure_filtered; -use std::rc::Rc; - -use asap_types::ir::operator_properties::{BinaryOpKind, Reduction}; -use asap_types::ir::{BinaryOperator, NonASAPOp, OperatorNode, ProjectItem, ScalarExpr}; -use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::pre_asap::expr_ir::ArithmeticOpKind; -use asap_types::pre_asap::schema::{ColumnId, DataType}; - -use asap_types::types::AccuracyTarget; - -use crate::replacement::{Replacement, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG}; - -/// The shape [`AvgToSumOverCountStrategy`] rewrites: a single `Avg{col}` -/// measure, no `HAVING`, grouped with an ordinary `by(...)` reduction (see -/// the module docs' "Scope" for why `without(...)`/`PerEntity` are -/// excluded). Returns the grouping key count and the summed column so -/// [`build_rewrite`] doesn't have to re-match. -/// `a / b` with PromQL arithmetic semantics and no vector matching. -fn arithmetic( - op: ArithmeticOpKind, - lhs: Rc, - rhs: Rc, -) -> Option> { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::BinaryOp { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(op), - vector_match: None, - }, - return_bool: false, - lhs, - rhs, - })) - .ok() -} - -fn avg_rewrite_target(node: &OperatorNode) -> Option<(usize, Option)> { - let Some(NonASAPOp::Aggregate { - reduction, - measures, - filters, - having: None, - child, - .. - }) = node.non_asap() - else { - return None; - }; - if any_measure_filtered(filters) { - return None; - } - let Reduction::Reduce(by) = reduction else { - return None; - }; - if by.is_without() { - return None; - } - let [AggIntent::Avg { col }] = measures.as_slice() else { - return None; - }; - // `AggIntent::Count` represents COUNT(*), not COUNT(col). AVG(col) can - // therefore be decomposed through it only when the averaged input is - // provably non-null; otherwise NULL rows would incorrectly contribute to - // the denominator. - let input_schema = &child.schema; - let value_col = col - .or_else(|| input_schema.column_id("value")) - .or_else(|| (0..input_schema.fields.len()).find(|i| !by.contains(i)))?; - if input_schema.fields.get(value_col)?.nullable { - return None; - } - Some((by.keys().len(), *col)) -} - -/// Build the rewritten `Project{ cast(sum) } / Aggregate{ Count }` DAG for -/// `root`, or `None` if `root` isn't [`avg_rewrite_target`]'s shape. `Sum` and -/// `Count` deliberately live in separate, single-measure aggregates so the -/// replacement fixpoint discovers each as an independently bindable target. -/// -/// The `Project`'s leading `by.len()` items are bare `Column(i)` -/// pass-throughs of the grouping keys — identical in name/type to the -/// original `Avg` aggregate's own leading columns, since both aggregates -/// share the same `reduction`/`child` and only differ in `measures` -/// (`aggregate_output_schema`'s grouping-column derivation never looks at -/// `measures` at all). The final item recomputes `sum / count`, casting the -/// numerator to `Float64` before division, and aliases it to the original -/// `avg` column's own name — matching -/// [`AggIntent::Avg::output_column`]'s `(name, Float64, nullable: false)` -/// exactly regardless of the summed column's own type (integer division -/// would otherwise silently reappear whenever the input column is itself -/// integer-typed: `Sum`'s output type tracks its input, `Count`'s is always -/// `Int64`, and `ScalarExpr::scalar_type`'s own `Arithmetic` type inference -/// types a `Div` of two `Int64` operands as `Int64` — the explicit operand -/// `Cast` is what keeps both the division and rewritten `avg` column -/// `Float64` the way the original always was, not an incidental extra step). -// These are conditional physical components, never an unconditional Rewrite. -// The caller must attach the finite-division execution guard before admission. -pub(crate) fn temporal_average_components(root: &Rc) -> Option> { - let Some(NonASAPOp::Aggregate { - reduction: Reduction::PerEntity, - measures, - filters, - child, - having: None, - .. - }) = root.non_asap() - else { - return None; - }; - if any_measure_filtered(filters) { - return None; - } - let [AggIntent::Avg { col }] = measures.as_slice() else { - return None; - }; - if !matches!(child.non_asap(), Some(NonASAPOp::TimeRange { .. })) { - return None; - } - let schema = &child.schema; - let value = schema - .fields - .get(col.or_else(|| schema.column_id("value"))?)?; - if value.nullable || value.dtype != DataType::Float64 { - return None; - } - let aggregate = |intent| { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(child), - })) - .ok() - }; - let rewritten = arithmetic( - ArithmeticOpKind::Div, - aggregate(AggIntent::Sum { col: *col })?, - aggregate(AggIntent::Count { - accuracy: AccuracyTarget::exact(), - })?, - )?; - (root.schema == rewritten.schema).then_some(rewritten) -} - -fn build_rewrite(root: &Rc) -> Option> { - let (group_count, col) = avg_rewrite_target(root)?; - let Some(NonASAPOp::Aggregate { - reduction, - output_names, - child, - .. - }) = root.non_asap() - else { - unreachable!("avg_rewrite_target already confirmed an Aggregate shape"); - }; - - // The original `avg` column's own name: `output_names[0]` if the - // producing front end overrode it (SQL threading DataFusion's own - // generated name — see `NonASAPOp::Aggregate::output_names`'s docs), - // else `AggIntent::Avg`'s synthetic default. Either way this is the - // *only* thing about the original output column this rewrite needs to - // reproduce — `AggIntent::Avg::output_column`'s `(Float64, nullable: - // false)` half is already reproduced structurally (see `build_rewrite`'s - // own doc comment) rather than looked up here. - let avg_name = output_names - .first() - .filter(|s| !s.is_empty()) - .cloned() - .unwrap_or_else(|| "avg".to_string()); - - let sum_agg = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: reduction.clone(), - measures: vec![AggIntent::Sum { col }], - output_names: Vec::new(), - filters: vec![], - having: None, - child: Rc::clone(child), - })) - .ok()?; - let count_agg = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: reduction.clone(), - measures: vec![AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }], - output_names: Vec::new(), - filters: vec![], - having: None, - child: Rc::clone(child), - })) - .ok()?; - - let sum_idx = group_count; - let mut cols: Vec = (0..group_count) - .map(|i| ProjectItem { - alias: None, - expr: ScalarExpr::Column(i), - }) - .collect(); - cols.push(ProjectItem { - alias: Some(avg_name), - expr: ScalarExpr::Cast { - expr: Box::new(ScalarExpr::Column(sum_idx)), - to: DataType::Float64, - try_cast: false, - }, - }); - - let float_sum = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { - cols, - qualifier: None, - child: sum_agg, - })) - .ok()?; - arithmetic(ArithmeticOpKind::Div, float_sum, count_agg) -} - -/// Compose adjacent per-entity and cross-entity accumulators when their -/// algebra, rather than a query-language spelling, proves equivalence. -pub(crate) fn composed_aggregate_rewrite(root: &Rc) -> Option> { - let original_schema = &root.schema; - let Some(NonASAPOp::Aggregate { - reduction: outer_reduction @ Reduction::Reduce(_), - measures: outer_measures, - output_names, - filters: outer_filters, - having: None, - child, - }) = root.non_asap() - else { - return None; - }; - let Some(NonASAPOp::Aggregate { - reduction: Reduction::PerEntity, - measures: inner_measures, - filters: inner_filters, - having: None, - child: inner_child, - .. - }) = child.non_asap() - else { - return None; - }; - if any_measure_filtered(outer_filters) || any_measure_filtered(inner_filters) { - return None; - } - let ([outer], [inner]) = (outer_measures.as_slice(), inner_measures.as_slice()) else { - return None; - }; - let composed = match (outer, inner) { - (AggIntent::Sum { col: None }, AggIntent::Sum { .. }) - | (AggIntent::Sum { col: None }, AggIntent::Count { .. }) - | (AggIntent::Min { col: None }, AggIntent::Min { .. }) - | (AggIntent::Max { col: None }, AggIntent::Max { .. }) => inner.clone(), - _ => return None, - }; - let aggregate = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: outer_reduction.clone(), - measures: vec![composed], - output_names: output_names.clone(), - filters: vec![], - having: None, - child: Rc::clone(inner_child), - })) - .ok()?; - - // The outer Sum sees PromQL's Float64 sample value, whereas the composed - // Count accumulator is Int64. Keep the original observable type. - let rewritten = if matches!( - (outer, inner), - (AggIntent::Sum { col: None }, AggIntent::Count { .. }) - ) { - let Reduction::Reduce(by) = outer_reduction else { - unreachable!() - }; - if by.is_without() { - return None; - } - let mut cols: Vec = (0..by.keys().len()) - .map(|i| ProjectItem { - alias: None, - expr: ScalarExpr::Column(i), - }) - .collect(); - cols.push(ProjectItem { - // PromQL deliberately supplies an empty output-name override. Use - // the already-derived caller-visible name instead of allowing - // Project to invent `col_N` and then failing schema equality. - alias: original_schema - .fields - .last() - .map(|column| column.name.clone()), - expr: ScalarExpr::Cast { - expr: Box::new(ScalarExpr::Column(by.keys().len())), - to: DataType::Float64, - try_cast: false, - }, - }); - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { - cols, - qualifier: None, - child: aggregate, - })) - .ok()? - } else { - aggregate - }; - - // Positional keys, aliases, types, and nullability are part of the rule's - // contract. A future schema change therefore disables rather than widens - // the rewrite. - (*original_schema == rewritten.schema).then_some(rewritten) -} - -/// Rewrites `Aggregate{ measures: [Avg{col}], .. }` into the semantically -/// equivalent pair of single-measure `Sum` and `Count` aggregates divided by -/// a `BinaryOp` — see the module docs for why keeping the accumulators in -/// separate relational nodes lets later strategies reach them independently. -/// -/// A unit struct: unlike [`ASAPStrategies`], this strategy doesn't -/// bind anything (its one [`Replacement`] is always [`Replacement::Rewrite`], -/// never [`Replacement::Summary`]) and so has no [`CostModel`](crate::CostModel) -/// to hold a reference to — the same "no state needed" shape -/// [`SharedSubDAGStrategy`] already has. -#[derive(Debug, Default, Clone, Copy)] -pub struct SemanticEquivalentRewriteStrategy; - -/// Backward-compatible name for callers that registered the original, narrower -/// average rewrite. It now denotes the same semantic-rewrite strategy. -pub use SemanticEquivalentRewriteStrategy as AvgToSumOverCountStrategy; - -impl ReplacementStrategy for SemanticEquivalentRewriteStrategy { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - avg_rewrite_target(target.root).is_some() - || composed_aggregate_rewrite(target.root).is_some() - } - - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - if let Some(rewritten) = composed_aggregate_rewrite(target.root) { - return vec![ReplacementSubDAG { - strategy: "SemanticEquivalentRewriteStrategy", - replacement: Replacement::SubDAG(rewritten), - provenance: crate::replacement::ReplacementProvenance::LogicalRewrite, - rationale: "compose compatible per-entity and cross-entity accumulators using their algebraic intent while preserving the original output schema".into(), - }]; - } - let Some(rewritten) = build_rewrite(target.root) else { - return Vec::new(); - }; - vec![ReplacementSubDAG { - strategy: "AvgToSumOverCountStrategy", - replacement: Replacement::SubDAG(rewritten), - provenance: crate::replacement::ReplacementProvenance::LogicalRewrite, - rationale: - "avg has no summary realization at all (replacement::realizations_for_intent \ - dispatches it to PassThrough) and so can never share or sketch; \ - rewriting it into sum/count under the same grouping — re-divided back \ - into the original avg column by a wrapping Project — computes the same \ - result from two ordinary mergeable accumulators SharedSubDAGStrategy \ - (and a future sketch-family search) can actually reuse across the \ - workload" - .to_string(), - }] - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::lower_promql; - use asap_types::ir::operator_properties::Source; - use asap_types::pre_asap::schema::{Field, Schema}; - use asap_types::types::AccuracyTarget; - use std::time::Duration; - - use crate::test_support::metric_scan; - use asap_types::ir::TimeRangeKind; - - fn avg_agg( - by: Vec, - col: Option, - child: Rc, - ) -> Rc { - avg_agg_with(by, col, vec![], None, child) - } - - fn avg_agg_with( - by: Vec, - col: Option, - output_names: Vec, - having: Option, - child: Rc, - ) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(by), - measures: vec![AggIntent::Avg { col }], - output_names, - filters: vec![], - having, - child, - })) - .unwrap() - } - - // Temporal averages expose two single-measure children without closing labels. - #[test] - fn temporal_average_components_preserves_schema_and_exposes_sum_count() { - let root = lower_promql("avg_over_time(a{job=\"api\"}[5m])", AccuracyTarget::exact()); - assert!(SemanticEquivalentRewriteStrategy - .replacements(&TargetSubDAG::new(&root)) - .is_empty()); - let rewritten = - temporal_average_components(&root).expect("conditional sum/count components"); - assert_eq!(root.schema.clone(), rewritten.schema.clone()); - assert!(matches!( - rewritten.non_asap(), - Some(NonASAPOp::BinaryOp { .. }) - )); - } - - // ── matches ────────────────────────────────────────────────────────── - - #[test] - fn matches_a_bare_avg_aggregate() { - let q = avg_agg(vec![], None, metric_scan(&[])); - let target = TargetSubDAG::new(&q); - assert!(AvgToSumOverCountStrategy.matches(&target)); - } - - #[test] - fn matches_a_grouped_avg_aggregate() { - let q = avg_agg(vec![2], None, metric_scan(&["job"])); - let target = TargetSubDAG::new(&q); - assert!(AvgToSumOverCountStrategy.matches(&target)); - } - - #[test] - fn does_not_match_a_multi_measure_aggregate() { - let q = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::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(&q); - assert!(!AvgToSumOverCountStrategy.matches(&target)); - assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); - } - - #[test] - fn does_not_match_a_having_bearing_avg_aggregate() { - let q = avg_agg_with( - vec![2], - None, - vec![], - Some(asap_types::ir::Predicate(ScalarExpr::Literal( - asap_types::pre_asap::expr_ir::ScalarValue::Boolean(true), - ))), - metric_scan(&["job"]), - ); - let target = TargetSubDAG::new(&q); - assert!(!AvgToSumOverCountStrategy.matches(&target)); - assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); - } - - #[test] - fn does_not_match_a_non_avg_intent() { - for intent in [ - AggIntent::Sum { col: None }, - AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }, - AggIntent::Min { col: None }, - ] { - let q = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![2]), - measures: vec![intent.clone()], - output_names: vec![], - filters: vec![], - having: None, - child: metric_scan(&["job"]), - })) - .unwrap(); - let target = TargetSubDAG::new(&q); - assert!( - !AvgToSumOverCountStrategy.matches(&target), - "expected no match for {intent:?}" - ); - assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); - } - } - - #[test] - fn does_not_match_a_without_grouped_avg_aggregate() { - let q = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(asap_types::ir::operator_properties::GroupKeys::without( - vec![2], - )), - measures: vec![AggIntent::Avg { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: metric_scan(&["job"]), - })) - .unwrap(); - let target = TargetSubDAG::new(&q); - assert!(!AvgToSumOverCountStrategy.matches(&target)); - assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); - } - - #[test] - fn does_not_match_a_per_entity_avg_aggregate() { - let q = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![AggIntent::Avg { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: metric_scan(&[]), - })) - .unwrap(); - let target = TargetSubDAG::new(&q); - assert!(!AvgToSumOverCountStrategy.matches(&target)); - assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); - } - - #[test] - fn does_not_match_a_non_aggregate_node() { - let scan = metric_scan(&["job"]); - let target = TargetSubDAG::new(&scan); - assert!(!AvgToSumOverCountStrategy.matches(&target)); - assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); - } - - // ── replacements / schema round-trip ───────────────────────────────── - - /// The rewritten DAG must keep `Sum` and `Count` in separate aggregates, - /// and its `output_schema()` must equal the original `Avg` aggregate's. - #[test] - fn avg_rewrites_and_schema_matches_exactly_when_ungrouped() { - let original = avg_agg(vec![], None, metric_scan(&[])); - let original_rc = Rc::clone(&original); - let target = TargetSubDAG::new(&original_rc); - - let replacements = AvgToSumOverCountStrategy.replacements(&target); - assert_eq!(replacements.len(), 1, "{replacements:?}"); - assert!(!replacements[0].rationale.is_empty()); - - let rewritten = match &replacements[0].replacement { - Replacement::SubDAG(rc) => rc, - other => panic!("expected a Rewrite replacement, got {other:?}"), - }; - let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = rewritten.non_asap() else { - panic!("expected sum/count BinaryOp, got {rewritten:?}"); - }; - let Some(NonASAPOp::Project { child: sum, .. }) = lhs.non_asap() else { - panic!("expected cast Project above Sum, got {lhs:?}"); - }; - assert!(matches!(sum.non_asap(), - Some(NonASAPOp::Aggregate { measures, .. }) - if matches!(measures.as_slice(), [AggIntent::Sum { col: None }]) - )); - assert!(matches!(rhs.non_asap(), - Some(NonASAPOp::Aggregate { measures, .. }) - if matches!(measures.as_slice(), [AggIntent::Count { accuracy }] if accuracy.is_exact()) - )); - - let original_schema = original.schema.clone(); - let rewritten_schema = rewritten.schema.clone(); - assert_eq!( - original_schema, rewritten_schema, - "the rewritten DAG must report exactly the same output schema as the original avg" - ); - } - - /// A named (SQL-style) output override survives the rewrite: the - /// `Project`'s final column is re-aliased to `output_names[0]`, not the - /// synthetic `"avg"` default. - #[test] - fn preserves_an_explicit_output_name_override() { - let q = avg_agg_with( - vec![], - None, - vec!["avg_latency".to_string()], - None, - metric_scan(&[]), - ); - let original_schema = q.schema.clone(); - let target = TargetSubDAG::new(&q); - - let replacements = AvgToSumOverCountStrategy.replacements(&target); - let rewritten = match &replacements[0].replacement { - Replacement::SubDAG(rc) => rc, - other => panic!("expected a Rewrite replacement, got {other:?}"), - }; - let rewritten_schema = rewritten.schema.clone(); - assert_eq!(original_schema, rewritten_schema); - assert_eq!(rewritten_schema.fields[0].name, "avg_latency"); - } - - /// A grouped rewrite must preserve the aggregate's grouping-key metadata; - /// CSE and roll-up legality both depend on it. - #[test] - fn grouped_avg_rewrite_preserves_the_whole_schema() { - let original = avg_agg(vec![2], None, metric_scan(&["job"])); - let original_schema = original.schema.clone(); - let original_rc = Rc::clone(&original); - let target = TargetSubDAG::new(&original_rc); - - let replacements = AvgToSumOverCountStrategy.replacements(&target); - let rewritten = match &replacements[0].replacement { - Replacement::SubDAG(rc) => rc, - other => panic!("expected a Rewrite replacement, got {other:?}"), - }; - let rewritten_schema = rewritten.schema.clone(); - - assert_eq!(rewritten_schema, original_schema); - } - - #[test] - fn default_search_discovers_bindable_sum_and_count_targets() { - let root = avg_agg(vec![2], None, metric_scan(&["job"])); - let space = crate::replacement::search_workload(vec![("avg", Rc::clone(&root))]).unwrap(); - - let avg_group = space - .candidates_for_target(&space.roots[0].1) - .expect("avg group"); - assert!(avg_group.candidates.iter().any(|candidate| { - candidate.provenance == crate::replacement::ReplacementProvenance::LogicalRewrite - })); - - let mut found_sum = false; - let mut found_count = false; - for group in space.target_subdag_candidates() { - let Some(NonASAPOp::Aggregate { measures, .. }) = group.target.non_asap() else { - continue; - }; - let expected = matches!(measures.as_slice(), [AggIntent::Sum { .. }]) - || matches!( - measures.as_slice(), - [AggIntent::Count { accuracy }] if accuracy.is_exact() - ); - if !expected { - continue; - } - assert!( - group - .candidates - .iter() - .any(|candidate| matches!(&candidate.replacement, - Replacement::SubDAG(node) if node.contains_asap())), - "rewritten accumulator must be independently bindable: {measures:?}" - ); - found_sum |= matches!(measures.as_slice(), [AggIntent::Sum { .. }]); - found_count |= matches!( - measures.as_slice(), - [AggIntent::Count { accuracy }] if accuracy.is_exact() - ); - } - assert!(found_sum && found_count); - } - - #[test] - fn works_with_a_bound_column_not_just_the_sample_value() { - let mut schema_cols = vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("job", DataType::Utf8, true), - Field::plain("bytes", DataType::Int64, false), - ]; - let child = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: { - let cols = std::mem::take(&mut schema_cols); - Schema::with_time_index(cols, 0, vec![]) - }, - })) - .unwrap(); - let original = avg_agg(vec![1], Some(2), child); - let original_schema = original.schema.clone(); - let original_rc = Rc::clone(&original); - let target = TargetSubDAG::new(&original_rc); - - let replacements = AvgToSumOverCountStrategy.replacements(&target); - let rewritten = match &replacements[0].replacement { - Replacement::SubDAG(rc) => rc, - other => panic!("expected a Rewrite replacement, got {other:?}"), - }; - let rewritten_schema = rewritten.schema.clone(); - - // The whole reason for the explicit `Cast` in `build_rewrite`: an - // `Int64` input column (`bytes`) makes `Sum`'s own output `Int64` - // too, and a bare (uncast) `Int64 / Int64` would type the avg - // column `Int64` — this assertion is what would catch that - // regression. - assert_eq!(rewritten_schema.fields, original_schema.fields); - assert_eq!( - rewritten_schema.fields.last().unwrap().dtype, - DataType::Float64 - ); - - let Some(NonASAPOp::BinaryOp { lhs, .. }) = rewritten.non_asap() else { - panic!("expected sum/count BinaryOp"); - }; - let Some(NonASAPOp::Project { cols, .. }) = lhs.non_asap() else { - panic!("expected cast Project above Sum"); - }; - assert!(matches!( - &cols.last().unwrap().expr, - ScalarExpr::Cast { - to: DataType::Float64, - .. - } - )); - } - - #[test] - fn does_not_rewrite_avg_of_a_nullable_column_via_count_star() { - let child = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - Field::plain("latency", DataType::Float64, true), - ], - 0, - vec![], - ), - })) - .unwrap(); - let q = avg_agg(vec![], Some(2), child); - let target = TargetSubDAG::new(&q); - - assert!(!AvgToSumOverCountStrategy.matches(&target)); - assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); - } - - fn nested_aggregate(outer: AggIntent, inner: AggIntent) -> Rc { - let temporal = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![inner], - output_names: vec![], - filters: vec![], - having: None, - child: OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( - NonASAPOp::TimeRange { - kind: TimeRangeKind::Range, - range: Duration::from_secs(300), - child: metric_scan(&["service"]), - }, - )) - .unwrap(), - })) - .unwrap(); - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![2]), - measures: vec![outer], - // Match the PromQL front end: an empty entry selects the intent's - // canonical output name rather than an explicit alias. - output_names: vec![String::new()], - filters: vec![], - having: None, - child: temporal, - })) - .unwrap() - } - - #[test] - fn semantic_rewrite_composes_every_supported_aggregate_pair() { - let exact_count = AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }; - for (outer, inner) in [ - (AggIntent::Sum { col: None }, AggIntent::Sum { col: None }), - (AggIntent::Sum { col: None }, exact_count), - (AggIntent::Min { col: None }, AggIntent::Min { col: None }), - (AggIntent::Max { col: None }, AggIntent::Max { col: None }), - ] { - let expected = inner.clone(); - let original = nested_aggregate(outer, inner); - let candidates = - SemanticEquivalentRewriteStrategy.replacements(&TargetSubDAG::new(&original)); - let [candidate] = candidates.as_slice() else { - panic!("supported pair should produce exactly one rewrite") - }; - let Replacement::SubDAG(rewritten) = &candidate.replacement else { - panic!("expected a logical rewrite") - }; - assert_eq!(original.schema.clone(), rewritten.schema.clone()); - let aggregate = match rewritten.non_asap() { - Some(NonASAPOp::Aggregate { .. }) => rewritten.as_ref(), - Some(NonASAPOp::Project { child, .. }) => child.as_ref(), - other => panic!("expected Aggregate or cast Project, got {other:?}"), - }; - let Some(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(by), - measures, - child, - .. - }) = aggregate.non_asap() - else { - panic!("expected composed cross-entity aggregate") - }; - assert_eq!(by.keys(), &[2]); - assert_eq!(measures, &[expected]); - assert!(matches!(child.non_asap(), - Some(NonASAPOp::TimeRange { range, child, .. }) - if *range == Duration::from_secs(300) - && matches!(child.non_asap(), Some(NonASAPOp::Scan { .. })) - )); - } - } - - #[test] - fn semantic_rewrite_rejects_non_composable_aggregate_pairs() { - for (outer, inner) in [ - (AggIntent::Sum { col: None }, AggIntent::Min { col: None }), - (AggIntent::Avg { col: None }, AggIntent::Sum { col: None }), - (AggIntent::Min { col: None }, AggIntent::Max { col: None }), - ] { - let original = nested_aggregate(outer, inner); - assert!(composed_aggregate_rewrite(&original).is_none()); - } - } - - #[test] - fn default_search_discovers_promql_shaped_sum_count_composition() { - let root = nested_aggregate( - AggIntent::Sum { col: None }, - AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }, - ); - let space = crate::replacement::search_workload(vec![("sum-count", root)]).unwrap(); - let root = &space.roots[0].1; - let group = space.candidates_for_target(root).expect("root memo group"); - let candidate = group - .candidates - .iter() - .find(|candidate| candidate.strategy == "SemanticEquivalentRewriteStrategy") - .expect("default search should run semantic rewrites"); - let Replacement::SubDAG(rewritten) = &candidate.replacement else { - panic!("expected logical rewrite") - }; - assert_eq!(rewritten.schema.clone().fields[1].name, "sum"); - } -} diff --git a/crates/asap-aware-mapping/src/rollup.rs b/crates/asap-aware-mapping/src/rollup.rs deleted file mode 100644 index 7eda22e1..00000000 --- a/crates/asap-aware-mapping/src/rollup.rs +++ /dev/null @@ -1,890 +0,0 @@ -//! [`RollupStrategy`] — group-by-lattice roll-up reuse (fine-to-coarse) as a -//! [`ReplacementStrategy`] (issue #254, part of #33). -//! -//! ## The optimization: AHA-style roll-up, not independent subpopulations -//! -//! `docs/asap_aware_mapping.md`'s "Degrees of freedom" section names this -//! axis directly: **"AHA vs treating hierarchical subpopulations -//! independently."** Two `Aggregate` nodes over the same source, grouped at -//! different granularities (e.g. `by (job)` and `by (job, region)`), are -//! today two *independent* passes over the source. But when the coarser -//! grouping's keys are a subset of the finer grouping's keys, the coarser -//! answer is derivable from the finer one *without ever touching the raw -//! source again* — the same "roll up the group-by lattice" reuse a SQL -//! `ROLLUP`/OLAP-cube engine already exploits between grouping levels of one -//! `GROUP BY ROLLUP(...)` (see `frontend-sql`'s own `ROLLUP` lowering -//! tests), generalized here to two *independently written* aggregates in a -//! workload rather than one multi-level `ROLLUP` clause. -//! -//! This is exactly Peilin's catalog entry (issue #33's comment thread): -//! "Rolling up aggregations on a fine-grained group by to get a -//! coarse-grained group by (like AHA)," alongside "CSE across aggregations, -//! and group by key management" — this strategy is the *cross-aggregate* -//! sibling of `ir::cse::share_common_sub_dags`'s *identical*-sub-DAG -//! sharing: CSE shares two structurally-*equal* aggregates onto one `Rc`; -//! this strategy relates two structurally-*different* (differently grouped) -//! aggregates over the same shared source. -//! -//! ## Why re-aggregating the finer side needs a *combinator*, not literally -//! "the same measure" reapplied -//! -//! Re-deriving `by (job)` from `by (job, region)` means computing, for each -//! `job`, "the combination of every `(job, region)` partial result for that -//! `job`." Whether that combination is *the same operator reapplied* depends -//! on the operator: -//! -//! - `Sum`/`Min`/`Max` are **self-combining**: a sum of sums is a sum, a min -//! of mins is a min. Reapplying the identical `AggIntent` over the finer -//! side's own output column is correct. -//! - Exact `Count` is **not** self-combining: re-`Count`ing the finer side's own -//! output rows counts the number of *finer groups* per coarser group (how -//! many distinct `region`s a `job` has), not the original row count. The -//! correct combinator is `Sum` — `count(A ∪ B) = count(A) + count(B)`, the -//! same "`COUNT(*)` over a pre-aggregated summary table becomes -//! `SUM(count)`" rule every OLAP rollup/cube/materialized-view-matching -//! implementation applies. Approximate `Count` is excluded because summing -//! finalized per-finer-group estimates does not preserve the coarser error -//! target. `Increase` mirrors exact count's additive combination (a -//! counter's total increase across sub-windows is additive), so it combines -//! via `Sum` too. -//! - `Rate` (`increase / duration`) has **no** valid self- or sum-combinator -//! here, so it is deliberately left unhandled (see [`rollup_combinator`]). -//! In practice this never matters: `Rate`/`Increase` are constructed with -//! `Reduction::PerEntity` (per-series, no `by` at all — see -//! `AggIntent::is_per_series` and `asap_frontend_promql::promql::reduction_for`), -//! never `Reduction::Reduce`, so they never carry a `by` set for this -//! strategy to compare in the first place; `PerEntity` nodes never match -//! ([`bindable_grouped_aggregate`] returns `None` for them). `Increase`'s -//! entry in [`rollup_combinator`] is a defensive completion of the match -//! (exhaustive-over-the-mergeable-vocabulary, "no silent fallthrough"), -//! not a case expected to fire today. -//! -//! [`rollup_combinator`] is the single place this substitution is decided — -//! [`is_legal_rollup_source`] (the standalone legality predicate issue #254 -//! asks for) consults it, and so does this module's `replacements`, so the -//! two can never disagree about which intents are eligible. -//! -//! ## `ColumnId` comparability — only sound for identical child IR -//! -//! A `ColumnId` is a *position* into a specific `Schema` (`crates/types/src/pre_asap/schema.rs`'s -//! own doc: "the same edge, the same schema, the same positional numbering"). -//! Comparing the coarser aggregate's `by` positions against the finer -//! aggregate's `by` positions is only meaningful when both aggregates have -//! pointer-identical or `PartialEq`-equal children, including equal schemas. -//! Thus both `by` lists index the same shape. The equality fallback matters -//! for scans that CSE conservatively declines to alias because they have no -//! declared unique key. Structurally different sources remain out of scope: -//! this module never reconciles `ColumnId`s across distinct schemas. -//! -//! ## Non-goals (tracked separately, not attempted here — same split -//! `replacement.rs`'s own module docs draw for `SharedSubDAGStrategy`'s -//! `consumer_count`) -//! -//! - **No sibling discovery inside this strategy.** Finding every aggregate -//! across a workload is not a per-target operation. The workload search -//! performs that traversal after CSE, then constructs [`RollupStrategy`] -//! with the discovered aggregate set. Direct/custom callers supply their -//! own set through [`RollupStrategy::new`]. -//! - **No materialized roll-up operator.** Actually building a pre-aggregated -//! summary/scan leaf at execution time is separate, larger work outside -//! `asap-aware-mapping`'s scope (see issue #254's own "Non-goal" section) -//! — this module only constructs the pre-ASAP `NonASAPOp::Aggregate` -//! rewrite; a `CostModel`/search engine decides whether to prefer it. -//! - **No cross-schema reconciliation** (see "`ColumnId` comparability" -//! above) and **no `without(...)` grouping support** — `without`'s kept -//! set is runtime-open (never enumerable at plan time, per -//! `GroupKeys`'s own doc), so there is no fixed `ColumnId` set to compare -//! against a superset/subset relationship at all; [`is_legal_rollup_source`] -//! declines both directions. - -use asap_types::ir::non_asap::any_measure_filtered; -use std::collections::HashSet; -use std::rc::Rc; - -use asap_types::ir::operator_properties::{GroupKeys, Reduction}; -use asap_types::ir::{NonASAPOp, OperatorNode}; -use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::pre_asap::schema::{ColumnId, Schema}; - -use crate::replacement::{Replacement, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG}; - -/// The `(by, intent, child)` shape this strategy operates on: a single -/// measure, no `HAVING` — the same bindable shape -/// [`crate::replacement::ASAPStrategies`] requires (see that module's -/// private `bindable_intent`) — **plus** a genuine [`Reduction::Reduce`] -/// grouping to compare (not [`Reduction::PerEntity`], which has no `by` set -/// at all). `None` for anything else, including a multi-measure or `HAVING` -/// aggregate, a non-`Aggregate` node, or a `PerEntity` reduction. -fn bindable_grouped_aggregate( - node: &OperatorNode, -) -> Option<(&GroupKeys, &AggIntent, &Rc)> { - let Some(NonASAPOp::Aggregate { - reduction, - measures, - filters, - having, - child, - .. - }) = node.non_asap() - else { - return None; - }; - let ([intent], None) = (measures.as_slice(), having) else { - return None; - }; - if any_measure_filtered(filters) { - return None; - } - let Reduction::Reduce(by) = reduction else { - return None; - }; - Some((by, intent, child)) -} - -/// The `AggIntent` to combine `intent`'s partial results with, when -/// re-aggregating over an already-computed finer aggregate's own measure -/// column at position `finer_measure_col` — see the module docs' "Why -/// re-aggregating the finer side needs a *combinator*" section for the -/// reasoning behind each arm. `None` for any intent this module does not -/// (yet) know a correct combinator for — including every intent -/// `agg_is_mergeable` permits but this module doesn't specifically handle -/// (`Rate`, and everything outside the `Sum`/`Count`/`Min`/`Max`/`Increase` -/// vocabulary `agg_is_mergeable`'s own doc names) — so `is_legal_rollup_source` -/// (which calls this) is *strictly narrower* than `agg_is_mergeable` alone, -/// deliberately: `agg_is_mergeable` answers "does *some* partial-state merge -/// exist", not "is self- or sum-recombination the right one," and this -/// module only ever proposes a rewrite it can construct correctly. -fn rollup_combinator(intent: &AggIntent, finer_measure_col: ColumnId) -> Option { - match intent { - // Self-combining: reapplying the identical operator over the finer - // side's own output column is correct unchanged. - AggIntent::Sum { .. } => Some(AggIntent::Sum { - col: Some(finer_measure_col), - }), - AggIntent::Min { .. } => Some(AggIntent::Min { - col: Some(finer_measure_col), - }), - AggIntent::Max { .. } => Some(AggIntent::Max { - col: Some(finer_measure_col), - }), - // Not self-combining — see the module docs. Both merge by addition - // over the finer side's own output column instead. - AggIntent::Count { accuracy } if accuracy.is_exact() => Some(AggIntent::Sum { - col: Some(finer_measure_col), - }), - AggIntent::Increase => Some(AggIntent::Sum { - col: Some(finer_measure_col), - }), - _ => None, - } -} - -/// **The standalone legality predicate issue #254 asks for**: is `finer`'s -/// grouping a legal roll-up source for `coarser`'s grouping, given that both -/// nodes compute an aggregation intent over the same shared child? -/// -/// Issue #256 (`GroupingStrategy`/Hydra axis, built in parallel from the -/// same base) is expected to call this exact function before assuming a -/// Hydra-backed aggregate composes with a roll-up — it is named and -/// exported for that reason, not left as private inline logic in `matches`/ -/// `replacements`. -/// -/// Legal iff, **in this order**: -/// -/// 1. `finer_intent == coarser_intent` — the two aggregates compute the -/// identical intent + column (verified here, not just assumed by the -/// caller, so this function is a complete, self-contained answer on its -/// own). -/// 2. [`rollup_combinator`] knows how to re-derive `finer_intent` from a -/// pre-aggregated column at all (excludes `Avg`/`StdDev`/`Variance` — -/// never `agg_is_mergeable` — and also excludes every `agg_is_mergeable` -/// intent this module doesn't specifically handle, e.g. `Rate`). -/// 3. Neither grouping is a `without(...)` exclusion grouping — `without`'s -/// kept set is runtime-open, so there is no fixed `ColumnId` set to -/// compare a superset/subset relationship against (see the module docs). -/// 4. `finer_output_schema` (the finer aggregate's own *output* schema, not -/// the shared child's) carries a provable unique key -/// ([`Schema::has_unique_key`]) — **the exact legality gate -/// `ir::cse::share_common_sub_dags` already applies to its own -/// sharing decisions**, reused verbatim here rather than re-invented: -/// `share_common_sub_dags`'s own doc ("Legality: gated by -/// `Schema::unique_keys`") states a producer's output is only safely -/// reusable across consumers when its row identity is provably stable — -/// exactly the property re-aggregating over `finer` as if it were a -/// fresh source requires. -/// 5. `coarser_by` is a **strict, proper** subset of `finer_by` (same -/// `ColumnId`s, finer strictly more of them) — an *equal* `by` is -/// `SharedSubDAGStrategy`'s CSE-sharing question, not a roll-up, so -/// equality is deliberately excluded here, not treated as a degenerate -/// roll-up. -pub fn is_legal_rollup_source( - finer_by: &GroupKeys, - finer_output_schema: &Schema, - finer_intent: &AggIntent, - coarser_by: &GroupKeys, - coarser_intent: &AggIntent, -) -> bool { - if finer_intent != coarser_intent { - return false; - } - if rollup_combinator(finer_intent, 0).is_none() { - return false; - } - if finer_by.is_without() || coarser_by.is_without() { - return false; - } - if !finer_output_schema.has_unique_key() { - return false; - } - is_strict_column_superset(finer_by.keys(), coarser_by.keys()) -} - -/// Whether `finer` is a strict, proper superset of `coarser` — every -/// `ColumnId` in `coarser` also appears in `finer`, and `finer` has more of -/// them (an equal-length or shorter `finer` can never be a proper -/// superset, so the length check alone rules out equality without a set -/// comparison). -fn is_strict_column_superset(finer: &[ColumnId], coarser: &[ColumnId]) -> bool { - let finer_set: HashSet<&ColumnId> = finer.iter().collect(); - let coarser_set: HashSet<&ColumnId> = coarser.iter().collect(); - if finer_set.len() <= coarser_set.len() { - return false; - } - coarser_set.is_subset(&finer_set) -} - -/// Wraps the group-by-lattice roll-up reuse (issue #254, part of #33) as a -/// [`ReplacementStrategy`]: given a coarser `Aggregate` [`TargetSubDAG`], -/// finds every already-known sibling `Aggregate` that has an identical child -/// IR and is a legal, strictly finer roll-up source for it (per -/// [`is_legal_rollup_source`]), and proposes replacing the target with a -/// re-aggregation over that sibling instead of the shared raw source. -/// -/// `siblings` is **caller-supplied, not discovered here** — see the module -/// docs' "Non-goals" on why finding the full sibling set across a workload -/// is a workload-wide traversal this strategy does not own. -pub struct RollupStrategy { - siblings: Vec>, -} - -impl RollupStrategy { - /// A strategy that owns clones of every node in `siblings` and considers - /// each as a candidate roll-up source (or target) — typically the full set of `Aggregate` - /// nodes a workload-wide discovery pass (issue #252) already found - /// sharing at least one child `Rc` with something else. - pub fn new(siblings: &[Rc]) -> Self { - Self { - siblings: siblings.to_vec(), - } - } - - /// Every sibling that is a legal, strictly finer roll-up source for - /// `target` — shared between `matches` and `replacements` so the two - /// can never disagree about which siblings qualify. - fn finer_sources(&self, target: &TargetSubDAG<'_>) -> Vec<&Rc> { - let Some((coarser_by, coarser_intent, coarser_child)) = - bindable_grouped_aggregate(target.root) - else { - return Vec::new(); - }; - - self.siblings - .iter() - .filter(|&candidate| { - if Rc::ptr_eq(candidate, target.root) { - return false; - } - let Some((finer_by, finer_intent, finer_child)) = - bindable_grouped_aggregate(candidate) - else { - return false; - }; - if !Rc::ptr_eq(finer_child, coarser_child) && finer_child != coarser_child { - return false; - } - is_legal_rollup_source( - finer_by, - &candidate.schema, - finer_intent, - coarser_by, - coarser_intent, - ) - }) - .collect() - } -} - -impl ReplacementStrategy for RollupStrategy { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - !self.finer_sources(target).is_empty() - } - - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - let Some((coarser_by, coarser_intent, _)) = bindable_grouped_aggregate(target.root) else { - return Vec::new(); - }; - let Some(NonASAPOp::Aggregate { output_names, .. }) = target.root.non_asap() else { - unreachable!("bindable_grouped_aggregate already confirmed Aggregate"); - }; - self.finer_sources(target) - .into_iter() - .filter_map(|finer| build_rollup(finer, coarser_by, coarser_intent, output_names)) - .collect() - } -} - -/// Build the coarser replacement: a new `NonASAPOp::Aggregate` grouped by -/// `coarser_by`'s columns (repositioned into `finer`'s own output schema — -/// see below), computing `rollup_combinator(intent, ..)` over `finer`'s own -/// measure column, with `child = finer` instead of the original shared -/// source. -/// -/// `coarser_by`'s `ColumnId`s are positions into the *shared child's* -/// schema (the same schema `finer_by`'s `ColumnId`s index into — see the -/// module docs' "`ColumnId` comparability" section). `finer`'s own output -/// schema is a *different* schema (`finer_by`'s columns, in order, followed -/// by its one measure column — `aggregate_output_schema`'s `by ++ measures` -/// shape), so each of `coarser_by`'s columns must be translated from its -/// position in the shared child to its position in `finer`'s output: the -/// index its `ColumnId` occupies within `finer_by`'s own ordered list. -fn build_rollup( - finer: &Rc, - coarser_by: &GroupKeys, - intent: &AggIntent, - output_names: &[String], -) -> Option { - let (finer_by, _, _) = bindable_grouped_aggregate(finer)?; - // `finer`'s own single measure sits right after its `by` columns in its - // output schema (`aggregate_output_schema`'s `by ++ measures` layout). - let finer_measure_col: ColumnId = finer_by.len(); - let combinator = rollup_combinator(intent, finer_measure_col)?; - - let remapped_by: Vec = coarser_by - .keys() - .iter() - .map(|id| finer_by.keys().iter().position(|f| f == id)) - .collect::>>()?; - - let rewritten = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(remapped_by), - measures: vec![combinator], - output_names: output_names.to_vec(), - filters: vec![], - having: None, - child: Rc::clone(finer), - })) - .ok()?; - - Some(ReplacementSubDAG { - strategy: "RollupStrategy", - replacement: Replacement::SubDAG(rewritten), - provenance: crate::replacement::ReplacementProvenance::LogicalRewrite, - rationale: format!( - "rolls up from the finer Aggregate grouped by {:?} (a strict superset of this \ - node's own {:?} grouping over the same shared source) instead of an independent \ - pass over the raw source — both compute {intent:?} over the same input, and the \ - finer side has a provable unique key (Schema::has_unique_key)", - finer_by.keys(), - coarser_by.keys(), - ), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use asap_types::ir::operator_properties::Source; - use asap_types::pre_asap::schema::{DataType, Field}; - use asap_types::types::AccuracyTarget; - - /// `[ts(0), value(1), job(2), region(3)]`. - fn metric_scan() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - Field::plain("job", DataType::Utf8, true), - Field::plain("region", DataType::Utf8, true), - ], - 0, - vec![], - ), - })) - .unwrap() - } - - fn agg(by: Vec, intent: AggIntent, child: &Rc) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(by), - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(child), - })) - .unwrap() - } - - fn without_agg( - excluded: Vec, - intent: AggIntent, - child: &Rc, - ) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(GroupKeys::without(excluded)), - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(child), - })) - .unwrap() - } - - // ── is_legal_rollup_source (the standalone predicate) ─────────────── - - #[test] - fn predicate_accepts_a_strict_superset_over_a_mergeable_intent_with_a_unique_key() { - let finer_schema = Schema::with_time_index(vec![], 0, vec![vec![0, 1]]); - assert!(is_legal_rollup_source( - &GroupKeys::by(vec![2, 3]), - &finer_schema, - &AggIntent::Sum { col: None }, - &GroupKeys::by(vec![2]), - &AggIntent::Sum { col: None }, - )); - } - - #[test] - fn predicate_rejects_mismatched_intents() { - let finer_schema = Schema::with_time_index(vec![], 0, vec![vec![0, 1]]); - assert!(!is_legal_rollup_source( - &GroupKeys::by(vec![2, 3]), - &finer_schema, - &AggIntent::Sum { col: None }, - &GroupKeys::by(vec![2]), - &AggIntent::Sum { col: Some(9) }, - )); - } - - #[test] - fn predicate_rejects_a_non_mergeable_intent() { - let finer_schema = Schema::with_time_index(vec![], 0, vec![vec![0, 1]]); - assert!(!is_legal_rollup_source( - &GroupKeys::by(vec![2, 3]), - &finer_schema, - &AggIntent::Avg { col: None }, - &GroupKeys::by(vec![2]), - &AggIntent::Avg { col: None }, - )); - } - - #[test] - fn predicate_rejects_an_intent_with_no_known_combinator() { - // Rate is `agg_is_mergeable` but this module has no correct - // self-/sum-combinator for it (see `rollup_combinator`'s doc). - let finer_schema = Schema::with_time_index(vec![], 0, vec![vec![0, 1]]); - assert!(!is_legal_rollup_source( - &GroupKeys::by(vec![2, 3]), - &finer_schema, - &AggIntent::Rate, - &GroupKeys::by(vec![2]), - &AggIntent::Rate, - )); - } - - #[test] - fn predicate_rejects_a_finer_side_with_no_unique_key() { - // A schema with an empty `unique_keys` — as if `finer` were, e.g., a - // `without(...)`-grouped or otherwise non-hoistable aggregate — even - // though the `by` sets themselves are a clean strict superset. - let no_unique_key_schema = Schema::new(vec![]); - assert!(!is_legal_rollup_source( - &GroupKeys::by(vec![2, 3]), - &no_unique_key_schema, - &AggIntent::Sum { col: None }, - &GroupKeys::by(vec![2]), - &AggIntent::Sum { col: None }, - )); - } - - #[test] - fn predicate_rejects_equal_by_sets() { - // Equality is `SharedSubDAGStrategy`'s question, not a roll-up. - let finer_schema = Schema::with_time_index(vec![], 0, vec![vec![0]]); - assert!(!is_legal_rollup_source( - &GroupKeys::by(vec![2]), - &finer_schema, - &AggIntent::Sum { col: None }, - &GroupKeys::by(vec![2]), - &AggIntent::Sum { col: None }, - )); - } - - #[test] - fn predicate_does_not_treat_duplicate_keys_as_a_strict_superset() { - let finer_schema = Schema::with_time_index(vec![], 0, vec![vec![0, 1]]); - assert!(!is_legal_rollup_source( - &GroupKeys::by(vec![2, 3, 3]), - &finer_schema, - &AggIntent::Sum { col: None }, - &GroupKeys::by(vec![2, 3]), - &AggIntent::Sum { col: None }, - )); - } - - #[test] - fn predicate_rejects_unrelated_by_sets() { - let finer_schema = Schema::with_time_index(vec![], 0, vec![vec![0, 1]]); - assert!(!is_legal_rollup_source( - &GroupKeys::by(vec![3, 4]), - &finer_schema, - &AggIntent::Sum { col: None }, - &GroupKeys::by(vec![2]), - &AggIntent::Sum { col: None }, - )); - } - - #[test] - fn predicate_rejects_a_without_grouping_on_either_side() { - let finer_schema = Schema::with_time_index(vec![], 0, vec![vec![0, 1]]); - assert!(!is_legal_rollup_source( - &GroupKeys::without(vec![2, 3]), - &finer_schema, - &AggIntent::Sum { col: None }, - &GroupKeys::by(vec![2]), - &AggIntent::Sum { col: None }, - )); - assert!(!is_legal_rollup_source( - &GroupKeys::by(vec![2, 3]), - &finer_schema, - &AggIntent::Sum { col: None }, - &GroupKeys::without(vec![2]), - &AggIntent::Sum { col: None }, - )); - } - - // ── RollupStrategy ──────────────────────────────────────────────────── - - #[test] - fn superset_by_over_identical_mergeable_intent_and_shared_child_rolls_up() { - let scan = metric_scan(); - let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &scan); - let coarse = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); - - let siblings = vec![Rc::clone(&fine), Rc::clone(&coarse)]; - let strategy = RollupStrategy::new(&siblings); - let target = TargetSubDAG::new(&coarse); - - assert!(strategy.matches(&target)); - let replacements = strategy.replacements(&target); - assert_eq!(replacements.len(), 1, "{replacements:?}"); - - let Replacement::SubDAG(rewritten) = &replacements[0].replacement else { - panic!("expected a Rewrite replacement"); - }; - let Some(NonASAPOp::Aggregate { - reduction, - measures, - child, - having, - .. - }) = rewritten.non_asap() - else { - panic!("expected an Aggregate rewrite, got {rewritten:?}"); - }; - assert!(having.is_none()); - assert!( - Rc::ptr_eq(child, &fine), - "child must be the finer aggregate" - ); - assert_eq!( - reduction.expect_reduce(), - &vec![0usize], - "coarser's `job` column (position 2 in the shared source) is the finer \ - aggregate's own column 0" - ); - assert_eq!( - measures, - &vec![AggIntent::Sum { col: Some(2) }], - "Sum is self-combining: re-Sum over the finer aggregate's own Sum output \ - column (position 2, right after its two `by` keys, job and region)" - ); - assert!(!replacements[0].rationale.is_empty()); - assert!(replacements[0].rationale.contains("finer")); - } - - #[test] - fn count_rolls_up_via_sum_not_count() { - // Count is not self-combining (see the module docs) — the rewritten - // measure must be Sum over the finer Count's own output column, not - // Count reapplied. - let scan = metric_scan(); - let fine = agg( - vec![2, 3], - AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }, - &scan, - ); - let coarse = agg( - vec![2], - AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }, - &scan, - ); - - let siblings = vec![Rc::clone(&fine), Rc::clone(&coarse)]; - let strategy = RollupStrategy::new(&siblings); - let target = TargetSubDAG::new(&coarse); - - let replacements = strategy.replacements(&target); - assert_eq!(replacements.len(), 1, "{replacements:?}"); - let Replacement::SubDAG(rewritten) = &replacements[0].replacement else { - panic!("expected a Rewrite replacement"); - }; - let Some(NonASAPOp::Aggregate { measures, .. }) = rewritten.non_asap() else { - panic!("expected an Aggregate rewrite"); - }; - assert_eq!( - measures, - &vec![AggIntent::Sum { col: Some(2) }], - "Count's own output column sits at position 2, right after its two `by` keys" - ); - } - - #[test] - fn approximate_count_does_not_roll_up_via_sum() { - let scan = metric_scan(); - let intent = AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01), - }; - let fine = agg(vec![2, 3], intent.clone(), &scan); - let coarse = agg(vec![2], intent, &scan); - let siblings = vec![Rc::clone(&fine), Rc::clone(&coarse)]; - let strategy = RollupStrategy::new(&siblings); - let target = TargetSubDAG::new(&coarse); - - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - } - - #[test] - fn default_workload_search_adds_rollup_for_two_query_workload() { - let fine_scan = metric_scan(); - let coarse_scan = metric_scan(); - let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &fine_scan); - let coarse = agg(vec![2], AggIntent::Sum { col: Some(1) }, &coarse_scan); - - let space = - crate::replacement::search_workload(vec![("fine", fine), ("coarse", coarse)]).unwrap(); - let coarse_group = space - .target_subdag_candidates() - .find(|group| { - matches!(group.target.non_asap(), - Some(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(by), - .. - }) if by.keys() == [2] - ) - }) - .expect("coarser aggregate group"); - - let rewrite = coarse_group - .candidates - .iter() - .find_map(|candidate| match &candidate.replacement { - // Old `Replacement::Rewrite`: a pure pre-ASAP sub-DAG. - Replacement::SubDAG(rewrite) if !rewrite.contains_asap() => Some(rewrite), - Replacement::SubDAG(_) | Replacement::ExactComposition(_) => None, - }) - .expect("default search must include the roll-up rewrite"); - let Some(NonASAPOp::Aggregate { child, .. }) = rewrite.non_asap() else { - panic!("expected aggregate rewrite, got {rewrite:?}"); - }; - assert!(matches!(child.non_asap(), - Some(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(by), - .. - }) if by.keys() == [2, 3] - )); - } - - #[test] - fn workload_search_does_not_roll_up_approximate_count() { - let intent = AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01), - }; - let fine = agg(vec![2, 3], intent.clone(), &metric_scan()); - let coarse = agg(vec![2], intent, &metric_scan()); - let space = - crate::replacement::search_workload(vec![("fine", fine), ("coarse", coarse)]).unwrap(); - let coarse_group = space - .target_subdag_candidates() - .find(|group| { - matches!(group.target.non_asap(), - Some(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(by), - .. - }) if by.keys() == [2] - ) - }) - .expect("coarser aggregate group"); - - assert!(coarse_group - .candidates - .iter() - .all(|candidate| !matches!(&candidate.replacement, - Replacement::SubDAG(rewrite) if !rewrite.contains_asap()))); - } - - #[test] - fn rollup_preserves_the_coarser_output_name() { - let scan = metric_scan(); - let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &scan); - let coarse = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![2]), - measures: vec![AggIntent::Sum { col: Some(1) }], - output_names: vec!["total_requests".into()], - filters: vec![], - having: None, - child: Rc::clone(&scan), - })) - .unwrap(); - let original_schema = coarse.schema.clone(); - - let siblings = vec![Rc::clone(&fine), Rc::clone(&coarse)]; - let strategy = RollupStrategy::new(&siblings); - let replacements = strategy.replacements(&TargetSubDAG::new(&coarse)); - let Replacement::SubDAG(rewritten) = &replacements[0].replacement else { - panic!("expected a Rewrite replacement"); - }; - - assert_eq!(rewritten.schema.clone(), original_schema); - let Some(NonASAPOp::Aggregate { output_names, .. }) = rewritten.non_asap() else { - unreachable!(); - }; - assert_eq!(output_names, &vec!["total_requests".to_string()]); - } - - #[test] - fn non_mergeable_intent_does_not_roll_up() { - let scan = metric_scan(); - let fine = agg(vec![2, 3], AggIntent::Avg { col: Some(1) }, &scan); - let coarse = agg(vec![2], AggIntent::Avg { col: Some(1) }, &scan); - - let siblings = vec![Rc::clone(&fine), Rc::clone(&coarse)]; - let strategy = RollupStrategy::new(&siblings); - let target = TargetSubDAG::new(&coarse); - - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - } - - #[test] - fn no_unique_key_on_the_finer_side_does_not_roll_up() { - // `without(...)` groupings never carry a provable unique key - // (`without_output_schema` always reports `unique_keys: []`) even - // though its `keys()` (the *excluded* positions here) happens to be - // numerically a superset of the coarser side's *kept* positions — - // `is_legal_rollup_source` rejects any `without` grouping outright, - // and would reject on the missing unique key regardless. - let scan = metric_scan(); - let fine = without_agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &scan); - let coarse = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); - - assert!( - !fine.schema.clone().has_unique_key(), - "fixture sanity: a without(...) aggregate has no provable unique key" - ); - - let siblings = vec![Rc::clone(&fine), Rc::clone(&coarse)]; - let strategy = RollupStrategy::new(&siblings); - let target = TargetSubDAG::new(&coarse); - - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - } - - #[test] - fn unrelated_by_sets_do_not_roll_up() { - // Neither `[job]` nor `[region]` is a superset of the other. - let scan = metric_scan(); - let a = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); - let b = agg(vec![3], AggIntent::Sum { col: Some(1) }, &scan); - - let siblings = vec![Rc::clone(&a), Rc::clone(&b)]; - let strategy = RollupStrategy::new(&siblings); - - let target_a = TargetSubDAG::new(&a); - assert!(!strategy.matches(&target_a)); - assert!(strategy.replacements(&target_a).is_empty()); - - let target_b = TargetSubDAG::new(&b); - assert!(!strategy.matches(&target_b)); - assert!(strategy.replacements(&target_b).is_empty()); - } - - #[test] - fn equal_by_sets_do_not_roll_up() { - // Equal groupings are `SharedSubDAGStrategy`'s CSE-sharing - // question (build once and share, or build independently) — a - // roll-up requires a *strict* superset, not equality. - let scan = metric_scan(); - let a = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); - let b = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); - - let siblings = vec![Rc::clone(&a), Rc::clone(&b)]; - let strategy = RollupStrategy::new(&siblings); - let target = TargetSubDAG::new(&a); - assert!(!strategy.matches(&target)); - } - - #[test] - fn structurally_identical_children_roll_up_without_cse_aliasing() { - // Scans without unique keys are deliberately not pointer-aliased by - // CSE. Structural equality still proves identical schemas and makes - // the two aggregates' positional ColumnIds comparable. - let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &metric_scan()); - let coarse = agg(vec![2], AggIntent::Sum { col: Some(1) }, &metric_scan()); - - let siblings = vec![Rc::clone(&fine), Rc::clone(&coarse)]; - let strategy = RollupStrategy::new(&siblings); - let target = TargetSubDAG::new(&coarse); - assert!(strategy.matches(&target)); - assert_eq!(strategy.replacements(&target).len(), 1); - } - - #[test] - fn does_not_match_a_multi_measure_or_having_aggregate() { - let scan = metric_scan(); - let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &scan); - let multi = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![2]), - measures: vec![ - AggIntent::Sum { col: Some(1) }, - AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }, - ], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(&scan), - })) - .unwrap(); - - let siblings = vec![Rc::clone(&fine), Rc::clone(&multi)]; - let strategy = RollupStrategy::new(&siblings); - let target = TargetSubDAG::new(&multi); - assert!(!strategy.matches(&target)); - assert!(strategy.replacements(&target).is_empty()); - } -} diff --git a/crates/asap-aware-mapping/src/test_support.rs b/crates/asap-aware-mapping/src/test_support.rs index 021dc10b..f10d8bb1 100644 --- a/crates/asap-aware-mapping/src/test_support.rs +++ b/crates/asap-aware-mapping/src/test_support.rs @@ -140,11 +140,6 @@ pub(crate) fn without_agg( ) } -/// A per-entity (per-series) single-measure aggregate. -pub(crate) fn agg_per_entity(intent: AggIntent, child: Rc) -> Rc { - aggregate(Reduction::PerEntity, vec![intent], vec![], None, child) -} - pub(crate) fn filter(pred: ScalarExpr, child: Rc) -> Rc { OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { pred: Predicate(pred), @@ -192,22 +187,6 @@ pub(crate) fn at_ingestion(root: &Rc) -> Rc { .expect("at-ingestion materialization timings apply") } -/// Time `root` under the default materialization assignment (which runs every -/// data-state / population-contract check) and export it as a physical ASAP DAG. -pub(crate) fn time_and_export( - root: &Rc, -) -> Result< - asap_types::ir::physical_export::PhysicalASAPDAG, - asap_types::post_asap::execution_data_state::ExecutionDataStateError, -> { - let timed = apply_materialization_timings( - root, - &MaterializationAssignment::default(), - &mut TimingMemo::new(), - )?; - asap_types::ir::physical_export::compile_physical_asap_dag(&timed) -} - /// The Count-Min summary of a Stage 1 candidate of `query`. pub(crate) fn cms_summary(query: &str) -> OperatorNode { use asap_types::ir::{ASAPOp, QueryRoot}; diff --git a/crates/asap-aware-mapping/src/topk_reuse.rs b/crates/asap-aware-mapping/src/topk_reuse.rs deleted file mode 100644 index dbf75059..00000000 --- a/crates/asap-aware-mapping/src/topk_reuse.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! Workload-aware reuse between compatible ordered limits. -//! -//! If two queries rank the same input and request top-k results with -//! `small_k < large_k`, the smaller result is exactly the first `small_k` -//! rows of the larger result when both limit over the same partitions. -//! This strategy therefore replaces -//! `Limit(small_k, Sort(X))` with `Limit(small_k, Limit(large_k, Sort(X)))`. - -use std::rc::Rc; - -use asap_types::ir::{NonASAPOp, OperatorNode}; - -use crate::replacement::{ - Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, -}; - -/// Derives a smaller top-k result from a compatible larger top-k sibling. -pub struct TopKLimitReuseStrategy { - limits: Vec>, -} - -impl TopKLimitReuseStrategy { - pub fn new(limits: &[Rc]) -> Self { - Self { - limits: limits.to_vec(), - } - } - - fn larger_sources<'a>(&'a self, target: &TargetSubDAG<'_>) -> Vec<&'a Rc> { - let Some(NonASAPOp::Limit { - n: Some(target_n), - offset: 0, - partition_by: target_partition_by, - child: target_child, - }) = target.root.non_asap() - else { - return Vec::new(); - }; - - let mut sources: Vec<_> = self - .limits - .iter() - .filter(|candidate| { - if Rc::ptr_eq(candidate, target.root) { - return false; - } - let Some(NonASAPOp::Limit { - n: Some(n), - offset: 0, - partition_by, - child, - }) = candidate.non_asap() - else { - return false; - }; - // A global top-k is not a per-group one (or the reverse). - n > target_n - && partition_by == target_partition_by - && (Rc::ptr_eq(child, target_child) || child.as_ref() == target_child.as_ref()) - }) - .collect(); - // Prefer the smallest sufficient materialized top-k when several - // larger siblings are available. - sources.sort_by_key(|source| match source.non_asap() { - Some(NonASAPOp::Limit { n: Some(n), .. }) => *n, - _ => unreachable!(), - }); - sources - } -} - -impl ReplacementStrategy for TopKLimitReuseStrategy { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - !self.larger_sources(target).is_empty() - } - - fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - let Some(NonASAPOp::Limit { - n: Some(target_n), - offset: 0, - partition_by, - .. - }) = target.root.non_asap() - else { - return Vec::new(); - }; - - self.larger_sources(target) - .into_iter() - .filter_map(|source| { - let source_n = match source.non_asap() { - Some(NonASAPOp::Limit { n: Some(n), .. }) => *n, - _ => unreachable!(), - }; - let rewritten = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Limit { - n: Some(*target_n), - offset: 0, - partition_by: partition_by.clone(), - child: Rc::clone(source), - })) - .ok()?; - Some(ReplacementSubDAG { - strategy: "TopKLimitReuseStrategy", - replacement: Replacement::SubDAG(rewritten), - provenance: ReplacementProvenance::LogicalRewrite, - rationale: format!( - "derives top-{target_n} from the compatible shared top-{source_n} result; both rank the identical input with the same ordering" - ), - }) - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::scan; - use asap_types::ir::operator_properties::GroupKeys; - use asap_types::pre_asap::Schema; - - fn scan_named(metric: &str) -> Rc { - scan(metric, Schema::with_time_index(vec![], 0, vec![])) - } - - fn limit(n: usize, offset: usize, child: Rc) -> Rc { - partitioned_limit(n, offset, GroupKeys::none(), child) - } - - fn partitioned_limit( - n: usize, - offset: usize, - partition_by: GroupKeys, - child: Rc, - ) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Limit { - n: Some(n), - offset, - partition_by, - child, - })) - .unwrap() - } - - /// A global top-10 is not the per-job top-5's source: its rows are the - /// ten best overall, not the five best of each job. - #[test] - fn global_limit_is_not_reused_for_a_per_group_limit() { - use asap_types::pre_asap::{DataType, Field}; - let child = scan( - "m", - Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("job", DataType::Utf8, false), - ], - 0, - vec![], - ), - ); - let per_job = partitioned_limit(5, 0, GroupKeys::by(vec![1]), Rc::clone(&child)); - let global = limit(10, 0, child); - let strategy = TopKLimitReuseStrategy::new(&[Rc::clone(&per_job), global]); - assert!(!strategy.matches(&TargetSubDAG::new(&per_job))); - } - - #[test] - fn smaller_limit_reuses_larger_compatible_limit() { - let child = scan_named("m"); - let small = limit(5, 0, Rc::clone(&child)); - let large = limit(10, 0, child); - let strategy = TopKLimitReuseStrategy::new(&[Rc::clone(&small), Rc::clone(&large)]); - let replacements = strategy.replacements(&TargetSubDAG::new(&small)); - assert_eq!(replacements.len(), 1); - let Replacement::SubDAG(rewrite) = &replacements[0].replacement else { - panic!() - }; - let Some(NonASAPOp::Limit { - n: Some(5), child, .. - }) = rewrite.non_asap() - else { - panic!() - }; - assert!(Rc::ptr_eq(child, &large)); - } - - #[test] - fn offset_or_different_input_is_not_reused() { - let a = scan_named("a"); - let b = scan_named("b"); - let small = limit(5, 0, a); - let large = limit(10, 0, b); - let offset = limit(20, 1, scan_named("a")); - let strategy = TopKLimitReuseStrategy::new(&[Rc::clone(&small), large, offset]); - assert!(!strategy.matches(&TargetSubDAG::new(&small))); - } -} diff --git a/crates/asap-aware-mapping/tests/cost_independence.rs b/crates/asap-aware-mapping/tests/cost_independence.rs index 022f45b8..05915ac3 100644 --- a/crates/asap-aware-mapping/tests/cost_independence.rs +++ b/crates/asap-aware-mapping/tests/cost_independence.rs @@ -12,11 +12,11 @@ const OPTIMIZATION_MODULES: &[&str] = &[ /// The modules that price plans. const COST_MODULES: &[&str] = &[ - "cost_model", - "recurrence", "analytical_cost", + "physical_handoff_cost", "physical_operator_statistics", "stage3_plan_selection", + "storage_io", ]; /// The source lines outside `#[cfg(test)]` items and comments, numbered. @@ -100,6 +100,9 @@ fn cost_imports(source: &str, forbidden: &[&str]) -> Vec<(usize, String)> { fn logical_and_physical_asap_optimization_import_no_cost_model() { let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let lib = std::fs::read_to_string(src.join("lib.rs")).unwrap(); + for module in COST_MODULES { + assert!(src.join(format!("{module}.rs")).exists(), "{module}"); + } let forbidden: Vec<&str> = COST_MODULES .iter() .copied() @@ -128,20 +131,20 @@ fn logical_and_physical_asap_optimization_import_no_cost_model() { #[test] fn the_check_finds_cost_imports_outside_tests() { let source = "\ -// use crate::cost_model::CostModel; +// use crate::stage3_plan_selection::CostModel; use crate::{ accuracy::AccuracyModel, - DefaultCostModel, + ResourceUnitCost, }; fn f() -> f64 { crate::analytical_cost::price() } #[cfg(test)] mod tests { - use crate::cost_model::CostModel; + use crate::stage3_plan_selection::CostModel; } "; - let lines: Vec<_> = cost_imports(source, &["DefaultCostModel"]) + let lines: Vec<_> = cost_imports(source, &["ResourceUnitCost"]) .into_iter() .map(|(number, _)| number) .collect(); diff --git a/crates/asap-aware-mapping/tests/per_computation_candidates.rs b/crates/asap-aware-mapping/tests/per_computation_candidates.rs index 03d1f577..a84107f7 100644 --- a/crates/asap-aware-mapping/tests/per_computation_candidates.rs +++ b/crates/asap-aware-mapping/tests/per_computation_candidates.rs @@ -1198,3 +1198,56 @@ fn per_series_avg_with_series_identity_composes_from_sum_and_count() { let [composed] = <[_; 1]>::try_from(composed(vec![root.clone()], &[0])).unwrap(); assert_eq!(columns(&composed), columns(&root)); } + +/// Exact per-series `rate` keeps every label and retypes the value column to the Rate state. +#[test] +fn per_series_rate_keeps_labels_and_retypes_its_state() { + use asap_types::post_asap::ExactParams; + let [QueryRoot::Operator(root)] = &exact_promql("rate(m{job=\"api\"}[5m])")[..] else { + panic!("one operator root"); + }; + let [out] = <[_; 1]>::try_from(composed(vec![root.clone()], &[0])).unwrap(); + let rate = FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate); + let ASAPOp::SummaryAgg { + family, reduction, .. + } = summary_agg(&out) + else { + unreachable!() + }; + assert_eq!(*family, rate); + assert_eq!(*reduction, Reduction::PerEntity); + let state = OperatorNode::reachable(&out) + .into_iter() + .find(|node| matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. }))) + .unwrap(); + // The state replaces the sample value in place, named `state`; the + // finalized output has the rate's own columns. + let label = Field::plain("job", DataType::Utf8, true); + assert_eq!( + columns(&state), + [ + ( + "ts".into(), + FieldDataType::Plain(DataType::Timestamp), + false + ), + ("state".into(), rate, false), + (label.name, label.dtype, label.nullable), + ] + ); + assert_eq!(state.schema.time_index, Some(0)); + assert_eq!(out.schema, root.schema); +} + +/// Exact grouped `avg` composes from sum and count with the aggregate's whole schema. +#[test] +fn grouped_avg_composition_preserves_the_whole_schema() { + let [QueryRoot::Operator(root)] = &exact_promql("avg by (job) (m)")[..] else { + panic!("one operator root"); + }; + let [out] = <[_; 1]>::try_from(composed(vec![root.clone()], &[0])).unwrap(); + assert!(OperatorNode::reachable(&out) + .iter() + .any(|node| matches!(node.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. })))); + assert_eq!(out.schema, root.schema); +} diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs index 87cc5622..dffe440b 100644 --- a/crates/integration-tests/tests/exact_composition.rs +++ b/crates/integration-tests/tests/exact_composition.rs @@ -1,38 +1,20 @@ //! Issue #171 — composing exact operators with summary plans across -//! explicit update/evaluation boundaries, end to end through -//! `search_workload_with` → `CandidateLogicalASAPDAGs::global_selection` → -//! `GlobalSelection::assemble_selected_dag`. +//! explicit update/evaluation boundaries, through the planner stages' +//! logical candidates (Stage 1). //! -//! 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 -//! schemas plus shared `Rc` identity — along with pins for every -//! already-supported exact-accumulator nesting. +//! Covers grouped fine-to-coarse and identity folds over a summary +//! evaluation, phase-aware summary construction and pre/post-ASAP schemas, +//! along with pins for every exact-accumulator nesting under an outer +//! sketch. 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, -}; -use asap_aware_mapping::{ - CostModel, DefaultCostModel, EvaluationRate, ExplanationKind, OperationPlacement, -}; -use asap_integration_tests::fixtures::lower_promql; +use asap_integration_tests::planning::logical_candidates; use asap_integration_tests::post_asap::{at_ingestion, post_asap_dag, timed}; use asap_types::ir::operator_properties::{Reduction, Source}; use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; -use asap_types::ir::timing::data_state; use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, TimeRangeKind}; -use asap_types::post_asap::{ - ExactKind, ExecutionDataState, ExecutionTiming, FieldDataType, SketchAlgorithm, SummaryUpdate, -}; +use asap_types::post_asap::{ExactKind, ExecutionTiming, FieldDataType, SummaryUpdate}; use asap_types::pre_asap::agg_intent::{default_quantile, AggIntent}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; @@ -95,204 +77,36 @@ fn fine_quantile() -> Rc { ) } -/// A deployment cost model that supplies every statistic the issue's -/// formulas need, so a composition can actually win — and advertises both -/// mixed-execution shapes. -struct StatsModel; - -/// Search, selection, and materialization must retain the caller's proven rule. -#[test] -fn custom_accuracy_rule_survives_root_target_and_materialization() { - use asap_aware_mapping::{AccuracyModel, DefaultAccuracyModel, PropagationStats}; - use asap_types::post_asap::{ - AccuracyError, AccuracyGuarantee, CompositionOperator, SketchStatistic, - }; - struct Model; - impl AccuracyModel for Model { - fn exact_operation_rule(&self, _: &ExactOperation) -> Option { - Some(CompositionOperator::ExactExtremum) - } - fn local_guarantee( - &self, - family: &FieldDataType, - query: &SketchStatistic, - ) -> Option { - DefaultAccuracyModel.local_guarantee(family, query) - } - fn propagate( - &self, - _: &CompositionOperator, - _: &[AccuracyGuarantee], - _: Option<&AccuracyGuarantee>, - _: &PropagationStats, - ) -> Result { - // Test-only oracle: the marker detects accidental use of the default model. - Ok(AccuracyGuarantee::exact("custom rule oracle")) - } - fn satisfies(&self, g: &AccuracyGuarantee, t: &AccuracyTarget) -> bool { - DefaultAccuracyModel.satisfies(g, t) - } - } - 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), - &Model, - ) - .unwrap(); - let selection = space.global_selection(&StatsModel); - assert!(selection - .for_target(&space.roots[0].1) - .unwrap() - .composition - .is_some()); - let node = selection - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .unwrap(); - let guarantee = node.guarantee.as_ref().unwrap(); - assert!(guarantee.is_exact()); - assert!(format!("{:?}", guarantee.provenance).contains("custom rule oracle")); -} - -/// An exact operator must not turn an unknown approximate-input bound into exactness. -#[test] -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), - &asap_aware_mapping::DefaultAccuracyModel, - ) - .unwrap(); - let selection = space.global_selection(&StatsModel); - assert!(selection - .for_target(&space.roots[0].1) - .unwrap() - .composition - .is_none()); -} - -impl CostModel for StatsModel { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } - - fn value_operation_support_evidence( - &self, - _operation: &ExactOperation, - _placement: OperationPlacement, - ) -> Option { - Some(true) - } - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn exact_composition_cost_inputs( - &self, - _request: &ExactCompositionCostRequest<'_>, - ) -> ExactCompositionCostInputs { - ExactCompositionCostInputs { - exact_cost_per_row: Some(0.1), - expected_input_rows: Some(50.0), - expected_output_rows: Some(10.0), - summary_maintenance_cost_per_update: Some(0.01), - summary_read_cost: Some(1.0), - update_rate: Some(100.0), - evaluation_rate: Some(EvaluationRate(1.0)), - raw_recompute_cost: Some(100.0), - unit: CostUnit::CostUnitsPerSecond, - provenance: CostProvenance { - model: "StatsModel".into(), - version: "test-1".into(), - }, - } - } +/// The Stage 1 logical candidate of the single query `root` (planned at +/// ε = 0.01) whose outer aggregate stays exact over the inner summary +/// evaluation: the read-time composition. +fn composed(root: &Rc) -> Rc { + logical_candidates(Rc::clone(root), AccuracyTarget::max_error(0.01)) + .into_iter() + .find(|candidate| { + !matches!( + below_projects(candidate).asap(), + Some(ASAPOp::SummaryEstimate { .. }) + ) && summary_evaluation(candidate).is_some() + }) + .expect("an exact fold over a summary evaluation") } -/// 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) +/// `node` with every `Project` above it skipped: Stage 1 restores a +/// rewritten aggregate's output names with a `Project`. +fn below_projects(mut node: &Rc) -> &Rc { + while let Some(NonASAPOp::Project { child, .. }) = node.non_asap() { + node = child; } + node } -/// Complete cost evidence does not imply runtime support evidence. -struct UnknownCapabilityModel; - -impl CostModel for UnknownCapabilityModel { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn exact_composition_cost_inputs( - &self, - request: &ExactCompositionCostRequest<'_>, - ) -> ExactCompositionCostInputs { - StatsModel.exact_composition_cost_inputs(request) +/// The first summary evaluation in `node`'s DAG. +fn summary_evaluation(node: &Rc) -> Option<&Rc> { + if matches!(node.asap(), Some(ASAPOp::SummaryEstimate { .. })) { + return Some(node); } -} - -#[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 group = space.candidates_for_target(&space.roots[0].1).unwrap(); - assert!(group.candidates.iter().any(|candidate| { - matches!(candidate.replacement, Replacement::ExactComposition(_)) - && candidate - .runtime_support_evidence(&UnknownCapabilityModel) - .is_none() - && UnknownCapabilityModel - .candidate_cost( - candidate, - &asap_aware_mapping::TargetSubDAG::new(&space.roots[0].1), - ) - .is_none() - })); - let selection = space.global_selection(&UnknownCapabilityModel); - assert!(selection - .for_target(&space.roots[0].1) - .unwrap() - .composition - .is_none()); - assert!(selection - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .is_some()); -} - -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)).unwrap() + node.children().into_iter().find_map(summary_evaluation) } fn is_plain(node: &OperatorNode) -> bool { @@ -306,16 +120,6 @@ fn names(node: &OperatorNode) -> Vec<&str> { node.schema.fields.iter().map(|f| f.name.as_str()).collect() } -/// The composed query-time shape: an exact `Aggregate` directly over a -/// summary evaluation, at query time. -fn is_query_time_fold(node: &OperatorNode) -> bool { - matches!( - node.non_asap(), - Some(NonASAPOp::Aggregate { child, .. }) - if matches!(child.operator, Operator::ASAP(ASAPOp::SummaryEstimate { .. })) - ) -} - // ── step 1: pin every already-supported exact-accumulator nesting ─────── #[test] @@ -381,14 +185,19 @@ 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 Replacement::SubDAG(root) = &candidates[0].replacement else { - unreachable!() - }; + // The KLL candidate over the inner exact accumulator. + let root = logical_candidates(Rc::clone(&outer), AccuracyTarget::max_error(0.01)) + .into_iter() + .map(|candidate| Rc::clone(below_projects(&candidate))) + .find(|candidate| { + matches!(candidate.asap(), Some(ASAPOp::SummaryEstimate { summary_input, .. }) + if matches!(summary_input.asap(), Some(ASAPOp::SummaryAgg { child, .. }) + if matches!(child.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. })))) + }) + .unwrap_or_else(|| panic!("{kind:?}: a KLL over the exact accumulator")); // Timing is not stored on the plan: time it with the outer summary // maintained (which also validates every edge) and inspect the copy. - let root = at_ingestion(root); + let root = at_ingestion(&root); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { panic!("expected KLL evaluation, got {:?}", root.operator); }; @@ -417,74 +226,14 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { // ── direction 1: outer exact fold over an inner summary evaluation ──────── -/// `max`/`avg` over a quantile does not collapse into one opaque kept -/// sub-DAG: the outer group holds an `ValueOperationAtQueryTime` -/// candidate referencing the inner target, the inner group keeps its own -/// sketch candidates, and with statistics the pair is committed and -/// materializes as `ValueOperationAtQueryTime → SummaryEstimate → SummaryAgg`. +/// `max`/`avg` over a quantile composes as an exact fold at query time over +/// the inner quantile's evaluation, with the pre-ASAP target's own schema. #[test] -fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { +fn max_and_avg_over_quantile_compose_at_query_time() { 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 root = Rc::clone(&space.roots[0].1); - let Some(NonASAPOp::Aggregate { child: inner, .. }) = root.non_asap() else { - unreachable!() - }; - - let outer_group = space.candidates_for_target(&root).unwrap(); - assert!( - outer_group - .candidates - .iter() - .any(|c| c.provenance == ReplacementProvenance::ValueOperationAtQueryTime), - "{intent:?}: outer group must hold an ValueOperationAtQueryTime candidate" - ); - let inner_group = space.candidates_for_target(inner).unwrap(); - assert!( - inner_group - .candidates - .iter() - .any(|c| matches!(&c.replacement, Replacement::SubDAG(n) - if matches!(n.operator, Operator::ASAP(ASAPOp::SummaryEstimate { .. })))), - "{intent:?}: the inner quantile keeps its own evaluation candidates" - ); - - let selection = space.global_selection(&StatsModel); - let selected = selection.for_target(&root).unwrap(); - let chosen = selected.chosen.expect("a decision"); - assert_eq!( - chosen.provenance, - ReplacementProvenance::ValueOperationAtQueryTime - ); - let decision = selected - .composition - .as_ref() - .expect("composition provenance"); - assert!(Rc::ptr_eq(decision.child_target, inner)); - assert!(decision.cost_rate < decision.baseline_rate); - assert_eq!(decision.inputs.unit, CostUnit::CostUnitsPerSecond); - assert_eq!(decision.inputs.provenance.model, "StatsModel"); - // The child was committed to a compatible candidate *from its own - // group* — the same candidate its own selection reports. - let child_candidate = decision.child_candidate.expect("read-time operation child"); - let inner_selected = selection.for_target(inner).unwrap(); - assert!(std::ptr::eq( - inner_selected.chosen.unwrap(), - child_candidate - )); - - let composed = selection.assemble_selected_query(&root).unwrap().unwrap(); - let Some(NonASAPOp::Aggregate { child, .. }) = composed.non_asap() else { - panic!( - "{intent:?}: expected ValueOperationAtQueryTime root, got {:?}", - composed.operator - ); - }; - assert!(matches!( - child.operator, - Operator::ASAP(ASAPOp::SummaryEstimate { .. }) - )); + let composed = composed(&root); + let child = summary_evaluation(&composed).unwrap(); assert!( child.guarantee.is_some(), "child has its KLL rank guarantee" @@ -511,21 +260,6 @@ fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { } } -/// `avg` keeps competing with `AvgToSumOverCountStrategy`: both candidates -/// live in the same group; nothing hard-codes the winner. -#[test] -fn avg_over_quantile_keeps_the_sum_over_count_rewrite_as_a_competitor() { - // `by (zone)` over `by (zone)`: the averaged column resolves to the - // 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 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)); - assert!(provenances.contains(&ReplacementProvenance::ValueOperationAtQueryTime)); -} - /// Grouped fine-to-coarse fold (`by (zone)` over `by (zone, host)`) and the /// identity fold (`by (zone)` over `by (zone)`) both compose; the operator /// is the same, only the fold's row multiplicity differs. @@ -537,22 +271,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 root = &space.roots[0].1; - let composed = space - .global_selection(&StatsModel) - .assemble_selected_query(root) - .unwrap() - .unwrap(); - assert!( - matches!( - composed.non_asap(), - Some(NonASAPOp::Aggregate { child, .. }) - if matches!(child.operator, Operator::ASAP(ASAPOp::SummaryEstimate { .. })) - ), - "{label}: {:?}", - composed.operator - ); + let composed = composed(&root); assert_eq!( timed(&composed).timing, Some(ExecutionTiming::QueryTime), @@ -562,147 +281,14 @@ fn identity_and_genuine_multi_row_folds_both_compose() { } } -/// One inner quantile consumed by two outer folds in two queries: CSE -/// collapses the inner target onto one `Rc`, both compositions commit to -/// the *same* child candidate, and both materializations share one -/// `Rc` for it — the summary is maintained once. -#[test] -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 selection = space.global_selection(&StatsModel); - - let roots: Vec> = space.roots.iter().map(|(_, r)| Rc::clone(r)).collect(); - let inner_of = |r: &Rc| match r.non_asap() { - Some(NonASAPOp::Aggregate { child, .. }) => Rc::clone(child), - _ => unreachable!(), - }; - assert!( - Rc::ptr_eq(&inner_of(&roots[0]), &inner_of(&roots[1])), - "CSE must intern the shared inner quantile" - ); - let inner = inner_of(&roots[0]); - assert_eq!( - space.candidates_for_target(&inner).unwrap().consumer_count, - 2 - ); - - let decisions: Vec<_> = roots - .iter() - .map(|r| { - selection - .for_target(r) - .unwrap() - .composition - .as_ref() - .expect("both roots compose") - }) - .collect(); - assert!(std::ptr::eq( - decisions[0].child_candidate.unwrap(), - decisions[1].child_candidate.unwrap() - )); - // Shared state counted once: the second parent sees zero marginal - // maintenance, so its rate is strictly lower than the first's. - assert!(decisions[1].cost_rate < decisions[0].cost_rate); - - let composed: Vec<_> = roots - .iter() - .map(|r| selection.assemble_selected_query(r).unwrap().unwrap()) - .collect(); - let child_of = |n: &Rc| match n.non_asap() { - Some(NonASAPOp::Aggregate { child, .. }) - if matches!( - child.operator, - Operator::ASAP(ASAPOp::SummaryEstimate { .. }) - ) => - { - Rc::clone(child) - } - _ => panic!("expected ValueOperationAtQueryTime, got {:?}", n.operator), - }; - assert!( - Rc::ptr_eq(&child_of(&composed[0]), &child_of(&composed[1])), - "both folds compose over the same Rc" - ); -} - -// ── direction 2: outer summary over an inner exact maintenance-time operation ─ - -/// `quantile(0.99, deriv(latency[5m]))`: `deriv` has no accumulator form. -/// The function target gets an `ValueOperationAtIngestionTime` candidate; with a -/// maintained summary above it and statistics, it is committed, and the -/// outer summary's materialization is re-linked over it. -#[test] -fn outer_summary_over_an_exact_function_composes_at_ingestion_time() { - use std::time::Duration; - let deriv = per_entity( - AggIntent::Deriv, - node(NonASAPOp::TimeRange { - range: Duration::from_secs(300), - kind: TimeRangeKind::Range, - child: metric_scan(&["zone"]), - }), - ); - let root = agg(vec![], default_quantile(0.99), deriv); - let space = plan(vec![("q", root)], &StatsModel); - let root = Rc::clone(&space.roots[0].1); - let Some(NonASAPOp::Aggregate { child: deriv, .. }) = root.non_asap() else { - unreachable!() - }; - assert!(space - .candidates_for_target(deriv) - .unwrap() - .candidates - .iter() - .any(|c| c.provenance == ReplacementProvenance::ValueOperationAtIngestionTime)); - - let selection = space.global_selection(&StatsModel); - let deriv_sel = selection.for_target(deriv).unwrap(); - assert_eq!( - deriv_sel.chosen.unwrap().provenance, - ReplacementProvenance::ValueOperationAtIngestionTime - ); - let decision = deriv_sel.composition.as_ref().unwrap(); - assert!(decision.child_candidate.is_none(), "function input is raw"); - assert!(decision.cost_rate < decision.baseline_rate); - - let composed = selection.assemble_selected_query(&root).unwrap().unwrap(); - // Walk the timed copy, with the outer summary maintained at ingestion time. - let composed = at_ingestion(&composed); - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &composed.operator else { - panic!("expected evaluation root, got {:?}", composed.operator); - }; - let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &summary_input.operator else { - panic!("expected SummaryAgg"); - }; - let Some(NonASAPOp::Aggregate { child: raw, .. }) = child.non_asap() else { - panic!( - "expected ValueOperationAtIngestionTime under the maintained summary, got {:?}", - child.operator - ); - }; - // The raw input is kept as-is. - assert!(matches!(raw.non_asap(), Some(NonASAPOp::TimeRange { .. }))); - assert!(!raw.contains_asap()); - assert_eq!(data_state(child), Some(ExecutionDataState::INGESTION_ROWS)); - assert_eq!(data_state(raw), Some(ExecutionDataState::INGESTION_ROWS)); -} - -// ── rejection, capability, statistics ─────────────────────────────────── +// ── summary construction phase ────────────────────────────────────────── /// Summary construction can consume query-time values without pretending /// they are available to an ingestion-time consumer. #[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 post = space - .global_selection(&StatsModel) - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .unwrap(); + let post = composed(&root); let illegal = std::rc::Rc::new( OperatorNode::with_schema( asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { @@ -727,112 +313,19 @@ fn summary_construction_follows_its_value_input_phase() { asap_types::ir::validate_at_ingestion(&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_query(&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 -/// independently selectable. -#[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 root = Rc::clone(&space.roots[0].1); - assert!(space - .candidates_for_target(&root) - .unwrap() - .candidates - .iter() - .any(|c| c.provenance == ReplacementProvenance::ValueOperationAtQueryTime)); - let selection = space.global_selection(&DefaultCostModel); - let selected = selection.for_target(&root).unwrap(); - assert!(selected.composition.is_none()); - assert!(!matches!( - selected.chosen.map(|c| &c.replacement), - Some(Replacement::ExactComposition(_)) - )); - let node = selection.assemble_selected_query(&root).unwrap().unwrap(); - assert!(!is_query_time_fold(&node)); - - let explanations = - asap_aware_mapping::explain_replacements(vec![("q", Rc::clone(&root))]).unwrap(); - assert!(explanations - .iter() - .any(|e| e.kind == ExplanationKind::ExactComposition)); -} - // ── Physical export: explicit timing ───────────────────────────────────── #[test] fn physical_export_carries_explicit_timing_for_a_composed_plan() { let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = plan(vec![("q", root)], &StatsModel); - let root = &space.roots[0].1; - let composed = space - .global_selection(&StatsModel) - .assemble_selected_query(root) - .unwrap() - .unwrap(); - // Timing is explicit in the wire-6 DAG: the root is a relational - // aggregate placed at query time. + let composed = composed(&root); + // Timing is explicit in the wire-6 DAG: the root finalizes the exact + // `max` state over the quantile evaluation, at query time. let wire = post_asap_dag(&composed); let wire_root = wire.nodes.iter().find(|n| n.id == wire.roots[0]).unwrap(); assert!(matches!( wire_root.payload, - PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Aggregate { .. }) + PhysicalASAPOperatorPayload::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) )); assert_eq!(wire_root.output_state.timing, ExecutionTiming::QueryTime); } - -/// The PromQL front end produces the exact issue shape and it composes. -#[test] -fn promql_max_by_zone_over_quantile_over_time_composes() { - let expr = lower_promql( - "max by (zone) (quantile_over_time(0.99, latency[5m]))", - AccuracyTarget::max_error(0.01), - ) - .unwrap(); - let space = plan(vec![("q", expr)], &StatsModel); - let root = &space.roots[0].1; - let selection = space.global_selection(&StatsModel); - let selected = selection.for_target(root).unwrap(); - assert_eq!( - selected.chosen.map(|c| c.provenance), - Some(ReplacementProvenance::ValueOperationAtQueryTime), - "{:?}", - space - .candidates_for_target(root) - .unwrap() - .candidates - .iter() - .map(|c| (c.strategy, c.provenance)) - .collect::>() - ); - let composed = selection.assemble_selected_query(root).unwrap().unwrap(); - assert!(is_query_time_fold(&composed), "{:?}", composed.operator); - assert_eq!(timed(&composed).timing, Some(ExecutionTiming::QueryTime)); - assert_eq!( - selected.composition.as_ref().map(|d| d.inputs.unit), - Some(CostUnit::CostUnitsPerSecond) - ); - let _ = OperationPlacement::Read; -} diff --git a/crates/integration-tests/tests/promql_to_post_asap.rs b/crates/integration-tests/tests/promql_to_post_asap.rs index 2dc5f806..a12a1bbe 100644 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ b/crates/integration-tests/tests/promql_to_post_asap.rs @@ -1,166 +1,96 @@ //! End-to-end query-string → post-ASAP IR pin (issue #98). //! //! Drives the full pipeline — PromQL text → non-ASAP `OperatorNode` -//! (`lower_promql`) → post-ASAP `OperatorNode` DAG (via -//! `ASAPStrategies::replacements`, see [`realize`] below) — and pins +//! (`lower_promql`) → post-ASAP `OperatorNode` DAG (a Stage 1 logical +//! candidate, see [`realize`] below) — and pins //! the summary-bound shape node by node, including the family `(Kind, //! Params)` committed on each edge's schema. use std::rc::Rc; -use asap_aware_mapping::accuracy::{ - AccuracyEvidenceProvider, DefaultAccuracyModel, OperandDomain, PropagationStats, -}; -use asap_aware_mapping::cost_model::DefaultCostModel; -use asap_aware_mapping::replacement::{retain_exact, RealizationError}; -use asap_aware_mapping::{ - search_workload, search_workload_with_targets, ASAPStrategies, AccuracyModel, Replacement, - ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, -}; +use asap_aware_mapping::accuracy::{AccuracyEvidenceProvider, OperandDomain}; +use asap_aware_mapping::stage2_physical_asap_optimization::PhysicalCandidate; +use asap_aware_mapping::stage3_plan_selection::Selection; +use asap_aware_mapping::PlanningModels; use asap_integration_tests::fixtures::lower_promql; -use asap_integration_tests::post_asap::{ - at_ingestion, at_ingestion_post_asap_dag, post_asap_dag, timed, -}; +use asap_integration_tests::planning::logical_candidates; +use asap_integration_tests::post_asap::{at_ingestion, at_ingestion_post_asap_dag, post_asap_dag}; use asap_types::ir::operator_properties::Reduction; use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; -use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, QueryRoot}; use asap_types::post_asap::{ - CompositionOperator, EntityIdentity, ExactKind, ExactParams, ExecutionDataStateError, - FieldDataType, GroupingStrategy, Schema, SketchAlgorithm, SketchKind, SketchParams, - SketchStatistic, SummaryInputExpr, SummaryUpdate, + EntityIdentity, ExactKind, ExactParams, FieldDataType, GroupingStrategy, Schema, + SketchAlgorithm, SketchKind, SketchParams, SketchStatistic, SummaryInputExpr, SummaryUpdate, }; use asap_types::pre_asap::expr_ir::ColumnRef; use asap_types::pre_asap::schema::DataType; use asap_types::types::AccuracyMetric; use asap_types::types::AccuracyTarget; - -/// This crate has no "bind me one tree" public API any more — -/// `ASAPStrategies::replacements` always returns every candidate, and -/// a caller decides what to keep. This test-only helper reproduces the -/// take-the-first-(`cost_model`-preferred)-candidate pattern so the -/// 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() - .replacements(&target) - .into_iter() - .next() - { - // A bound decision (summary DAG or kept sub-DAG); a logical rewrite - // is not a binding, so it falls back to keeping the target. - Some(ReplacementSubDAG { - replacement: Replacement::SubDAG(node), - provenance, +use asap_types::workload::{DataWorkload, QueryRequirements, QueryWorkloadEntry}; + +/// The first logical candidate (Stage 1, in enumeration order) of `root` as +/// a query at `accuracy` that uses an ASAP operator, or the plain exact one +/// when none does: the summary-bound form the pins below inspect. +fn realize(root: &Rc, accuracy: AccuracyTarget) -> Rc { + let candidates = logical_candidates(Rc::clone(root), accuracy); + let node = candidates + .iter() + .find(|candidate| candidate.contains_asap()) + .or(candidates.first()) + .cloned() + .expect("Stage 1 offers at least the exact candidate"); + node.validate_structure() + .expect("planned dag satisfies the unified IR contract"); + node +} + +/// Whether `node` or anything below it is a `SummaryAgg` of `algorithm`. +fn uses_sketch(node: &OperatorNode, algorithm: &SketchAlgorithm) -> bool { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), .. - }) if !provenance.is_logical_rewrite() => Ok(node), - _ => retain_exact(root), - } - .inspect(|node| { - node.validate_structure() - .expect("planned dag satisfies the unified IR contract") - }) -} - -#[test] -fn distinct_over_time_offers_hll_cardinality_evaluation() { - // The real frontend must reach an existing HLL candidate without a - // function-specific post-ASAP node or a sample-count rewrite. - let root = lower_promql( - "distinct_over_time(cpu_usage{job=\"worker\"}[5m])", - AccuracyTarget::max_error(0.02), - ) - .unwrap(); - let candidates = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); - for candidate in &candidates { - if let Replacement::SubDAG(node) = &candidate.replacement { - node.validate_structure().unwrap(); - } + }) if kind.algorithm() == algorithm => true, + _ => node + .children() + .iter() + .any(|child| uses_sketch(child, algorithm)), } - assert!(candidates.iter().any(|candidate| { - let Replacement::SubDAG(node) = &candidate.replacement else { return false }; - let Some(ASAPOp::SummaryEstimate { summary_input, query, .. }) = node.asap() else { return false }; - matches!(query, SketchStatistic::Cardinality) - && matches!(summary_input.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) - if kind.algorithm() == &SketchAlgorithm::Hll) - }), "no HLL cardinality candidate: {candidates:?}"); } -fn lower_search_and_materialize(query: &str) -> Rc { - let pre = lower_promql(query, AccuracyTarget::exact()).expect("lowering failed"); - let space = search_workload(vec![("query", pre)]).unwrap(); - let selection = space.global_selection(&DefaultCostModel); - selection - .assemble_selected_query(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered") +/// The logical candidates of `root` at `accuracy` that build a sketch of +/// `algorithm`, each with the `Project`s Stage 1 restores names with skipped. +fn candidates_using( + root: &Rc, + accuracy: AccuracyTarget, + algorithm: SketchAlgorithm, +) -> Vec> { + logical_candidates(Rc::clone(root), accuracy) + .into_iter() + .filter(|candidate| uses_sketch(candidate, &algorithm)) + .map(|candidate| Rc::clone(below_projects(&candidate))) + .collect() } -#[test] -fn value_ranked_topk_preserves_summary_children_in_post_asap_dag() { - for query in [ - "topk(3, rate(cpu_seconds_total[5m]))", - "topk by (job) (2, max_over_time(memory_bytes[6h]))", - ] { - let root = timed(&lower_search_and_materialize(query)); - let Some(NonASAPOp::Limit { - n, - offset, - child: sort, - .. - }) = root.non_asap() - else { - panic!( - "expected query-time Limit for {query}, got {:?}", - root.operator - ); - }; - assert_eq!( - root.timing, - Some(asap_types::post_asap::ExecutionTiming::QueryTime) - ); - assert!(n.is_some_and(|n| n > 0) && *offset == 0); - let Some(NonASAPOp::Sort { child, .. }) = sort.non_asap() else { - panic!("expected query-time Sort under Limit for {query}"); - }; - assert_eq!( - sort.timing, - Some(asap_types::post_asap::ExecutionTiming::QueryTime) - ); - let Some(ASAPOp::FinalizeExactAccumulator { child: state }) = child.asap() else { - panic!( - "Sort must consume finalized values for {query}: {:?}", - child.operator - ); - }; - assert!(matches!(state.asap(), Some(ASAPOp::SummaryAgg { .. }))); - assert!(child - .schema - .fields - .iter() - .all(|field| matches!(field.dtype, FieldDataType::Plain(_)))); +/// `node` with every `Project` above it skipped: Stage 1 restores a +/// rewritten aggregate's output names with a `Project`. +fn below_projects(mut node: &Rc) -> &Rc { + while let Some(NonASAPOp::Project { child, .. }) = node.non_asap() { + node = child; } + node } -#[test] -fn exact_counter_weighted_topk_fails_closed_without_membership_certificate() { - for query in [ - "topk(2, sum by(job)(rate(m[1m])))", - "topk(3, sum by(job)(rate(cpu_seconds_total[1h])))", - "topk(3, sum by(job)(increase(requests_total[6h])))", - ] { - let root = lower_search_and_materialize(query); - assert!( - !root.contains_asap(), - "exact target must not accept an uncertified membership sidecar for {query}: {:?}", - root.operator - ); - } +/// `query` lowered at an exact target and realized ([`realize`]). +fn lower_and_realize(query: &str) -> Rc { + let pre = lower_promql(query, AccuracyTarget::exact()).expect("lowering failed"); + realize(&pre, AccuracyTarget::exact()) } #[test] fn instant_topk_and_unsupported_child_remain_local_residuals() { for query in ["topk(3, memory_bytes)", "topk(3, deriv(memory_bytes[5m]))"] { - let root = lower_search_and_materialize(query); + let root = lower_and_realize(query); let Some(NonASAPOp::Limit { child: sort, .. }) = root.non_asap() else { panic!("expected Limit for {query}"); }; @@ -183,11 +113,6 @@ fn dtype<'a>(schema: &'a Schema, name: &str) -> &'a FieldDataType { .dtype } -fn lower_and_realize(query: &str) -> Rc { - let pre = lower_promql(query, AccuracyTarget::exact()).expect("lowering failed"); - realize(&pre).expect("binding failed") -} - #[test] fn promql_binary_arithmetic_retains_two_summary_leaves() { for op in ["+", "-", "*", "/", "%", "^", "atan2"] { @@ -210,7 +135,7 @@ fn promql_binary_arithmetic_retains_two_summary_leaves() { #[test] fn value_ranked_topk_over_binary_ratio_finalizes_both_summary_operands() { let query = "topk(1, sum by(job)(increase(a[6h])) / sum by(job)(increase(b[6h])))"; - let root = lower_search_and_materialize(query); + let root = lower_and_realize(query); let Some(NonASAPOp::Limit { n: Some(1), offset: 0, @@ -237,384 +162,45 @@ fn value_ranked_topk_over_binary_ratio_finalizes_both_summary_operands() { } } -struct SeparatedTopK; - -impl AccuracyEvidenceProvider for SeparatedTopK { - fn topk_max_distinct_items(&self, _: &OperatorNode) -> Option { - Some(1000) - } - - fn propagation_stats( - &self, - op: &CompositionOperator, - _family: &FieldDataType, - _query: Option<&SketchStatistic>, - ) -> PropagationStats { - matches!(op, CompositionOperator::TopKSelection) - .then_some(PropagationStats { - topk_selected_lower_bound: Some(101.0), - topk_excluded_upper_bound: Some(100.0), - topk_interval_failure_probability: Some(0.001), - ..Default::default() - }) - .unwrap_or_default() - } -} - -// Rate-weighted summaries must consume finalized rates, never raw counter deltas. -#[test] -fn grouped_rate_topk_consumes_finalized_rate_values() { - let root = lower_promql( - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - AccuracyTarget::max_error(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopK, - ); - let plan = strategy - .replacements(&TargetSubDAG::new(&root)) - .into_iter() - .find_map(|candidate| match candidate.replacement { - Replacement::SubDAG(node) if candidate.rationale.contains("CmsWithHeap") => Some(node), - _ => None, - }) - .expect("rate-weighted CMS plan"); - let dag = post_asap_dag(&plan); - assert!(!dag.nodes.iter().any(|node| matches!( - node.payload, - PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Join { .. }) - ))); - let node = dag - .nodes - .iter() - .find(|node| { - matches!(&node.payload, - PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) - if kind.algorithm() == &SketchAlgorithm::CmsWithHeap) - }) - .unwrap(); - assert_eq!( - node.output_state.timing, - asap_types::post_asap::ExecutionTiming::QueryTime - ); - let PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { input, .. }) = &node.payload else { - unreachable!() - }; - assert_eq!( - input.weight, - SummaryInputExpr::Column(ColumnRef::SampleValue) - ); - assert_eq!( - input.item, - Some(SummaryInputExpr::Column(ColumnRef::Named("service".into()))) - ); -} - -// Selection is adaptive: a per-key score bound alone cannot certify all returned rows. -#[test] -fn weighted_topk_keeps_candidates_with_missing_population_evidence() { - struct NoPopulationBound; - impl AccuracyEvidenceProvider for NoPopulationBound { - fn propagation_stats( - &self, - op: &CompositionOperator, - family: &FieldDataType, - query: Option<&SketchStatistic>, - ) -> PropagationStats { - SeparatedTopK.propagation_stats(op, family, query) - } - } - let root = lower_promql( - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - AccuracyTarget::max_error(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &NoPopulationBound, - ); - let candidates = strategy.replacements(&TargetSubDAG::new(&root)); - assert!(candidates - .iter() - .any(|candidate| candidate.rationale.contains("CmsWithHeap") - && candidate.has_missing_accuracy_evidence())); -} - -// Unknown requirements must survive physical export for deployment to inspect. -#[test] -fn weighted_topk_exports_symbolic_evidence_requirements() { - let root = lower_promql( - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - AccuracyTarget::max_error(0.01).failure_probability(0.01), - ) - .unwrap(); - let candidates = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); - let candidate = candidates - .iter() - .find(|candidate| candidate.rationale.contains("CmsWithHeap")) - .unwrap(); - assert!(candidate.has_missing_accuracy_evidence()); - let Replacement::SubDAG(node) = &candidate.replacement else { - panic!("summary candidate") - }; - let dag = post_asap_dag(node); - let exported = serde_json::to_string(&dag).unwrap(); - assert!(exported.contains("topk_max_distinct_items")); - assert!(exported.contains("topk_membership_margin")); - assert!(node.guarantee.as_ref().unwrap().has_unknown()); -} - -// Supplied invalid facts are distinct from absent evidence. -#[test] -fn weighted_topk_rejects_invalid_population_evidence() { - struct InvalidPopulation; - impl AccuracyEvidenceProvider for InvalidPopulation { - fn topk_max_distinct_items(&self, _: &OperatorNode) -> Option { - Some(0) - } - } - let root = lower_promql( - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - AccuracyTarget::max_error(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &InvalidPopulation, - ); - assert!(strategy.replacements(&TargetSubDAG::new(&root)).is_empty()); -} - -// The summary's estimate is projected back to logical service/job score rows. +/// An arm with no summary (`stddev_over_time`) stays plain exact work while +/// the other arm still binds its exact accumulator. #[test] -fn rate_and_increase_topk_use_summary_scores_and_grouped_limits() { - for query in [ - "topk(2, sum by(job)(rate(m[1m])))", - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - "topk(2, sum by(job)(increase(m[6h])))", - ] { - let root = lower_promql( - query, - AccuracyTarget::max_error(0.01).failure_probability(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopK, - ); - let plan = strategy - .replacements(&TargetSubDAG::new(&root)) - .into_iter() - .find_map(|candidate| match candidate.replacement { - Replacement::SubDAG(node) if candidate.rationale.contains("CmsWithHeap") => { - Some(node) - } - _ => None, - }) - .expect("weighted summary"); - let Some(NonASAPOp::Limit { - n: Some(2), - offset: 0, - partition_by, - child: sorted, - }) = plan.non_asap() - else { - panic!("grouped limit") - }; - let Some(NonASAPOp::Sort { - partition_by: sort_groups, - child: projected, - .. - }) = sorted.non_asap() - else { - panic!("grouped sort") - }; - assert_eq!(partition_by, sort_groups); - assert_eq!(partition_by.len(), usize::from(query.contains("topk by"))); - let Some(NonASAPOp::Project { - child: evaluation, .. - }) = projected.non_asap() - else { - panic!("logical output projection") - }; - let Some(ASAPOp::SummaryEstimate { - summary_input, - query: SketchStatistic::TopK { k }, - }) = evaluation.asap() - else { - panic!("heap evaluation") - }; - assert!(*k > 2, "candidate capacity is independent of output count"); - let Some(ASAPOp::SummaryAgg { - child: rates, - input, - .. - }) = summary_input.asap() - else { - panic!("weighted summary") - }; - assert_eq!( - input.weight, - SummaryInputExpr::Column(ColumnRef::SampleValue) - ); - assert!(matches!( - rates.asap(), - Some(ASAPOp::FinalizeExactAccumulator { .. }) - )); - // Not materialized by default: everything runs at query time. - let dag = post_asap_dag(&plan); - assert!(dag.nodes.iter().all(|node| { - node.output_state.timing == asap_types::post_asap::ExecutionTiming::QueryTime - && node.materialization == asap_types::post_asap::Materialization::NotMaterialized - })); - // The candidate pins the finalize boundary below the weighted summary - // to query time, so that summary cannot be materialized at ingestion. - let mut assignment = asap_types::ir::MaterializationAssignment::default(); - assignment.set( - summary_input, - asap_types::post_asap::Materialization::MatAtIngestion( - asap_types::post_asap::Medium::Memory, - ), - ); - assert_eq!( - asap_types::ir::apply_materialization_timings( - &plan, - &assignment, - &mut asap_types::ir::TimingMemo::new() - ) - .err(), - Some(ExecutionDataStateError::UpstreamNotAtIngestion { - operator: "SummaryAgg" - }) - ); - let guarantee = plan.guarantee.as_ref().unwrap(); - assert!(guarantee.spec.failure_probability.evaluate().unwrap() <= 0.01); - assert!(guarantee.provenance.iter().any(|source| matches!(source, - asap_types::post_asap::GuaranteeSource::ChildGuarantee { guarantee, .. } - if guarantee.spec.accuracy_metric == asap_types::post_asap::AccuracyMetric::ABSOLUTE_STREAM_L1))); - } -} - -#[test] -fn promql_binary_arithmetic_preserves_both_scalar_operand_orders() { - for (query, scalar_left) in [("rate(a[1m]) / 2", false), ("2 / rate(a[1m])", true)] { - let root = lower_and_realize(query); - let Some(NonASAPOp::Project { cols, .. }) = root.non_asap() else { - panic!("expected Project") - }; - let ScalarExpr::Arithmetic { left, right, .. } = &cols[1].expr else { - panic!() - }; - let (scalar, sample) = if scalar_left { - (left, right) - } else { - (right, left) - }; - assert_eq!(**scalar, ScalarExpr::literal_f64(2.0)); - assert_eq!(**sample, ScalarExpr::Column(1)); - assert!(root.schema.has_promql_series_identity()); - } -} - -#[test] -fn promql_binary_arithmetic_falls_back_as_a_whole_for_unsupported_arm() { +fn promql_binary_arithmetic_keeps_an_unsupported_arm_exact() { let root = lower_and_realize("rate(a[1m]) + stddev_over_time(b[1m])"); - assert!(!root.contains_asap()); + let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = root.non_asap() else { + panic!("expected BinaryOp, got {:?}", root.operator); + }; + assert!(matches!( + lhs.asap(), + Some(ASAPOp::FinalizeExactAccumulator { .. }) + )); + assert!(!rhs.contains_asap()); } #[test] -fn promql_binary_arithmetic_preserves_nested_structure_and_rejects_modifiers() { +fn promql_binary_arithmetic_preserves_nested_structure_and_vector_matching() { let nested = lower_and_realize("(rate(a[1m]) + rate(b[1m])) / 2"); let Some(NonASAPOp::Project { child: lhs, .. }) = nested.non_asap() else { panic!("expected outer BinaryOp, got {:?}", nested.operator); }; assert!(matches!(lhs.non_asap(), Some(NonASAPOp::BinaryOp { .. }))); + // A vector-matching modifier stays on the BinaryOp over the two + // finalized rates. let modified = lower_and_realize("rate(a[1m]) + on(job) rate(b[1m])"); - assert!(!modified.contains_asap()); -} - -#[test] -fn promql_binary_arithmetic_never_relabels_approximate_children_as_exact() { - let pre = lower_promql( - "quantile_over_time(0.9, a[1m]) + quantile_over_time(0.9, b[1m])", - AccuracyTarget::max_error(0.01), - ) - .expect("lowering failed"); - let root = realize(&pre).expect("binding failed"); - let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = root.non_asap() else { - panic!("expected BinaryOp, got {:?}", root.operator); - }; - assert!(lhs.guarantee.as_ref().is_some_and(|g| !g.is_exact())); - assert!(rhs.guarantee.as_ref().is_some_and(|g| !g.is_exact())); - assert!( - root.guarantee.is_none(), - "unknown composed error must fail closed" - ); -} - -#[test] -fn ddsketch_quantile_ratio_meets_the_shared_relative_error_target() { - // Enforced finite, positive, nonempty windows justify both DDSketch - // interpolation bounds and a nonzero denominator. Shared state does not - // require independence for the deterministic ratio bound. - let requested = AccuracyTarget::max_error(0.01) - .failure_probability(0.01) - .accuracy_metric(AccuracyMetric::Relative); - let target = requested.clone(); - let query = lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - requested, - ) - .expect("lowering failed"); - - let evidence = FixtureQuantileDomain { - lower: 1.0, - upper: 100.0, - }; - let space = search_workload_with_targets( - vec![("ratio", query, Some(target.clone()))], - &asap_aware_mapping::replacement::default_strategies_with_evidence( - &DefaultCostModel, - &evidence, - ), - &DefaultAccuracyModel, - ) - .unwrap(); - let root = &space.roots[0].1; - let selected = space.global_selection(&DefaultCostModel); - let chosen = selected - .for_target(root) - .and_then(|selection| selection.chosen.as_ref()) - .expect("the certified DDSketch ratio should be selectable"); - let Replacement::SubDAG(node) = &chosen.replacement else { - panic!("expected a summary candidate") - }; - let guarantee = node.guarantee.as_ref().expect("ratio guarantee"); - assert_eq!(guarantee.spec.failure_probability.evaluate(), Some(0.0)); - assert!( - DefaultAccuracyModel.satisfies(guarantee, &target), - "ratio guarantee should satisfy the requested target: {guarantee:?}" - ); - - let shared = asap_types::ir::cse::share_common_sub_dags(vec![("ratio", node.clone())]); - let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = shared[0].1.non_asap() else { - panic!("expected binary ratio") - }; - let producer = |evaluation: &Rc| match &evaluation.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => Rc::clone(summary_input), - other => panic!("expected DDSketch evaluation, got {other:?}"), + let Some(NonASAPOp::BinaryOp { + operator, lhs, rhs, .. + }) = modified.non_asap() + else { + panic!("expected BinaryOp, got {:?}", modified.operator); }; - assert!( - Rc::ptr_eq(&producer(lhs), &producer(rhs)), - "the two quantile evaluations should share one DDSketch producer" - ); + assert!(operator.vector_match.is_some()); + for operand in [lhs, rhs] { + assert!(matches!( + operand.asap(), + Some(ASAPOp::FinalizeExactAccumulator { .. }) + )); + } } #[test] @@ -626,13 +212,13 @@ fn planner_only_e2e_temporal_topk_preserves_query_update_and_evaluation_contract ( "topk by (service) (5, count_over_time(requests[1m]))", SummaryInputExpr::Constant(1.0), - "CmsWithHeap", + SketchAlgorithm::CmsWithHeap, vec![ColumnRef::Named("service".into())], ), ( "topk(5, sum_over_time(requests[1m]))", SummaryInputExpr::Column(ColumnRef::SampleValue), - "CountSketchWithHeap", + SketchAlgorithm::CountSketchWithHeap, vec![], ), ]; @@ -642,21 +228,14 @@ fn planner_only_e2e_temporal_topk_preserves_query_update_and_evaluation_contract AccuracyTarget::max_error(0.01).failure_probability(0.01), ) .expect("lower temporal Top-K"); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopK, - ); - let candidate = strategy - .replacements(&TargetSubDAG::new(&pre)) - .into_iter() - .find_map(|candidate| match candidate.replacement { - Replacement::SubDAG(node) if candidate.rationale.contains(expected_family) => { - Some(node) - } - _ => None, - }) - .expect("heap-backed temporal Top-K candidate"); + let candidate = candidates_using( + &pre, + AccuracyTarget::max_error(0.01).failure_probability(0.01), + expected_family.clone(), + ) + .into_iter() + .next() + .expect("heap-backed temporal Top-K candidate"); let Some(ASAPOp::SummaryEstimate { summary_input, query: SketchStatistic::TopK { k, .. }, @@ -680,7 +259,7 @@ fn planner_only_e2e_temporal_topk_preserves_query_update_and_evaluation_contract let FieldDataType::Sketch(kind, _) = family else { panic!("expected a heap-backed sketch family, got {family:?}") }; - assert_eq!(format!("{:?}", kind.algorithm()), expected_family); + assert_eq!(kind.algorithm(), &expected_family); let heap_size = match kind.params() { SketchParams::CmsWithHeap { heap_size, .. } | SketchParams::CountSketchWithHeap { heap_size, .. } => *heap_size, @@ -807,20 +386,19 @@ fn planner_heap_topk_reference_execution_matches_ground_truth() { AccuracyTarget::max_error(0.01).failure_probability(0.01), ) .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &SeparatedTopK, - ); // This reference executor consumes keyed heap updates. The inventory - // also contains maintained exact values followed by sort/limit; those - // have a different execution contract and must not enter this fixture. - let candidates: Vec<_> = strategy.replacements(&TargetSubDAG::new(&pre)).into_iter().filter(|candidate| matches!(&candidate.replacement, Replacement::SubDAG(plan) if matches!(plan.asap(), Some(ASAPOp::SummaryEstimate { query: SketchStatistic::TopK { .. }, .. })))).collect(); + // also contains exact values followed by sort/limit; those have a + // different execution contract and must not enter this fixture. + let accuracy = AccuracyTarget::max_error(0.01).failure_probability(0.01); + let candidates: Vec<_> = [ + SketchAlgorithm::CmsWithHeap, + SketchAlgorithm::CountSketchWithHeap, + ] + .into_iter() + .flat_map(|algorithm| candidates_using(&pre, accuracy.clone(), algorithm)) + .collect(); assert!(!candidates.is_empty(), "no heap candidate for {query}"); - for candidate in candidates { - let Replacement::SubDAG(plan) = candidate.replacement else { - panic!("expected summary plan for {query}") - }; + for plan in candidates { let expected: Vec<_> = expected .iter() .map(|(job, score)| (format!("__name__=requests,job={job}"), *score)) @@ -833,10 +411,11 @@ fn planner_heap_topk_reference_execution_matches_ground_truth() { /// `quantile(0.99, rate(http_requests_total[5m]))` at ε = 0.01: /// /// ```text -/// SummaryEstimate { query: Quantile{0.99} } → {quantile_0_99: Float64} -/// └─ SummaryAgg { Kll{k:269}, input: SampleValue } → {value: Sketch(Kll, {k:269})} -/// └─ SummaryAgg { Rate, input: SampleValue } → {ts, value: ExactAggregate(Rate), …} -/// └─ TimeRange{5m} → Scan → {ts, value} +/// SummaryEstimate { query: Quantile{0.99} } → {quantile: Float64} +/// └─ SummaryAgg { Kll{k:269}, input: SampleValue } → {state: Sketch(Kll, {k:269})} +/// └─ FinalizeExactAccumulator +/// └─ SummaryAgg { Rate, input: SampleValue } → {ts, state: ExactAggregate(Rate), …} +/// └─ TimeRange{5m} → Scan → {ts, value} /// ``` /// /// The nested tree exercises both realizations: the approximate quantile @@ -849,7 +428,19 @@ fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { AccuracyTarget::max_error(0.01), ) .expect("lowering failed"); - let root = realize(&pre_asap).expect("binding failed"); + // The KLL candidate over the per-series Rate accumulator. + let root = &candidates_using( + &pre_asap, + AccuracyTarget::max_error(0.01), + SketchAlgorithm::Kll, + ) + .into_iter() + .find(|plan| { + matches!(plan.asap(), Some(ASAPOp::SummaryEstimate { summary_input, .. }) + if matches!(summary_input.asap(), Some(ASAPOp::SummaryAgg { child, .. }) + if matches!(child.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. })))) + }) + .expect("a KLL over the finalized Rate accumulator"); // Root: the sketch evaluation, back to a plain row shape. let Some(ASAPOp::SummaryEstimate { @@ -861,7 +452,7 @@ fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { }; assert!(matches!(query, SketchStatistic::Quantile { q } if *q == 0.99)); assert_eq!( - dtype(&root.schema, "quantile_0_99"), + dtype(&root.schema, "quantile"), &FieldDataType::Plain(DataType::Float64), "the summary-state type must not propagate past the estimate" ); @@ -901,7 +492,7 @@ fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { "global quantile — no group keys, full reduction" ); assert_eq!( - dtype(&summary_input.schema, "value"), + dtype(&summary_input.schema, "state"), &FieldDataType::Sketch( SketchKind::new( SketchAlgorithm::Kll, @@ -939,7 +530,7 @@ fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { ); assert_eq!(reduction, &Reduction::PerEntity); assert_eq!( - dtype(&child.schema, "value"), + dtype(&child.schema, "state"), &FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate) ); assert_eq!( @@ -972,93 +563,6 @@ fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { ); } -/// An exact workload binds zero sketches: `sum by (job) (m)` at -/// `AccuracyTarget::exact()` still gets its mergeable exact accumulator, and -/// `avg(m)` (non-mergeable) passes through as a whole logical sub-DAG. -#[test] -fn promql_exact_workload_binds_accumulators_not_sketches() { - let pre_asap = lower_promql( - "sum by (job) (http_requests_total)", - AccuracyTarget::exact(), - ) - .expect("lowering failed"); - let root = realize(&pre_asap).expect("binding failed"); - let Some(ASAPOp::SummaryAgg { - family, reduction, .. - }) = root.asap() - else { - panic!("expected SummaryAgg, got {:?}", root.operator); - }; - assert_eq!( - family, - &FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) - ); - assert_eq!( - reduction, - &Reduction::by(vec![2]), - "job is col 2 in [ts, value, job]" - ); - assert_eq!( - dtype(&root.schema, "job"), - &FieldDataType::Plain(DataType::Utf8), - "group keys pass through verbatim" - ); - - let pre_asap = - lower_promql("avg(http_requests_total)", AccuracyTarget::exact()).expect("lowering failed"); - let root = realize(&pre_asap).expect("binding failed"); - assert!( - !root.contains_asap(), - "avg has no mergeable accumulator — stays logical" - ); -} - -#[test] -fn promql_sum_of_count_over_time_is_composed_by_default_search() { - let original = lower_promql( - "sum by (service) (count_over_time(metrics[5m]))", - AccuracyTarget::exact(), - ) - .expect("lowering failed"); - let original_schema = original.schema.clone(); - let space = search_workload(vec![("query", original)]).unwrap(); - let root = &space.roots[0].1; - let group = space.candidates_for_target(root).expect("root memo group"); - let candidate = group - .candidates - .iter() - .find(|candidate| candidate.strategy == "SemanticEquivalentRewriteStrategy") - .expect("default search should compose the lowered PromQL query"); - let Replacement::SubDAG(rewritten) = &candidate.replacement else { - panic!("expected logical rewrite") - }; - assert!(candidate.is_logical_rewrite(), "expected logical rewrite"); - - assert_eq!(rewritten.schema, original_schema); - let Some(NonASAPOp::Project { child, .. }) = rewritten.non_asap() else { - panic!("sum(count_over_time) needs a Float64 cast Project") - }; - let Some(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(by), - measures, - child, - .. - }) = child.non_asap() - else { - panic!("expected one composed aggregate") - }; - assert_eq!(by.keys(), &[2]); - assert!(matches!( - measures.as_slice(), - [asap_types::pre_asap::AggIntent::Count { accuracy }] if accuracy.is_exact() - )); - assert!(matches!( - child.non_asap(), - Some(NonASAPOp::TimeRange { range, child, .. }) - if range.as_secs() == 300 && matches!(child.non_asap(), Some(NonASAPOp::Scan { .. })) - )); -} - #[test] fn nested_summary_explicitly_finalizes_exact_child_at_ingestion_time() { // Real workload selection must expose the state-to-value edge; an outer @@ -1068,12 +572,15 @@ fn nested_summary_explicitly_finalizes_exact_child_at_ingestion_time() { AccuracyTarget::max_error(0.05), ) .unwrap(); - let space = search_workload(vec![("query", pre)]).unwrap(); - let selected = space.global_selection(&DefaultCostModel); - let plan = selected - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .unwrap(); + // The KLL candidate over the inner exact Sum accumulator. + let plan = candidates_using(&pre, AccuracyTarget::max_error(0.05), SketchAlgorithm::Kll) + .into_iter() + .find(|plan| { + matches!(plan.asap(), Some(ASAPOp::SummaryEstimate { summary_input, .. }) + if matches!(summary_input.asap(), Some(ASAPOp::SummaryAgg { child, .. }) + if matches!(child.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. })))) + }) + .expect("a KLL over the finalized Sum accumulator"); // Stored timings are gone: time the plan with its outer summary // maintained and read the timed copy. let timed_plan = at_ingestion(&plan); @@ -1117,93 +624,32 @@ fn nested_summary_explicitly_finalizes_exact_child_at_ingestion_time() { #[test] fn physical_node_owns_phase_independently_of_binary_payload() { use asap_types::post_asap::ExecutionTiming; - for (query, expected) in [ - ( - // One selector: both operands cover the same series. - "quantile(0.9, sum_over_time(m[1m]) + sum_over_time(m[1m]))", - ExecutionTiming::IngestionTime, - ), - ( - "sum_over_time(m[1m]) + sum_over_time(n[1m])", - ExecutionTiming::QueryTime, - ), - ] { - let input = lower_promql(query, AccuracyTarget::max_error(0.05)).unwrap(); - let search = search_workload(vec![("q", input)]).unwrap(); - let choice = search.global_selection(&DefaultCostModel); - let plan = choice - .assemble_selected_query(&search.roots[0].1) - .unwrap() - .unwrap(); - let dag = at_ingestion_post_asap_dag(&plan); - let node = dag - .nodes - .iter() - .find(|node| { - matches!( - node.payload, - PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::BinaryOp { .. }) - ) - }) - .unwrap(); - assert_eq!(node.output_state.timing, expected); - let wire = serde_json::to_value(&node.payload).unwrap(); - assert!(wire.get("timing").is_none()); - let mut obsolete = wire.clone(); - obsolete["timing"] = serde_json::json!(expected.as_str()); - assert!(serde_json::from_value::(obsolete).is_err()); - let restored: PhysicalASAPOperatorPayload = serde_json::from_value(wire).unwrap(); - assert_eq!(restored, node.payload); - } -} - -/// Missing domain evidence permits a candidate but cannot certify its accuracy. -#[test] -fn ddsketch_ratio_without_domain_proof_is_uncertified() { - let pre = lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - AccuracyTarget::max_error(0.01), - ) - .unwrap(); - let root = realize(&pre).unwrap(); - assert!(matches!(root.non_asap(), Some(NonASAPOp::BinaryOp { .. }))); - assert!(root.guarantee.is_none()); - let space = search_workload_with_targets( - vec![("unproven", pre, Some(AccuracyTarget::max_error(0.01)))], - &asap_aware_mapping::default_strategies(), - &DefaultAccuracyModel, - ) - .unwrap(); - let root_group = space - .target_subdag_candidates() - .find(|group| Rc::ptr_eq(&group.target, &space.roots[0].1)) - .expect("root memo group"); - assert!( - root_group.candidates.iter().any(|candidate| { + // The one-selector case (both operands over the same series, so the + // BinaryOp may run at ingestion) is `arithmetic_over_one_selector_executes` + // in materialization_e2e.rs, ignored until #580. + let query = "sum_over_time(m[1m]) + sum_over_time(n[1m])"; + let expected = ExecutionTiming::QueryTime; + let input = lower_promql(query, AccuracyTarget::max_error(0.05)).unwrap(); + let plan = realize(&input, AccuracyTarget::max_error(0.05)); + let dag = at_ingestion_post_asap_dag(&plan); + let node = dag + .nodes + .iter() + .find(|node| { matches!( - &candidate.replacement, - Replacement::SubDAG(node) - if matches!(node.non_asap(), Some(NonASAPOp::BinaryOp { .. })) - && node.guarantee.is_none() + node.payload, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::BinaryOp { .. }) ) - }), - "backend must receive the uncertified ratio candidate for its own selection" - ); - - let selection = space.global_selection(&DefaultCostModel); - assert!( - selection - .for_target(&space.roots[0].1) - .expect("selected root group") - .chosen - .is_none(), - "Planner must not automatically select an uncertified ratio" - ); - let materialized = selection - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .expect("materialized root"); - assert!(!materialized.contains_asap()); + }) + .unwrap(); + assert_eq!(node.output_state.timing, expected); + let wire = serde_json::to_value(&node.payload).unwrap(); + assert!(wire.get("timing").is_none()); + let mut obsolete = wire.clone(); + obsolete["timing"] = serde_json::json!(expected.as_str()); + assert!(serde_json::from_value::(obsolete).is_err()); + let restored: PhysicalASAPOperatorPayload = serde_json::from_value(wire).unwrap(); + assert_eq!(restored, node.payload); } struct FixtureQuantileDomain { @@ -1221,35 +667,49 @@ impl AccuracyEvidenceProvider for FixtureQuantileDomain { } } -/// Unknown, zero, mixed-sign, nonfinite and zero-mapped domains cannot certify a ratio. -#[test] -fn ddsketch_ratio_rejects_unsafe_domains() { - for (lower, upper) in [ - (0., 0.), - (-1., 1.001), - (0., 100.), - (f64::NAN, 100.), - (1., f64::INFINITY), - (2., 1.), - (f64::MIN_POSITIVE / 2., f64::MIN_POSITIVE / 2.), - ] { - let evidence = FixtureQuantileDomain { lower, upper }; - let pre = lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - AccuracyTarget::max_error(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &evidence, - ); - let replacements = strategy.replacements(&TargetSubDAG::new(&pre)); - assert!( - replacements.is_empty(), - "unsafe domain [{lower}, {upper}] got {replacements:?}" - ); - } +/// `query` lowered at `planned` and selected with `evidence` at `accuracy`: +/// the physical candidates +/// whose two summaries are both DDSketch, and plan selection over all of them. +fn ddsketch_ratio_selection( + query: &str, + planned: AccuracyTarget, + accuracy: AccuracyTarget, + evidence: &dyn AccuracyEvidenceProvider, +) -> (Vec, Selection) { + let root = lower_promql(query, planned).unwrap(); + let run = asap_aware_mapping::run_stage1_to_stage3( + vec![(0, QueryRoot::Operator(root))], + &[QueryWorkloadEntry { + requirements: QueryRequirements { + accuracy, + max_latency_ms: None, + }, + ..Default::default() + }], + &DataWorkload::default(), + PlanningModels::builtin().with_evidence(evidence), + ) + .unwrap(); + let ratios = run + .stage2_physical_asap_optimization + .into_iter() + .filter(|candidate| { + let sketches: Vec<_> = candidate + .dag + .nodes + .iter() + .filter_map(|node| match &node.payload { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + }) => Some(kind.algorithm().clone()), + _ => None, + }) + .collect(); + sketches == [SketchAlgorithm::DDSketch, SketchAlgorithm::DDSketch] + }) + .collect(); + (ratios, run.stage3_plan_selection.unwrap()) } /// A missing proof for one side must not hide an invalid proof for the other. @@ -1274,17 +734,19 @@ fn ddsketch_ratio_rejects_one_invalid_domain_when_the_other_is_missing() { } } - let pre = lower_promql( + let (ratios, selection) = ddsketch_ratio_selection( "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", AccuracyTarget::max_error(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, + AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Relative), &PartialUnsafeDomain, ); - assert!(strategy.replacements(&TargetSubDAG::new(&pre)).is_empty()); + assert!(!ratios.is_empty()); + assert!( + ratios + .iter() + .all(|candidate| !selection.costs.contains_key(&candidate.id)), + "{selection:?}" + ); } /// The committed planner alpha is exercised against the pinned sketch implementation. @@ -1295,48 +757,38 @@ fn ddsketch_ratio_bound_holds_for_signed_pinned_sketch_evaluations() { lower: if sign < 0. { -100. } else { 1. }, upper: if sign < 0. { -1. } else { 100. }, }; - let pre = lower_promql( + let target = AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Relative); + let (ratios, selection) = ddsketch_ratio_selection( "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", AccuracyTarget::max_error(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, + target.clone(), &evidence, ); - let candidates = strategy.replacements(&TargetSubDAG::new(&pre)); - let Replacement::SubDAG(node) = &candidates[0].replacement else { - panic!("summary") - }; - let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = node.non_asap() else { - panic!("ratio") - }; - let alpha = |node: &OperatorNode| { - let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = node.asap() else { - panic!("evaluation") - }; - let Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) = summary_input.asap() - else { - panic!("sketch") - }; - let SketchParams::DDSketch { alpha } = kind.params() else { - panic!("DDSketch") - }; - *alpha - }; - assert_eq!(alpha(lhs), alpha(rhs)); - let bound = node - .guarantee - .as_ref() - .unwrap() - .spec - .error - .evaluate() - .unwrap(); + let accepted: Vec<_> = ratios + .iter() + .filter(|candidate| selection.costs.contains_key(&candidate.id)) + .collect(); + assert!(!accepted.is_empty(), "{selection:?}"); + // Both operands read one alpha, which the accepted ratio's target bounds. + let alphas: Vec = accepted[0] + .dag + .nodes + .iter() + .filter_map(|node| match &node.payload { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + }) => match kind.params() { + SketchParams::DDSketch { alpha } => Some(*alpha), + _ => None, + }, + _ => None, + }) + .collect(); + assert_eq!(alphas.len(), 2); + assert_eq!(alphas[0], alphas[1]); + let alpha = alphas[0]; + let bound = target.error; for values in [ vec![sign; 30], (1..=100).map(|i| sign * i as f64).collect(), @@ -1349,7 +801,7 @@ fn ddsketch_ratio_bound_holds_for_signed_pinned_sketch_evaluations() { sorted[r.floor() as usize] * (1. - r.fract()) + sorted[r.ceil() as usize] * r.fract() }; - let mut sketch = asap_sketchlib::DdSketch::new(alpha(lhs)); + let mut sketch = asap_sketchlib::DdSketch::new(alpha); for v in values { sketch.try_update(v).unwrap(); } @@ -1361,36 +813,6 @@ fn ddsketch_ratio_bound_holds_for_signed_pinned_sketch_evaluations() { } } -/// Empty or overlarge population contracts cannot promise a supported evaluation. -#[test] -fn ddsketch_ratio_requires_a_supported_population_size() { - struct PopulationEvidence(u64); - impl AccuracyEvidenceProvider for PopulationEvidence { - fn operand_domain(&self, _: &OperatorNode) -> Option { - Some(OperandDomain { - lower: 1., - upper: 10., - max_samples: self.0, - contract: "enforced fixture count and range".into(), - }) - } - } - let pre = lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - AccuracyTarget::max_error(0.01), - ) - .unwrap(); - for count in [0, (1u64 << 53) + 1] { - let evidence = PopulationEvidence(count); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultCostModel, - &DefaultAccuracyModel, - &evidence, - ); - assert!(strategy.replacements(&TargetSubDAG::new(&pre)).is_empty()); - } -} - // Every `without` aggregation candidate exports a valid DAG: its summary state // column carries the family instead of the evaluation's Float64 value. #[test] @@ -1401,15 +823,9 @@ fn without_aggregation_candidates_export_valid_dags() { ] { for query in ["sum without (pod) (m)", "quantile without (pod) (0.5, m)"] { let root = lower_promql(query, accuracy.clone()).unwrap(); - let space = search_workload_with_targets( - vec![(0, root, Some(accuracy.clone()))], - &asap_aware_mapping::default_strategies(), - &DefaultAccuracyModel, - ) - .unwrap(); - let inventory = space.enumerate_candidate_dags_for_root(&0, 65_536).unwrap(); - assert!(!inventory.candidates.is_empty(), "{query}"); - for (_, node) in inventory.candidates.iter().flatten() { + let candidates = logical_candidates(root, accuracy.clone()); + assert!(!candidates.is_empty(), "{query}"); + for node in &candidates { post_asap_dag(node); } } diff --git a/crates/integration-tests/tests/sql_to_post_asap.rs b/crates/integration-tests/tests/sql_to_post_asap.rs index 3548a76d..e55e9def 100644 --- a/crates/integration-tests/tests/sql_to_post_asap.rs +++ b/crates/integration-tests/tests/sql_to_post_asap.rs @@ -1,15 +1,15 @@ //! End-to-end SQL query-string → post-ASAP IR pin (issue #191). //! //! The SQL counterpart of `promql_to_post_asap.rs`: drives SQL text — -//! `lower_sql` (text → non-ASAP `OperatorNode` tree) → -//! `ASAPStrategies::replacements` (→ a tree with ASAP operators, -//! see [`realize`] below) — and pins the resulting sketch-vs-exact-accumulator -//! shape node by node, the way `promql_to_post_asap.rs` does for PromQL. +//! `lower_sql` (text → non-ASAP `OperatorNode` tree) → the planner stages' +//! logical candidates (→ a tree with ASAP operators, see [`realize`] +//! below) — and pins the resulting sketch-vs-exact-accumulator shape node by +//! node, the way `promql_to_post_asap.rs` does for PromQL. //! //! ## A structural wrinkle PromQL doesn't have //! //! `lower_promql` returns a *bare* `NonASAPOp::Aggregate` for a top-level -//! aggregation (`sum by (job) (m)`, `quantile(0.99, …)`), so [`realize`] can +//! aggregation (`sum by (job) (m)`, `quantile(0.99, …)`), so a summary can //! bind it directly at the DAG root. `lower_sql` never does: DataFusion's //! planner always wraps even a single, unaliased aggregate in an identity //! `Project` (confirmed below), so a SQL DAG's *root* is normally `Project { @@ -19,12 +19,8 @@ use std::rc::Rc; -use asap_aware_mapping::replacement::{retain_exact, RealizationError}; -use asap_aware_mapping::{ - search_workload, ASAPStrategies, DefaultCostModel, Replacement, ReplacementStrategy, - ReplacementSubDAG, TargetSubDAG, -}; use asap_frontend_sql::{lower_sql, lower_sql_dialect, SqlCatalog}; +use asap_integration_tests::planning::logical_candidates; use asap_integration_tests::post_asap::post_asap_dag; use asap_types::ir::operator_properties::Reduction; use asap_types::ir::physical_export::{EdgeRole, PhysicalASAPNodeId, PhysicalASAPOperatorPayload}; @@ -38,28 +34,44 @@ use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; use asap_types::workload::SqlDialect; -/// This crate has no "bind me one tree" public API any more — -/// `ASAPStrategies::replacements` always returns every candidate, and -/// a caller decides what to keep. This test-only helper reproduces the -/// take-the-first-(`cost_model`-preferred)-summary-candidate pattern so the -/// 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() - .replacements(&target_dag) - .into_iter() - .next() - { - Some(ReplacementSubDAG { - replacement: Replacement::SubDAG(node), - .. - }) if node.contains_asap() => Ok(node), - _ => retain_exact(target), +/// The first logical candidate (Stage 1, in enumeration order) of `root` as +/// a query at `accuracy` that uses an ASAP operator, or the plain exact one +/// when none does: the summary-bound form the pins below inspect. +fn realize(root: &Rc, accuracy: AccuracyTarget) -> Rc { + let candidates = logical_candidates(Rc::clone(root), accuracy); + let node = candidates + .iter() + .find(|candidate| candidate.contains_asap()) + .or(candidates.first()) + .cloned() + .expect("Stage 1 offers at least the exact candidate"); + node.validate_structure() + .expect("planned dag satisfies the unified IR contract"); + node +} + +/// `node` with every `Project` above it skipped: Stage 1 restores a +/// rewritten aggregate's output names with a `Project`, and SQL wraps every +/// query in one. +fn below_projects(mut node: &Rc) -> &Rc { + while let Some(NonASAPOp::Project { child, .. }) = node.non_asap() { + node = child; + } + node +} + +/// The first `SummaryAgg` reached from `node` through unary nodes, estimates +/// and finalized accumulators. +fn first_summary_agg(node: &Rc) -> &Rc { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { .. }) => node, + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + first_summary_agg(summary_input) + } + _ => first_summary_agg( + unary_child(node).unwrap_or_else(|| panic!("no SummaryAgg below {:?}", node.operator)), + ), } - .inspect(|node| { - node.validate_structure() - .expect("planned dag satisfies the unified IR contract") - }) } /// The single input of a unary non-ASAP node (Project, Filter, Sort, ...) or @@ -87,15 +99,6 @@ fn wire_pred(pred: &Predicate) -> Predicate { })) } -fn dtype<'a>(schema: &'a Schema, name: &str) -> &'a FieldDataType { - &schema - .fields - .iter() - .find(|f| f.name == name) - .unwrap_or_else(|| panic!("no field {name:?} in {schema:?}")) - .dtype -} - fn col(name: &str, dtype: DataType) -> Field { Field::plain(name, dtype, false) } @@ -148,8 +151,8 @@ async fn clickhouse_temporal_sql_reuses_rate_and_increase_physical_summaries() { ) .await .expect("explicit temporal SQL must lower"); - let physical = - realize(inner_aggregate(&pre_asap)).expect("temporal reducer must be planned"); + let planned = realize(&pre_asap, AccuracyTarget::exact()); + let physical = first_summary_agg(&planned); let Operator::ASAP(ASAPOp::SummaryAgg { family, reduction, @@ -194,12 +197,7 @@ async fn clickhouse_outer_sum_recursively_binds_inner_temporal_aggregate() { ) .await .expect("nested temporal SQL must lower"); - let space = search_workload(vec![("nested", Rc::clone(&pre_asap))]).unwrap(); - let selection = space.global_selection(&DefaultCostModel); - let root = selection - .assemble_selected_query(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); + let root = realize(&pre_asap, AccuracyTarget::exact()); fn has_temporal_summary(node: &OperatorNode) -> bool { match &node.operator { @@ -210,6 +208,7 @@ async fn clickhouse_outer_sum_recursively_binds_inner_temporal_aggregate() { Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { has_temporal_summary(summary_input) } + Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) => has_temporal_summary(child), _ => unary_child(node).is_some_and(|child| has_temporal_summary(child)), } } @@ -217,29 +216,30 @@ async fn clickhouse_outer_sum_recursively_binds_inner_temporal_aggregate() { has_temporal_summary(&root), "inner {function} was hidden: {root:?}" ); + // The outer sum is an exact Sum state over the finalized per-series + // values, so the export keeps both summaries. + let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &first_summary_agg(&root).operator + else { + unreachable!() + }; + assert_eq!( + family, + &FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) + ); let dag = post_asap_dag(&root); - assert!(dag.nodes.iter().any(|node| matches!( - node.payload, - PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Aggregate { .. }) - ))); - } -} - -/// The `Aggregate` node beneath the identity `Project` DataFusion's planner -/// always wraps a top-level aggregate in — see the module docs above. -fn inner_aggregate(node: &Rc) -> &Rc { - match node.non_asap() { - Some(NonASAPOp::Project { child, .. }) => inner_aggregate(child), - Some(NonASAPOp::Aggregate { .. }) => node, - _ => panic!( - "expected a Project{{Aggregate}} shape, got {:?}", - node.operator - ), + assert_eq!( + dag.nodes + .iter() + .filter(|node| matches!( + node.payload, + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) + )) + .count(), + 2 + ); } } -/// A complete SQL frontend result retains its projection and recursively -/// materializes the selected aggregate beneath it. #[tokio::test] async fn sql_full_query_retains_project_and_binds_inner_aggregate() { let pre_asap = lower( @@ -255,12 +255,7 @@ async fn sql_full_query_retains_project_and_binds_inner_aggregate() { else { panic!("sanity: a SQL root is a Project, unlike lower_promql's bare Aggregate"); }; - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); - let selection = space.global_selection(&DefaultCostModel); - let root = selection - .assemble_selected_query(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); + let root = realize(&pre_asap, AccuracyTarget::max_error(0.01)); let Some(NonASAPOp::Project { child, cols, @@ -278,7 +273,7 @@ async fn sql_full_query_retains_project_and_binds_inner_aggregate() { ); assert!( matches!( - child.operator, + below_projects(child).operator, Operator::ASAP(ASAPOp::SummaryEstimate { .. }) ), "the Aggregate under Project must be summary-bound" @@ -301,12 +296,7 @@ async fn sql_join_recursively_binds_both_temporal_aggregate_children() { ) .await .expect("two-subquery rate ratio must lower"); - let space = search_workload(vec![("ratio", Rc::clone(&pre_asap))]).unwrap(); - let selection = space.global_selection(&DefaultCostModel); - let root = selection - .assemble_selected_query(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); + let root = realize(&pre_asap, AccuracyTarget::exact()); let Some(NonASAPOp::Project { child: join, cols, .. }) = root.non_asap() @@ -343,8 +333,11 @@ async fn sql_join_recursively_binds_both_temporal_aggregate_children() { op: asap_types::pre_asap::CompareOpKind::Eq, right, .. - } if matches!(left.as_ref(), ScalarExpr::Column(0)) - && matches!(right.as_ref(), ScalarExpr::Column(2)) + } if matches!( + (left.as_ref(), right.as_ref()), + (ScalarExpr::Column(0), ScalarExpr::Column(2)) + | (ScalarExpr::Column(2), ScalarExpr::Column(0)) + ) )); assert_eq!( join.schema @@ -402,40 +395,6 @@ async fn sql_join_recursively_binds_both_temporal_aggregate_children() { assert_eq!(roles, vec![EdgeRole::Left, EdgeRole::Right]); } -#[tokio::test] -async fn unsupported_sql_join_shapes_remain_fail_closed() { - for sql in [ - "SELECT a.service FROM (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) a LEFT JOIN (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) b ON a.service=b.service", - "SELECT a.service FROM (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) a INNER JOIN (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) b ON a.v>b.v", - "SELECT a.service FROM (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) a INNER JOIN (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) b ON a.service=a.service", - ] { - let pre_asap = lower_sql_dialect( - sql, - &catalog(), - SqlDialect::ClickhouseSQL, - AccuracyTarget::exact(), - ) - .await - .unwrap_or_else(|error| panic!("join must lower before fail-closed mapping: {error}")); - let space = search_workload(vec![("unsupported-join", Rc::clone(&pre_asap))]).unwrap(); - let selection = space.global_selection(&DefaultCostModel); - let root = selection - .assemble_selected_query(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); - let Some(NonASAPOp::Project { child, .. }) = root.non_asap() else { - panic!("SQL projection must remain explicit: {:?}", root.operator); - }; - assert!( - is_kept_non_asap(child), - "unsupported join was partially accelerated: {:?}", - child.operator - ); - } -} - -/// Relational parents emitted around a derived-table aggregate remain -/// explicit read-time nodes while the aggregate is summary-bound. #[tokio::test] async fn sql_relational_parents_retain_summary_bound_aggregate() { let pre_asap = lower( @@ -446,12 +405,7 @@ async fn sql_relational_parents_retain_summary_bound_aggregate() { AccuracyTarget::max_error(0.01), ) .await; - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); - let selection = space.global_selection(&DefaultCostModel); - let root = selection - .assemble_selected_query(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); + let root = realize(&pre_asap, AccuracyTarget::max_error(0.01)); let mut node = root.as_ref(); let mut saw_project = false; @@ -537,12 +491,7 @@ async fn sql_filter_keeps_read_predicate_and_summary_population_selection() { }; assert_eq!(expected_source_predicates.len(), 1, "fixture source WHERE"); - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); - let selection = space.global_selection(&DefaultCostModel); - let root = selection - .assemble_selected_query(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); + let root = realize(&pre_asap, AccuracyTarget::max_error(0.01)); let mut node = root.as_ref(); let mut retained_read_predicate = None; @@ -581,60 +530,6 @@ async fn sql_filter_keeps_read_predicate_and_summary_population_selection() { ))); } -/// If the child has no legal summary implementation, retain only that child -/// as the fallback leaf and keep the supported Filter as an explicit local -/// read-time operation. -#[tokio::test] -async fn sql_filter_preserves_local_fallback_boundary_for_unsupported_child() { - let pre_asap = lower( - "SELECT t.service, t.avg_bytes FROM \ - (SELECT service, AVG(bytes) AS avg_bytes FROM metrics GROUP BY service) t \ - WHERE t.avg_bytes > 100", - AccuracyTarget::exact(), - ) - .await; - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); - let selection = space.global_selection(&DefaultCostModel); - let root = selection - .assemble_selected_query(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); - - let mut node = root.as_ref(); - let mut saw_filter = false; - loop { - if let Some(NonASAPOp::BinaryOp { .. }) = node.non_asap() { - assert!( - is_kept_non_asap(node), - "AVG's unsupported rewritten child should be kept whole, got {node:?}" - ); - break; - } - saw_filter |= matches!(node.non_asap(), Some(NonASAPOp::Filter { .. })); - node = unary_child(node).unwrap_or_else(|| { - panic!( - "expected local value operations over fallback child, got {:?}", - node.operator - ) - }); - } - assert!(saw_filter, "supported Filter must remain explicit"); -} - -/// `SELECT approx_percentile_cont(latency, 0.99) FROM metrics` at ε = 0.01, -/// with the wrapping `Project` stripped (see module docs): -/// -/// ```text -/// SummaryEstimate { query: Quantile{0.99} } → {…: Float64} -/// └─ SummaryAgg { Kll{k:269}, input: metrics.latency } → {…: Sketch(Kll, {k:269})} -/// └─ Scan (kept non-ASAP) → {ts, service, latency, bytes} -/// ``` -/// -/// The SQL counterpart of `promql_to_post_asap.rs`'s -/// `promql_quantile_of_rate_binds_kll_over_rate_accumulator`: same intent -/// (`Quantile`), same 99%-confidence KLL sizing (k=269 from ε=0.01), but the summarised -/// column is the intent's own *named* SQL column rather than PromQL's -/// synthetic sample value. #[tokio::test] async fn sql_quantile_binds_kll_sketch_over_named_column() { let pre_asap = lower( @@ -642,8 +537,8 @@ async fn sql_quantile_binds_kll_sketch_over_named_column() { AccuracyTarget::max_error(0.01), ) .await; - let agg = inner_aggregate(&pre_asap); - let root = realize(agg).expect("binding failed"); + let planned = realize(&pre_asap, AccuracyTarget::max_error(0.01)); + let root = below_projects(&planned); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, @@ -744,8 +639,8 @@ async fn sql_count_distinct_with_epsilon_binds_hll_over_named_column() { AccuracyTarget::max_error(0.01), ) .await; - let agg = inner_aggregate(&pre_asap); - let root = realize(agg).expect("binding failed"); + let planned = realize(&pre_asap, AccuracyTarget::max_error(0.01)); + let root = below_projects(&planned); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, @@ -793,81 +688,33 @@ async fn sql_count_distinct_with_epsilon_binds_hll_over_named_column() { assert_eq!(reduction, &Reduction::by(vec![])); } -/// An exact workload binds zero sketches: `SUM(bytes) GROUP BY service` at -/// `AccuracyTarget::exact()` still gets its mergeable exact accumulator, and -/// `AVG(bytes)` (non-mergeable) stays a whole logical sub-DAG untouched. SQL -/// counterpart of `promql_to_post_asap.rs`'s -/// `promql_exact_workload_binds_accumulators_not_sketches`. #[tokio::test] -async fn sql_exact_workload_binds_accumulators_not_sketches() { - let pre_asap = lower( - "SELECT service, SUM(bytes) FROM metrics GROUP BY service", - AccuracyTarget::exact(), - ) - .await; - let agg = inner_aggregate(&pre_asap); - let root = realize(agg).expect("binding failed"); - let Operator::ASAP(ASAPOp::SummaryAgg { - family, reduction, .. - }) = &root.operator - else { - panic!("expected SummaryAgg, got {:?}", root.operator); - }; - assert_eq!( - family, - &FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) - ); - assert_eq!( - reduction, - &Reduction::by(vec![1]), - "service is col 1 in [ts, service, latency, bytes]" - ); - assert_eq!( - dtype(&root.schema, "service"), - &FieldDataType::Plain(DataType::Utf8), - "group keys pass through verbatim" - ); - - let pre_asap = lower("SELECT AVG(bytes) FROM metrics", AccuracyTarget::exact()).await; - let agg = inner_aggregate(&pre_asap); - let root = realize(agg).expect("binding failed"); - assert!( - is_kept_non_asap(&root), - "avg has no mergeable accumulator — stays logical" - ); - assert!( - root.guarantee.as_ref().is_some_and(|g| g.is_exact()), - "a kept logical sub_dag is exact" - ); -} - -#[tokio::test] -async fn map_projection_export_preserves_unsupported_child_boundary() { +async fn map_projection_export_keeps_the_map_over_an_avg_of_exact_states() { let pre = lower_sql_dialect( "SELECT map('job', t.service) AS labels, t.avg_bytes FROM (SELECT service, AVG(bytes) AS avg_bytes FROM metrics GROUP BY service) t WHERE t.avg_bytes > 100", &catalog(), SqlDialect::ClickhouseSQL, AccuracyTarget::exact(), ).await.unwrap(); - let space = search_workload(vec![("map_query", pre)]).unwrap(); - let root = space - .global_selection(&DefaultCostModel) - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .unwrap(); + let root = realize(&pre, AccuracyTarget::exact()); let dag = post_asap_dag(&root); assert!(dag.nodes.iter().any(|node| matches!(&node.payload, PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Project { cols, .. }) if cols.iter().any(|item| matches!(&item.expr, ScalarExpr::FunctionCall { name, .. } if name == "map")) ))); - let mut node = root.as_ref(); - loop { - if let Some(NonASAPOp::BinaryOp { .. }) = node.non_asap() { - assert!( - is_kept_non_asap(node), - "fallback child must stay whole: {node:?}" - ); - break; - } - node = unary_child(node) - .unwrap_or_else(|| panic!("unexpected map/fallback composition: {:?}", node.operator)); - } + // The AVG under the map projection is built from exact Sum and Count + // states, divided at read time. + let families: Vec<_> = dag + .nodes + .iter() + .filter_map(|node| match &node.payload { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family, .. }) => Some(family), + _ => None, + }) + .collect(); + assert_eq!( + families, + vec![ + &FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + &FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), + ] + ); } diff --git a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs index 21c173b2..171236be 100644 --- a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs +++ b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs @@ -652,6 +652,39 @@ async fn sql_avg_counts_its_own_input() { assert_eq!(execute(&root, &rows), want); } +/// SQL `avg` as `sum` and `count` keeps an explicit output alias. +#[tokio::test] +async fn sql_avg_keeps_its_explicit_output_name() { + let (_, root) = + avg_as_sum_and_count(&avg_table(), "SELECT avg(value) AS avg_latency FROM t").await; + assert_eq!(root.schema.fields.last().unwrap().name, "avg_latency"); +} + +/// SQL `avg` of a non-null Int64 column as `sum` and `count` keeps the exact plan's Float64 columns. +#[tokio::test] +async fn sql_avg_of_an_int64_column_keeps_the_exact_columns() { + let catalog = SqlCatalog::new().with_table( + "t", + Schema::new(vec![ + Field::plain("k", DataType::Utf8, true), + Field::plain("n", DataType::Int64, false), + ]), + ); + let query = "SELECT k, avg(n) FROM t GROUP BY k"; + let (exact, root) = avg_as_sum_and_count(&catalog, query).await; + assert_eq!( + root.schema.fields.last().unwrap().plain_dtype(), + Some(&DataType::Float64) + ); + let row = |k: &str, n: i64| vec![("k", Value::Utf8(k.into())), ("n", Value::Int64(n))]; + let rows = vec![row("a", 1), row("a", 2), row("b", 4)]; + let want = vec![ + vec!["\"a\"".to_string(), "1.5".to_string()], + vec!["\"b\"".to_string(), "4".to_string()], + ]; + assert_eq!(execute(&exact, &rows), want); +} + /// `count(*)` counts a non-null column, never a nullable one; executed, it /// counts the rows whose nullable column is NULL too. #[tokio::test] diff --git a/crates/types/src/ir/cse.rs b/crates/types/src/ir/cse.rs index e7811af9..3305468b 100644 --- a/crates/types/src/ir/cse.rs +++ b/crates/types/src/ir/cse.rs @@ -413,6 +413,39 @@ mod tests { ); } + /// Grouped quantiles differing only in accuracy keep two roots over one shared scan. + #[test] + fn differing_accuracy_is_never_merged_but_the_scan_child_is_shared() { + let shared_scan = scan(); + let quantile = |epsilon: f64| { + node(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![1]), + measures: vec![AggIntent::Quantile { + col: Some(2), + q: 0.99, + accuracy: AccuracyTarget::max_error(epsilon), + }], + output_names: vec![], + filters: vec![], + having: None, + child: Rc::clone(&shared_scan), + }) + }; + let a = quantile(0.01); + assert!(a.schema.has_unique_key(), "fixture sanity: grouped"); + let (ra, rb) = two_roots(a, quantile(0.05)); + assert!(!Rc::ptr_eq(&ra, &rb)); + assert_ne!(ra, rb); + let ( + Some(NonASAPOp::Aggregate { child: child_a, .. }), + Some(NonASAPOp::Aggregate { child: child_b, .. }), + ) = (ra.non_asap(), rb.non_asap()) + else { + panic!("expected Aggregate roots"); + }; + assert!(Rc::ptr_eq(child_a, child_b)); + } + #[test] fn median_and_explicit_half_percentile_merge() { // Two spellings that lower to the identical grouped `Quantile { q: 0.5 }`. diff --git a/crates/types/src/post_asap/execution_data_state.rs b/crates/types/src/post_asap/execution_data_state.rs index 2f68af73..5f4e6554 100644 --- a/crates/types/src/post_asap/execution_data_state.rs +++ b/crates/types/src/post_asap/execution_data_state.rs @@ -9,9 +9,6 @@ use thiserror::Error; -use crate::ir::SchemaDerivationError; -use crate::pre_asap::schema::Schema; - /// When a post-ASAP value is produced. #[derive( Default, Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, @@ -224,21 +221,6 @@ pub enum ExecutionDataStateError { InvalidPhysicalDAG(#[from] crate::ir::physical_export::PhysicalASAPDAGValidationError), } -/// `schema` as a summary-planning node output: fields and time axis kept, -/// unique keys dropped, closed. -pub fn lift_plain(schema: &Schema) -> Schema { - Schema::lifted(schema.fields.clone(), schema.time_index) -} - -/// Why an exact operator's output schema could not be derived. -#[derive(Debug, Error)] -pub enum ExactOperationSchemaError { - #[error("exact operator input carries summary state, not plain columns")] - NonPlainInput, - #[error("schema derivation failed: {0}")] - Schema(#[from] SchemaDerivationError), -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/types/src/post_asap/mod.rs b/crates/types/src/post_asap/mod.rs index fc22b984..bc0d26e7 100644 --- a/crates/types/src/post_asap/mod.rs +++ b/crates/types/src/post_asap/mod.rs @@ -15,8 +15,8 @@ //! //! [`sketch::GroupingStrategy`] is a second, orthogonal axis: how many //! physical instances of a summary exist across a grouped aggregate's `by` -//! subpopulations (per-subpopulation vs. one shared Hydra instance — see -//! `asap_aware_mapping::grouping`). It rides on `ASAPOp::SummaryAgg` and on +//! subpopulations (per-subpopulation vs. one shared Hydra instance; the +//! planner does not offer Hydra yet, #703). It rides on `ASAPOp::SummaryAgg` and on //! sketch-valued edge types. //! //! The rest: what each family can do ([`family_capability`]), accuracy @@ -34,8 +34,8 @@ pub mod summary_window; pub use crate::pre_asap::schema::{Field, FieldDataType, Schema}; pub use execution_data_state::{ - lift_plain, DataPrimitive, ExactOperationSchemaError, ExecutionDataState, - ExecutionDataStateError, ExecutionTiming, Materialization, Medium, + DataPrimitive, ExecutionDataState, ExecutionDataStateError, ExecutionTiming, Materialization, + Medium, }; pub use family_capability::{FamilyCapability, MergeInputRule, MergedError, StatisticKind}; pub use guarantee::{ diff --git a/crates/types/src/post_asap/query_time/error_estimation.rs b/crates/types/src/post_asap/query_time/error_estimation.rs index f2bf229d..9191325a 100644 --- a/crates/types/src/post_asap/query_time/error_estimation.rs +++ b/crates/types/src/post_asap/query_time/error_estimation.rs @@ -63,9 +63,7 @@ //! issue #239. //! //! Integration point (2) — tighter *plan-time* sizing under an expected-case -//! (non-adversarial) assumption — is wired for real, since it touches code -//! that already exists: see -//! `crates/asap-aware-mapping/src/implementation.rs::posterior_aware_size_params`. +//! (non-adversarial) assumption — is not implemented in the planner stages. // ── Posterior (query-time) estimators ─────────────────────────────────────── @@ -477,7 +475,7 @@ mod tests { #[test] fn classic_cms_sizing_matches_implementation_rs_formula() { // Same worked example as - // `asap-aware-mapping::replacement::tests::epsilon_delta_sizes_cms_depth` + // `asap-aware-mapping::accuracy::estimators::cms` sizing // (eps=0.001, delta=0.001 ⇒ width=2719, depth=7), pinned here too so // the two independent (layering-forced) reimplementations can't // silently drift apart undetected. diff --git a/crates/types/src/post_asap/query_time/mod.rs b/crates/types/src/post_asap/query_time/mod.rs index ecb89935..25de41bc 100644 --- a/crates/types/src/post_asap/query_time/mod.rs +++ b/crates/types/src/post_asap/query_time/mod.rs @@ -19,18 +19,9 @@ //! this crate's. Nothing in `asap-types` or `asap-aware-mapping` calls //! into this module today; it is a library waiting for a runtime that //! doesn't exist yet in this workspace (see [`error_estimation`]'s own -//! docs for exactly what's blocked and why). -//! -//! Contrast with `asap-aware-mapping::replacement::posterior_aware_size_params` -//! (issue #239, PR #248): that function is real, wired *planning*-time -//! code — it lives in the planning crate, not here, and does not call -//! into this module. It borrows the same underlying intuition (a -//! non-adversarial workload can use a smaller sketch than the worst case) -//! but as an explicit, caller-supplied planning-time assumption, not a -//! query-time measurement — the two are independent by construction. See -//! issue #250 for the still-unimplemented idea of actually connecting -//! them: recording this module's query-time observations over time and -//! feeding them into a future replan. +//! docs for exactly what's blocked and why). See issue #250 for the +//! still-unimplemented idea of recording this module's query-time +//! observations over time and feeding them into a future replan. pub mod error_estimation; diff --git a/crates/types/src/post_asap/sketch.rs b/crates/types/src/post_asap/sketch.rs index a6005f73..8b9c180c 100644 --- a/crates/types/src/post_asap/sketch.rs +++ b/crates/types/src/post_asap/sketch.rs @@ -425,9 +425,9 @@ pub enum StatModelParams { // family/kind answers an intent — any family could in principle grow its own // per-subpopulation vs. shared-multi-subpopulation variant, so it is a // second, independent axis, not a member of any one family's own kind -// vocabulary. See `asap_aware_mapping::grouping`'s module docs for where this -// axis actually plugs into the post-ASAP IR and the legality rules gating -// when `SharedMultiSubpopulation` is offered as a candidate at all. +// vocabulary. It is legal only for a grouped aggregate (a non-empty `by`) +// whose family has a Hydra variant ([`hydra_kind_for`]); the planner does +// not offer it as a candidate yet (#703). /// A shared-multi-subpopulation summary family — one physical structure /// serving every subpopulation of a grouped aggregate instead of one @@ -513,8 +513,7 @@ pub enum HydraParams { /// Sizing knob for the one physical structure shared across every /// subpopulation. Correctly sizing this against an estimated /// subpopulation cardinality is a cost-model concern — out of scope - /// for the legality axis this type lives on (see - /// `asap_aware_mapping::grouping`'s module docs) — so this is + /// for the legality axis this type lives on — so this is /// deliberately not derived from any cardinality estimate here. shared_buckets: u32, }, @@ -570,9 +569,7 @@ pub fn hydra_kind_for(algorithm: &SketchAlgorithm) -> Option { /// `HydraParams` fields. `None` when `per_subpopulation_params` doesn't /// belong to the [`SketchAlgorithm`] `kind` wraps: a caller bug, since /// [`hydra_kind_for`] and the algorithm a `SketchParams` came from must -/// agree; callers that got both from the same already-ranked -/// `Realization` (as `asap_aware_mapping::grouping` does) cannot hit -/// this. +/// agree. /// /// This function is generic over which inner sketch type `kind` wraps /// precisely because [`SketchParams`] already is: it destructures whichever @@ -761,8 +758,8 @@ pub enum SketchStatistic { /// `value: Some(v)` is a per-item point lookup (e.g. /// `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`. + /// `SummaryExecutor` from a `Filter` predicate because a summary + /// evaluation is read without access to the rest of the DAG. PointCount { key: ColumnRef, value: Option, diff --git a/crates/types/src/workload.rs b/crates/types/src/workload.rs index 2f101dba..1ca6c8bb 100644 --- a/crates/types/src/workload.rs +++ b/crates/types/src/workload.rs @@ -774,12 +774,37 @@ mod tests { with(-1.0, None).validate(), Err(WorkloadError::InvalidRate(-1.0)) ); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!( + matches!( + with(bad, None).validate(), + Err(WorkloadError::InvalidRate(_)) + ), + "{bad}" + ); + } assert_eq!( with(1.0, Some(0)).validate(), Err(WorkloadError::ZeroSampleInterval) ); } + /// A repeating query with a zero fixed interval is rejected. + #[test] + fn zero_repetition_interval_is_rejected() { + let mut workload = base_workload(); + workload.repeating_queries = Some(vec![RepeatingEntry { + query: Query("dashboard".into()), + demand: RepeatedDemand::FixedInterval(RepetitionInterval(0)), + requirements: QueryRequirements::default(), + predictability: Predictability::Predictable { known_at: None }, + }]); + assert_eq!( + workload.validate(), + Err(WorkloadError::ZeroRepetitionInterval) + ); + } + fn base_workload() -> QueryWorkload { QueryWorkload { language: QueryLanguage::PromQL,