diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index b60e0f9b..87a01051 100644 --- a/crates/integration-tests/src/lib.rs +++ b/crates/integration-tests/src/lib.rs @@ -97,7 +97,7 @@ pub mod fixtures { } } -/// Timing and export helpers for post-ASAP plans. +/// Export helper for post-ASAP plans. pub mod post_asap { use asap_types::ir::physical_export::{compile_physical_asap_dag, PhysicalASAPDAG}; use asap_types::ir::{ @@ -105,32 +105,15 @@ pub mod post_asap { }; use std::rc::Rc; - /// Time `root` under the default assignment (every summary computed at - /// query time). Returns the timed copy; read `node.timing` on it. - pub fn timed(root: &Rc) -> Rc { - timed_with(root, &MaterializationAssignment::all_query_time()) - } - - /// Time `root` with every summary maintained at ingestion time. - pub fn maintained(root: &Rc) -> Rc { - timed_with(root, &MaterializationAssignment::all_ingestion_time()) - } - - fn timed_with( - root: &Rc, - assignment: &MaterializationAssignment, - ) -> Rc { - apply_materialization_timings(root, assignment, &mut TimingMemo::new()) - .expect("materialization timing failed") - } - - /// Time `root` (default assignment), then export the physical DAG. + /// Time `root` with every summary computed at query time, then export the + /// physical DAG. pub fn post_asap_dag(root: &Rc) -> PhysicalASAPDAG { - compile_physical_asap_dag(&timed(root)).expect("post-ASAP DAG export failed") - } - - /// Time `root` with every summary maintained, then export the physical DAG. - pub fn maintained_post_asap_dag(root: &Rc) -> PhysicalASAPDAG { - compile_physical_asap_dag(&maintained(root)).expect("post-ASAP DAG export failed") + let timed = apply_materialization_timings( + root, + &MaterializationAssignment::all_query_time(), + &mut TimingMemo::new(), + ) + .expect("materialization timing failed"); + compile_physical_asap_dag(&timed).expect("post-ASAP DAG export failed") } } diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs deleted file mode 100644 index a93f2b86..00000000 --- a/crates/integration-tests/tests/exact_composition.rs +++ /dev/null @@ -1,736 +0,0 @@ -//! Issue #171 — composing exact operators with summary plans across -//! explicit update/evaluation boundaries, end to end through -//! `search_workload_with` → `candidate_selection::global_selection` → -//! `GlobalSelection::assemble_selected_dag`. -//! -//! 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, unknown runtime support, -//! a cost model without statistics, and pre/post-ASAP -//! schemas plus shared `Rc` identity — along with pins for every -//! already-supported exact-accumulator nesting. - -use std::rc::Rc; - -use asap_integration_tests::fixtures::lower_promql; -use asap_integration_tests::post_asap::{maintained, timed}; -use asap_logical_optimizer::pass1::exact_composition::ExactOperation; -use asap_logical_optimizer::pass1::replacement::{ - default_strategies, search_workload_with, ASAPStrategies, Replacement, ReplacementProvenance, - ReplacementStrategy, TargetSubDAG, -}; -use asap_logical_optimizer::{ExplanationKind, OperationPlacement}; -use asap_plan_selection::candidate_selection::{global_selection, runtime_support_evidence}; -use asap_plan_selection::cost::cost_model::{ - CostProvenance, CostUnit, ExactCompositionCostInputs, ExactCompositionCostRequest, -}; -use asap_plan_selection::{CostModel, DefaultCostModel, EvaluationRate}; -use asap_types::ir::operator::agg_intent::{default_quantile, AggIntent}; -use asap_types::ir::operator::operator_properties::{Reduction, Source}; -use asap_types::ir::properties::timing::data_state; -use asap_types::ir::properties::{ExecutionDataState, ExecutionTiming}; -use asap_types::ir::schema::{DataType, Field, Schema}; -use asap_types::ir::schema::{ExactKind, FieldDataType, SketchAlgorithm, SummaryUpdate}; -use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, TimeRangeKind}; - -use asap_types::types::AccuracyTarget; - -// ── fixtures ──────────────────────────────────────────────────────────── - -fn node(op: NonASAPOp) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(op)) - .expect("fixture node derives its schema") -} - -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)), - ); - node(NonASAPOp::Scan { - source: Source::TimeSeries { - metric: "latency".into(), - }, - predicates: vec![], - schema: Schema::with_time_index(columns, 0, vec![]), - }) -} - -fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { - node(NonASAPOp::Aggregate { - reduction: Reduction::by(by), - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child, - }) -} - -fn per_entity(intent: AggIntent, child: Rc) -> Rc { - node(NonASAPOp::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child, - }) -} - -/// `quantile by (zone, host) (latency)` — the fine-grained inner summary. -fn fine_quantile() -> Rc { - agg( - vec![2, 3], - default_quantile(0.99), - metric_scan(&["zone", "host"]), - ) -} - -/// 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_logical_optimizer::{AccuracyModel, DefaultAccuracyModel, PropagationStats}; - use asap_types::ir::properties::{AccuracyError, CompositionOperator, ResultGuarantee}; - use asap_types::ir::schema::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, - _: &[ResultGuarantee], - _: Option<&ResultGuarantee>, - _: &PropagationStats, - ) -> Result { - // Test-only oracle: the marker detects accidental use of the default model. - Ok(ResultGuarantee::exact("custom rule oracle")) - } - fn satisfies(&self, g: &ResultGuarantee, t: &AccuracyTarget) -> bool { - DefaultAccuracyModel.satisfies(g, t) - } - } - let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = asap_logical_optimizer::pass1::replacement::search_workload_with_targets( - vec![("q", root, Some(AccuracyTarget::Exact))], - &default_strategies(), - &Model, - ); - let selection = global_selection(&space, &StatsModel); - assert!(selection.composition(&space.roots[0].1).is_some()); - let node = selection - .assemble_selected_dag(&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_logical_optimizer::pass1::replacement::search_workload_with_targets( - vec![("q", root, Some(AccuracyTarget::Exact))], - &default_strategies(), - &asap_logical_optimizer::DefaultAccuracyModel, - ); - let selection = global_selection(&space, &StatsModel); - assert!(selection.composition(&space.roots[0].1).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(), - }, - } - } -} - -/// 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) - } -} - -#[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)]); - let group = space.candidates_for_target(&space.roots[0].1).unwrap(); - assert!(group.candidates.iter().any(|candidate| { - matches!(candidate.replacement, Replacement::ExactComposition(_)) - && runtime_support_evidence(candidate, &UnknownCapabilityModel).is_none() - && UnknownCapabilityModel - .candidate_cost( - candidate, - &asap_logical_optimizer::TargetSubDAG::new(&space.roots[0].1), - ) - .is_none() - })); - let selection = global_selection(&space, &UnknownCapabilityModel); - assert!(selection.composition(&space.roots[0].1).is_none()); - assert!(selection - .assemble_selected_dag(&space.roots[0].1) - .unwrap() - .is_some()); -} - -fn plan( - roots: Vec<(&'static str, Rc)>, -) -> asap_logical_optimizer::CandidateLogicalASAPDAGs<&'static str> { - search_workload_with(roots, &default_strategies()) -} - -fn is_plain(node: &OperatorNode) -> bool { - node.schema - .fields - .iter() - .all(|f| matches!(f.dtype, FieldDataType::Plain(_))) -} - -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] -fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { - use std::time::Duration; - let cases: Vec<(Rc, ExactKind)> = vec![ - ( - agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["zone"]), - ), - ExactKind::Sum, - ), - ( - agg( - vec![2], - AggIntent::Count { - accuracy: AccuracyTarget::Exact, - }, - metric_scan(&["zone"]), - ), - ExactKind::Count, - ), - ( - agg( - vec![2], - AggIntent::Min { col: None }, - metric_scan(&["zone"]), - ), - ExactKind::Min, - ), - ( - agg( - vec![2], - AggIntent::Max { col: None }, - metric_scan(&["zone"]), - ), - ExactKind::Max, - ), - ( - per_entity( - AggIntent::Rate, - node(NonASAPOp::TimeRange { - range: Duration::from_secs(300), - kind: TimeRangeKind::Range, - child: metric_scan(&["zone"]), - }), - ), - ExactKind::Rate, - ), - ( - per_entity( - AggIntent::Increase, - node(NonASAPOp::TimeRange { - range: Duration::from_secs(300), - kind: TimeRangeKind::Range, - child: metric_scan(&["zone"]), - }), - ), - ExactKind::Increase, - ), - ]; - for (inner, kind) in cases { - let outer = agg(vec![], default_quantile(0.9), inner); - let target = TargetSubDAG::new(&outer); - let candidates = ASAPStrategies::default().replacements(&target); - let Replacement::SubDAG(root) = &candidates[0].replacement else { - unreachable!() - }; - // 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 = maintained(root); - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { - panic!("expected KLL evaluation, got {:?}", root.operator); - }; - let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &summary_input.operator else { - panic!("expected outer SummaryAgg"); - }; - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: finalized }) = &child.operator - else { - panic!("{kind:?}: missing maintenance finalization"); - }; - assert_eq!( - child.timing, - Some(ExecutionTiming::IngestionTime), - "{kind:?}: finalization runs at maintenance time" - ); - assert!( - matches!( - &finalized.operator, - Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(k, _), .. }) if *k == kind - ), - "{kind:?}: expected the exact accumulator under its finalization, got {:?}", - finalized.operator - ); - } -} - -// ── 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`. -#[test] -fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { - for intent in [AggIntent::Max { col: None }, AggIntent::Avg { col: None }] { - let root = agg(vec![0], intent.clone(), fine_quantile()); - let space = plan(vec![("q", Rc::clone(&root))]); - 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 = global_selection(&space, &StatsModel); - let selected = selection.for_target(&root).unwrap(); - let chosen = selected.chosen.expect("a decision"); - assert_eq!( - chosen.provenance, - ReplacementProvenance::ValueOperationAtQueryTime - ); - let decision = selection - .composition(&root) - .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_dag(&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 { .. }) - )); - assert!( - child.guarantee.is_some(), - "child has its KLL rank guarantee" - ); - assert!( - composed.guarantee.is_none(), - "rank error has no definition-backed conversion through max/average" - ); - assert!(is_plain(&composed)); - assert_eq!( - names(&composed), - root.schema - .fields - .iter() - .map(|c| c.name.as_str()) - .collect::>(), - "the composed plan's schema is the pre-ASAP target's own" - ); - assert_eq!( - timed(&composed).timing, - Some(ExecutionTiming::QueryTime), - "{intent:?}: the exact fold runs at query time" - ); - } -} - -/// `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)]); - 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. -#[test] -fn identity_and_genuine_multi_row_folds_both_compose() { - let identity_inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); - for (label, inner) in [ - ("identity", identity_inner), - ("fine-to-coarse", fine_quantile()), - ] { - let root = agg(vec![0], AggIntent::Max { col: None }, inner); - let space = plan(vec![("q", root)]); - let root = &space.roots[0].1; - let composed = global_selection(&space, &StatsModel) - .assemble_selected_dag(root) - .unwrap() - .unwrap(); - assert!( - matches!( - composed.non_asap(), - Some(NonASAPOp::Aggregate { child, .. }) - if matches!(child.operator, Operator::ASAP(ASAPOp::SummaryEstimate { .. })) - ), - "{label}: {:?}", - composed.operator - ); - assert_eq!( - timed(&composed).timing, - Some(ExecutionTiming::QueryTime), - "{label}" - ); - assert_eq!(names(&composed), vec!["zone", "max"], "{label}"); - } -} - -/// 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)]); - let selection = global_selection(&space, &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.composition(r).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_dag(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)]); - 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 = global_selection(&space, &StatsModel); - let deriv_sel = selection.for_target(deriv).unwrap(); - assert_eq!( - deriv_sel.chosen.unwrap().provenance, - ReplacementProvenance::ValueOperationAtIngestionTime - ); - let decision = selection.composition(deriv).unwrap(); - assert!(decision.child_candidate.is_none(), "function input is raw"); - assert!(decision.cost_rate < decision.baseline_rate); - - let composed = selection.assemble_selected_dag(&root).unwrap().unwrap(); - // Walk the timed copy, with the outer summary maintained at ingestion time. - let composed = maintained(&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 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))]); - let post = global_selection(&space, &StatsModel) - .assemble_selected_dag(&space.roots[0].1) - .unwrap() - .unwrap(); - let illegal = std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child: post, - family: FieldDataType::ExactAggregate( - ExactKind::Max, - asap_types::ir::schema::ExactParams::Max, - ), - input: SummaryUpdate::column(asap_types::ir::scalar::ColumnRef::SampleValue), - reduction: Reduction::by(vec![]), - grouping: Default::default(), - filter: None, - }), - Schema::lifted(vec![], None), - ) - .with_guarantee(None), - ); - // Even under an ingestion-time consumer the state is built at query - // time, because a evaluation sits below it. - let state = asap_types::ir::planned_data_state(&illegal, ExecutionTiming::IngestionTime); - assert_eq!(state.timing, ExecutionTiming::QueryTime); - asap_types::ir::validate_maintained(&illegal, state.timing).unwrap(); -} - -/// 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)]); - 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 = global_selection(&space, &DefaultCostModel); - let selected = selection.for_target(&root).unwrap(); - assert!(selection.composition(&root).is_none()); - assert!(!matches!( - selected.chosen.map(|c| &c.replacement), - Some(Replacement::ExactComposition(_)) - )); - let node = selection.assemble_selected_dag(&root).unwrap().unwrap(); - assert!(!is_query_time_fold(&node)); - - let explanations = asap_logical_optimizer::explain_replacements(vec![("q", Rc::clone(&root))]); - assert!(explanations - .iter() - .any(|e| e.kind == ExplanationKind::ExactComposition)); -} - -/// 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::Epsilon(0.01), - ) - .unwrap(); - let space = plan(vec![("q", expr)]); - let root = &space.roots[0].1; - let selection = global_selection(&space, &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_dag(root).unwrap().unwrap(); - assert!(is_query_time_fold(&composed), "{:?}", composed.operator); - assert_eq!(timed(&composed).timing, Some(ExecutionTiming::QueryTime)); - assert_eq!( - selection.composition(root).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 deleted file mode 100644 index 2e15c749..00000000 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ /dev/null @@ -1,1394 +0,0 @@ -//! 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 -//! the summary-bound shape node by node, including the family `(Kind, -//! Params)` committed on each edge's schema. - -use std::rc::Rc; - -use asap_integration_tests::fixtures::lower_promql; -use asap_integration_tests::post_asap::{ - maintained, maintained_post_asap_dag, post_asap_dag, timed, -}; -use asap_logical_optimizer::accuracy::{ - AccuracyEvidenceProvider, DefaultAccuracyModel, EqualSplitAllocator, PropagationStats, - QuantileInputDomain, -}; -use asap_logical_optimizer::pass1::replacement::{ - is_logical_rewrite, retain_exact, RealizationError, -}; -use asap_logical_optimizer::{ - search_workload, search_workload_with_targets, ASAPStrategies, AccuracyModel, Replacement, - ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, -}; -use asap_plan_selection::candidate_selection::global_selection; -use asap_plan_selection::cost::cost_model::DefaultCostModel; -use asap_types::ir::operator::operator_properties::Reduction; -use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; -use asap_types::ir::properties::CompositionOperator; -use asap_types::ir::scalar::ColumnRef; -use asap_types::ir::schema::DataType; -use asap_types::ir::schema::{ - EntityIdentity, ExactKind, ExactParams, FieldDataType, GroupingStrategy, Schema, - SketchAlgorithm, SketchKind, SketchParams, SketchStatistic, SummaryInputExpr, SummaryUpdate, -}; -use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; -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() - .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), - .. - }) if !is_logical_rewrite(&node) => 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::Epsilon(0.02), - ) - .unwrap(); - let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); - for candidate in &candidates { - if let Replacement::SubDAG(node) = &candidate.replacement { - node.validate_structure().unwrap(); - } - } - 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)]); - let selection = global_selection(&space, &DefaultCostModel); - selection - .assemble_selected_dag(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered") -} - -#[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::ir::properties::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::ir::properties::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(_)))); - } -} - -#[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 - ); - } -} - -#[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 Some(NonASAPOp::Limit { child: sort, .. }) = root.non_asap() else { - panic!("expected Limit for {query}"); - }; - let Some(NonASAPOp::Sort { child, .. }) = sort.non_asap() else { - panic!("expected Sort for {query}"); - }; - assert!( - !child.contains_asap(), - "only the unsupported child should remain exact for {query}" - ); - } -} - -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 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"] { - let root = lower_and_realize(&format!("rate(a[1m]) {op} rate(b[1m])")); - let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = root.non_asap() else { - panic!("expected BinaryOp for {op}, got {:?}", root.operator); - }; - for operand in [lhs, rhs] { - let Some(ASAPOp::FinalizeExactAccumulator { child }) = operand.asap() else { - panic!( - "expected an explicit exact evaluation, got {:?}", - operand.operator - ); - }; - assert!(matches!(child.asap(), Some(ASAPOp::SummaryAgg { .. }))); - } - } -} - -#[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 Some(NonASAPOp::Limit { - n: Some(1), - offset: 0, - child: sort, - .. - }) = root.non_asap() - else { - panic!("expected Limit root, got {:?}", root.operator); - }; - let Some(NonASAPOp::Sort { child: binary, .. }) = sort.non_asap() else { - panic!("expected Sort below Limit, got {:?}", sort.operator); - }; - let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = binary.non_asap() else { - panic!("expected BinaryOp below Sort, got {:?}", binary.operator); - }; - for operand in [lhs, rhs] { - let Some(ASAPOp::FinalizeExactAccumulator { child }) = operand.asap() else { - panic!( - "expected exact accumulator finalization, got {:?}", - operand.operator - ); - }; - assert!(matches!(child.asap(), Some(ASAPOp::SummaryAgg { .. }))); - } -} - -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::Epsilon(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &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::ir::properties::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::Epsilon(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &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::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ) - .unwrap(); - let candidates = ASAPStrategies::default().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::Epsilon(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &InvalidPopulation, - ); - assert!(strategy.replacements(&TargetSubDAG::new(&root)).is_empty()); -} - -// The summary's estimate is projected back to logical service/job score rows. -#[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::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &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 { .. }) - )); - let dag = post_asap_dag(&plan); - for phase in [ - asap_types::ir::properties::ExecutionTiming::IngestionTime, - asap_types::ir::properties::ExecutionTiming::QueryTime, - ] { - let phases = dag.nodes.iter().map(|node| (node.id, phase)).collect(); - let placed = dag.with_execution_phases(&phases).unwrap(); - assert!(placed - .nodes - .iter() - .all(|node| node.output_state.timing == phase)); - } - let guarantee = plan.guarantee.as_ref().unwrap(); - assert!(guarantee.failure_probability.evaluate().unwrap() <= 0.01); - assert!(guarantee.provenance.iter().any(|source| matches!(source, - asap_types::ir::properties::GuaranteeSource::ChildGuarantee { guarantee, .. } - if guarantee.metric == asap_types::ir::properties::ErrorMetric::Frequency))); - } -} - -#[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() { - let root = lower_and_realize("rate(a[1m]) + stddev_over_time(b[1m])"); - assert!(!root.contains_asap()); -} - -#[test] -fn promql_binary_arithmetic_preserves_nested_structure_and_rejects_modifiers() { - 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 { .. }))); - - 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::Epsilon(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 target = AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }; - let query = lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - target.clone(), - ) - .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_logical_optimizer::pass1::replacement::default_strategies_with_evidence(&evidence), - &DefaultAccuracyModel, - ); - let root = &space.roots[0].1; - let selected = global_selection(&space, &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.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:?}"), - }; - assert!( - Rc::ptr_eq(&producer(lhs), &producer(rhs)), - "the two quantile evaluations should share one DDSketch producer" - ); -} - -#[test] -fn planner_only_e2e_temporal_topk_preserves_query_update_and_evaluation_contract() { - // Self-contained Planner E2E: each case starts from PromQL text and ends - // at the post-ASAP summary DAG. No controller/backend types, - // fixtures, configuration, or runtime are involved. - let cases = [ - ( - "topk by (service) (5, count_over_time(requests[1m]))", - SummaryInputExpr::Constant(1.0), - "CmsWithHeap", - vec![ColumnRef::Named("service".into())], - ), - ( - "topk(5, sum_over_time(requests[1m]))", - SummaryInputExpr::Column(ColumnRef::SampleValue), - "CountSketchWithHeap", - vec![], - ), - ]; - for (source, expected_update, expected_family, excluded_labels) in cases { - let pre = lower_promql( - source, - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ) - .expect("lower temporal Top-K"); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &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 Some(ASAPOp::SummaryEstimate { - summary_input, - query: SketchStatistic::TopK { k, .. }, - }) = candidate.asap() - else { - panic!("expected Top-K estimate, got {:?}", candidate.operator) - }; - assert_eq!( - *k, 5, - "the requested Top-K cardinality must survive binding" - ); - let Some(ASAPOp::SummaryAgg { - input: state_input, - family, - child, - .. - }) = summary_input.asap() - else { - panic!("expected structured Top-K state input") - }; - let FieldDataType::Sketch(kind, _) = family else { - panic!("expected a heap-backed sketch family, got {family:?}") - }; - assert_eq!(format!("{:?}", kind.algorithm()), expected_family); - let heap_size = match kind.params() { - SketchParams::CmsWithHeap { heap_size, .. } - | SketchParams::CountSketchWithHeap { heap_size, .. } => *heap_size, - params => panic!("expected heap-bearing Top-K parameters, got {params:?}"), - }; - assert_eq!(heap_size, 100u32.max(*k as u32)); - assert_eq!( - state_input.item.as_ref(), - Some(&SummaryInputExpr::EntityIdentity( - EntityIdentity::PromqlLabelSet { - excluding: excluded_labels - } - )) - ); - assert_eq!(state_input.weight, expected_update); - assert!(!child.contains_asap()); - } -} - -/// Execute the ungrouped temporal TopK subset with exact state. This tests -/// the emitted update contract, not sketch approximation or backend execution. -fn execute_topk_reference(plan: &OperatorNode) -> Vec<(String, f64)> { - use std::collections::BTreeMap; - let Some(ASAPOp::SummaryEstimate { - summary_input, - query: SketchStatistic::TopK { k }, - }) = plan.asap() - else { - panic!("expected TopK evaluation") - }; - let Some(ASAPOp::SummaryAgg { - input, - child, - reduction, - .. - }) = summary_input.asap() - else { - panic!("expected summary updates") - }; - assert_eq!(reduction, &Reduction::by(vec![])); - // The fused raw input is the kept non-ASAP sub-DAG itself. - assert!(!child.contains_asap(), "expected fused raw input"); - let Some(NonASAPOp::TimeRange { range, child, .. }) = child.non_asap() else { - panic!("expected temporal input") - }; - let Some(NonASAPOp::Scan { - source: asap_types::ir::operator::Source::TimeSeries { metric }, - predicates, - .. - }) = child.non_asap() - else { - panic!("expected metric scan") - }; - assert!( - predicates.is_empty(), - "fixture executor does not support filters" - ); - let Some(SummaryInputExpr::EntityIdentity(EntityIdentity::PromqlLabelSet { excluding })) = - &input.item - else { - panic!("expected PromQL item identity") - }; - // api wins by sample count; worker wins by sum. Negative updates must - // subtract, and samples outside (evaluation - range, evaluation] cannot rank. - let samples = [ - ("requests", "api", 10, 1.0), - ("requests", "api", 20, 2.0), - ("requests", "api", 30, 3.0), - ("requests", "api", 60, 4.0), - ("requests", "worker", 10, 150.0), - ("requests", "worker", 20, -50.0), - ("requests", "cron", 10, 10.0), - ("requests", "cron", 20, 10.0), - ("requests", "cron", 30, 10.0), - ("requests", "expired", 0, 10000.0), - ("requests", "future", 61, 10000.0), - ("other", "unrelated", 30, 10000.0), - ]; - let mut totals = BTreeMap::::new(); - for (name, job, timestamp, value) in samples { - if name != metric || timestamp <= 60_i64 - range.as_secs() as i64 || timestamp > 60 { - continue; - } - let labels = [("__name__", name), ("job", job)] - .into_iter() - .filter(|(label, _)| !excluding.contains(&ColumnRef::Named((*label).into()))) - .map(|(label, value)| format!("{label}={value}")) - .collect::>() - .join(","); - let weight = match &input.weight { - SummaryInputExpr::Constant(weight) => *weight, - SummaryInputExpr::Column(ColumnRef::SampleValue) => value, - unsupported => panic!("unsupported fixture update: {unsupported:?}"), - }; - *totals.entry(labels).or_default() += weight; - } - let mut ranked: Vec<_> = totals.into_iter().collect(); - ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0))); - ranked.truncate(*k); - ranked -} - -#[test] -fn planner_heap_topk_reference_execution_matches_ground_truth() { - // Pin numeric results independently of the emitted IR: swapping weights, - // losing identity, changing the window, or dropping k changes the answer. - for (query, expected) in [ - ("topk(1, count_over_time(requests[1m]))", vec![("api", 4.0)]), - ( - "topk(2, count_over_time(requests[1m]))", - vec![("api", 4.0), ("cron", 3.0)], - ), - ( - "topk(1, sum_over_time(requests[1m]))", - vec![("worker", 100.0)], - ), - ( - "topk(2, sum_over_time(requests[1m]))", - vec![("worker", 100.0), ("cron", 30.0)], - ), - ] { - let pre = lower_promql( - query, - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &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(); - 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}") - }; - let expected: Vec<_> = expected - .iter() - .map(|(job, score)| (format!("__name__=requests,job={job}"), *score)) - .collect(); - assert_eq!(execute_topk_reference(&plan), expected, "{query}"); - } - } -} - -/// `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} -/// ``` -/// -/// The nested tree exercises both realizations: the approximate quantile -/// binds a KLL sketch + evaluation; the per-series `rate` binds the exact -/// counter-reset-aware accumulator (no estimate — its state is the value). -#[test] -fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { - let pre_asap = lower_promql( - "quantile(0.99, rate(http_requests_total[5m]))", - AccuracyTarget::Epsilon(0.01), - ) - .expect("lowering failed"); - let root = realize(&pre_asap).expect("binding failed"); - - // Root: the sketch evaluation, back to a plain row shape. - let Some(ASAPOp::SummaryEstimate { - summary_input, - query, - }) = root.asap() - else { - panic!("expected SummaryEstimate root, got {:?}", root.operator); - }; - assert!(matches!(query, SketchStatistic::Quantile { q } if *q == 0.99)); - assert_eq!( - dtype(&root.schema, "quantile_0_99"), - &FieldDataType::Plain(DataType::Float64), - "the summary-state type must not propagate past the estimate" - ); - - // The quantile: KLL committed, k=269 sized for ε=0.01 at 99% confidence. - // is an aggregation operator with no `by(...)`: a genuine full - // reduction, one output row — not to be confused with the inner rate's - // per-entity grouping below, even though both once collapsed to the - // same empty `by: []` (issue #163). - let Some(ASAPOp::SummaryAgg { - child, - family, - input, - reduction, - .. - }) = summary_input.asap() - else { - panic!("expected SummaryAgg, got {:?}", summary_input.operator); - }; - assert_eq!( - family, - &FieldDataType::Sketch( - SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 269 }), - GroupingStrategy::default() - ) - ); - assert_eq!(input, &SummaryUpdate::column(ColumnRef::SampleValue)); - assert_eq!( - reduction, - &Reduction::by(vec![]), - "global quantile — no group keys, full reduction" - ); - assert_eq!( - dtype(&summary_input.schema, "value"), - &FieldDataType::Sketch( - SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 269 }), - GroupingStrategy::default() - ) - ); - - let Some(ASAPOp::FinalizeExactAccumulator { child }) = child.asap() else { - panic!("rate needs a maintenance evaluation"); - }; - - // The rate: exact counter-reset-aware accumulator, per-series (labels - // and time axis preserved), no estimate wrapper. `rate(...)` has no - // grouping concept at all — every entity stays its own summary. - let Some(ASAPOp::SummaryAgg { - child: leaf, - family, - reduction, - .. - }) = child.asap() - else { - panic!( - "expected inner SummaryAgg for rate, got {:?}", - child.operator - ); - }; - assert_eq!( - family, - &FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate) - ); - assert_eq!(reduction, &Reduction::PerEntity); - assert_eq!( - dtype(&child.schema, "value"), - &FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate) - ); - assert_eq!( - child.schema.time_index, - Some(0), - "per-series keeps the time axis" - ); - - // The leaf: unrewritten pass-through — TimeRange marker over the Scan. - // The kept leaf is the non-ASAP sub-DAG itself. - assert!( - !leaf.contains_asap(), - "expected kept leaf, got {:?}", - leaf.operator - ); - let Some(NonASAPOp::TimeRange { - range, child: scan, .. - }) = leaf.non_asap() - else { - panic!("expected TimeRange leaf, got {:?}", leaf.operator); - }; - assert_eq!(range.as_secs(), 300); - assert!(matches!(scan.non_asap(), Some(NonASAPOp::Scan { .. }))); - assert!( - leaf.schema - .fields - .iter() - .all(|f| matches!(f.dtype, FieldDataType::Plain(_))), - "logical edges carry only plain columns" - ); -} - -/// 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)]); - 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!(is_logical_rewrite(rewritten), "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::ir::operator::AggIntent::Count { - accuracy: AccuracyTarget::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 - // sketch must not interpret exact accumulator bytes as input samples. - let pre = lower_promql( - "quantile(0.9, sum_over_time(m[1m]))", - AccuracyTarget::Epsilon(0.05), - ) - .unwrap(); - let space = search_workload(vec![("query", pre)]); - let selected = global_selection(&space, &DefaultCostModel); - let plan = selected - .assemble_selected_dag(&space.roots[0].1) - .unwrap() - .unwrap(); - // Stored timings are gone: time the plan with its outer summary - // maintained and read the timed copy. - let timed_plan = maintained(&plan); - let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = timed_plan.asap() else { - panic!("expected selected quantile summary"); - }; - let Some(ASAPOp::SummaryAgg { child, .. }) = summary_input.asap() else { - panic!("expected maintained outer summary"); - }; - let Some(ASAPOp::FinalizeExactAccumulator { child: source }) = child.asap() else { - panic!( - "missing explicit accumulator finalization: {:?}", - child.operator - ); - }; - assert_eq!( - child.timing, - Some(asap_types::ir::properties::ExecutionTiming::IngestionTime) - ); - assert!(matches!( - source.asap(), - Some(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Sum, _), - .. - }) - )); - assert!(child - .schema - .fields - .iter() - .all(|field| matches!(field.dtype, FieldDataType::Plain(_)))); - assert!(child - .schema - .fields - .iter() - .any(|field| matches!(field.dtype, FieldDataType::Plain(DataType::Float64)))); - // Explicit boundary is a valid post-ASAP DAG. - post_asap_dag(&plan); -} - -#[test] -fn physical_node_owns_phase_independently_of_binary_payload() { - use asap_types::ir::properties::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::Epsilon(0.05)).unwrap(); - let search = search_workload(vec![("q", input)]); - let choice = global_selection(&search, &DefaultCostModel); - let plan = choice - .assemble_selected_dag(&search.roots[0].1) - .unwrap() - .unwrap(); - let dag = maintained_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::Epsilon(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::Epsilon(0.01)))], - &asap_logical_optimizer::default_strategies(), - &DefaultAccuracyModel, - ); - 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| { - matches!( - &candidate.replacement, - Replacement::SubDAG(node) - if matches!(node.non_asap(), Some(NonASAPOp::BinaryOp { .. })) - && node.guarantee.is_none() - ) - }), - "backend must receive the uncertified ratio candidate for its own selection" - ); - - let selection = global_selection(&space, &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_dag(&space.roots[0].1) - .unwrap() - .expect("materialized root"); - assert!(!materialized.contains_asap()); -} - -struct FixtureQuantileDomain { - lower: f64, - upper: f64, -} -impl AccuracyEvidenceProvider for FixtureQuantileDomain { - fn quantile_input_domain(&self, _: &OperatorNode) -> Option { - Some(QuantileInputDomain { - lower: self.lower, - upper: self.upper, - max_samples: 1000, - contract: "enforced nonempty finite fixture window".into(), - }) - } -} - -/// 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::Epsilon(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &evidence, - ); - let replacements = strategy.replacements(&TargetSubDAG::new(&pre)); - assert!( - replacements.is_empty(), - "unsafe domain [{lower}, {upper}] got {replacements:?}" - ); - } -} - -/// A missing proof for one side must not hide an invalid proof for the other. -#[test] -fn ddsketch_ratio_rejects_one_invalid_domain_when_the_other_is_missing() { - struct PartialUnsafeDomain; - impl AccuracyEvidenceProvider for PartialUnsafeDomain { - fn quantile_input_domain(&self, operand: &OperatorNode) -> Option { - let Some(NonASAPOp::Aggregate { measures, .. }) = operand.non_asap() else { - return None; - }; - matches!( - measures.as_slice(), - [asap_types::ir::operator::agg_intent::AggIntent::Quantile { q, .. }] if *q == 0.9 - ) - .then(|| QuantileInputDomain { - lower: -1.0, - upper: 1.0, - max_samples: 1000, - contract: "unsafe numerator".into(), - }) - } - } - - let pre = lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &PartialUnsafeDomain, - ); - assert!(strategy.replacements(&TargetSubDAG::new(&pre)).is_empty()); -} - -/// The committed planner alpha is exercised against the pinned sketch implementation. -#[test] -fn ddsketch_ratio_bound_holds_for_signed_pinned_sketch_evaluations() { - for sign in [-1., 1.] { - let evidence = FixtureQuantileDomain { - lower: if sign < 0. { -100. } else { 1. }, - upper: if sign < 0. { -1. } else { 100. }, - }; - let pre = lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &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().bound.evaluate().unwrap(); - for values in [ - vec![sign; 30], - (1..=100).map(|i| sign * i as f64).collect(), - vec![sign, sign, sign, sign * 30., sign * 100.], - ] { - let mut sorted = values.clone(); - sorted.sort_by(f64::total_cmp); - let exact = |q: f64| { - let r = q * (sorted.len() - 1) as f64; - sorted[r.floor() as usize] * (1. - r.fract()) - + sorted[r.ceil() as usize] * r.fract() - }; - let mut sketch = asap_sketchlib::DdSketch::new(alpha(lhs)); - for v in values { - sketch.try_update(v).unwrap(); - } - let want = exact(0.9) / exact(0.5); - let got = sketch.quantile_interpolated(0.9).unwrap() - / sketch.quantile_interpolated(0.5).unwrap(); - assert!((got - want).abs() / want.abs() <= bound + 1e-12); - } - } -} - -/// 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 quantile_input_domain(&self, _: &OperatorNode) -> Option { - Some(QuantileInputDomain { - 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::Epsilon(0.01), - ) - .unwrap(); - for count in [0, (1u64 << 53) + 1] { - let evidence = PopulationEvidence(count); - let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( - &DefaultAccuracyModel, - &EqualSplitAllocator, - &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] -fn without_aggregation_candidates_export_valid_dags() { - for accuracy in [ - AccuracyTarget::Exact, - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ] { - 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_logical_optimizer::default_strategies(), - &DefaultAccuracyModel, - ); - 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() { - 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 deleted file mode 100644 index b9c7e2df..00000000 --- a/crates/integration-tests/tests/sql_to_post_asap.rs +++ /dev/null @@ -1,855 +0,0 @@ -//! 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. -//! -//! ## 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 -//! 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 { -//! child: Aggregate { .. } }`. Final materialization retains that projection -//! as a query-time non-ASAP node and independently plans its child, keeping -//! both SELECT-list semantics and the summary-bound aggregate visible. - -use std::rc::Rc; - -use asap_frontend_sql::{lower_sql, lower_sql_dialect, SqlCatalog}; -use asap_integration_tests::post_asap::post_asap_dag; -use asap_logical_optimizer::pass1::replacement::{retain_exact, RealizationError}; -use asap_logical_optimizer::{ - search_workload, ASAPStrategies, Replacement, ReplacementStrategy, ReplacementSubDAG, - TargetSubDAG, -}; -use asap_plan_selection::candidate_selection::global_selection; -use asap_plan_selection::DefaultCostModel; -use asap_types::ir::operator::operator_properties::Reduction; -use asap_types::ir::physical_export::{EdgeRole, PhysicalASAPNodeId, PhysicalASAPOperatorPayload}; -use asap_types::ir::scalar::ColumnRef; -use asap_types::ir::schema::{DataType, Field, Schema}; -use asap_types::ir::schema::{ - ExactKind, ExactParams, FieldDataType, GroupingStrategy, SketchAlgorithm, SketchKind, - SketchParams, SketchStatistic, SummaryUpdate, -}; -use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, Predicate, ScalarExpr}; -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() - .replacements(&target_dag) - .into_iter() - .next() - { - Some(ReplacementSubDAG { - replacement: Replacement::SubDAG(node), - .. - }) if node.contains_asap() => Ok(node), - _ => retain_exact(target), - } - .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 -/// of a `FinalizeExactAccumulator`; `None` for anything else. -fn unary_child(node: &OperatorNode) -> Option<&Rc> { - match &node.operator { - Operator::NonASAP(op) => match op.children().as_slice() { - [child] => Some(*child), - _ => None, - }, - Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) => Some(child), - Operator::ASAP(_) => None, - } -} - -/// A sub-DAG kept as plain (non-ASAP) work: no ASAP operator anywhere below. -fn is_kept_non_asap(node: &OperatorNode) -> bool { - node.non_asap().is_some() && !node.contains_asap() -} - -/// Mirror a scalar-only predicate (no operator references) to its wire form. -fn wire_pred(pred: &Predicate) -> Predicate { - Predicate(pred.0.map_operator_refs(&mut |_| -> PhysicalASAPNodeId { - panic!("fixture predicate references no operator") - })) -} - -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) -} - -/// `metrics(ts, service, latency, bytes)` — mirrors -/// `frontend-sql/tests/sql_lowering.rs`'s catalog. -fn catalog() -> SqlCatalog { - SqlCatalog::new().with_table( - "metrics", - Schema::with_time_index( - vec![ - col("ts", DataType::Timestamp), - col("service", DataType::Utf8), - col("latency", DataType::Float64), - col("bytes", DataType::Int64), - ], - 0, - vec![vec![0, 1]], - ), - ) -} - -async fn lower(sql: &str, accuracy: AccuracyTarget) -> Rc { - lower_sql(sql, &catalog(), accuracy) - .await - .unwrap_or_else(|e| panic!("lower failed for {sql:?}: {e}")) -} - -#[tokio::test] -async fn clickhouse_temporal_sql_reuses_rate_and_increase_physical_summaries() { - for (function, expected) in [ - ( - "asap_rate", - FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate), - ), - ( - "asap_increase", - FieldDataType::ExactAggregate(ExactKind::Increase, ExactParams::Increase), - ), - ] { - let sql = format!( - "SELECT service, {function}(latency, ts, 300000) AS v \ - FROM metrics WHERE bytes > 0 GROUP BY service" - ); - let pre_asap = lower_sql_dialect( - &sql, - &catalog(), - SqlDialect::ClickhouseSQL, - AccuracyTarget::Exact, - ) - .await - .expect("explicit temporal SQL must lower"); - let physical = - realize(inner_aggregate(&pre_asap)).expect("temporal reducer must be planned"); - let Operator::ASAP(ASAPOp::SummaryAgg { - family, - reduction, - child, - .. - }) = &physical.operator - else { - panic!("expected a shared SummaryAgg, got {:?}", physical.operator); - }; - assert_eq!(family, &expected); - assert_eq!(reduction, &Reduction::PerEntity); - assert!( - is_kept_non_asap(child), - "expected a retained temporal SQL input, got {:?}", - child.operator - ); - assert!( - matches!(child.non_asap(), Some(NonASAPOp::TimeRange { range, child, .. }) - if *range == std::time::Duration::from_secs(300) - && matches!(child.non_asap(), Some(NonASAPOp::Project { .. }))) - ); - } -} - -#[tokio::test] -async fn clickhouse_outer_sum_recursively_binds_inner_temporal_aggregate() { - for (function, window_ms) in [ - ("asap_rate", 300_000), - ("asap_rate", 3_600_000), - ("asap_increase", 300_000), - ] { - let sql = format!( - "SELECT sum(v) AS value FROM (\ - SELECT service, {function}(latency, ts, {window_ms}) AS v \ - FROM metrics GROUP BY service)" - ); - let pre_asap = lower_sql_dialect( - &sql, - &catalog(), - SqlDialect::ClickhouseSQL, - AccuracyTarget::Exact, - ) - .await - .expect("nested temporal SQL must lower"); - let space = search_workload(vec![("nested", Rc::clone(&pre_asap))]); - let selection = global_selection(&space, &DefaultCostModel); - let root = selection - .assemble_selected_dag(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); - - fn has_temporal_summary(node: &OperatorNode) -> bool { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Rate | ExactKind::Increase, _), - .. - }) => true, - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - has_temporal_summary(summary_input) - } - _ => unary_child(node).is_some_and(|child| has_temporal_summary(child)), - } - } - assert!( - has_temporal_summary(&root), - "inner {function} was hidden: {root:?}" - ); - 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 - ), - } -} - -/// 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( - "SELECT approx_percentile_cont(latency, 0.99) AS p99 FROM metrics", - AccuracyTarget::Epsilon(0.01), - ) - .await; - let Some(NonASAPOp::Project { - cols: expected_cols, - qualifier: expected_qualifier, - .. - }) = pre_asap.non_asap() - 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))]); - let selection = global_selection(&space, &DefaultCostModel); - let root = selection - .assemble_selected_dag(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); - let Some(NonASAPOp::Project { - child, - cols, - qualifier, - }) = root.non_asap() - else { - panic!("expected retained Project root, got {:?}", root.operator); - }; - assert_eq!(cols, expected_cols, "projection expressions and aliases"); - assert_eq!(qualifier, expected_qualifier, "projection qualifier"); - assert_eq!(root.schema.fields[0].name, "p99", "project output schema"); - assert_eq!( - root.schema.fields[0].dtype, - FieldDataType::Plain(DataType::Float64) - ); - assert!( - matches!( - child.operator, - Operator::ASAP(ASAPOp::SummaryEstimate { .. }) - ), - "the Aggregate under Project must be summary-bound" - ); -} - -/// A relational join remains a read-time node while both derived-table -/// aggregates are independently selected as physical summaries. -#[tokio::test] -async fn sql_join_recursively_binds_both_temporal_aggregate_children() { - let pre_asap = lower_sql_dialect( - "SELECT a.service, a.v / b.v AS ratio FROM \ - (SELECT service, asap_rate(latency, ts, 300000) AS v FROM metrics WHERE service='errors' GROUP BY service) a \ - INNER JOIN \ - (SELECT service, asap_rate(latency, ts, 300000) AS v FROM metrics WHERE service='requests' GROUP BY service) b \ - ON b.service=a.service", - &catalog(), - SqlDialect::ClickhouseSQL, - AccuracyTarget::Exact, - ) - .await - .expect("two-subquery rate ratio must lower"); - let space = search_workload(vec![("ratio", Rc::clone(&pre_asap))]); - let selection = global_selection(&space, &DefaultCostModel); - let root = selection - .assemble_selected_dag(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); - let Some(NonASAPOp::Project { - child: join, cols, .. - }) = root.non_asap() - else { - panic!( - "expected Project above relational join, got {:?}", - root.operator - ); - }; - assert!(matches!( - &cols[1].expr, - ScalarExpr::Arithmetic { - op: asap_types::ir::scalar::ArithmeticOpKind::Div, - .. - } - )); - let Some(NonASAPOp::Join { - left, - right, - kind, - pred, - }) = join.non_asap() - else { - panic!( - "expected read-time relational join, got {:?}", - join.operator - ); - }; - assert_eq!(kind, &asap_types::ir::operator::JoinKind::Inner); - assert!(matches!( - &pred.0, - ScalarExpr::Compare { - left, - op: asap_types::ir::scalar::CompareOpKind::Eq, - right, - .. - } if matches!(left.as_ref(), ScalarExpr::Column(0)) - && matches!(right.as_ref(), ScalarExpr::Column(2)) - )); - assert_eq!( - join.schema - .fields - .iter() - .map(|field| field.name.as_str()) - .collect::>(), - vec!["service", "v", "service", "v"] - ); - for child in [left, right] { - let Some(NonASAPOp::Project { - child: aggregate, .. - }) = child.non_asap() - else { - panic!( - "derived table Project was not retained: {:?}", - child.operator - ); - }; - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: aggregate }) = - &aggregate.operator - else { - panic!("derived table Project must consume finalized exact values"); - }; - assert!(matches!( - aggregate.operator, - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate), - .. - }) - )); - } - assert!(join - .guarantee - .as_ref() - .is_some_and(|value| value.is_exact())); - let dag = post_asap_dag(&root); - let join_id = dag - .nodes - .iter() - .find(|node| { - matches!( - node.payload, - PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Join { .. }) - ) - }) - .expect("relational join node") - .id; - let roles = dag - .edges - .iter() - .filter(|edge| edge.consumer == join_id) - .map(|edge| edge.role) - .collect::>(); - 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))]); - let selection = global_selection(&space, &DefaultCostModel); - let root = selection - .assemble_selected_dag(&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( - "SELECT t.service, t.p FROM \ - (SELECT service, approx_percentile_cont(latency, 0.9) AS p \ - FROM metrics GROUP BY service) t \ - WHERE t.p > 100 ORDER BY t.p DESC LIMIT 5", - AccuracyTarget::Epsilon(0.01), - ) - .await; - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]); - let selection = global_selection(&space, &DefaultCostModel); - let root = selection - .assemble_selected_dag(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); - - let mut node = root.as_ref(); - let mut saw_project = false; - let mut saw_filter = false; - let mut saw_sort = false; - let mut saw_limit = false; - loop { - if let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator { - assert!(matches!( - summary_input.operator, - Operator::ASAP(ASAPOp::SummaryAgg { .. }) - )); - break; - } - match node.non_asap() { - Some(NonASAPOp::Project { .. }) => saw_project = true, - Some(NonASAPOp::Filter { .. }) => saw_filter = true, - Some(NonASAPOp::Sort { .. }) => saw_sort = true, - Some(NonASAPOp::Limit { n, offset, .. }) => { - assert_eq!((*n, *offset), (Some(5), 0)); - saw_limit = true; - } - _ => {} - } - node = unary_child(node).unwrap_or_else(|| { - panic!( - "expected relational parents over SummaryEstimate, got {:?}", - node.operator - ) - }); - } - assert!(saw_project && saw_filter && saw_sort && saw_limit); -} - -/// A post-aggregate predicate is read-time work, while a source predicate is -/// part of the rows that populate the maintained summary. Neither predicate -/// may be dropped or moved across the aggregation boundary. -#[tokio::test] -async fn sql_filter_keeps_read_predicate_and_summary_population_selection() { - let pre_asap = lower( - "SELECT t.service, t.p FROM \ - (SELECT service, approx_percentile_cont(latency, 0.9) AS p \ - FROM metrics WHERE service = 'api' GROUP BY service) t \ - WHERE t.p > 100", - AccuracyTarget::Epsilon(0.01), - ) - .await; - let expected_read_predicate = { - let mut node = &pre_asap; - loop { - match node.non_asap() { - Some(NonASAPOp::Filter { pred, .. }) => break pred.clone(), - Some( - NonASAPOp::Project { child, .. } - | NonASAPOp::Sort { child, .. } - | NonASAPOp::Limit { child, .. }, - ) => node = child, - _ => panic!( - "expected a Filter above the aggregate, got {:?}", - node.operator - ), - } - } - }; - let expected_source_predicates = { - let mut node = &pre_asap; - loop { - match node.non_asap() { - Some(NonASAPOp::Scan { predicates, .. }) => break predicates.clone(), - Some( - NonASAPOp::Project { child, .. } - | NonASAPOp::Filter { child, .. } - | NonASAPOp::Aggregate { child, .. } - | NonASAPOp::Sort { child, .. } - | NonASAPOp::Limit { child, .. }, - ) => node = child, - _ => panic!( - "expected a unary SQL plan over Scan, got {:?}", - node.operator - ), - } - } - }; - assert_eq!(expected_source_predicates.len(), 1, "fixture source WHERE"); - - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]); - let selection = global_selection(&space, &DefaultCostModel); - let root = selection - .assemble_selected_dag(&space.roots[0].1) - .expect("materialization failed") - .expect("root must be discovered"); - - let mut node = root.as_ref(); - let mut retained_read_predicate = None; - loop { - if let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator { - let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &summary_input.operator else { - panic!("expected SummaryAgg below SummaryEstimate"); - }; - assert!( - is_kept_non_asap(child), - "expected raw summary population below SummaryAgg" - ); - let Some(NonASAPOp::Scan { predicates, .. }) = child.non_asap() else { - panic!("expected source selection to remain a Scan"); - }; - assert_eq!(predicates, &expected_source_predicates); - break; - } - if let Some(NonASAPOp::Filter { pred, .. }) = node.non_asap() { - retained_read_predicate = Some(pred.clone()); - } - node = unary_child(node).unwrap_or_else(|| { - panic!( - "expected read-time operations over a summary, got {:?}", - node.operator - ) - }); - } - assert_eq!(retained_read_predicate, Some(expected_read_predicate)); - - let dag = post_asap_dag(&root); - let expected_wire = wire_pred(retained_read_predicate.as_ref().unwrap()); - assert!(dag.nodes.iter().any(|node| matches!( - &node.payload, - PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Filter { pred, .. }) if *pred == expected_wire - ))); -} - -/// 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))]); - let selection = global_selection(&space, &DefaultCostModel); - let root = selection - .assemble_selected_dag(&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( - "SELECT approx_percentile_cont(latency, 0.99) FROM metrics", - AccuracyTarget::Epsilon(0.01), - ) - .await; - let agg = inner_aggregate(&pre_asap); - let root = realize(agg).expect("binding failed"); - - let Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) = &root.operator - else { - panic!("expected SummaryEstimate root, got {:?}", root.operator); - }; - assert!(matches!(query, SketchStatistic::Quantile { q } if *q == 0.99)); - assert_eq!( - root.schema.fields.len(), - 1, - "no GROUP BY — a single output column" - ); - assert_eq!( - root.schema.fields[0].dtype, - FieldDataType::Plain(DataType::Float64), - "the summary-state type must not propagate past the estimate" - ); - - 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 }), - GroupingStrategy::default() - ) - ); - assert_eq!( - input, - &SummaryUpdate::column(ColumnRef::Qualified { - table: "metrics".into(), - name: "latency".into(), - }), - "SQL binds the intent's own named input column, not a synthetic sample value" - ); - assert_eq!( - reduction, - &Reduction::by(vec![]), - "global quantile — no GROUP BY, full reduction" - ); - assert_eq!( - summary_input.schema.fields[0].dtype, - FieldDataType::Sketch( - SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 269 }), - GroupingStrategy::default() - ) - ); - - assert!( - is_kept_non_asap(child), - "expected a kept non-ASAP leaf, got {:?}", - child.operator - ); - assert!(matches!(child.non_asap(), Some(NonASAPOp::Scan { .. }))); - assert!( - child - .schema - .fields - .iter() - .all(|f| matches!(f.dtype, FieldDataType::Plain(_))), - "logical edges carry only plain columns" - ); -} - -/// `SELECT COUNT(DISTINCT service) FROM metrics` at ε = 0.01 lowers to -/// `AggIntent::Cardinality` (`sql_lowering.rs::count_distinct_is_cardinality`) -/// — unlike `Quantile`/`Count`/`TopK`, its preferred candidate is HLL, not -/// KLL/CMS (`replacement::summary_candidates`), so this exercises a -/// distinct branch of the sketch-vs-exact decision than the quantile test -/// above. -#[tokio::test] -async fn sql_count_distinct_with_epsilon_binds_hll_rse_over_named_column() { - let pre_asap = lower( - "SELECT COUNT(DISTINCT service) FROM metrics", - AccuracyTarget::Epsilon(0.01), - ) - .await; - let agg = inner_aggregate(&pre_asap); - let root = realize(agg).expect("binding failed"); - - let Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) = &root.operator - else { - panic!("expected SummaryEstimate root, got {:?}", root.operator); - }; - assert!(matches!(query, SketchStatistic::Cardinality)); - assert_eq!( - root.schema.fields[0].dtype, - FieldDataType::Plain(DataType::Int64), - "COUNT(DISTINCT …) reads back out as an integer count" - ); - - let Operator::ASAP(ASAPOp::SummaryAgg { - family, - input, - reduction, - .. - }) = &summary_input.operator - else { - panic!("expected SummaryAgg, got {:?}", summary_input.operator); - }; - assert_eq!( - family, - &FieldDataType::Sketch( - SketchKind::new(SketchAlgorithm::Hll, SketchParams::Hll { precision: 14 }), - GroupingStrategy::default() - ) - ); - assert_eq!( - input, - &SummaryUpdate::column(ColumnRef::Qualified { - table: "metrics".into(), - name: "service".into(), - }) - ); - 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() { - 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)]); - let root = global_selection(&space, &DefaultCostModel) - .assemble_selected_dag(&space.roots[0].1) - .unwrap() - .unwrap(); - 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)); - } -} diff --git a/crates/plan-selection/src/candidate_selection.rs b/crates/plan-selection/src/candidate_selection.rs deleted file mode 100644 index 5a86a690..00000000 --- a/crates/plan-selection/src/candidate_selection.rs +++ /dev/null @@ -1,2860 +0,0 @@ -//! Legacy whole-workload selection over the Stage 1 search space -//! ([`CandidateLogicalASAPDAGs`]): per-target cost ranking, recurrence -//! profiles and global selection. These are free functions over Stage 1 -//! types, so that Stage 1 does not depend on the cost model. Assembling the -//! selected DAG is Stage 1's [`GlobalSelection`]; [`CostedGlobalSelection`] -//! adds the cost comparison behind each chosen exact composition. -//! -//! The stage pipeline does not call this module. It is deleted under #580. - -use std::collections::{HashMap, HashSet, VecDeque}; -use std::rc::Rc; - -use asap_types::ir::schema::{FieldDataType, GroupingStrategy, SketchAlgorithm}; -use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode}; - -use crate::cost::cost_model::{ - raw_recompute_cost_rate, CostModel, CseCandidate, ExactCompositionCostInputs, - ExactCompositionCostRequest, ShareDecision, -}; -use crate::cost::recurrence::{ - CostRate, Horizon, RecurrenceError, RecurrenceProfile, RootRecurrence, UpdateRate, -}; -use asap_logical_optimizer::pass1::exact_composition::OperationPlacement; -use asap_logical_optimizer::pass1::replacement::{ - bindable_intent, cse_candidate_pair, direct_child_counts, is_logical_rewrite, realize_child, - CandidateLogicalASAPDAGs, GlobalSelection, PreparedComposition, Replacement, - ReplacementProvenance, ReplacementSubDAG, TargetSubDAG, TargetSubDAGCandidates, - TargetSubDAGSelection, -}; - -/// Physical feasibility evidence for `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( - candidate: &ReplacementSubDAG, - cost_model: &dyn CostModel, -) -> Option { - match &candidate.replacement { - Replacement::ExactComposition(composition) => { - cost_model.value_operation_support_evidence(&composition.op, composition.placement) - } - // Any summary decision, including one rooted in a relational - // operator above its evaluations, asks the deployment for support. - Replacement::SubDAG(node) if !is_logical_rewrite(node) => { - cost_model.summary_support_evidence(node) - } - Replacement::SubDAG(_) => Some(true), - } -} - -/// 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<'a, Id>( - space: &'a CandidateLogicalASAPDAGs, - cost_model: &dyn CostModel, -) -> Vec> { - space - .order() - .iter() - .map(|ptr| { - let group = &space.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 [`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<'a, Id>( - space: &'a CandidateLogicalASAPDAGs, - cost_model: &dyn CostModel, - profiles: &RecurrenceProfileMap, - horizon: Option, -) -> Result>, RecurrenceError> { - space - .order() - .iter() - .map(|ptr| { - let group = &space.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 -/// [`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) - } -} - -/// 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 -/// `space.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 `space.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() != space.roots.len()`. -pub fn recurrence_profiles( - space: &CandidateLogicalASAPDAGs, - root_recurrence: &[RootRecurrence], - update_rate: Option, -) -> Result { - if root_recurrence.len() != space.roots.len() { - return Err( - crate::cost::recurrence::RecurrenceError::RootCountMismatch { - expected: space.roots.len(), - got: root_recurrence.len(), - }, - ); - } - if let Some(rate) = update_rate { - crate::cost::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::cost::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 space.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) = space.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(space.order().len()); - for ptr in space.order() { - let rate = rates.get(ptr).copied().unwrap_or(0.0); - let evaluation_rate = (rate > 0.0).then_some(crate::cost::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(&space.groups()[ptr].target); - profiles.insert( - *ptr, - ( - node, - RecurrenceProfile { - evaluation_rate, - one_shot_consumers, - update_rate: site_update_rate, - }, - ), - ); - } - - Ok(RecurrenceProfileMap { profiles }) -} - -/// Record `times` occurrences of `recurrence` against `ptr` — `times > 1` -/// when a single parent structurally references `ptr` more than once (see -/// [`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 -/// [`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::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)?; - 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::cost_model::DefaultCostModel::estimate_cost`] already use, -/// wrapped to swallow the (here, uninteresting) error into `None`. -fn realize_one(target: &Rc) -> Option> { - realize_child(target).ok() -} - -/// The `SketchAlgorithm` a bound [`Replacement::SubDAG`] candidate ultimately -/// realizes, if any (`None` for an `ExactAggregate`/pass-through -/// sub-DAG — nothing to rank against another `SketchAlgorithm`). -/// -/// Mirrors this module's own `#[cfg(test)]`-only `summary_family_algorithm` -/// helper (in the test module below), which does the identical -/// `SummaryEstimate`-unwrap-then-match for that module's own tests; that -/// copy is test-only, so this needs its own for real (non-test) ranking -/// code — the same "duplicate a small, self-contained traversal rather than -/// restructure a test helper" call this file's own top doc already makes -/// for `discover_targets`. -pub(crate) fn sketch_kind_of(node: &OperatorNode) -> Option { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - sketch_kind_of(summary_input) - } - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) => Some(kind.algorithm().clone()), - _ => None, - } -} - -/// The grouping strategy used by a bound summary candidate, unwrapping its -/// evaluation node when necessary. -fn summary_grouping(node: &OperatorNode) -> Option<&GroupingStrategy> { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - summary_grouping(summary_input) - } - Operator::ASAP(ASAPOp::SummaryAgg { grouping, .. }) => Some(grouping), - _ => None, - } -} - -// ── global_selection ───────────────────────────────────────────────────── - -/// Why [`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, -} - -/// [`global_selection`]'s result: the [`GlobalSelection`] it committed to, -/// plus the cost comparison behind each exact composition it chose. The -/// selection is a Stage 1 type and carries no cost data, so the comparison -/// is kept here. -#[derive(Debug)] -pub struct CostedGlobalSelection<'a> { - selection: GlobalSelection<'a>, - compositions: HashMap<*const OperatorNode, CompositionDecision<'a>>, -} - -impl<'a> CostedGlobalSelection<'a> { - /// The decision behind `target`'s chosen exact composition, if it chose one. - pub fn composition(&self, target: &Rc) -> Option<&CompositionDecision<'a>> { - self.compositions.get(&Rc::as_ptr(target)) - } -} - -impl<'a> std::ops::Deref for CostedGlobalSelection<'a> { - type Target = GlobalSelection<'a>; - - fn deref(&self) -> &Self::Target { - &self.selection - } -} - -/// 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 [`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 runtime_support_evidence(candidate, cost_model) != Some(true) { - continue; - } - let child_ptr = Rc::as_ptr(&composition.child_target); - let Some(child_group) = groups.get(&child_ptr) else { - continue; - }; - let already_committed = context.committed_child.get(&child_ptr).copied(); - let cost = |summary: &OperatorNode, shared: bool| { - let request = ExactCompositionCostRequest { - target: &group.target, - composition, - summary, - effective_consumer_count: effective, - }; - let mut inputs = cost_model.exact_composition_cost_inputs(&request); - if shared { - // Shared state is counted once: an earlier parent already - // pays this child's maintenance, so the marginal cost here - // is zero — a *known* zero, unlike an unknown input. - if let Some(maintenance) = inputs.summary_maintenance_cost_per_update.as_mut() { - *maintenance = 0.0; - } - } - let rate = inputs.composed_plan_cost_rate(composition.placement)?; - let baseline = raw_recompute_cost_rate(&inputs)?; - (rate < baseline).then_some((rate, baseline, inputs)) - }; - match composition.placement { - OperationPlacement::Read => { - let child_candidates: Vec<&'a ReplacementSubDAG> = match already_committed { - // SAFETY-free: the pointer was taken from `groups`'s own - // candidate storage, which outlives this borrow. - Some(ptr) => child_group - .candidates - .iter() - .filter(|c| std::ptr::eq(*c, ptr)) - .collect(), - None => child_group.candidates.iter().collect(), - }; - for child_candidate in child_candidates { - if !is_automatically_selectable(child_candidate, cost_model) { - continue; - } - let Replacement::SubDAG(summary) = &child_candidate.replacement else { - continue; - }; - if is_logical_rewrite(summary) || !composition.accepts_child(summary) { - continue; - } - let Some(prepared) = plans.iter().find(|p| { - p.target == Rc::as_ptr(&group.target) - && p.operation.same_as(composition) - && Rc::ptr_eq(&p.child, summary) - }) else { - continue; - }; - let Some((rate, baseline, inputs)) = cost(summary, already_committed.is_some()) - else { - continue; - }; - options.push(CompositionOption { - candidate, - decision: CompositionDecision { - plan: Rc::clone(&prepared.plan), - child_target: &composition.child_target, - child_candidate: Some(child_candidate), - cost_rate: rate, - baseline_rate: baseline, - inputs, - }, - }); - } - } - OperationPlacement::Maintenance => { - let Some(prepared) = plans.iter().find(|p| { - p.target == Rc::as_ptr(&group.target) && p.operation.same_as(composition) - }) else { - continue; - }; - // An maintenance-time operation only pays off beneath a - // maintained summary; with nothing above it, its output is - // never read and the raw fallback is the same computation. - let Some(parent) = context.maintaining_parent.get(&Rc::as_ptr(&group.target)) - else { - continue; - }; - let Some((rate, baseline, inputs)) = cost(parent, false) else { - continue; - }; - options.push(CompositionOption { - candidate, - decision: CompositionDecision { - plan: Rc::clone(&prepared.plan), - child_target: &composition.child_target, - child_candidate: None, - cost_rate: rate, - baseline_rate: baseline, - inputs, - }, - }); - } - } - } - options -} - -/// 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 -/// [`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<'a, Id>( - space: &'a CandidateLogicalASAPDAGs, - cost_model: &dyn CostModel, -) -> CostedGlobalSelection<'a> { - global_selection_impl(space, cost_model, None, None) - .expect("structural global selection cannot produce a recurrence error") -} - -/// Recurrence-aware counterpart to [`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<'a, Id>( - space: &'a CandidateLogicalASAPDAGs, - cost_model: &dyn CostModel, - profiles: &RecurrenceProfileMap, - horizon: Option, -) -> Result, RecurrenceError> { - global_selection_impl(space, cost_model, Some(profiles), horizon) -} - -fn global_selection_impl<'a, Id>( - space: &'a CandidateLogicalASAPDAGs, - cost_model: &dyn CostModel, - profiles: Option<&RecurrenceProfileMap>, - horizon: Option, -) -> Result, RecurrenceError> { - let dag = reference_dag(space); - let topo = topological_order(space.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<'a>> = HashMap::new(); - let mut compositions: HashMap<*const OperatorNode, CompositionDecision<'a>> = HashMap::new(); - let mut context = CompositionContext::default(); - - for ptr in &topo { - let group = &space.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, - space.groups(), - effective, - cost_model, - &context, - space.composition_plans(), - ) - .into_iter() - .min_by(|a, b| a.decision.cost_rate.0.total_cmp(&b.decision.cost_rate.0)) - }; - if let Some(option) = &composed { - if let Some(child_candidate) = option.decision.child_candidate { - context.committed_child.insert( - Rc::as_ptr(option.decision.child_target), - child_candidate as *const ReplacementSubDAG, - ); - } - if let Replacement::ExactComposition(composition) = &option.candidate.replacement { - if composition.placement == OperationPlacement::Maintenance { - // A chain of functions feeds the same summary. - if let Some(parent) = context.maintaining_parent.get(ptr).cloned() { - context - .maintaining_parent - .insert(Rc::as_ptr(&composition.child_target), parent); - } - } - } - } - - let complete_plan_choice = (!forced.is_some() - && composed.is_none() - && cost_model.candidate_cost_covers_complete_plan()) - .then(|| { - let effective_target = TargetSubDAG::with_consumer_count(&group.target, effective); - let bound = group - .candidates - .iter() - .filter(|candidate| { - !is_cse_candidate(candidate) - && !is_composition_candidate(candidate) - && is_automatically_selectable(candidate, cost_model) - }) - .filter_map(|candidate| { - cost_model - .candidate_cost(candidate, &effective_target) - .map(|cost| (candidate, cost)) - }) - .min_by(|(_, left), (_, right)| left.0.total_cmp(&right.0)) - .map(|(candidate, _)| candidate); - bound.or_else(|| { - (cost_model.allow_uncosted_legacy_selection() && effective >= 2) - .then(|| { - decide_with_effective_count(group, effective, cost_model).and_then( - |decision| { - let candidate = pick_shared_sub_dag_candidate(group, decision)?; - chosen_share.insert(*ptr, decision); - Some(candidate) - }, - ) - }) - .flatten() - }) - }) - .flatten(); - - let chosen = if let Some(forced) = forced { - Some(forced) - } else if let Some(option) = composed { - composition_decision = Some(option.decision); - Some(option.candidate) - } else if cost_model.candidate_cost_covers_complete_plan() { - complete_plan_choice - } else if effective >= 2 && cse_candidate_pair(group).is_some() { - let decision = if let Some(profiles) = profiles { - decide_group_with_recurrence( - group, - effective, - profiles.for_target(&group.target), - horizon, - cost_model, - )? - } else { - decide_with_effective_count(group, effective, cost_model) - }; - match decision { - Some(decision) => { - let cse = pick_shared_sub_dag_candidate(group, decision); - let effective_target = - TargetSubDAG::with_consumer_count(&group.target, effective); - let logical = group - .candidates - .iter() - .filter(|candidate| { - !is_cse_candidate(candidate) - && !is_composition_candidate(candidate) - && is_automatically_selectable(candidate, cost_model) - }) - .filter_map(|candidate| { - cost_model - .candidate_cost(candidate, &effective_target) - .map(|cost| (candidate, cost)) - }) - .min_by(|(_, a), (_, b)| a.0.total_cmp(&b.0)) - .map(|(candidate, _)| candidate); - let cse = cse.filter(|candidate| { - cost_model - .candidate_cost(candidate, &effective_target) - .is_some() - || cost_model.allow_uncosted_legacy_selection() - }); - match (cse, logical) { - (Some(cse), Some(logical)) - if cost_model - .candidate_cost(cse, &effective_target) - .is_none_or(|cse_cost| { - cost_model - .candidate_cost(logical, &effective_target) - .is_some_and(|logical_cost| logical_cost.0 < cse_cost.0) - }) => - { - Some(logical) - } - (cse, _) => { - if cse.is_some() { - chosen_share.insert(*ptr, decision); - } - cse - } - } - } - // `realize_child` couldn't produce even a logical fallback — - // not expected in practice for a target that's already - // part of a legitimate workload DAG (mirrors - // `cse_preference`'s own doc on this same degrade). - // Falling back to ordinary local ranking is still a - // valid answer, just not a cross-group-aware one; this - // group also contributes no Share collapse to its own - // children (see `multiplier`'s `_ => effective` arm). - None => rank_group(group, cost_model).into_iter().find(|candidate| { - !is_composition_candidate(candidate) - && is_automatically_selectable(candidate, cost_model) - && (cost_model - .candidate_cost( - candidate, - &TargetSubDAG::with_consumer_count(&group.target, effective), - ) - .is_some() - || cost_model.allow_uncosted_legacy_selection()) - }), - } - } else { - let effective_target = TargetSubDAG::with_consumer_count(&group.target, effective); - rank_group(group, cost_model) - .into_iter() - .find(|candidate| { - !is_cse_candidate(candidate) - && !is_composition_candidate(candidate) - && is_automatically_selectable(candidate, cost_model) - && (cost_model - .candidate_cost(candidate, &effective_target) - .is_some() - || cost_model.allow_uncosted_legacy_selection()) - }) - .or_else(|| { - cse_candidate_pair(group) - .map(|(share, _)| share) - .filter(|candidate| { - cost_model - .candidate_cost(candidate, &effective_target) - .is_some() - || cost_model.allow_uncosted_legacy_selection() - }) - }) - }; - - // Record the maintained summary this site's bound candidate - // builds, for a child that may compose an `ValueOperationAtIngestionTime` - // beneath it. - if let (Some(Replacement::SubDAG(node)), Some(NonASAPOp::Aggregate { child, .. })) = - (chosen.map(|c| &c.replacement), group.target.non_asap()) - { - if let Some(summary) = maintained_summary(node) { - context - .maintaining_parent - .insert(Rc::as_ptr(child), Rc::clone(summary)); - } - } - - let outgoing_multiplier = multiplier(*ptr, &effective_uses, &chosen_share); - match chosen { - Some(ReplacementSubDAG { - replacement: Replacement::SubDAG(source), - provenance: ReplacementProvenance::AccuracyReconciliation, - .. - }) => { - // Accuracy reconciliation reads another discovered memo - // group, rather than inlining that group's children. Let - // the source group receive the uses and propagate them - // through its own selected realization when its turn - // arrives in topological order. - *effective_uses.entry(Rc::as_ptr(source)).or_insert(0) += outgoing_multiplier; - } - _ => { - let selected_rewrite = match chosen.map(|candidate| &candidate.replacement) { - Some(Replacement::SubDAG(rewrite)) if is_logical_rewrite(rewrite) => rewrite, - Some(Replacement::SubDAG(_) | Replacement::ExactComposition(_)) | None => { - &group.target - } - }; - for (child, edge_count) in direct_child_counts(selected_rewrite) { - *effective_uses.entry(child).or_insert(0) += edge_count * outgoing_multiplier; - } - } - } - - groups.insert( - *ptr, - TargetSubDAGSelection { - target: &group.target, - consumer_count: group.consumer_count, - effective_consumer_count: effective, - chosen, - }, - ); - if let Some(decision) = composition_decision { - compositions.insert(*ptr, decision); - } - } - - let composition_plans = compositions - .iter() - .map(|(ptr, decision)| (*ptr, Rc::clone(&decision.plan))) - .collect(); - Ok(CostedGlobalSelection { - selection: GlobalSelection::new(space.order().to_vec(), groups, composition_plans), - compositions, - }) -} - -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() - && runtime_support_evidence(candidate, 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 -/// [`global_selection`]'s `effective_consumer_count` differ from -/// [`TargetSubDAGCandidates::consumer_count`] whenever a `RecomputeIndependently` -/// ancestor sits anywhere on the path from a root to a site — see the -/// module docs' "Whole-plan (cross-group) selection" section. -fn multiplier( - parent_ptr: *const OperatorNode, - effective_uses: &HashMap<*const OperatorNode, usize>, - chosen_share: &HashMap<*const OperatorNode, ShareDecision>, -) -> usize { - let effective = *effective_uses.get(&parent_ptr).expect( - "topological_order guarantees a parent is processed (and its effective_consumer_count \ - recorded) before any of its children", - ); - match chosen_share.get(&parent_ptr) { - Some(ShareDecision::Share) => 1, - _ => effective, - } -} - -/// [`CostModel::cse_share_decision`] for `group`, against an explicit -/// `effective_consumer_count` instead of `group.consumer_count` — the -/// cross-group-aware counterpart to [`cse_preference`], which uses the raw -/// structural count. `None` only when [`realize_child`] can't produce even a -/// logical fallback for `group.target` (see that function's own doc). -fn decide_with_effective_count( - group: &TargetSubDAGCandidates, - effective_consumer_count: usize, - cost_model: &dyn CostModel, -) -> Option { - let bound = realize_child(&group.target).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).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 [`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 [`global_selection`]'s recurrence starts from. - external_root_uses: HashMap<*const OperatorNode, usize>, -} - -/// Build an ordering DAG containing every edge that could be selected: -/// the original target's edges plus every rewrite candidate's edges. An -/// accuracy-reconciliation rewrite points at another discovered memo group, -/// so it contributes an edge to that group itself; other rewrites contribute -/// their relational children as before. The -/// DAG is deliberately only used for topological ordering; effective-use -/// counts are propagated through the one candidate actually selected. -fn reference_dag(space: &CandidateLogicalASAPDAGs) -> ReferenceDAG { - let mut dag = ReferenceDAG { - parents_of: HashMap::new(), - children_of: HashMap::new(), - external_root_uses: HashMap::new(), - }; - for (_, root) in &space.roots { - *dag.external_root_uses.entry(Rc::as_ptr(root)).or_insert(0) += 1; - } - for ptr in space.order() { - let group = &space.groups()[ptr]; - record_possible_edges(*ptr, &group.target, &mut dag); - for candidate in &group.candidates { - if let Replacement::SubDAG(rewrite) = &candidate.replacement { - if !is_logical_rewrite(rewrite) { - continue; - } - if candidate.provenance == ReplacementProvenance::AccuracyReconciliation { - add_edge(*ptr, Rc::as_ptr(rewrite), 1, &mut dag); - } else { - record_possible_edges(*ptr, rewrite, &mut dag); - } - } - } - } - dag -} - -/// Record one `parent_ptr -> child` edge (both directions — see -/// [`ReferenceDAG`]'s fields), retaining the greatest multiplicity seen -/// when the target and alternative rewrites expose the same edge. -fn add_edge( - parent_ptr: *const OperatorNode, - child_ptr: *const OperatorNode, - edge_count: usize, - dag: &mut ReferenceDAG, -) { - let siblings = dag.parents_of.entry(child_ptr).or_default(); - match siblings.iter_mut().find(|(p, _)| *p == parent_ptr) { - Some((_, count)) => *count = (*count).max(edge_count), - None => siblings.push((parent_ptr, edge_count)), - } - let kids = dag.children_of.entry(parent_ptr).or_default(); - if !kids.contains(&child_ptr) { - kids.push(child_ptr); - } -} - -fn record_possible_edges( - parent_ptr: *const OperatorNode, - node: &OperatorNode, - dag: &mut ReferenceDAG, -) { - for (child_ptr, edge_count) in direct_child_counts(node) { - add_edge(parent_ptr, child_ptr, edge_count, dag); - } -} - -/// A topological order over `order` (parent before every child) via Kahn's -/// algorithm on `dag`'s reverse adjacency — needed because -/// `discover_targets`'s own `order` is only a valid *discovery* order -/// (first-seen-first), not a valid topological one: a node reached via two -/// different root paths can have a parent that's discovered *after* it (see -/// this function's own test for a worked diamond example), which is exactly -/// backwards for [`global_selection`]'s recurrence. -fn topological_order( - order: &[*const OperatorNode], - dag: &ReferenceDAG, -) -> Vec<*const OperatorNode> { - let mut in_degree: HashMap<*const OperatorNode, usize> = HashMap::new(); - for ptr in order { - let degree = dag.parents_of.get(ptr).map(Vec::len).unwrap_or(0); - in_degree.insert(*ptr, degree); - } - - let mut queue: VecDeque<*const OperatorNode> = order - .iter() - .copied() - .filter(|ptr| in_degree[ptr] == 0) - .collect(); - - let mut topo = Vec::with_capacity(order.len()); - while let Some(ptr) = queue.pop_front() { - topo.push(ptr); - if let Some(children) = dag.children_of.get(&ptr) { - for child in children { - if let Some(degree) = in_degree.get_mut(child) { - *degree -= 1; - if *degree == 0 { - queue.push_back(*child); - } - } - } - } - } - - assert_eq!( - topo.len(), - order.len(), - "topological_order: the discovered-site reference dag has a cycle — every \ - OperatorNode is built from Rc children, which can't form one, so this indicates a bug \ - in reference_dag rather than a real cyclic workload", - ); - topo -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cost::cost_model::{Cost, DefaultCostModel}; - use crate::test_support::{agg, lower_promql, metric_scan}; - use asap_logical_optimizer::accuracy::{ - AccuracyModel, DefaultAccuracyModel, EqualSplitAllocator, PropagationStats, - }; - use asap_logical_optimizer::pass1::replacement::{ - default_strategies, search_workload, search_workload_with, search_workload_with_targets, - ASAPStrategies, ReplacementStrategy, - }; - use asap_logical_optimizer::pass2::reconciliation::AccuracyReconciliationStrategy; - use asap_types::ir::operator::agg_intent::{default_cardinality, default_quantile, AggIntent}; - use asap_types::ir::operator::operator_properties::Reduction; - use asap_types::ir::properties::{ - AccuracyError, CompositionOperator, ErrorMetric, ResultGuarantee, - }; - use asap_types::ir::schema::ColumnId; - use asap_types::ir::schema::SketchStatistic; - use asap_types::ir::{Predicate, ScalarExpr}; - use asap_types::types::AccuracyTarget; - - fn equi_pred(left: ColumnId, right: ColumnId) -> Predicate { - Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(left)), - op: asap_types::ir::scalar::CompareOpKind::Eq, - right: Box::new(ScalarExpr::Column(right)), - semantics: asap_types::ir::ExprSemantics::Sql, - }) - } - - fn quantile_eps_intent(q: f64, e: f64) -> AggIntent { - AggIntent::Quantile { - col: None, - q, - accuracy: AccuracyTarget::Epsilon(e), - } - } - - #[test] - fn relational_join_is_exact_only_when_both_inputs_are_exact() { - // `relational_join_guarantee` folded into assembly's generic - // "keep the operator, assemble its children" branch: an assembled - // inner equi-`Join` is exact exactly when both assembled inputs are. - let join = |left_intent: AggIntent, right_intent: AggIntent| { - let left = agg(vec![2], left_intent, metric_scan(&["job"])); - let right = agg( - vec![2], - right_intent, - crate::test_support::scan("n", metric_scan(&["job"]).schema.clone()), - ); - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { - kind: asap_types::ir::operator::operator_properties::JoinKind::Inner, - pred: equi_pred(0, 2), - left, - right, - })) - .unwrap() - }; - let is_exact = |node: &OperatorNode| { - node.guarantee - .as_ref() - .is_some_and(ResultGuarantee::is_exact) - }; - for (root, both_exact_expected) in [ - ( - join(AggIntent::Sum { col: None }, AggIntent::Sum { col: None }), - true, - ), - ( - join(AggIntent::Sum { col: None }, quantile_eps_intent(0.5, 0.05)), - false, - ), - ] { - let space = search_workload(vec![(0usize, Rc::clone(&root))]); - let assembled = global_selection(&space, &DefaultCostModel) - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .unwrap(); - let Some(NonASAPOp::Join { left, right, .. }) = assembled.non_asap() else { - panic!("the join is kept and its inputs assembled: {assembled:?}"); - }; - assert_eq!( - is_exact(&assembled), - is_exact(left) && is_exact(right), - "join guarantee must be exact iff both inputs are exact" - ); - if both_exact_expected { - assert!(is_exact(&assembled), "exact inputs give an exact join"); - } - } - } - - // ── cost-based ranking ─────────────────────────────────────────────── - - #[test] - fn cost_sorted_orders_shared_sub_dag_candidates_by_cse_share_decision() { - // Many consumers of a cheap-to-recompute, cheap-to-maintain exact - // accumulator: cse_share_decision should prefer Share (see - // cost_model.rs's own `cse_share_decision_shares_when_recompute_dominates_maintenance`). - let mut roots = Vec::new(); - let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - for i in 0..20 { - roots.push((i, Rc::new((*shared).clone()))); - } - let space = search_workload(roots); - let group = space.candidates_for_target(&space.roots[0].1).unwrap(); - assert_eq!(group.consumer_count, 20); - - let ranked = cost_sorted(&space, &DefaultCostModel); - let ranked_group = ranked - .iter() - .find(|g| Rc::ptr_eq(g.target, &space.roots[0].1)) - .unwrap(); - assert!(matches!( - &ranked_group.candidates[0].replacement, - Replacement::SubDAG(rc) if Rc::ptr_eq(rc, &group.target) - )); - let rewrites: Vec<&ReplacementSubDAG> = ranked_group - .candidates - .iter() - .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if !n.contains_asap())) - .copied() - .collect(); - assert_eq!(rewrites.len(), 2); - let first_shares_target = match &rewrites[0].replacement { - Replacement::SubDAG(rc) => Rc::ptr_eq(rc, &group.target), - Replacement::ExactComposition(_) => false, - }; - assert!( - first_shares_target, - "with 20 cheap consumers, Share should rank first: {rewrites:?}" - ); - } - - #[test] - fn cost_sorted_orders_sketch_candidates_by_rank_candidates() { - struct PreferDDSketch; - impl CostModel for PreferDDSketch { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { - let dd = v.remove(pos); - v.insert(0, dd); - } - v - } - } - - let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); - let ranked = cost_sorted(&space, &PreferDDSketch); - let agg_group = ranked - .iter() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .unwrap(); - assert_eq!(agg_group.candidates.len(), 2); - let first_kind = match &agg_group.candidates[0].replacement { - Replacement::SubDAG(node) => sketch_kind_of(node), - Replacement::ExactComposition(_) => None, - }; - assert_eq!(first_kind, Some(SketchAlgorithm::DDSketch)); - } - - #[test] - fn grouping_cost_cannot_resurrect_unprovable_hydra_candidates() { - struct EstimatedSubpopulations(usize); - - impl CostModel for EstimatedSubpopulations { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn estimated_subpopulation_count(&self, _target: &OperatorNode) -> Option { - Some(self.0) - } - } - - fn first_grouping(estimated_count: usize) -> GroupingStrategy { - let model = EstimatedSubpopulations(estimated_count); - let intent = AggIntent::Count { - accuracy: AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - }; - let root = agg(vec![2, 3], intent, metric_scan(&["tenant_id", "endpoint"])); - let space = search_workload(vec![("tenant_endpoint_count", root)]); - let ranked = cost_sorted(&space, &model); - let aggregate = ranked - .iter() - .find(|group| matches!(group.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .expect("aggregate group"); - let Replacement::SubDAG(node) = &aggregate.candidates[0].replacement else { - panic!("grouping candidate must be a summary") - }; - summary_grouping(node) - .expect("bound summary grouping") - .clone() - } - - assert_eq!( - first_grouping(10_000), - GroupingStrategy::PerSubpopulationInstance - ); - assert_eq!( - first_grouping(10), - GroupingStrategy::PerSubpopulationInstance - ); - } - - /// [`RankedTargetSubDAGCandidates::costs`] is a per-candidate annotation, aligned - /// index-for-index with `candidates` — each entry must equal what - /// calling [`CostModel::estimate_cost`] directly on that same candidate - /// and target produces, not some other (or stale) number. - #[test] - fn cost_sorted_pairs_each_candidate_with_its_own_estimate_cost() { - let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); - let ranked = cost_sorted(&space, &DefaultCostModel); - let agg_group = ranked - .iter() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .unwrap(); - assert_eq!( - agg_group.costs.len(), - agg_group.candidates.len(), - "costs must be aligned 1:1 with candidates" - ); - assert!(!agg_group.costs.is_empty()); - - let target = TargetSubDAG::with_consumer_count(agg_group.target, agg_group.consumer_count); - for (candidate, &cost) in agg_group.candidates.iter().zip(&agg_group.costs) { - assert_eq!( - cost, - DefaultCostModel.estimate_cost(candidate, &target), - "RankedTargetSubDAGCandidates::costs must match calling CostModel::estimate_cost directly \ - for the same candidate/target" - ); - } - } - - // ── global_selection (issue #271) ─────────────────────────────────── - - /// A `CostModel` with a constant, `sub-DAG`-independent recompute cost - /// and shared-maintenance cost, chosen (40 recompute-per-use, 100 - /// maintenance) so that a `SharedSubDAGStrategy` group's - /// `cse_share_decision` flips exactly between a consumer count of 2 - /// (recompute total 80, below maintenance: `RecomputeIndependently`) - /// and a consumer count of 3 (recompute total 120, above - /// maintenance: `Share`) — the precise threshold - /// `effective_consumer_count_corrects_a_nested_groups_share_decision` - /// needs to cross. - struct ConstantCseCost; - impl CostModel for ConstantCseCost { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn cse_recompute_cost(&self, _candidate: &CseCandidate) -> Cost { - Cost(40.0) - } - fn cse_shared_maintenance_cost(&self, _candidate: &CseCandidate) -> Cost { - Cost(100.0) - } - } - - /// A costed logical choice must not panic when an explicitly allowed CSE - /// choice has no numeric cost. - #[test] - fn costed_logical_candidate_beats_uncosted_legacy_cse_choice() { - struct MixedCost; - impl CostModel for MixedCost { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - _target: &TargetSubDAG<'_>, - ) -> Option { - (!is_cse_candidate(candidate)).then_some(Cost(1.0)) - } - } - - let aggregate = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("left", Rc::clone(&aggregate)), ("right", aggregate)]); - let root = &space.roots[0].1; - assert!(cse_candidate_pair(space.candidates_for_target(root).unwrap()).is_some()); - let selected = global_selection(&space, &MixedCost); - let chosen = selected.for_target(root).unwrap().chosen.unwrap(); - assert!(!is_cse_candidate(chosen)); - } - - #[test] - fn global_selection_matches_cost_sorted_for_a_non_interacting_workload() { - // No nested sharing at all — global_selection's effective_consumer_count - // must equal the group's own raw consumer_count, and its `chosen` - // candidate must be cost_sorted's top pick, for both the sketch - // group and its child Scan. - let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); - - let ranked = cost_sorted(&space, &DefaultCostModel); - let selected = global_selection(&space, &DefaultCostModel); - assert_eq!(ranked.len(), selected.target_selections().count()); - - for ranked_group in &ranked { - let selected_group = selected.for_target(ranked_group.target).unwrap(); - assert_eq!( - selected_group.effective_consumer_count, ranked_group.consumer_count, - "no ancestor is ever RecomputeIndependently here, so effective must equal raw" - ); - assert_eq!( - selected_group.chosen.map(|c| &c.rationale), - ranked_group.candidates.first().map(|c| &c.rationale), - "with no cross-group interaction, global_selection's pick must match \ - cost_sorted's top-ranked candidate" - ); - } - } - - #[test] - fn global_selection_leaves_an_unmatched_group_as_none() { - // A bare Scan: no registered strategy has an opinion on it, so it - // gets a group with an empty candidate list (see TargetSubDAGCandidates's own - // doc) — global_selection must not invent a candidate for it. - let root = metric_scan(&["job"]); - let space = search_workload(vec![("q", root)]); - let selected = global_selection(&space, &DefaultCostModel); - let scan_group = selected - .target_selections() - .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Scan { .. }))) - .unwrap(); - assert!(scan_group.chosen.is_none()); - assert_eq!(scan_group.effective_consumer_count, 1); - } - - #[test] - fn global_selection_falls_back_to_local_ranking_for_sketch_family_groups() { - // ASAPStrategies groups have no cross-group-aware cost hook - // (rank_candidates takes no consumer_count) — global_selection must - // still return cost_sorted's own top pick for them (documented in - // the module docs' "Whole-plan (cross-group) selection" section), - // not silently drop the candidate or fall back to discovery order. - struct PreferDDSketch; - impl CostModel for PreferDDSketch { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let mut v = candidates.to_vec(); - if let Some(pos) = v.iter().position(|k| *k == SketchAlgorithm::DDSketch) { - let dd = v.remove(pos); - v.insert(0, dd); - } - v - } - } - - let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); - let selected = global_selection(&space, &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!(global_selection(&space, &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 group = TargetSubDAGCandidates { - target: Rc::clone(&target), - consumer_count: 2, - rejected: Vec::new(), - candidates: vec![ - ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG(Rc::clone(&target)), - provenance: ReplacementProvenance::CseShare, - rationale: "share".into(), - }, - ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG(Rc::new(target.as_ref().clone())), - provenance: ReplacementProvenance::CseRecompute, - rationale: "recompute".into(), - }, - ReplacementSubDAG { - strategy: "TestStrategy", - replacement: Replacement::SubDAG( - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( - NonASAPOp::PromqlVectorFromScalar(ScalarExpr::EvalTimestamp), - )) - .unwrap(), - ), - provenance: ReplacementProvenance::LogicalRewrite, - rationale: "different rewrite strategy".into(), - }, - ], - }; - - assert!(cse_candidate_pair(&group).is_some()); - let ranked = rank_group(&group, &ConstantCseCost); - assert_eq!( - ranked - .iter() - .map(|c| c.rationale.as_str()) - .collect::>(), - vec!["recompute", "different rewrite strategy", "share"], - "the preferred CSE choice must be ranked without losing the unrelated rewrite" - ); - let chosen = pick_shared_sub_dag_candidate( - &group, - decide_with_effective_count(&group, 2, &ConstantCseCost).unwrap(), - ) - .unwrap(); - assert_eq!(chosen.provenance, ReplacementProvenance::CseRecompute); - } - - #[test] - fn effective_consumer_count_corrects_a_nested_groups_share_decision() { - // The interaction issue #271 describes: an outer shared sub-DAG `a` - // (referenced by 2 roots, so consumer_count == 2) wraps an inner - // shared sub-DAG `c` (referenced once through `a`'s own child edge, - // plus once more directly by a third, separate root — so `c`'s own - // *raw* structural consumer_count is also 2, independent of `a`). - // - // root1 ─┐ - // ├─▶ a = Filter(child = c) ─▶ c = Dedup(job) - // root2 ─┘ - // root3 ───────────────────────────▶ c (same shared Rc) - // - // `a` and `c` are both non-`Aggregate` nodes (`Filter`/`Dedup`) so - // neither is bindable — each group is a *clean* two-candidate - // SharedSubDAGStrategy share-vs-recompute pair, with no - // ASAPStrategies `Summary` candidate mixed in to complicate - // ranking (see `shared_aggregate_across_two_roots_gets_both_strategies_candidates` - // for what a *mixed*-shape group looks like — deliberately avoided - // here to isolate the SharedSubDAGStrategy-only interaction). - // - // Under ConstantCseCost, consumer_count == 2 loses to maintenance - // (2 * 40 = 80 < 100 ⇒ RecomputeIndependently); consumer_count == 3 wins - // (3 * 40 = 120 > 100 ⇒ Share). `cost_sorted` only ever sees `c`'s raw - // count (2) and picks RecomputeIndependently for it — the WRONG - // answer once `a` itself is accounted for: `a`'s own decision is - // also RecomputeIndependently (same 80-vs-100 threshold), so `a` - // actually runs twice, and each run recomputes `c` once more — - // `c`'s *true* effective count is 2 (via `a`) + 1 (via root3) = 3, - // which flips its own decision to Share. Only global_selection, - // which folds `a`'s decision into `c`'s effective_consumer_count - // before deciding `c`, gets this right. - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; - - let c = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["job"]), - })) - .unwrap() - }; - let a = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), - child: c(), - })) - .unwrap() - }; - - let space = search_workload(vec![("root1", a()), ("root2", a()), ("root3", c())]); - - // Fixture sanity: root1/root2 merged onto one shared `a`, and `c` - // (root1/root2's shared child, and root3 itself) merged onto one - // shared `c` with raw consumer_count 2, and both groups are clean - // (non-mixed) two-candidate SharedSubDAGStrategy pairs. - assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); - let a_rc = &space.roots[0].1; - let Some(NonASAPOp::Filter { child: c_via_a, .. }) = a_rc.non_asap() else { - panic!("expected root1/root2 to still be a Filter"); - }; - assert!(Rc::ptr_eq(c_via_a, &space.roots[2].1)); - let a_group = space.candidates_for_target(a_rc).unwrap(); - let c_group = space.candidates_for_target(c_via_a).unwrap(); - assert_eq!( - a_group.consumer_count, 2, - "fixture sanity: a has 2 consumers" - ); - assert_eq!( - c_group.consumer_count, 2, - "fixture sanity: c has 2 raw consumers (via a's child edge, and via root3)" - ); - assert_eq!( - a_group.candidates.len(), - 2, - "fixture sanity: a is a clean Rewrite pair" - ); - assert_eq!( - c_group.candidates.len(), - 2, - "fixture sanity: c is a clean Rewrite pair" - ); - - // The naive/local answer: cost_sorted ranks c using its raw count - // (2) alone and prefers RecomputeIndependently. - let ranked = cost_sorted(&space, &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 = global_selection(&space, &ConstantCseCost); - let a_selected = selected.for_target(a_rc).unwrap(); - let c_selected = selected.for_target(c_via_a).unwrap(); - - assert_eq!( - a_selected.effective_consumer_count, 2, - "a has no interacting ancestor" - ); - let a_shares = matches!( - &a_selected.chosen.unwrap().replacement, - Replacement::SubDAG(rc) if Rc::ptr_eq(rc, a_rc) - ); - assert!( - !a_shares, - "fixture sanity: a itself must also choose RecomputeIndependently" - ); - - assert_eq!( - c_selected.effective_consumer_count, 3, - "c's effective count must be 2 (a, itself recomputed twice) + 1 (root3)" - ); - let c_shares = matches!( - &c_selected.chosen.unwrap().replacement, - Replacement::SubDAG(rc) if Rc::ptr_eq(rc, c_via_a) - ); - assert!( - c_shares, - "global_selection must flip c to Share once a's own recomputation is accounted for" - ); - } - - #[test] - fn complete_plan_costs_reject_unbound_cse_arms() { - struct CompletePlanCost; - impl CostModel for CompletePlanCost { - fn candidate_cost_covers_complete_plan(&self) -> bool { - true - } - - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - _target: &TargetSubDAG<'_>, - ) -> Option { - assert!(!is_cse_candidate(candidate)); - None - } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn cse_share_decision(&self, _candidate: &CseCandidate) -> ShareDecision { - ShareDecision::RecomputeIndependently - } - } - - let shared = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["job"]), - })) - .unwrap(); - let space = search_workload(vec![ - ("left", Rc::clone(&shared)), - ("right", Rc::clone(&shared)), - ]); - let planned = &space.roots[0].1; - - let selected = global_selection(&space, &CompletePlanCost); - assert!(selected.for_target(planned).unwrap().chosen.is_none()); - } - - #[test] - fn effective_repetition_materializes_a_cse_choice_for_a_single_edge_child() { - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; - - let c = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["job"]), - })) - .unwrap() - }; - let a = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), - child: c(), - })) - .unwrap() - }; - let space = search_workload(vec![("root1", a()), ("root2", a())]); - let a_rc = &space.roots[0].1; - let Some(NonASAPOp::Filter { child: c_rc, .. }) = a_rc.non_asap() else { - panic!("expected Filter root"); - }; - - assert_eq!(space.candidates_for_target(c_rc).unwrap().consumer_count, 1); - assert!(cse_candidate_pair(space.candidates_for_target(c_rc).unwrap()).is_some()); - - let selected = global_selection(&space, &ConstantCseCost); - let child = selected.for_target(c_rc).unwrap(); - assert_eq!(child.effective_consumer_count, 2); - assert!(child.chosen.is_some()); - } - - #[test] - fn shared_ancestor_keeps_a_single_use_cse_descendant_selected() { - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; - - struct AlwaysShare; - impl CostModel for AlwaysShare { - fn allow_uncosted_legacy_selection(&self) -> bool { - true - } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn cse_share_decision(&self, _candidate: &CseCandidate) -> ShareDecision { - ShareDecision::Share - } - } - - let child = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["job"]), - })) - .unwrap() - }; - let parent = || { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), - child: child(), - })) - .unwrap() - }; - let space = search_workload(vec![("root1", parent()), ("root2", parent())]); - let parent_rc = &space.roots[0].1; - let Some(NonASAPOp::Filter { - child: child_rc, .. - }) = parent_rc.non_asap() - else { - panic!("expected Filter root"); - }; - - let selected = global_selection(&space, &AlwaysShare); - assert_eq!( - selected - .for_target(parent_rc) - .unwrap() - .effective_consumer_count, - 2 - ); - let child_selection = selected.for_target(child_rc).unwrap(); - assert_eq!(child_selection.effective_consumer_count, 1); - assert_eq!( - child_selection.chosen.map(|candidate| candidate.provenance), - Some(ReplacementProvenance::CseShare), - "a descendant collapsed to one execution still needs a selected plan" - ); - } - - #[test] - fn global_selection_propagates_uses_through_the_selected_rewrite() { - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; - - struct ReplaceFilterChild; - impl ReplacementStrategy for ReplaceFilterChild { - fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - matches!(target.root.non_asap(), Some(NonASAPOp::Filter { .. })) - } - - fn replacements(&self, _target: &TargetSubDAG<'_>) -> Vec { - vec![ReplacementSubDAG { - strategy: "ReplaceFilterChild", - replacement: Replacement::SubDAG( - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( - NonASAPOp::Dedup { - cols: vec![0], - child: metric_scan(&["replacement"]), - }, - )) - .unwrap(), - ), - provenance: ReplacementProvenance::LogicalRewrite, - rationale: "replace the Filter and its input".into(), - }] - } - } - - let original_child = metric_scan(&["original"]); - let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), - child: Rc::clone(&original_child), - })) - .unwrap(); - let strategies: Vec> = vec![Box::new(ReplaceFilterChild)]; - let space = search_workload_with(vec![("q", root)], &strategies); - let root = &space.roots[0].1; - let selected = global_selection(&space, &DefaultCostModel); - let Replacement::SubDAG(rewrite) = &selected - .for_target(root) - .unwrap() - .chosen - .unwrap() - .replacement - else { - panic!("expected logical rewrite"); - }; - let Some(NonASAPOp::Dedup { - child: replacement_child, - .. - }) = rewrite.non_asap() - else { - panic!("expected Dedup rewrite"); - }; - let Some(NonASAPOp::Filter { - child: original_child, - .. - }) = root.non_asap() - else { - panic!("expected Filter root"); - }; - - assert_eq!( - selected - .for_target(original_child) - .unwrap() - .effective_consumer_count, - 0 - ); - assert_eq!( - selected - .for_target(replacement_child) - .unwrap() - .effective_consumer_count, - 1 - ); - } - - // A cheap but physically infeasible candidate must not be selected. - #[test] - fn explicit_summary_infeasibility_prevents_selection() { - struct Unsupported; - impl CostModel for Unsupported { - fn rank_candidates( - &self, - _: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn candidate_cost(&self, _: &ReplacementSubDAG, _: &TargetSubDAG<'_>) -> Option { - Some(Cost(1.0)) - } - fn summary_support_evidence(&self, _: &OperatorNode) -> Option { - Some(false) - } - } - let root = lower_promql("sum_over_time(a[1m])", AccuracyTarget::Exact); - let space = search_workload(vec![("q", root)]); - let selected = global_selection(&space, &Unsupported); - assert!(selected - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_none()); - } - - // Composable temporal/grouped Sum must be executable as one producer. - #[test] - fn grouped_temporal_sum_has_one_summary_producer_candidate() { - let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); - let candidates = ASAPStrategies::default().replacements(&TargetSubDAG::new(&root)); - assert!(candidates - .iter() - .any(|candidate| matches!(&candidate.replacement, - Replacement::SubDAG(node) if matches!(&node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) - if !child.contains_asap())))); - struct PreferComposed; - impl CostModel for PreferComposed { - fn rank_candidates( - &self, - _: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - _: &TargetSubDAG<'_>, - ) -> Option { - Some(Cost( - if matches!(&candidate.replacement, - Replacement::SubDAG(node) if matches!(&node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) - if !child.contains_asap())) - { - 1.0 - } else { - 100.0 - }, - )) - } - } - let space = search_workload(vec![("q", root.clone())]); - let selected = global_selection(&space, &PreferComposed); - let node = selected - .assemble_selected_dag(&space.roots[0].1) - .unwrap() - .unwrap(); - assert!(matches!(&node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) - if !child.contains_asap())); - } - - // Mixed candidate ranking must honor explicit costs, not legacy estimates. - #[test] - fn mixed_candidate_ranking_uses_explicit_candidate_costs() { - struct ExplicitCosts; - impl CostModel for ExplicitCosts { - fn rank_candidates( - &self, - _: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - _: &TargetSubDAG<'_>, - ) -> Option { - Some(Cost( - if candidate.provenance == ReplacementProvenance::LogicalRewrite { - 1.0 - } else { - 100.0 - }, - )) - } - } - let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); - let space = search_workload(vec![("q", root)]); - let selection = global_selection(&space, &ExplicitCosts); - let selected = selection - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .unwrap(); - assert_eq!(selected.provenance, ReplacementProvenance::LogicalRewrite); - } - - #[test] - fn global_selection_compares_a_logical_rewrite_with_the_cse_choice() { - struct PreferLogicalRewrite; - - impl CostModel for PreferLogicalRewrite { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn estimate_cost( - &self, - candidate: &ReplacementSubDAG, - _target: &TargetSubDAG<'_>, - ) -> f64 { - match candidate.provenance { - ReplacementProvenance::LogicalRewrite => 0.0, - _ => 100.0, - } - } - } - - let a = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); - let b = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); - let space = search_workload(vec![("a", a), ("b", b)]); - let root = &space.roots[0].1; - let selected = global_selection(&space, &PreferLogicalRewrite); - - assert_eq!( - selected - .for_target(root) - .and_then(|group| group.chosen) - .map(|candidate| candidate.provenance), - Some(ReplacementProvenance::LogicalRewrite) - ); - } - - #[test] - fn topological_order_puts_a_later_discovered_parent_before_its_child() { - // Mirrors nested_shared_sub-DAG_below_an_unshared_parent_is_still_discovered's - // diamond fixture: discover_targets's own `order` visits root_b (a - // parent of `shared`) *after* `shared` itself, because `shared` was - // already fully walked via root_a first. A naive "process - // discover_targets's own order" DP would see root_b's child edge - // after already processing `shared` — topological_order must not - // make that mistake. - use asap_types::ir::scalar::ScalarValue; - use asap_types::ir::Predicate; - - let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let root_a = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(1))), - child: shared.clone(), - })) - .unwrap(); - let root_b = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(2))), - child: shared, - })) - .unwrap(); - let roots = vec![("a", root_a), ("b", root_b)]; - - let space = search_workload(roots); - let order = space.order(); - 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" - ); - } - - // ── Selection over Stage 1 candidates (split from `replacement` tests) ─ - - // Every exposed query result has a evaluation; internal accumulator frontiers stay states. - #[test] - fn selected_query_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)]); - let selected = global_selection(&space, &DefaultCostModel) - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .unwrap(); - assert!( - selected - .schema - .fields - .iter() - .all(|field| matches!(field.dtype, FieldDataType::Plain(_))), - "{query}: query root leaks state: {:?}", - selected.schema - ); - } - } - - /// Candidates kept with missing accuracy evidence are never chosen automatically. - #[test] - fn selection_never_chooses_a_candidate_missing_accuracy_evidence() { - let never_unproven = |space: &CandidateLogicalASAPDAGs<&str>| { - let selected = global_selection(space, &DefaultCostModel); - assert!(!selected - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_some_and(ReplacementSubDAG::has_missing_accuracy_evidence)); - assert!(selected - .assemble_selected_dag(&space.roots[0].1) - .unwrap() - .is_some()); - }; - let count = AggIntent::Count { - accuracy: AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - }; - never_unproven(&search_workload(vec![( - "q", - agg(vec![2], count, metric_scan(&["job"])), - )])); - never_unproven(&search_workload_with_targets( - vec![( - "q", - agg(vec![2], default_cardinality(), metric_scan(&["job"])), - Some(AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }), - )], - &default_strategies(), - &DefaultAccuracyModel, - )); - let inner = agg( - vec![2], - AggIntent::Count { - accuracy: AccuracyTarget::Epsilon(0.01), - }, - metric_scan(&["job"]), - ); - let topk = agg( - vec![], - AggIntent::TopK { - k: 10, - accuracy: AccuracyTarget::Epsilon(0.01), - }, - inner, - ); - never_unproven(&search_workload_with_targets( - vec![("q", topk, Some(AccuracyTarget::Epsilon(0.01)))], - &default_strategies(), - &DefaultAccuracyModel, - )); - } - - /// A root target that rejects every summary leaves nothing to select. - #[test] - fn nothing_is_selected_when_the_root_target_rejects_every_summary() { - let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload_with_targets( - vec![("q", q, Some(AccuracyTarget::Epsilon(0.001)))], - &default_strategies(), - &DefaultAccuracyModel, - ); - let root = &space.roots[0].1; - assert!(global_selection(&space, &DefaultCostModel) - .for_target(root) - .unwrap() - .chosen - .is_none()); - } - - /// Admits rank-over-rank composition, which `DefaultAccuracyModel` has no - /// rule for, so a nested quantile summary can be selected. - struct RankAdditiveModel; - - impl AccuracyModel for RankAdditiveModel { - fn local_guarantee( - &self, - family: &FieldDataType, - query: &SketchStatistic, - ) -> Option { - DefaultAccuracyModel.local_guarantee(family, query) - } - - fn propagate( - &self, - op: &CompositionOperator, - inputs: &[ResultGuarantee], - local: Option<&ResultGuarantee>, - stats: &PropagationStats, - ) -> Result { - let rank = |g: &ResultGuarantee| g.is_exact() || g.metric == ErrorMetric::Rank; - if let (CompositionOperator::ApproximateAggregate, true, Some(local)) = - (op, inputs.iter().all(rank), local) - { - let relabel = |g: &ResultGuarantee| ResultGuarantee { - metric: ErrorMetric::AbsoluteValue, - ..g.clone() - }; - let inputs: Vec<_> = inputs.iter().map(relabel).collect(); - let mut out = - DefaultAccuracyModel.propagate(op, &inputs, Some(&relabel(local)), stats)?; - out.metric = local.metric; - return Ok(out); - } - DefaultAccuracyModel.propagate(op, inputs, local, stats) - } - - fn satisfies(&self, guarantee: &ResultGuarantee, target: &AccuracyTarget) -> bool { - DefaultAccuracyModel.satisfies(guarantee, target) - } - } - - #[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_intent(0.5, 0.1), - metric_scan(&["job"]), - ); - let outer = agg(vec![], quantile_eps_intent(0.99, 0.1), inner); - let strategies: Vec> = vec![Box::new( - ASAPStrategies::new_with_planning_inputs(&RankAdditiveModel, &EqualSplitAllocator), - )]; - let space = search_workload_with(vec![("q", Rc::clone(&outer))], &strategies); - let root = &space.roots[0].1; - let group = space.candidates_for_target(root).unwrap(); - 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, &AccuracyTarget::Epsilon(0.1)) - }) - } - Replacement::SubDAG(_) => false, - Replacement::ExactComposition(_) => false, - })); - let ranked = cost_sorted(&space, &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 = global_selection(&space, &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 { .. }) - )); - } - - // ── Accuracy reconciliation (moved from `accuracy::reconciliation` tests) ─ - - mod accuracy_reconciliation { - use super::*; - use asap_types::ir::operator::operator_properties::Source; - use asap_types::ir::schema::{DataType, Field, Schema}; - - /// `[ts(0), value(1), job(2)]`, uniquely keyed by `ts` so CSE can hoist it. - 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 quantile( - q: f64, - accuracy: AccuracyTarget, - child: &Rc, - ) -> Rc { - agg( - vec![2], - AggIntent::Quantile { - col: None, - q, - accuracy, - }, - Rc::clone(child), - ) - } - - // ── 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::Epsilon(0.01), &scan); - let loose = quantile(0.99, AccuracyTarget::Epsilon(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::Epsilon(0.01), &scan); - let loose = quantile(0.99, AccuracyTarget::Epsilon(0.05), &scan); - let space = search_workload(vec![("tight", tight), ("loose", loose)]); - let loose_root = &space.roots[1].1; - - let cost_model = DefaultCostModel; - let ranked = cost_sorted(&space, &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 = global_selection(&space, &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(asap_logical_optimizer::pass1::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::Epsilon(0.05), &scan)).clone(); - let loose_b = (*quantile(0.99, AccuracyTarget::Epsilon(0.05), &scan)).clone(); - let tight = (*quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan)).clone(); - - let space = search_workload(vec![ - ("loose_a", Rc::new(loose_a)), - ("loose_b", Rc::new(loose_b)), - ("tight", Rc::new(tight)), - ]); - - // 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 = cost_sorted(&space, &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 = global_selection(&space, &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(asap_logical_optimizer::pass1::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::Epsilon(0.01), &scan); - let loose = quantile(0.99, AccuracyTarget::Epsilon(0.05), &scan); - let space = search_workload(vec![ - ("tight", Rc::clone(&tight)), - ("loose", Rc::clone(&loose)), - ]); - - let selected = global_selection(&space, &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/plan-selection/src/cost/cost_model.rs b/crates/plan-selection/src/cost/cost_model.rs index 5f42fb2e..315f439e 100644 --- a/crates/plan-selection/src/cost/cost_model.rs +++ b/crates/plan-selection/src/cost/cost_model.rs @@ -31,7 +31,7 @@ //! `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). -//! [`cost_sorted`](crate::candidate_selection::cost_sorted) +//! `cost_sorted` //! (via [`asap_logical_optimizer::pass1::replacement`]'s own `cse_preference`) and //! [`DefaultCostModel::estimate_cost`] are this crate's own callers. @@ -251,7 +251,7 @@ fn finite_rate(units_per_second: f64) -> Option { /// A CSE-detected, legality-gated shared sub-DAG with two or more consumers /// — the unit [`CostModel::cse_share_decision`] decides over. Built by -/// [`cost_sorted`](crate::candidate_selection::cost_sorted) +/// `cost_sorted` /// (via [`asap_logical_optimizer::pass1::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 @@ -619,7 +619,7 @@ pub trait CostModel { /// [`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 [`cost_sorted`](crate::candidate_selection::cost_sorted)), + /// on `cost_sorted`), /// not just order candidates against each other — that ordering job /// already belongs to [`rank_candidates`](Self::rank_candidates) (for a /// [`ASAPStrategies`](asap_logical_optimizer::pass1::replacement::ASAPStrategies) @@ -768,25 +768,6 @@ fn hydra_grid_cells(params: &HydraParams) -> f64 { } } -/// 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. /// @@ -912,70 +893,6 @@ mod tests { ); } - 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); - } - // ── Recurring-cost formulas (issue #171) ───────────────────────────── fn known_inputs() -> ExactCompositionCostInputs { diff --git a/crates/plan-selection/src/cost/recurrence.rs b/crates/plan-selection/src/cost/recurrence.rs index fa88830b..9bb8392f 100644 --- a/crates/plan-selection/src/cost/recurrence.rs +++ b/crates/plan-selection/src/cost/recurrence.rs @@ -75,7 +75,7 @@ //! //! - [`EvaluationRate`]: derived from [`asap_types::workload::RepeatingEntry::demand`] //! values of every repeating consumer reaching a target (via -//! [`evaluation_rate_of`], or [`recurrence_profiles`](crate::candidate_selection::recurrence_profiles) +//! [`evaluation_rate_of`], or `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. @@ -216,7 +216,7 @@ pub enum RecurrenceError { CostRate with a one-shot Cost without distorting the comparison" )] InvalidHorizon(Horizon), - /// [`recurrence_profiles`](crate::candidate_selection::recurrence_profiles) was called + /// `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 @@ -239,7 +239,7 @@ pub enum RecurrenceError { /// applied at every point an `UpdateRate` enters a [`RecurrenceProfile`] /// ([`RecurrenceProfile::with_update_rate`], /// [`update_rate_from_data_workload`], -/// [`recurrence_profiles`](crate::candidate_selection::recurrence_profiles)'s own parameter) +/// `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. @@ -373,7 +373,7 @@ impl RecurrenceProfile { } /// How one workload root recurs — the opaque per-root tag -/// [`recurrence_profiles`](crate::candidate_selection::recurrence_profiles) threads down to +/// `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 @@ -632,19 +632,12 @@ pub(crate) fn decide( #[cfg(test)] mod tests { use super::*; - use crate::candidate_selection::cost_sorted_with_recurrence; - use crate::candidate_selection::global_selection_with_recurrence; - use crate::candidate_selection::recurrence_profiles; use crate::cost::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] @@ -789,9 +782,7 @@ mod tests { use asap_types::ir::schema::{ ExactKind, ExactParams, Field, FieldDataType, GroupingStrategy, Schema, }; - use asap_types::ir::{ - ASAPOp, BinaryOperator, ExprSemantics, NonASAPOp, OperatorNode, Predicate, ScalarExpr, - }; + use asap_types::ir::{ASAPOp, NonASAPOp, OperatorNode}; use std::rc::Rc; @@ -1130,387 +1121,6 @@ mod tests { } } - // ── multiple roots sharing a sub-DAG, via CandidateLogicalASAPDAGs ────────────────── - - use asap_logical_optimizer::pass1::replacement::search_workload; - use asap_types::ir::operator::agg_intent::AggIntent; - use asap_types::ir::operator::operator_properties::Reduction as QueryReduction; - use asap_types::ir::scalar::{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. `candidate_selection::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); - - // 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 = - recurrence_profiles(&space, &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); - 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 = - recurrence_profiles(&space, &[repeating(10), repeating(10)], update_rate).unwrap(); - let infrequent = recurrence_profiles( - &space, - &[repeating(100_000), repeating(100_000)], - update_rate, - ) - .unwrap(); - - let frequent_ranked = - cost_sorted_with_recurrence(&space, &DeterministicUnitCostModel, &frequent, None) - .unwrap(); - let infrequent_ranked = - cost_sorted_with_recurrence(&space, &DeterministicUnitCostModel, &infrequent, None) - .unwrap(); - let first_provenance = - |ranked: &[crate::candidate_selection::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(asap_logical_optimizer::pass1::replacement::ReplacementProvenance::CseShare) - ); - assert_eq!( - first_provenance(&infrequent_ranked), - Some(asap_logical_optimizer::pass1::replacement::ReplacementProvenance::CseRecompute) - ); - - let frequent_selected = - global_selection_with_recurrence(&space, &DeterministicUnitCostModel, &frequent, None) - .unwrap(); - let infrequent_selected = global_selection_with_recurrence( - &space, - &DeterministicUnitCostModel, - &infrequent, - None, - ) - .unwrap(); - assert_eq!( - frequent_selected - .for_target(&shared.target) - .and_then(|group| group.chosen) - .map(|candidate| candidate.provenance), - Some(asap_logical_optimizer::pass1::replacement::ReplacementProvenance::CseShare) - ); - assert_eq!( - infrequent_selected - .for_target(&shared.target) - .and_then(|group| group.chosen) - .map(|candidate| candidate.provenance), - Some(asap_logical_optimizer::pass1::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); - let err = recurrence_profiles( - &space, - &[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); - let err = recurrence_profiles(&space, &[], 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); - let err = recurrence_profiles( - &space, - &[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); - - 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 = - recurrence_profiles(&space, &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::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)]); - - 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 = recurrence_profiles(&space, &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(); diff --git a/crates/plan-selection/src/lib.rs b/crates/plan-selection/src/lib.rs index cc17479b..61efc5d7 100644 --- a/crates/plan-selection/src/lib.rs +++ b/crates/plan-selection/src/lib.rs @@ -4,8 +4,6 @@ //! //! - [`cost`] — the [`CostModel`] trait, analytical and evidence-based //! pricing, recurrence, and the physical lowering and storage I/O they price. -//! - [`candidate_selection`] — the legacy cost-ranked selection over a Stage 1 -//! search (deleted under #580). //! //! Each Stage 2 candidate is checked against every query's accuracy target //! with the accuracy model, and Count-Min is admitted only over weights proven @@ -39,15 +37,11 @@ //! there). [`select_exhaustive`] builds and prices every combination, for //! display and for checking the program. [`plan_stages`] runs the whole //! pipeline from the frontends' roots. -pub mod candidate_selection; pub mod cost; #[cfg(test)] mod test_support; pub use asap_types::deployment::DeploymentCapabilities; -pub use candidate_selection::{ - CompositionDecision, CostedGlobalSelection, RankedTargetSubDAGCandidates, RecurrenceProfileMap, -}; pub use cost::cost_model::{ maintenance_operation_plan_cost_rate, raw_recompute_cost_rate, read_operation_plan_cost_rate, CostModel, CostProvenance, CostUnit, DefaultCostModel, ExactCompositionCostInputs, diff --git a/crates/plan-selection/src/test_support.rs b/crates/plan-selection/src/test_support.rs index 1c0cefb4..ba41181b 100644 --- a/crates/plan-selection/src/test_support.rs +++ b/crates/plan-selection/src/test_support.rs @@ -40,85 +40,3 @@ pub(crate) fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc` whose schema is derived by -// `OperatorNode::new_shared`, so a fixture is exactly what a front end -// would hand the planner. - -use asap_types::ir::operator::agg_intent::AggIntent; -use asap_types::ir::operator::operator_properties::{Reduction, Source}; -use asap_types::ir::schema::{ColumnId, DataType, Field, Schema}; -use asap_types::ir::{NonASAPOp, Predicate}; - -/// A `TimeSeries("m")` scan over `[ts(0), value(1), labels...]`, time index 0, -/// no unique key. -pub(crate) fn metric_scan(labels: &[&str]) -> Rc { - metric_scan_with_keys(labels, vec![]) -} - -/// [`metric_scan`] with explicit `unique_keys` (a `[[0]]` key makes CSE -/// willing to hoist the scan). -pub(crate) fn metric_scan_with_keys( - labels: &[&str], - unique_keys: Vec>, -) -> 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)), - ); - scan("m", Schema::with_time_index(columns, 0, unique_keys)) -} - -/// A predicate-free `TimeSeries(metric)` scan with the given schema. -pub(crate) fn scan(metric: &str, schema: Schema) -> Rc { - scan_from( - Source::TimeSeries { - metric: metric.into(), - }, - schema, - ) -} - -pub(crate) fn scan_from(source: Source, schema: Schema) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source, - predicates: vec![], - schema, - })) - .unwrap() -} - -/// A general aggregate node. -pub(crate) fn aggregate( - reduction: Reduction, - measures: Vec, - output_names: Vec, - having: Option, - child: Rc, -) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction, - measures, - output_names, - filters: vec![], - having, - child, - })) - .unwrap() -} - -/// `intent by (by)` — a single-measure, `HAVING`-free grouped aggregate. -pub(crate) fn agg( - by: Vec, - intent: AggIntent, - child: Rc, -) -> Rc { - aggregate(Reduction::by(by), vec![intent], vec![], None, child) -}