diff --git a/Cargo.lock b/Cargo.lock index 2877f3db0..ae3f95d4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,8 +332,6 @@ dependencies = [ "asap-executor", "asap-frontend-promql", "asap-planner", - "asap-stage0-memo", - "asap-stage1-pass1-per-computation-candidates", "asap-types", "asap_sketchlib", "chrono", diff --git a/crates/accuracy/src/propagation.rs b/crates/accuracy/src/propagation.rs index 11b5d8c02..da54aab98 100644 --- a/crates/accuracy/src/propagation.rs +++ b/crates/accuracy/src/propagation.rs @@ -1605,3 +1605,77 @@ pub fn pinned_column(conjunct: &ScalarExpr) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::composition::raw_data_samples_sign_statistic; + use asap_types::ir::schema::{ + GroupingStrategy, NonNegativeWeightProof, SketchAlgorithm, SummaryInputExpr, + }; + + /// A Count-Min estimate has a bound only over weights proven + /// non-negative: an open requirement (`RawDataSamplesOfMetric`) is not a proof. + #[test] + fn count_min_estimate_has_no_bound_over_an_open_requirement() { + let family = FieldDataType::Sketch(SketchAlgorithm::Cms, GroupingStrategy::default()); + let query = SketchStatistic::PointCount { + key: ColumnRef::SampleValue, + value: None, + }; + let with = |weight_domain| SummaryUpdate { + item: Some(SummaryInputExpr::Column(ColumnRef::SampleValue)), + weight: SummaryInputExpr::Column(ColumnRef::SampleValue), + weight_domain, + }; + assert!(!estimate_has_bound( + &family, + &with(WeightDomain::RawDataSamplesOfMetric { metric: "m".into() }), + &query + )); + assert!(!estimate_has_bound( + &family, + &with(WeightDomain::UnknownOrSigned), + &query + )); + assert!(estimate_has_bound( + &family, + &with(WeightDomain::NonNegative { + proof: NonNegativeWeightProof::CounterRawDataSamples + }), + &query + )); + } + + /// A Count-Min estimate over samples of `m` gets its bound gated on + /// `m`'s sign: unknown until plan selection binds it, the local bound + /// once bound to 1. + #[test] + fn count_min_estimate_over_raw_data_samples_is_gated_on_the_metric() { + let family = FieldDataType::Sketch(SketchAlgorithm::Cms, GroupingStrategy::default()); + let query = SketchStatistic::PointCount { + key: ColumnRef::SampleValue, + value: None, + }; + let update = SummaryUpdate { + item: Some(SummaryInputExpr::Column(ColumnRef::SampleValue)), + weight: SummaryInputExpr::Column(ColumnRef::SampleValue), + weight_domain: WeightDomain::RawDataSamplesOfMetric { metric: "m".into() }, + }; + let gated = estimate_bound_over_raw_data_samples(&family, &update, &query).expect("gated"); + assert_eq!(gated.spec.error.evaluate(), None); + let local = local_guarantee(&family, &query).unwrap(); + let statistic = raw_data_samples_sign_statistic("m"); + // Every other statistic (a stream norm) bound alike. + let others = |s: &str| Some(if s == statistic { 1.0 } else { 1_000.0 }); + let bound = gated.bind(&others).spec.error.evaluate(); + assert!(bound.is_some()); + assert_eq!(bound, local.bind(&others).spec.error.evaluate()); + let unconfirmed = gated + .bind(&|s| (s != statistic).then_some(1_000.0)) + .spec + .error + .evaluate(); + assert_eq!(unconfirmed, None); + } +} diff --git a/crates/accuracy/src/test_support.rs b/crates/accuracy/src/test_support.rs index c92e9ff58..6a1a4d88e 100644 --- a/crates/accuracy/src/test_support.rs +++ b/crates/accuracy/src/test_support.rs @@ -7,7 +7,7 @@ use std::rc::Rc; use asap_types::ir::operator::operator_properties::Reduction; use asap_types::ir::properties::guarantee::AccuracyGuarantee; use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::schema::{FieldDataType, SketchStatistic, SummaryUpdate}; +use asap_types::ir::schema::{FieldDataType, SketchStatistic}; use asap_types::ir::{OperatorNode, ScalarExpr}; use crate::composition::StreamNorm; @@ -42,24 +42,6 @@ pub fn estimates_raw_data_samples_of(node: &OperatorNode) -> Option { propagation::estimates_raw_data_samples_of(node) } -/// `propagation::estimate_has_bound`. -pub fn estimate_has_bound( - family: &FieldDataType, - update: &SummaryUpdate, - query: &SketchStatistic, -) -> bool { - propagation::estimate_has_bound(family, update, query) -} - -/// `propagation::estimate_bound_over_raw_data_samples`. -pub fn estimate_bound_over_raw_data_samples( - family: &FieldDataType, - update: &SummaryUpdate, - query: &SketchStatistic, -) -> Option { - propagation::estimate_bound_over_raw_data_samples(family, update, query) -} - /// `propagation::source_distinct_guarantee`. pub fn source_distinct_guarantee( family: &FieldDataType, @@ -74,14 +56,6 @@ pub fn satisfies(guarantee: &AccuracyGuarantee, target: &AccuracyTarget) -> bool crate::satisfies(guarantee, target) } -/// `crate::estimators::local_guarantee`. -pub fn local_guarantee( - family: &FieldDataType, - query: &SketchStatistic, -) -> Option { - crate::estimators::local_guarantee(family, query) -} - /// `crate::estimators::cms::cms_depth`. pub fn cms_depth(delta: f64) -> u32 { crate::estimators::cms::cms_depth(delta) diff --git a/crates/devtools/Cargo.toml b/crates/devtools/Cargo.toml index 1e6c69df1..80079502b 100644 --- a/crates/devtools/Cargo.toml +++ b/crates/devtools/Cargo.toml @@ -37,8 +37,6 @@ tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread"] } [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-stage2-physical-optimization = { path = "../stage2-physical-optimization" } -# Pass 1's target list (`per_computation_candidates`) for the tests. -asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } [[test]] diff --git a/crates/devtools/src/bin/sketch_coverage.rs b/crates/devtools/src/bin/sketch_coverage.rs index 6b573f020..6333ff1f9 100644 --- a/crates/devtools/src/bin/sketch_coverage.rs +++ b/crates/devtools/src/bin/sketch_coverage.rs @@ -4,7 +4,7 @@ // corpus list exactly, so the two reports are directly comparable) with an // *approximate* `AccuracyTarget`, runs Pass 1 of logical ASAP-aware // optimization (`pass1_memo`, every -// summary supported, no accuracy pruning) over each query, and +// summary supported) over each query, and // reports which summary candidates Pass 1 offers for it, plus the fraction of // lowered queries with at least one. // @@ -14,7 +14,6 @@ // every corpus lowers with. Without an approximate target, Pass 1 offers no // summary candidate. -use asap_accuracy::{IgnoreAccuracyGuarantees, NoAccuracyEvidence}; use asap_devtools::corpora::{ bgp_catalog, dqc_catalog, netflow_catalog, parse_sample_interval, promql_lines, sql_statements, }; @@ -25,7 +24,7 @@ use asap_stage1_pass1_per_computation_candidates::pass1_memo; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::schema::FieldDataType; use asap_types::ir::{ASAPOp, Operator, OperatorNode, QueryRoot}; -use asap_types::workload::{DataWorkload, SqlDialect}; +use asap_types::workload::SqlDialect; use std::collections::BTreeSet; use std::rc::Rc; @@ -54,7 +53,6 @@ fn analyze_corpus( name: &'static str, roots: Vec<(String, Rc)>, failed: usize, - accuracy: &AccuracyTarget, ) -> CorpusCoverage { let lowered = roots.len(); let mut rejected = 0; @@ -64,14 +62,7 @@ fn analyze_corpus( rejected += 1; continue; }; - let Ok(pass1) = pass1_memo( - memo, - std::slice::from_ref(accuracy), - &IgnoreAccuracyGuarantees, - &NoAccuracyEvidence, - &DataWorkload::default(), - None, - ) else { + let Ok(pass1) = pass1_memo(memo, None) else { rejected += 1; continue; }; @@ -187,7 +178,7 @@ async fn main() { Err(_) => failed += 1, } } - results.push(analyze_corpus(name, roots, failed, &accuracy)); + results.push(analyze_corpus(name, roots, failed)); } // ── SQL corpora ── @@ -222,7 +213,7 @@ async fn main() { Err(_) => failed += 1, } } - results.push(analyze_corpus(name, roots, failed, &accuracy)); + results.push(analyze_corpus(name, roots, failed)); } for r in &results { diff --git a/crates/devtools/src/candidate_labels.rs b/crates/devtools/src/candidate_labels.rs index 94cc6e651..bdfa216ff 100644 --- a/crates/devtools/src/candidate_labels.rs +++ b/crates/devtools/src/candidate_labels.rs @@ -390,39 +390,6 @@ mod tests { use super::*; use crate::planner_layering_examples::example1; use crate::planning_stages::lower_promql_roots; - use asap_stage1_pass1_per_computation_candidates::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - - /// Selected DAG labels expose accumulator state without a legacy choice ID. - #[test] - fn connected_labels_describe_selected_states() { - let workload = example1(); - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&lower_promql_roots(&workload).unwrap()).unwrap(), - ) - .unwrap(); - let choice = vec![0; inventory.targets.len()]; - let roots: Vec<_> = compose_logical_candidate(&inventory, &choice).unwrap(); - let label = connected_candidate_label(&roots); - assert!(label.contains("Q1 ") && label.contains("Q2 ")); - assert!(label.contains("acc"), "{label}"); - } - - /// A state reused by two query outputs is named once in the sharing suffix. - #[test] - fn connected_labels_name_actual_shared_state() { - let workload = example1(); - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&lower_promql_roots(&workload).unwrap()).unwrap(), - ) - .unwrap(); - let roots = - compose_logical_candidate(&inventory, &vec![0; inventory.targets.len()]).unwrap(); - let label = connected_candidate_label(&[roots[1].clone(), roots[1].clone()]); - assert!(label.contains("shared Sum acc (Q1, Q2)"), "{label}"); - assert_eq!(label.matches("shared Sum acc").count(), 1); - } /// Raw logical operators have an exact label, rather than an empty choice label. #[test] diff --git a/crates/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs index 8cdc6772c..3dfde1c0e 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -7,7 +7,7 @@ use asap_planner::{ run_planning, PlanSearchSettings, PlanningModels, PlanningReport, PlanningRun, SelectionError, }; use asap_stage0_memo::memo_of_queries; -use asap_stage1_pass1_per_computation_candidates::{pass1_memo, Pass1Error}; +use asap_stage1_pass1_per_computation_candidates::pass1_memo; use asap_stage1_pass2_reuse_candidates::{ pass2_after_local_window_composition, pass2_local_window_composition, }; @@ -288,38 +288,20 @@ pub fn run_stages_until( return Ok(stages); } let run = &mut stages.run; - let accuracy_targets: Vec<_> = entries - .iter() - .map(|entry| entry.requirements.accuracy.clone()) - .collect(); - let accuracy = models.accuracy_check_inputs(); let pass1 = pass1_memo( stage0_dag, - &accuracy_targets, - accuracy.model, - accuracy.evidence, - data, models .deployment_capabilities .supported_summaries .as_deref(), - ); + ) // As `run_planning`: a query Pass 1 cannot plan is reported before // contradictory deployment capabilities. - if let Err(Pass1Error::PerComputation(error)) = &pass1 { - return Err(PlanningStagesError::Pass1(error.to_string())); - } + .map_err(|error| PlanningStagesError::Pass1(error.to_string()))?; if let Err(error) = models.deployment_capabilities.validate(data) { run.selection = Err(SelectionError::InvalidDeploymentCapabilities(error)); return Ok(stages); } - let pass1 = match pass1 { - Ok(pass1) => pass1, - Err(error) => { - run.selection = Err(SelectionError::LogicalASAPCandidates(error.to_string())); - return Ok(stages); - } - }; run.pass1_memo = Some(pass1.clone()); if until == RunUntil::Pass1 { return Ok(stages); @@ -372,7 +354,7 @@ pub fn run_stages_until( &physical, entries, data, - &accuracy, + &models.accuracy_check_inputs(), models.cost_inputs, &realizer, ) { diff --git a/crates/executor/Cargo.toml b/crates/executor/Cargo.toml index 7661ad112..e3706488f 100644 --- a/crates/executor/Cargo.toml +++ b/crates/executor/Cargo.toml @@ -27,8 +27,6 @@ test-support = ["planner-types/test-support"] [dev-dependencies] asap-executor = { path = ".", features = ["test-support"] } rmp-serde = "1" -asap-stage0-memo = { path = "../stage0-memo" } -asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } # The planning stages through the facade (a dev-dependency cycle, for # integration tests only). asap-planner = { path = "../planner" } diff --git a/crates/executor/tests/common/mod.rs b/crates/executor/tests/common/mod.rs index 8a226a05a..092d5aa7a 100644 --- a/crates/executor/tests/common/mod.rs +++ b/crates/executor/tests/common/mod.rs @@ -21,38 +21,6 @@ pub fn selected_dag( Rc::clone(selected) } -/// Exercise each local implementation rule directly, keeping the other -/// computations at their default choice. A bounded solver archive need not -/// contain every algorithm that executor binding tests need to exercise. -pub fn computation_candidates(root: Rc) -> Vec> { - use asap_stage1_pass1_per_computation_candidates::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .expect("Pass 1 accepts the query"); - let mut choices = vec![vec![0; inventory.targets.len()]]; - for (index, target) in inventory.targets.iter().enumerate() { - for choice in 1..target.choice_count() { - let mut selected = vec![0; inventory.targets.len()]; - selected[index] = choice; - choices.push(selected); - } - } - choices - .into_iter() - .map(|choice| { - let mut roots = compose_logical_candidate(&inventory, &choice) - .expect("a generated computation candidate composes"); - match roots.remove(0) { - QueryRoot::Operator(root) => root, - QueryRoot::Scalar(_) => panic!("one operator root"), - } - }) - .collect() -} - fn run_all_stages( root: Rc, accuracy: planner_types::ir::properties::AccuracyTarget, diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index dbe28ea89..8f5628b71 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -10,7 +10,7 @@ asap-types = { path = "../types", features = ["test-support"] } asap-frontend-promql = { path = "../frontend-promql" } asap-frontend-sql = { path = "../frontend-sql" } asap-stage0-memo = { path = "../stage0-memo" } -asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } +asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates" } asap-stage2-physical-optimization = { path = "../stage2-physical-optimization" } asap-accuracy = { path = "../accuracy" } asap-cost = { path = "../cost" } diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index fce296805..1e2b34629 100644 --- a/crates/integration-tests/src/lib.rs +++ b/crates/integration-tests/src/lib.rs @@ -193,61 +193,23 @@ pub mod planning { } } - /// Exact computation and each explicit single-target rule application. - /// This fixture catalog is independent of the bounded MILP's visited plans. + /// Exact computation and each explicit single-target rule application, + /// each as one plan for `root`. Pass 1 now adds these ways as memo + /// candidates; realizing one plan per way from the memo is #1207 PR 6. pub fn explicit_local_candidates(root: Rc) -> Vec> { - use asap_stage1_pass1_per_computation_candidates::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - let local = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap(); - let mut choices = std::collections::BTreeSet::from([vec![0; local.targets.len()]]); - for (target, options) in local.targets.iter().enumerate() { - for choice in 1..options.choice_count() { - let mut selected = vec![0; local.targets.len()]; - selected[target] = choice; - choices.insert(selected); - } - } - choices - .into_iter() - .map(|choice| { - match compose_logical_candidate(&local, &choice) - .expect("explicit local rule composes") - .remove(0) - { - QueryRoot::Operator(node) => node, - QueryRoot::Scalar(_) => panic!("operator fixture"), - } - }) - .collect() + let _ = root; + todo!("#1207 PR 6: realize one plan per way to compute from Pass 1's memo") } /// One explicit plan using `family` wherever a local target supports it, - /// keeping all other computations exact. No workload Cartesian enumeration. + /// keeping all other computations exact. Realizing it from Pass 1's + /// memo is #1207 PR 6. pub fn explicit_summary_candidate( root: Rc, family: asap_types::ir::schema::SketchAlgorithm, ) -> Rc { - use asap_stage1_pass1_per_computation_candidates::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - use asap_types::stages::HowToComputeAggregateMeasure; - let local = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap(); - let choices = local.targets.iter().map(|target| target.ways_to_compute.iter() - .position(|candidate| matches!(candidate, HowToComputeAggregateMeasure::Summary(found) if *found == family)).unwrap_or(0)).collect::>(); - match compose_logical_candidate(&local, &choices) - .expect("explicit family choices compose") - .remove(0) - { - QueryRoot::Operator(node) => node, - QueryRoot::Scalar(_) => panic!("operator fixture"), - } + let _ = (root, family); + todo!("#1207 PR 6: realize the plan of one summary family from Pass 1's memo") } /// Logical plans visited by the bounded search for `root`, in archive order. diff --git a/crates/integration-tests/tests/per_computation_candidates_sql.rs b/crates/integration-tests/tests/per_computation_candidates_sql.rs index bd6d2d4a5..2288c2697 100644 --- a/crates/integration-tests/tests/per_computation_candidates_sql.rs +++ b/crates/integration-tests/tests/per_computation_candidates_sql.rs @@ -1,69 +1,80 @@ -//! SQL → Pass 1 per-computation candidates: where each `HAVING` is applied, -//! the guarantees composed over estimates, and what a Top-K heap sketch -//! ranks. +//! SQL → Pass 1 memo candidates: where each `HAVING` is applied, the +//! guarantees composed over estimates, and what a Top-K heap sketch ranks. use asap_frontend_sql::{lower_sql, SqlCatalog}; -use asap_stage1_pass1_per_computation_candidates::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, -}; +use asap_stage1_pass1_per_computation_candidates::pass1_memo; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::scalar::ColumnRef; use asap_types::ir::schema::{DataType, Field, Schema}; use asap_types::ir::schema::{FieldDataType, SketchAlgorithm, SummaryInputExpr}; -use asap_types::ir::{ASAPOp, OperatorNode, QueryRoot}; -use asap_types::stages::{ComputationTarget, HavingPlacement, HowToComputeAggregateMeasure}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, QueryRoot}; +use asap_types::stages::Memo; -async fn targets(sql: &str) -> Vec { - let catalog = SqlCatalog::new().with_table( - "flows", - Schema::new(vec![ +/// Pass 1's memo of `sql` over `flows(src, bytes)`. +async fn pass1(sql: &str) -> Memo { + pass1_over( + vec![ Field::plain("src", DataType::Utf8, false), Field::plain("bytes", DataType::Float64, false), - ]), - ); + ], + sql, + ) + .await +} + +/// Pass 1's memo of `sql` over `flows(fields)`. +async fn pass1_over(fields: Vec, sql: &str) -> Memo { + let catalog = SqlCatalog::new().with_table("flows", Schema::new(fields)); let accuracy = AccuracyTarget::max_error(0.01).failure_probability(0.01); let root = lower_sql(sql, &catalog, accuracy) .await .unwrap_or_else(|e| panic!("lower failed for {sql:?}: {e}")); - generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap() - .targets + let memo = asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(); + pass1_memo(memo, None).unwrap_or_else(|e| panic!("Pass 1 failed for {sql:?}: {e}")) } -fn placements(targets: &[ComputationTarget]) -> Vec { - targets - .iter() - .flat_map(|target| target.having_filters.iter().map(|entry| entry.placement)) - .collect() +/// Whether some candidate applies a `HAVING` below the aggregate: a +/// `Filter` group over the aggregate's input, which a state build reads. +fn having_pushed_below(memo: &Memo) -> bool { + let groups = memo.groups(); + groups.iter().any(|group| { + let Operator::NonASAP(NonASAPOp::Aggregate { child, .. }) = &group.expression else { + return false; + }; + groups.iter().any(|filter| { + matches!(&filter.expression, Operator::NonASAP(NonASAPOp::Filter { child: input, .. }) if input == child) + }) + }) } +/// A key-only `HAVING` is pushed below the aggregate. #[tokio::test] async fn key_only_having_is_pushed_below() { - let targets = targets("SELECT src, count(*) FROM flows GROUP BY src HAVING src = 'a'").await; - assert_eq!(placements(&targets), vec![HavingPlacement::BelowAggregate]); + let memo = pass1("SELECT src, count(*) FROM flows GROUP BY src HAVING src = 'a'").await; + assert!(having_pushed_below(&memo)); } +/// A `HAVING` over an aggregate value stays above the output. #[tokio::test] async fn value_having_stays_above() { - let targets = - targets("SELECT src, count(*) FROM flows GROUP BY src HAVING count(*) > 10").await; - assert_eq!(placements(&targets), vec![HavingPlacement::AboveOutput]); + let memo = pass1("SELECT src, count(*) FROM flows GROUP BY src HAVING count(*) > 10").await; + assert!(!having_pushed_below(&memo)); } +/// A global aggregate's `HAVING`, even a constant one, stays above the output. #[tokio::test] async fn global_constant_having_stays_above() { - let targets = targets("SELECT count(*) FROM flows HAVING false").await; - assert_eq!(placements(&targets), vec![HavingPlacement::AboveOutput]); + let memo = pass1("SELECT count(*) FROM flows HAVING false").await; + assert!(!having_pushed_below(&memo)); } +/// A `HAVING` that reads a key and a value stays above the output. #[tokio::test] async fn mixed_key_and_value_having_stays_above() { - let targets = - targets("SELECT src, count(*) FROM flows GROUP BY src HAVING src = 'a' AND count(*) > 10") + let memo = + pass1("SELECT src, count(*) FROM flows GROUP BY src HAVING src = 'a' AND count(*) > 10") .await; - assert_eq!(placements(&targets), vec![HavingPlacement::AboveOutput]); + assert!(!having_pushed_below(&memo)); } /// Whether `node` reads a quantile sketch (KLL or DDSketch) anywhere below it. @@ -192,9 +203,8 @@ async fn a_sql_fold_over_estimates_leaves_its_row_count_unbound() { } } -/// The item and weight columns of every heap sketch build in each composed -/// candidate of `sql` over `flows(key: key_type, bytes)` that picks a heap -/// sketch for its top-k. +/// The item and weight columns of every heap sketch state group in Pass 1's +/// memo of `sql` over `flows(key: key_type, bytes)`. async fn heap_updates(key: &str, key_type: DataType, sql: &str) -> Vec<(String, String)> { heap_updates_over( vec![ @@ -208,13 +218,7 @@ async fn heap_updates(key: &str, key_type: DataType, sql: &str) -> Vec<(String, /// [`heap_updates`] over `flows(fields)`. async fn heap_updates_over(fields: Vec, sql: &str) -> Vec<(String, String)> { - let catalog = SqlCatalog::new().with_table("flows", Schema::new(fields)); - let accuracy = AccuracyTarget::max_error(0.01).failure_probability(0.01); - let root = lower_sql(sql, &catalog, accuracy).await.unwrap(); - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap(); + let memo = pass1_over(fields, sql).await; let name = |expr: &SummaryInputExpr| match expr { SummaryInputExpr::Column(ColumnRef::Named(name)) => name.clone(), SummaryInputExpr::Column(ColumnRef::Qualified { table, name }) => { @@ -234,45 +238,28 @@ async fn heap_updates_over(fields: Vec, sql: &str) -> Vec<(String, String SummaryInputExpr::Constant(value) => value.to_string(), other => panic!("a named column or constant: {other:?}"), }; - let mut updates = Vec::new(); - for (t, target) in inventory.targets.iter().enumerate() { - for c in 0..target.choice_count() { - let (candidate, _) = target.computation_for(c).unwrap(); - if !matches!( - candidate, - HowToComputeAggregateMeasure::Summary( - SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap - ) - ) { - continue; - } - let mut choice = vec![0; inventory.targets.len()]; - choice[t] = c; - let composed = compose_logical_candidate(&inventory, &choice) - .unwrap_or_else(|e| panic!("{candidate:?} composes: {e}")); - let QueryRoot::Operator(root) = &composed[0] else { - panic!("operator root") - }; - for node in OperatorNode::reachable(root) { - if let Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(..), - input, - .. - }) = node.asap() - { - updates.push((name(input.item.as_ref().unwrap()), name(&input.weight))); - } - } - } - } - updates + memo.groups() + .iter() + .filter_map(|group| match &group.expression { + Operator::ASAP(ASAPOp::SummaryAgg { + family: + FieldDataType::Sketch( + SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap, + _, + ), + input, + .. + }) => Some((name(input.item.as_ref().unwrap()), name(&input.weight))), + _ => None, + }) + .collect() } const COUNT_RANKED_TOPK: &str = "SELECT KEY, count(*) FROM flows GROUP BY KEY ORDER BY count(*) DESC LIMIT 10"; /// An Int64 key is not mistaken for the ranked value: both heap sketches -/// compose over raw rows, keyed by the key with unit weight. +/// are built over raw rows, keyed by the key with unit weight. #[tokio::test] async fn topk_heap_over_an_int64_key_counts_raw_rows() { let updates = heap_updates( @@ -311,7 +298,7 @@ async fn topk_heap_over_a_key_named_value_counts_raw_rows() { /// SQL's per-group top-k idiom (`ROW_NUMBER() OVER (PARTITION BY city /// ORDER BY COUNT(*) DESC) … WHERE rn <= 5`) is a heavy-hitter top-k, as -/// PromQL `topk by (city)` is: both heap sketches compose, keyed by the +/// PromQL `topk by (city)` is: both heap sketches are offered, keyed by the /// host and fed raw rows with unit weight. #[tokio::test] async fn rownumber_per_group_topk_offers_heap_sketches() { diff --git a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs index 090cea7b7..dbc5d4ce1 100644 --- a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs +++ b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs @@ -1,6 +1,6 @@ -//! Frontends → Pass 1 → composed candidate logical ASAP DAGs: every candidate of -//! a query is a valid DAG with the query's output columns (names, types and -//! nullability). +//! Frontends → Pass 1's memo: every candidate Pass 1 adds outputs its +//! group's schema (`Memo::add_candidate` checks it), so every way to compute +//! a query keeps its output columns (names, types and nullability). use std::rc::Rc; use std::sync::Arc; @@ -12,18 +12,14 @@ use asap_executor::{ values::{Batch, Value}, }; use asap_frontend_sql::{lower_sql, SqlCatalog}; -use asap_stage1_pass1_per_computation_candidates::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, -}; +use asap_stage1_pass1_per_computation_candidates::pass1_memo; use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::scalar::{ColumnRef, CompareOpKind, ScalarValue}; use asap_types::ir::schema::{DataType, Field, Schema}; -use asap_types::ir::schema::{ - ExactKind, FieldDataType, SketchAlgorithm, SummaryInputExpr, SummaryUpdate, -}; -use asap_types::ir::{ASAPOp, NonASAPOp, OperatorNode, QueryRoot, ScalarExpr}; -use asap_types::stages::HowToComputeAggregateMeasure; +use asap_types::ir::schema::{FieldDataType, SketchAlgorithm, SummaryInputExpr, SummaryUpdate}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, QueryRoot, ScalarExpr}; +use asap_types::stages::{Memo, MemoGroupId}; use asap_types::workload::{ BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, QueryRequirements, QueryWorkload, @@ -71,76 +67,63 @@ fn promql(query: &str) -> QueryRoot { DurationMs(1000), )), }; - asap_frontend_promql::lower_promql_query_workload(&workload) + // As the planner does before Stage 0 (`Stage1ToStage3Pass`): each + // series' identity is realized as a column. + match asap_frontend_promql::lower_promql_query_workload(&workload) .unwrap_or_else(|e| panic!("lower failed for {query:?}: {e}")) .remove(0) + { + QueryRoot::Operator(node) => QueryRoot::Operator( + asap_types::ir::schema::schema_support::with_promql_series_identity(&node) + .unwrap_or(node), + ), + scalar => scalar, + } } -fn output_columns(root: &QueryRoot) -> Vec<(String, String, bool)> { - match root { - QueryRoot::Operator(node) => node - .schema - .fields - .iter() - .map(|field| { - ( - field.name.clone(), - format!("{:?}", field.dtype), - field.nullable, - ) - }) - .collect(), - QueryRoot::Scalar(_) => vec![], - } +/// Pass 1's memo of the one query `root`. +fn pass1(root: QueryRoot) -> Memo { + let memo = asap_stage0_memo::memo_of_queries(&[root]).unwrap(); + pass1_memo(memo, None).unwrap() +} + +/// The one `Aggregate` group of `memo`. +fn root_group(memo: &Memo) -> MemoGroupId { + let [group] = aggregate_groups(memo)[..] else { + panic!("one aggregate group expected") + }; + group } -/// Compose every candidate of `root`; returns how many there were. -fn compose_all(label: &str, root: QueryRoot) -> usize { - let expected = output_columns(&root); - let inventory = - generate_per_computation_candidates(&asap_stage0_memo::memo_of_queries(&[root]).unwrap()) - .unwrap(); - let radix: Vec<_> = inventory - .targets +/// Pass 1 over `root`; returns how many candidates its aggregate groups +/// have. Each outputs its group's schema, or `Memo::add_candidate` would +/// have rejected it and Pass 1 failed. +fn candidates_of_aggregates(label: &str, root: QueryRoot) -> usize { + let memo = asap_stage0_memo::memo_of_queries(&[root]).unwrap(); + let memo = pass1_memo(memo, None).unwrap_or_else(|e| panic!("{label}: {e}")); + aggregate_groups(&memo) .iter() - .map(|target| target.choice_count()) - .collect(); - let total: usize = radix.iter().product(); - let mut choice = vec![0; radix.len()]; - for _ in 0..total { - let composed = compose_logical_candidate(&inventory, &choice) - .unwrap_or_else(|e| panic!("{label} {choice:?}: {e}")); - let root = &composed[0]; - root.validate_structure() - .unwrap_or_else(|e| panic!("{label} {choice:?}: {e}")); - let columns = output_columns(root); - // A heap top-k answers the declared `[keys.., topk_k]` with one row - // per ranked item: `topk_k` its Float64 estimate (the value the - // exact top-k's physical plan answers too), then the item labels. - let heap_topk = expected - .last() - .is_some_and(|(name, _, _)| name.starts_with("topk_")) - && columns.get(expected.len() - 1).map(|c| c.1.as_str()) == Some("Plain(Float64)"); - if heap_topk { - let (ranked, keys) = expected.split_last().unwrap(); - assert_eq!(&columns[..keys.len()], keys, "{label} {choice:?}"); - assert_eq!(columns[keys.len()].0, ranked.0, "{label} {choice:?}"); - } else { - assert_eq!(columns, expected, "{label} {choice:?}"); - } - for (digit, radix) in choice.iter_mut().zip(&radix).rev() { - *digit += 1; - if *digit < *radix { - break; - } - *digit = 0; - } - } - total + .map(|group| memo.groups()[group.0 as usize].candidates.len()) + .sum() +} + +/// The groups of `memo` whose expression is an `Aggregate`. +fn aggregate_groups(memo: &Memo) -> Vec { + memo.groups() + .iter() + .enumerate() + .filter(|(_, group)| { + matches!( + group.expression, + Operator::NonASAP(NonASAPOp::Aggregate { .. }) + ) + }) + .map(|(index, _)| MemoGroupId(index as u32)) + .collect() } #[tokio::test] -async fn sql_candidates_compose() { +async fn sql_candidates_output_the_query_columns() { for query in [ "SELECT src, count(*) FROM flows GROUP BY src HAVING src = 'a'", "SELECT src, count(*) FROM flows GROUP BY src HAVING count(*) > 10", @@ -151,12 +134,12 @@ async fn sql_candidates_compose() { "SELECT src, approx_percentile_cont(bytes, 0.99) FILTER (WHERE bytes > 10) FROM flows GROUP BY src", "SELECT approx_percentile_cont(bytes, 0.99) FILTER (WHERE bytes > 10) FROM flows", ] { - assert!(compose_all(query, sql(query).await) >= 1, "{query}"); + assert!(candidates_of_aggregates(query, sql(query).await) > 1, "{query}"); } } #[test] -fn promql_candidates_compose() { +fn promql_candidates_output_the_query_columns() { for query in [ "avg by (job) (m)", "avg_over_time(m[5m])", @@ -168,64 +151,36 @@ fn promql_candidates_compose() { "count_over_time(m[5m])", "quantile_over_time(0.99, m[5m])", ] { - assert!(compose_all(query, promql(query)) >= 1, "{query}"); + assert!( + candidates_of_aggregates(query, promql(query)) > 1, + "{query}" + ); } } -/// The summary estimates of `root`'s candidates that vary one target's -/// choice, every other target taking its first. -fn composed_estimates(root: QueryRoot) -> Vec> { - let inventory = - generate_per_computation_candidates(&asap_stage0_memo::memo_of_queries(&[root]).unwrap()) - .unwrap(); - let targets = inventory.targets.len(); - let choices: Vec<_> = inventory - .targets +/// The family and update of every sketch state group of Pass 1's memo +/// of `root`. +fn sketch_builds(root: QueryRoot) -> Vec<(SketchAlgorithm, SummaryUpdate)> { + pass1(root) + .groups() .iter() - .enumerate() - .flat_map(|(target, computation)| { - (1..computation.choice_count()).map(move |index| { - let mut choice = vec![0; targets]; - choice[target] = index; - choice - }) - }) - .collect(); - choices - .into_iter() - .flat_map(|choice| { - let composed = compose_logical_candidate(&inventory, &choice).unwrap(); - let QueryRoot::Operator(node) = &composed[0] else { - panic!("operator root expected") - }; - OperatorNode::reachable(node) + .filter_map(|group| match &group.expression { + Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + input, + .. + }) => Some((*kind, input.clone())), + _ => None, }) - .filter(|node| matches!(node.asap(), Some(ASAPOp::SummaryEstimate { .. }))) .collect() } -fn update_of(estimate: &OperatorNode) -> (SketchAlgorithm, SummaryUpdate) { - let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = estimate.asap() else { - unreachable!() - }; - let Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - input, - .. - }) = summary_input.asap() - else { - panic!("sketch state expected") - }; - (*kind, input.clone()) -} - -/// A heap sketch's bound is per item frequency, not Top-K membership, and -/// its weights are not proven non-negative: a Top-K estimate gets no -/// guarantee. A unit-count CMS estimate keeps its bound. +/// A PromQL top-k over per-series sums offers both heap families, and a +/// count offers Count-Min. #[test] -fn topk_estimates_carry_no_frequency_bound() { - let topk = composed_estimates(promql("topk by (job) (10, sum_over_time(m[1m]))")); - let algorithms: Vec<_> = topk.iter().map(|node| update_of(node).0).collect(); +fn topk_offers_both_heap_families() { + let topk = sketch_builds(promql("topk by (job) (10, sum_over_time(m[1m]))")); + let algorithms: Vec<_> = topk.iter().map(|(kind, _)| *kind).collect(); assert_eq!( algorithms, [ @@ -233,89 +188,62 @@ fn topk_estimates_carry_no_frequency_bound() { SketchAlgorithm::CountSketchWithHeap ] ); - assert!(topk.iter().all(|node| node.guarantee.is_none())); - - let count = composed_estimates(promql("count by (job) (m)")); - let cms = count - .iter() - .find(|node| update_of(node).0 == SketchAlgorithm::Cms) - .expect("CMS candidate"); - assert!(cms.guarantee.as_ref().is_some_and(|g| !g.is_exact())); + let count = sketch_builds(promql("count by (job) (m)")); + assert!(count.iter().any(|(kind, _)| *kind == SketchAlgorithm::Cms)); } /// Top-K over a grouped sum is a whole-expression heap: it absorbs the /// sum and ranks its group keys, read from the rows the sum reads. #[test] fn topk_over_an_aggregate_ranks_its_labels() { - let estimates = composed_estimates(promql("topk(3, sum by (job) (rate(m[5m])))")); - assert_eq!(estimates.len(), 2); - for estimate in &estimates { + let builds = sketch_builds(promql("topk(3, sum by (job) (rate(m[5m])))")); + assert_eq!(builds.len(), 2); + for (_, update) in &builds { assert_eq!( - update_of(estimate).1.item, + update.item, Some(SummaryInputExpr::Column(ColumnRef::Named("job".into()))) ); } } -/// A subquery inside a measure filter is substituted too: each choice for -/// the inner quantile gives its own DAG, and an approximate inner result clears -/// the outer measure's guarantee. +/// A subquery inside a measure filter is an input group of the filtered +/// state: the outer count's state reads the inner quantile's group, which +/// has its own candidates (exact and the quantile sketches' readouts). #[tokio::test] -async fn subquery_in_a_measure_filter_is_substituted() { +async fn subquery_in_a_measure_filter_is_an_input_group_of_the_state() { let query = "SELECT src, count(*) FILTER (WHERE bytes > (SELECT approx_percentile_cont(bytes, 0.5) FROM flows)) \ FROM flows GROUP BY src"; - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[sql(query).await]).unwrap(), - ) - .unwrap(); - assert_eq!(inventory.targets.len(), 2); - let inner = inventory - .targets + let memo = pass1(sql(query).await); + let subqueries: Vec = memo + .groups() .iter() - .position(|target| { - inventory - .aggregate(target) - .non_asap() - .and_then(|op| match op { - NonASAPOp::Aggregate { reduction, .. } => reduction.group_keys(), - _ => None, - }) - .is_some_and(|by| by.keys().is_empty()) + .filter_map(|group| match &group.expression { + Operator::ASAP(ASAPOp::SummaryAgg { + filter: Some(filter), + .. + }) => Some( + filter + .0 + .operator_refs() + .into_iter() + .copied() + .collect::>(), + ), + _ => None, }) - .expect("inner quantile"); - let candidates = &inventory.targets[inner].ways_to_compute; - assert!(candidates.len() > 1); - let (mut seen, mut approximate_inner) = (Vec::new(), false); - for (index, candidate) in candidates.iter().enumerate() { - let mut choice = vec![0; 2]; - choice[inner] = index; - let composed = compose_logical_candidate(&inventory, &choice).unwrap(); - let QueryRoot::Operator(root) = &composed[0] else { - panic!("operator root expected") - }; - let shown = format!("{root:?}"); - assert!(!seen.contains(&shown), "{candidate:?} composes as another"); - seen.push(shown); - let finalize = OperatorNode::reachable(root) - .into_iter() - .find(|node| { - matches!( - node.asap(), - Some(ASAPOp::FinalizeExactAccumulator { child }) - if matches!(child.asap(), Some(ASAPOp::SummaryAgg { filter: Some(_), .. })) - ) - }) - .expect("filtered outer count"); - let Some(ASAPOp::FinalizeExactAccumulator { child }) = finalize.asap() else { - unreachable!() - }; - let exact = |node: &OperatorNode| node.guarantee.as_ref().is_some_and(|g| g.is_exact()); - // The state's children: its input rows, then the subquery. - let inputs_exact = child.children().into_iter().all(|node| exact(node)); - assert_eq!(exact(&finalize), inputs_exact, "{candidate:?}"); - approximate_inner |= !inputs_exact; + .flatten() + .collect(); + assert!(!subqueries.is_empty()); + // The subquery reads the inner quantile's aggregate group. + for subquery in subqueries { + let below = memo.groups()[subquery.0 as usize].expression.children(); + assert!(below.iter().any(|group| { + matches!( + memo.groups()[group.0 as usize].expression, + Operator::NonASAP(NonASAPOp::Aggregate { .. }) + ) && memo.groups()[group.0 as usize].candidates.len() > 1 + })); } - assert!(approximate_inner); } /// Run the logical DAG `root` over `rows` of its one scanned table, each row @@ -392,49 +320,48 @@ fn execute(root: &Rc, rows: &[Vec<(&str, Value)>]) -> Vec (Rc, Rc) { - let exact = lower_sql(query, catalog, accuracy()).await.unwrap(); - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(exact.clone())]).unwrap(), - ) - .unwrap(); - let choice: Vec<_> = inventory - .targets + let _ = (catalog, query); + todo!("#1207 PR 6: realize the sum-and-count plan of `avg` from Pass 1's memo") +} + +/// The candidate of `memo`'s root group that is not its own expression +/// and computes `avg` from its `sum` and `count` states, and every group +/// below it. +fn avg_candidate_groups(memo: &Memo) -> Vec { + let root = root_group(memo); + let group = &memo.groups()[root.0 as usize]; + let mut below: Vec = Vec::new(); + let mut to_visit: Vec = group.candidates[1..] .iter() - .map(|target| { - target - .ways_to_compute - .iter() - .position(|c| { - *c == HowToComputeAggregateMeasure::ExactAccumulator(vec![ - ExactKind::Sum, - ExactKind::Count, - ]) - }) - .expect("avg as sum and count") + .flat_map(|candidate| { + candidate + .expression + .operator + .children() + .into_iter() + .copied() }) .collect(); - let composed = - compose_logical_candidate(&inventory, &choice).unwrap_or_else(|e| panic!("{query}: {e}")); - let QueryRoot::Operator(root) = &composed[0] else { - panic!("operator root expected") - }; - assert!( - OperatorNode::reachable(root) - .iter() - .any(|node| matches!(node.non_asap(), Some(NonASAPOp::Join { .. }))), - "{query}: sum joined with count expected" - ); - assert_eq!( - output_columns(&QueryRoot::Operator(root.clone())), - output_columns(&QueryRoot::Operator(exact.clone())), - "{query}" - ); - (exact, root.clone()) + while let Some(next) = to_visit.pop() { + if !below.contains(&next) { + below.push(next); + to_visit.extend( + memo.groups()[next.0 as usize] + .expression + .children() + .into_iter() + .copied(), + ); + } + } + below } fn avg_table() -> SqlCatalog { @@ -507,13 +434,25 @@ async fn filtered_sql_avg_is_null_without_matching_rows() { "SELECT k, avg(value) FILTER (WHERE value > 100) FROM t GROUP BY k", "SELECT avg(value) FILTER (WHERE value > 100) FROM t", ] { - let (_, root) = avg_as_sum_and_count(&catalog, query).await; - assert!(root.schema.fields.last().unwrap().nullable, "{query}"); - let null_when_no_count = OperatorNode::reachable(&root).iter().any(|node| { - let Some(NonASAPOp::Project { cols, child, .. }) = node.non_asap() else { + let exact = lower_sql(query, &catalog, accuracy()).await.unwrap(); + let memo = pass1(QueryRoot::Operator(exact)); + let root = &memo.groups()[root_group(&memo).0 as usize]; + assert!( + root.group_properties.schema.fields.last().unwrap().nullable, + "{query}" + ); + assert!(root.candidates.len() > 1, "{query}"); + let null_when_no_count = memo.groups().iter().any(|group| { + let Operator::NonASAP(NonASAPOp::Project { cols, child, .. }) = &group.expression + else { return false; }; - let count = child.schema.fields.len() - 1; + let count = memo.groups()[child.0 as usize] + .group_properties + .schema + .fields + .len() + - 1; matches!( &cols.last().unwrap().expr, ScalarExpr::Case { branches, .. } if matches!( @@ -525,28 +464,19 @@ async fn filtered_sql_avg_is_null_without_matching_rows() { ) ) }); - assert!(null_when_no_count, "{query}: {root:#?}"); + assert!(null_when_no_count, "{query}"); } } /// PromQL `avg` divides its `sum` and `count` vectors with a `BinaryOp`. #[test] fn promql_avg_divides_sum_and_count_vectors() { - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[promql("avg by (job) (m)")]).unwrap(), - ) - .unwrap(); - assert_eq!( - inventory.targets[0].ways_to_compute[0], - HowToComputeAggregateMeasure::ExactAccumulator(vec![ExactKind::Sum, ExactKind::Count]) - ); - let composed = compose_logical_candidate(&inventory, &[0]).unwrap(); - let QueryRoot::Operator(root) = &composed[0] else { - panic!("operator root expected") - }; - let kinds: Vec<_> = OperatorNode::reachable(root) + let memo = pass1(promql("avg by (job) (m)")); + let root = &memo.groups()[root_group(&memo).0 as usize]; + assert!(root.candidates.len() > 1); + let kinds: Vec<_> = avg_candidate_groups(&memo) .iter() - .map(|node| node.operator.kind_name()) + .map(|group| memo.groups()[group.0 as usize].expression.kind_name()) .collect(); assert!( kinds.contains(&"BinaryOp") && !kinds.contains(&"Join"), @@ -565,45 +495,18 @@ fn table(columns: &[(&str, bool)]) -> SqlCatalog { SqlCatalog::new().with_table("t", Schema::new(fields)) } -/// Every candidate of `query` over `catalog`, composed, by candidate. -async fn every_candidate( - catalog: &SqlCatalog, - query: &str, -) -> Vec<( - HowToComputeAggregateMeasure, - Result< - Vec, - asap_stage1_pass1_per_computation_candidates::PerComputationCandidateError, - >, -)> { +/// Pass 1's memo of `query` over `catalog`. +async fn pass1_sql(catalog: &SqlCatalog, query: &str) -> Memo { let root = lower_sql(query, catalog, accuracy()).await.unwrap(); - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap(); - assert_eq!(inventory.targets.len(), 1, "{query}"); - inventory.targets[0] - .ways_to_compute - .iter() - .enumerate() - .map(|(index, candidate)| { - ( - candidate.clone(), - compose_logical_candidate(&inventory, &[index]), - ) - }) - .collect() + pass1(QueryRoot::Operator(root)) } -/// The update of every `count` state (exact or sketch) below `root`. -fn count_updates(root: &QueryRoot) -> Vec { - let QueryRoot::Operator(root) = root else { - panic!("operator root expected") - }; - OperatorNode::reachable(root) +/// The update of every `count` state (exact or sketch) in `memo`. +fn count_updates(memo: &Memo) -> Vec { + memo.groups() .iter() - .filter_map(|node| match node.asap() { - Some(ASAPOp::SummaryAgg { family, input, .. }) + .filter_map(|group| match &group.expression { + Operator::ASAP(ASAPOp::SummaryAgg { family, input, .. }) if !matches!( family, FieldDataType::ExactAggregate(asap_types::ir::schema::ExactKind::Sum, _) @@ -618,10 +521,7 @@ fn count_updates(root: &QueryRoot) -> Vec { /// Whether `update` reads only the column `name` of `t`. fn reads_only(update: &SummaryUpdate, name: &str) -> bool { - let column = SummaryInputExpr::Column(ColumnRef::Qualified { - table: "t".into(), - name: name.into(), - }); + let column = SummaryInputExpr::Column(ColumnRef::Named(name.into())); match &update.item { Some(item) => *item == column && update.weight == SummaryInputExpr::Constant(1.0), None => update.weight == column, @@ -655,12 +555,17 @@ async fn sql_avg_counts_its_own_input() { assert_eq!(execute(&root, &rows), want); } -/// SQL `avg` as `sum` and `count` keeps an explicit output alias. +/// SQL `avg` as `sum` and `count` keeps an explicit output alias: its +/// candidate outputs the root group's schema, which names it. #[tokio::test] async fn sql_avg_keeps_its_explicit_output_name() { - let (_, root) = - avg_as_sum_and_count(&avg_table(), "SELECT avg(value) AS avg_latency FROM t").await; - assert_eq!(root.schema.fields.last().unwrap().name, "avg_latency"); + let memo = pass1_sql(&avg_table(), "SELECT avg(value) AS avg_latency FROM t").await; + assert!(memo.groups()[root_group(&memo).0 as usize].candidates.len() > 1); + let QueryRoot::Operator(query_root) = &memo.query_roots[0] else { + panic!("operator root expected") + }; + let output = &memo.groups()[query_root.0 as usize].group_properties.schema; + assert_eq!(output.fields.last().unwrap().name, "avg_latency"); } /// SQL `avg` of a non-null Int64 column as `sum` and `count` keeps the exact plan's Float64 columns. @@ -689,22 +594,19 @@ async fn sql_avg_of_an_int64_column_keeps_the_exact_columns() { } /// `count(*)` with no column known to be non-null (one nullable numeric -/// column, or several and none named `value`) is unsupported for every -/// candidate, not a count of a nullable column or an invalid DAG. +/// column, or several and none named `value`) gets no accumulator or +/// sketch: the root group keeps only its plain exact aggregate, not a count +/// of a nullable column. #[tokio::test] -async fn sql_count_star_without_a_non_null_column_is_unsupported() { +async fn sql_count_star_without_a_non_null_column_is_only_exact() { for columns in [&[("v", true)][..], &[("a", true), ("b", true)][..]] { - let catalog = table(columns); - for (candidate, composed) in - every_candidate(&catalog, "SELECT k, count(*) FROM t GROUP BY k").await - { - match composed { - Err(asap_stage1_pass1_per_computation_candidates::PerComputationCandidateError::Unsupported( - _, - )) => {} - other => panic!("{columns:?} {candidate:?}: {other:?}"), - } - } + let memo = pass1_sql(&table(columns), "SELECT k, count(*) FROM t GROUP BY k").await; + assert_eq!( + memo.groups()[root_group(&memo).0 as usize].candidates.len(), + 1, + "{columns:?}" + ); + assert!(count_updates(&memo).is_empty(), "{columns:?}"); } } @@ -714,13 +616,10 @@ async fn sql_count_star_without_a_non_null_column_is_unsupported() { #[tokio::test] async fn sql_count_of_a_nullable_column_counts_its_values() { let catalog = table(&[("v", true), ("n", false)]); - for (candidate, composed) in - every_candidate(&catalog, "SELECT k, count(v) FROM t GROUP BY k").await - { - let composed = composed.unwrap_or_else(|e| panic!("{candidate:?}: {e}")); - composed[0].validate_structure().unwrap(); - let updates = count_updates(&composed[0]); - assert_eq!(updates.len(), 1, "{candidate:?}"); - assert!(reads_only(&updates[0], "v"), "{candidate:?}: {updates:?}"); + let memo = pass1_sql(&catalog, "SELECT k, count(v) FROM t GROUP BY k").await; + let updates = count_updates(&memo); + assert!(!updates.is_empty()); + for update in &updates { + assert!(reads_only(update, "v"), "{updates:?}"); } } diff --git a/crates/planner/src/run.rs b/crates/planner/src/run.rs index 65c1666bf..6b9f9d207 100644 --- a/crates/planner/src/run.rs +++ b/crates/planner/src/run.rs @@ -9,9 +9,7 @@ use std::collections::HashSet; use std::rc::Rc; use std::time::Instant; -use asap_stage1_pass1_per_computation_candidates::{ - pass1_memo, Pass1Error, PerComputationCandidateError, -}; +use asap_stage1_pass1_per_computation_candidates::{pass1_memo, PerComputationCandidateError}; use asap_stage1_pass2_reuse_candidates::generate_logical_asap_memo_candidates; use asap_stage2_physical_optimization::{ generate_physical_asap_memo_candidates, Stage2AssignmentRealizer, @@ -121,34 +119,20 @@ pub fn run_planning( } // Stage 0: the memo of logical groups. let initial = memo_of_queries(&roots)?; - // Pass 1: the per-computation candidates the deployment can run and - // some query can use. - let accuracy_targets: Vec<_> = entries - .iter() - .map(|entry| entry.requirements.accuracy.clone()) - .collect(); - let accuracy = models.accuracy_check_inputs(); + // Pass 1: the initial candidates the deployment can run. A query Pass 1 + // cannot plan is an input error. let pass1 = pass1_memo( initial, - &accuracy_targets, - accuracy.model, - accuracy.evidence, - data, models .deployment_capabilities .supported_summaries .as_deref(), - ); - // A query Pass 1 cannot plan is an input error. - let pass1 = match pass1 { - Err(Pass1Error::PerComputation(error)) => return Err(error), - pass1 => pass1, - }; + )?; let mut report = PlanningReport { pass1_ms: elapsed_ms(started), ..PlanningReport::default() }; - let pass1_memo = pass1.as_ref().ok().filter(|_| settings.inspect).cloned(); + let pass1_memo = settings.inspect.then(|| pass1.clone()); let realizer = Stage2AssignmentRealizer { original_roots: &roots, entries, @@ -164,8 +148,6 @@ pub fn run_planning( .deployment_capabilities .validate(data) .map_err(SelectionError::InvalidDeploymentCapabilities)?; - let pass1 = - pass1.map_err(|error| SelectionError::LogicalASAPCandidates(error.to_string()))?; // Pass 2: local window composition over every legal // tumbling-window width, then the sharing rules. let started = Instant::now(); diff --git a/crates/stage1-pass1-per-computation-candidates/Cargo.toml b/crates/stage1-pass1-per-computation-candidates/Cargo.toml index 0a4df4c1a..6df7c52df 100644 --- a/crates/stage1-pass1-per-computation-candidates/Cargo.toml +++ b/crates/stage1-pass1-per-computation-candidates/Cargo.toml @@ -13,10 +13,6 @@ asap-types = { path = "../types" } asap-accuracy = { path = "../accuracy" } thiserror = "2" -[features] -# The test helpers (`test_support`) other crates' tests share. -test-support = [] - [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-accuracy = { path = "../accuracy", features = ["test-support"] } diff --git a/crates/stage1-pass1-per-computation-candidates/src/lib.rs b/crates/stage1-pass1-per-computation-candidates/src/lib.rs index b8cc17b9e..b34f4ebbc 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/lib.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/lib.rs @@ -1,40 +1,31 @@ //! `asap-stage1-pass1-per-computation-candidates` — #509 Stage 1, Pass 1: -//! per-computation candidate generation and composition, and the summary -//! realizations it offers. Its one entry is [`pass1_memo`], which fills the -//! memo groups of Stage 0's [`Memo`](asap_types::stages::Memo) with their -//! initial candidates; Pass 2 extends that memo. +//! each memo group's initial candidates. Its one entry is [`pass1_memo`], +//! which fills the memo groups of Stage 0's +//! [`Memo`](asap_types::stages::Memo) with their initial candidates (exact, +//! exact accumulator, one summary build per listed parameter configuration +//! with separate readouts, fused builds), every intermediate result its own +//! group; Pass 2 extends that memo. //! //! It depends only on `asap-types` (its input and output memo are in -//! `asap_types::stages`) and the shared accuracy model (`asap-accuracy`). -//! It depends on no other stage crate, and the planner facade calls it -//! after Stage 0. It depends on no query-language frontend either; its own -//! tests lower PromQL through `asap-frontend-promql` (a dev-dependency). -//! The `test-support` feature exports the Pass 1 internals other crates' -//! tests call (`test_support`). +//! `asap_types::stages`) and the shared accuracy model (`asap-accuracy`, +//! for the statistic each intent reads). It depends on no other stage +//! crate, and the planner facade calls it after Stage 0. It depends on no +//! query-language frontend either; its tests build their input memo through +//! `Memo::find_or_add_group` and `Memo::add_query`. //! -//! Accuracy guarantees come from the built-in analytical accuracy rules in -//! `asap-accuracy` (per-family estimators, composition and budget -//! allocation). They derive a machine-readable -//! [`AccuracyGuarantee`](asap_types::ir::properties::AccuracyGuarantee) -//! for every finalized value and propagate it through -//! approximate-over-approximate compositions under conservative rules (no -//! independence assumptions, unknown statistics stay unknown). A -//! composition with no rule leaves the guarantee `None` and keeps the -//! candidate. Pass 1 takes the deployment's `AccuracyModel` and evidence -//! only to prune summaries no query reading them can use (when they cannot -//! accept an output without its guarantee) and sizes every summary for its -//! consumers; plan selection (Stage 3) checks each selected candidate's -//! guarantee against its query's `AccuracyTarget` and never resizes. +//! Pass 1 reads no accuracy target beyond each measure's own (an exact one +//! offers no summary): the error of each build follows from its parameters +//! (`parameter_list`), and the metric rule, the parameter floors from +//! validity caps and the pruning caps are decided per edge after Pass 2 +//! (#1207 PR 4); plan selection (Stage 3) checks each query's composed +//! accuracy at its root. /// Stage 1, Pass 1's entry: the initial candidates of the memo groups. mod logical_memo_candidates; -/// Stage 1, Pass 1's computation targets, their candidates and their -/// composition. No execution timing is assigned: that is a Stage 2 -/// materialization decision. Its target list -/// (`PerComputationCandidates`) is Pass 1's working form, public only to -/// tests (`crate::test_support`, feature `test-support`). +/// Stage 1, Pass 1's computation targets over the memo groups and the +/// candidates that compute them. mod per_computation_candidates; -/// Frontend-to-Pass-1 acceptance tests over Pass 1's working form. +/// Pass 1's tests over memos built through the `Memo` methods. #[cfg(test)] mod per_computation_candidates_tests; /// The summary families that can serve an aggregate intent, the input a @@ -43,9 +34,6 @@ mod realization; /// Fixtures this crate's own tests share. #[cfg(test)] mod test_fixtures; -/// Test helpers other crates' tests call (feature `test-support`). -#[cfg(any(test, feature = "test-support"))] -pub mod test_support; -pub use logical_memo_candidates::{pass1_memo, Pass1Error}; +pub use logical_memo_candidates::pass1_memo; pub use per_computation_candidates::PerComputationCandidateError; diff --git a/crates/stage1-pass1-per-computation-candidates/src/logical_memo_candidates.rs b/crates/stage1-pass1-per-computation-candidates/src/logical_memo_candidates.rs index 428094cb6..e47252177 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/logical_memo_candidates.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/logical_memo_candidates.rs @@ -3,25 +3,12 @@ //! configuration, fused builds), with every intermediate result its own //! group (a [`Memo`]), which Pass 2 extends. -use asap_types::ir::properties::AccuracyTarget; -use asap_types::workload::DataWorkload; - -use asap_accuracy::{AccuracyEvidenceProvider, AccuracyModel}; - -use crate::per_computation_candidates::{ - generate_per_computation_candidates, PerComputationCandidateError, -}; use asap_types::ir::schema::SupportedSummary; use asap_types::stages::Memo; -/// Why [`pass1_memo`] failed. -#[derive(Debug, thiserror::Error)] -pub enum Pass1Error { - /// A query's DAG cannot be planned: it already holds ASAP or physical - /// annotations, or a measure's accuracy target is invalid. - #[error(transparent)] - PerComputation(#[from] PerComputationCandidateError), -} +use crate::per_computation_candidates::{ + add_pass1_candidates, computation_targets, PerComputationCandidateError, +}; /// Stage 1, Pass 1: the initial candidates of the memo groups of `memo` /// (Stage 0's, without candidates). It finds one computation target per @@ -29,25 +16,17 @@ pub enum Pass1Error { /// execution, drops the summaries the deployment does not support /// (`supported_summaries`, the deployment's /// `DeploymentCapabilities::supported_summaries`; `None` supports every -/// one) and, unless `accuracy` or `evidence` may accept an output without -/// its guarantee, the summaries no query reading them can use (a query -/// `q`'s target is `accuracy_targets[q]`). It then adds each way to compute -/// a group as candidates, through `Memo::find_or_add_group` and -/// `Memo::add_candidate`. +/// one), and adds each way to compute a group as candidates, through +/// `Memo::find_or_add_group` and `Memo::add_candidate`: every group's own +/// expression, exact accumulators, one summary build per configuration of +/// the family's predefined list with separate readouts, and fused builds. +/// Pruning by the queries' accuracy targets is decided per edge, after +/// Pass 2 (#1207 PR 4). pub fn pass1_memo( - memo: Memo, - accuracy_targets: &[AccuracyTarget], - accuracy: &dyn AccuracyModel, - evidence: &dyn AccuracyEvidenceProvider, - data: &DataWorkload, + mut memo: Memo, supported_summaries: Option<&[SupportedSummary]>, -) -> Result { - let mut per_computation = generate_per_computation_candidates(&memo)? - .without_unsupported_summaries(supported_summaries); - if !accuracy.may_accept_regardless_of_guarantee() && !evidence.may_provide_evidence() { - per_computation = - per_computation.without_summaries_no_query_can_use(accuracy_targets, data, accuracy); - } - let _ = (memo, per_computation); - todo!("#1207 PR 3: add each way to compute as candidates, one per listed θ") +) -> Result { + let targets = computation_targets(&memo, supported_summaries)?; + add_pass1_candidates(&mut memo, &targets)?; + Ok(memo) } diff --git a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs index e59abc541..0c3d2cca5 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs @@ -1,142 +1,126 @@ -//! Stage 1 (logical ASAP-aware optimization), Pass 1: per-computation -//! candidate generation, as defined in -//! `docs/design_docs/proposals/planner-layering.md`. +//! Stage 1, Pass 1: each memo group's initial candidates (concepts doc +//! `memo-groups-properties-requirements.md`, section 3, "Stage 1, Pass 1"; +//! #1203, #1207). //! -//! [`generate_per_computation_candidates`] walks the memo groups of the -//! workload's memo groups ([`Memo`]) and -//! returns, for every eligible computation, its unranked candidates. A -//! computation ([`ComputationTarget`]) is one measure of an `Aggregate` -//! group whose intent has a realization besides plain exact execution, once -//! however many queries read the group; nested aggregates and operators -//! read by scalar roots are found too. -//! Pass 1 does not consider sharing across computations; that is Pass 2. +//! [`computation_targets`] walks the memo groups and finds, for every +//! aggregate group, one target ([`ComputationTarget`]) per measure whose +//! intent has a realization besides plain exact execution, once however +//! many queries read the group. [`add_pass1_candidates`] then adds each way +//! to compute a group as candidates, through `Memo::find_or_add_group` and +//! `Memo::add_candidate`: //! -//! - A candidate says how a target is computed: exact or a summary -//! ([`HowToComputeAggregateMeasure`]). Each target has exactly one exact option: -//! its exact accumulator states (`ExactAccumulator`; `[Sum, Count]` for -//! `avg`) when they can be built, otherwise plain `Exact`. Whether they are -//! materialized is decided in Stage 2. -//! - A computation that fuses a target with the targets beneath it is the -//! outer target's [`FusedComputation`], which absorbs the ones beneath and -//! has its own candidates: the whole-expression top-k over a per-item -//! `sum` or `count` (`Summary` of a heap family) and the nested sum, a -//! grouped `sum` over a `sum` (`ExactAccumulator([Sum])`). -//! - Summary choices here are unsized families. Adding them to memo groups -//! creates one build candidate per listed parameter configuration -//! (`parameter_list`); Stage 3 checks each query's composed accuracy at its -//! root. -//! - Candidates are pruned only when provably unable to meet the accuracy -//! requirement or to run, or when the exact candidate provably dominates -//! them (a PromQL top-k's heap over its instant vector, one row per item). -//! - A measure filter or `HAVING` prunes nothing. The target records the -//! measure filter (applied as `SummaryAgg.filter`) and one [`HavingEntry`] -//! per consumer `Filter` directly above the aggregate: a key-only predicate -//! applied by every consumer of the aggregate's group, across queries, may go -//! below the aggregate, anything else stays above the output (approximate -//! outputs then get guarantee `None`). So every candidate of a group -//! computes the group's own rows. +//! - **Exact.** Every group's own expression is a candidate of it. +//! - **Exact accumulator.** An accumulator-state group +//! (`SummaryAgg` of an exact family) and a `FinalizeExactAccumulator` +//! candidate over it; `avg` finalizes its `sum` and `count` states and +//! divides them. +//! - **Summary.** One state group per family (`SummaryAgg` of the family), +//! with one build candidate per configuration of the family's predefined +//! list (`parameter_list`), and a readout (`SummaryEstimate`) group over +//! it, read by the result group. +//! - **Fused builds.** A whole-expression top-k (a heap over the rows below +//! a per-item `sum` or `count`) and a nested distributive aggregate (one +//! exact `Sum`, `Min` or `Max` state over the rows below a `sum` of a +//! `sum`, a `min` of a `min` or a `max` of a `max`): each computes several +//! operators of one appearance as one operator, and its builds list the +//! groups of the operators it replaces in `fused_groups` (concepts doc +//! §3, "Fused groups"). //! -//! The input must be a logical DAG: ASAP operators are rejected. A logical -//! DAG has no physical properties (#1200). No cost model is used. The accuracy model -//! is used only to prune summaries no query reading them can use -//! ([`PerComputationCandidates::without_summaries_no_query_can_use`]); -//! ranking and discarding valid candidates belong to Stage 3. +//! Every intermediate result is its own group, and every candidate is one +//! operator over input groups. A candidate outputs its group's schema +//! exactly (`add_candidate` checks it), so a readout whose column names +//! differ from the group's is read through a renaming `Project` group. +//! $\boldsymbol\theta$ lives only in `LogicalProperties::parameters`, never +//! in an operator. //! -//! # Whole-workload candidates (logical ASAP-aware optimization, end of Pass 1) +//! Each candidate's first input requirements ([`input_requirements`]) carry +//! only the direct need of its operator: a top-k readout needs a heap of at +//! least `k` items. Pass 1 has no other parameter floor: the consumers that +//! read a state group are readouts, finalizers and merges, whose error +//! propagation is linear, so no validity cap applies to a state group's +//! input (concepts doc, section 4). The metric rule and the pruning caps are +//! decided per edge, after the last Pass 2 rule (#1207 PR 4). //! -//! [`compose_logical_candidate`] is Algorithm 1's -//! `SUBSTITUTE(d, s_1 ↦ c_1, …)`: it picks one per-computation candidate per -//! target (`choice[i]` indexes `targets[i]`'s candidates, then its fused -//! computation's, [`ComputationTarget::computation_for`]) and builds one whole-workload -//! candidate DAG. An aggregate whose chosen candidates are all `Exact` is -//! kept. Otherwise each of its measures is substituted on its own over the -//! aggregate's input: +//! A measure filter or `HAVING` prunes nothing. The measure filter is +//! applied as `SummaryAgg.filter`. A `HAVING` is the aggregate's own +//! `having` or a consumer `Filter` group directly above the aggregate group, +//! read from the memo: a key-only predicate ([`key_only_having`]) is pushed +//! below the aggregate by the candidates that compute the filtered result +//! (the aggregate group, or that `Filter` group); any other stays a `Filter` +//! above the output. //! -//! - `ExactAccumulator` with one state: `SummaryAgg` with the exact family → -//! `FinalizeExactAccumulator`. -//! - `ExactAccumulator([Sum, Count])` of an `avg`: the finalized `sum` and -//! `count` accumulators divided: a PromQL `BinaryOp`, or for SQL rows a -//! join on the group keys (NULL keys match) and a SQL division, NULL where -//! the count is 0. -//! - `Summary`: `SummaryAgg` → `SummaryEstimate`. -//! - `Exact` (or a measure that is no target): a single-measure `Aggregate`. -//! -//! A fused computation's candidate reads the absorbed targets' input instead: -//! the whole-expression top-k is one heap `SummaryAgg` keyed by the ranked -//! item → `SummaryEstimate` (over a grouped sum of per-series sums or counts, -//! it reads the per-series target's input, the raw data samples); the nested sum is `SummaryAgg` with the exact -//! `Sum` family grouped by the outer keys → `FinalizeExactAccumulator`. -//! -//! The substituted output keeps the aggregate's column names. The measures -//! of a multi-measure aggregate are joined back on the group keys (NULL keys -//! match, as grouping treats them). A measure filter becomes -//! `SummaryAgg.filter`, so a group with no matching row stays. A -//! `BelowAggregate` `HAVING` becomes a `Filter` below the state, with its key -//! columns renumbered to the input's, and the consumer `Filter` it came from -//! is dropped; it is not a `SummaryAgg.filter`, which would keep the dropped -//! groups. An `AboveOutput` `HAVING` stays a `Filter` above the output. -//! -//! Summaries are sized here, each for its own measure's accuracy requirement. -//! When a query's value composes several approximate inputs (every sketch -//! summary below its root, when its guarantee is not exact), each is sized -//! for an equal share of that requirement: `ε/n` (a relative error -//! `1 − (1 + ε)^{−1/n}`) and `δ/n` for `n` inputs (#669b). Only the inputs -//! approximate in this candidate count. This composition sizes for ε and δ -//! per output value; attached to the memo groups, each candidate's -//! approximate inputs are sized for the values its consumers' targets cover -//! (`δ / (n · V)`, -//! [`pass1_memo`](crate::logical_memo_candidates::pass1_memo)), -//! their final sizes. Pass 2 inherits them or takes the strictest shared -//! consumer's, and Stage 3 only checks the composed accuracy. -//! -//! Every node of the candidate gets a theoretical guarantee, composed with -//! the built-in accuracy model through operators (#669): exact over exact -//! inputs; a `SummaryEstimate` the sized family's bound where that bound -//! answers the estimate (not for Top-K, where a heap sketch bounds item -//! frequencies, not membership; not for a Count-Min family over weights not -//! proven non-negative); aggregation and arithmetic over approximate values -//! by their composition rules, with statistics left symbolic for plan -//! selection to bind; and `None` where no rule applies (comparisons and -//! value predicates, rank errors). Plan selection's accuracy model decides -//! a candidate without one. -use std::collections::{HashMap, HashSet}; -use std::rc::Rc; +//! The input must be a logical memo: a group whose expression is an ASAP +//! operator is rejected. No cost model and no accuracy target is read. +use std::collections::BTreeMap; +use asap_types::ir::operator::non_asap::any_measure_filtered; use asap_types::ir::operator::operator_properties::{BinaryOpKind, JoinKind, Reduction, Source}; use asap_types::ir::operator::AggIntent; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::scalar::expr_ir::ArithmeticOpKind; use asap_types::ir::scalar::{resolve_column_ref, ColumnRef, CompareOpKind, ScalarValue}; -use asap_types::ir::schema::{DataType, Schema}; use asap_types::ir::schema::{ - EntityIdentity, ExactKind, FieldDataType, GroupingStrategy, NonNegativeWeightProof, - SketchAlgorithm, StatisticKind, SummaryInputExpr, SummaryUpdate, WeightDomain, + DataType, EntityIdentity, ExactKind, FieldDataType, GroupingStrategy, NonNegativeWeightProof, + Schema, SketchAlgorithm, SketchParams, SketchStatistic, StatisticKind, SummaryInputExpr, + SummaryUpdate, SupportedSummary, WeightDomain, }; use asap_types::ir::{ - ASAPOp, BinaryOperator, ExprSemantics, NonASAPOp, Operator, OperatorNode, OperatorResultKind, - Predicate, ProjectItem, QueryRoot, ScalarExpr, SchemaDerivationError, + ASAPOp, BinaryOperator, ExprSemantics, NonASAPOp, Operator, OperatorResultKind, Predicate, + ProjectItem, ScalarExpr, SchemaDerivationError, +}; +use asap_types::stages::{ + parameter_list, CandidateError, CandidateExpression, CandidateForMemoGroup, + HowToComputeAggregateMeasure, LogicalProperties, LogicalPropertiesError, LogicalRequirements, + Memo, MemoGroupId, OriginOfCandidateForMemoGroup, }; use thiserror::Error; -use asap_accuracy::composition::parse_raw_data_samples_sign_statistic; -use asap_accuracy::estimators::hll; -use asap_accuracy::propagation::{ - bind_guarantees, raw_data_samples_of_metric, root_guarantee, with_guarantees, -}; use asap_accuracy::sizing::{accuracy_budget, sizing_target, sketch_statistic}; -use asap_accuracy::AccuracyModel; -use asap_types::ir::properties::AccuracyGuarantee; -use asap_types::workload::bounds::{declared_non_negative, series_read, values_per_result}; -use asap_types::workload::DataWorkload; - -use crate::realization::summary_candidates; use asap_types::deployment_capabilities::summary_supported; -use asap_types::ir::schema::SupportedSummary; -use asap_types::stages::{ - ComputationTarget, FusedComputation, HavingEntry, HavingPlacement, - HowToComputeAggregateMeasure, Memo, MemoGroupId, + +use crate::realization::{ + expression, raw_data_samples_of_metric, realize_keyed_additive_summary_input, schema, + summary_candidates, }; +/// One measure of an aggregate group with at least one realization besides +/// exact execution. +#[derive(Debug, Clone)] +pub(crate) struct ComputationTarget { + /// The memo group of the `Aggregate` the measure belongs to. + pub aggregate_group: MemoGroupId, + /// Index into the aggregate's measures. + pub measure_index: usize, + /// The measure's unranked ways to compute it, the exact option first. + pub ways_to_compute: Vec, + /// The fused computation of this measure together with the target + /// directly beneath it, offered beside this target's own candidates; + /// `None` when no fusion applies. + pub fused_computation: Option, +} + +/// A target's measure computed together with the targets beneath it, as +/// one operator that replaces their operators in this appearance of the +/// sub-expression (concepts doc §3, "Fused groups"): the whole-expression +/// top-k over a per-item `sum` or `count` (`Summary` of a heap family), or +/// a nested distributive aggregate, a grouped `sum`, `min` or `max` over the +/// same reduction (`ExactAccumulator([Sum])`, `[Min]` or `[Max]`). It has +/// the outer target's aggregate, measure, filter and `HAVING`. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct FusedComputation { + /// The groups of the operators it replaces, which its builds list in + /// `fused_groups`: the aggregate directly beneath, then, for a + /// whole-expression heap over the raw data samples beneath a grouped sum + /// of per-series sums or counts (`topk(k, sum by (G) + /// (sum_over_time(m[W])))`), the per-series aggregate beneath that, then + /// the groups any of these targets' own fused computations replace. + pub absorbed_groups: Vec, + /// How the fused computation is computed: exact or a summary. Empty + /// when Pass 1 pruned every one (today only for a family the deployment + /// does not support); the fusion is then not offered. + pub ways_to_compute: Vec, +} + /// Unranked candidates of one measure and the candidates pruned for it. #[derive(Debug, Clone, PartialEq)] pub(super) struct ComputationChoices { @@ -173,385 +157,16 @@ fn prune_own( pruned } -/// Compact Pass 1 inventory over the memo groups it walked: one target per -/// measure of each aggregate group, however many queries read the group. -/// Pass 1's working form, not its output: -/// [`pass1_memo`](crate::pass1_memo) -/// builds, prunes and attaches it to the memo groups. Public only to tests -/// (feature `test-support`). -#[derive(Debug, Clone)] -pub struct PerComputationCandidates { - /// Each query's root over the memo groups' logical nodes, in workload - /// order: a group several queries read is one node. Whole-workload - /// candidates are these roots with chosen candidates substituted. - pub roots: Vec, - /// Each memo group's logical node, by group index. - pub(crate) logical_nodes: Rc<[Rc]>, - /// The targets; a whole-workload choice has one digit per target, - /// ranging over [`ComputationTarget::choice_count`]. - pub targets: Vec, -} - -impl PerComputationCandidates { - /// The `Aggregate` node of `target`'s memo group. - pub fn aggregate(&self, target: &ComputationTarget) -> &Rc { - &self.logical_nodes[target.aggregate_group.0 as usize] - } - - /// The number of whole-workload candidates: the size of the Cartesian - /// product of the targets' candidates, where a target with a fused - /// computation and the targets beneath that it absorbs count as their - /// own combinations plus the fused computation's candidates (the - /// absorbed targets are not computed then), saturating at `usize::MAX`. - /// A target with `n` candidates and `f` fused ones counts `n · Π m + f`, - /// the product over the targets it absorbs that no other of them - /// absorbs, each counted the same way. It is exactly the number of - /// choices enumeration keeps ([`Self::absorbed_choice_varies`]). - #[cfg(any(test, feature = "test-support"))] - pub fn combinations(&self) -> usize { - let absorbed: HashSet = absorbed_targets(&self.targets).collect(); - (0..self.targets.len()) - .filter(|index| !absorbed.contains(index)) - .map(|index| self.combinations_of(index)) - .fold(1usize, usize::saturating_mul) - } - - /// [`Self::combinations`] of target `index` with the targets it absorbs. - #[cfg(any(test, feature = "test-support"))] - fn combinations_of(&self, index: usize) -> usize { - let target = &self.targets[index]; - let n = target.ways_to_compute.len(); - let Some(fused) = &target.fused_computation else { - return n; - }; - let absorbed_by_another = |a: usize| { - fused.absorbed_targets.iter().any(|&b| { - self.targets[b] - .fused_computation - .as_ref() - .is_some_and(|inner| inner.absorbed_targets.contains(&a)) - }) - }; - fused - .absorbed_targets - .iter() - .filter(|&&a| !absorbed_by_another(a)) - .map(|&a| self.combinations_of(a)) - .fold(n, usize::saturating_mul) - .saturating_add(fused.ways_to_compute.len()) - } - - /// Whether `choice` picks a fused computation and a candidate other - /// than the first for a target it absorbs: the same whole-workload - /// candidate as the one with the first, so enumeration skips it. - #[cfg(any(test, feature = "test-support"))] - pub fn absorbed_choice_varies(&self, choice: &[usize]) -> bool { - self.targets.iter().zip(choice).any(|(target, &index)| { - target - .computation_for(index) - .and_then(|(_, absorbed)| absorbed) - .is_some_and(|inner| inner.iter().any(|&i| choice[i] != 0)) - }) - } - - /// This inventory without the summary candidates the executor does not - /// support (`supported_summaries`, the deployment's - /// `DeploymentCapabilities::supported_summaries`; `None` supports - /// every one): a target's own are removed, and so are a fused - /// computation's (with all of them removed, the fusion is not offered). - /// The readout is the statistic the measure's estimate reads (the outer measure's for a - /// fused computation). With every summary supported it is unchanged. - pub fn without_unsupported_summaries( - mut self, - supported_summaries: Option<&[SupportedSummary]>, - ) -> Self { - for target in self.targets.iter_mut() { - let aggregate = &self.logical_nodes[target.aggregate_group.0 as usize]; - let Some(NonASAPOp::Aggregate { measures, .. }) = aggregate.non_asap() else { - continue; - }; - let Some(readout) = sketch_statistic(&measures[target.measure_index]).map(|s| s.kind()) - else { - continue; - }; - let unsupported = |candidate: &HowToComputeAggregateMeasure| { - matches!(candidate, HowToComputeAggregateMeasure::Summary(family) - if !summary_supported(supported_summaries, family, readout)) - }; - prune_own(&mut target.ways_to_compute, unsupported); - if let Some(fused) = &mut target.fused_computation { - fused.ways_to_compute.retain(|way| !unsupported(way)); - } - } - self - } - - /// This inventory without the summary candidates no query can use - /// (#1047, accuracy A1 of #1095): a summary of a target (its own, or - /// its fused computation's) is removed when, for every query - /// whose root reaches the target's aggregate, - /// `output_can_meet_target` says no: the query's output, composed - /// with that summary and every other target exact, has no error bound - /// that can meet the query's target (`query_targets[q]`) at any size, - /// as `model` judges metrics. Every other target exact is the most - /// favorable combination: an approximate input never adds a guarantee. - /// A summary some query can use is kept, even when another query - /// reaching the group cannot; plan selection rejects it for that query. - /// - /// Pass 1 cannot see Pass 2's reuse of one group's summary by another - /// group (rollup, window rollup, finer-aggregate reuse, top-k reuse), so - /// a summary is also kept whenever another target, over another - /// aggregate reading one of the same sources, offers the same family - /// and some query reading that target can use it: Pass 2 may build that - /// target's state from this one, and the reused state composes through - /// that target's queries as its own would. This keeps more than those - /// rules' preconditions need. Exact candidates are never - /// pruned, and with every fused summary pruned the fusion is not - /// offered. A summary whose composition fails is kept. - /// - /// Pass 1's entry (`pass1_memo`) - /// calls it only when the deployment's accuracy model and evidence - /// cannot accept an output without its guarantee. - pub fn without_summaries_no_query_can_use( - mut self, - query_targets: &[AccuracyTarget], - data: &DataWorkload, - model: &dyn AccuracyModel, - ) -> Self { - // The aggregates each query's root reaches. - let reached: Vec> = self - .roots - .iter() - .map(|root| { - root.operator_refs() - .into_iter() - .flat_map(OperatorNode::reachable) - .filter(|node| matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .map(|node| Rc::as_ptr(&node)) - .collect() - }) - .collect(); - // Each target's sources, and every family it offers. - let sources: Vec> = self - .targets - .iter() - .map(|target| sources_read(self.aggregate(target))) - .collect(); - let families = |target: &ComputationTarget| -> Vec { - (0..target.choice_count()) - .filter_map(|choice| match target.computation_for(choice) { - Some((HowToComputeAggregateMeasure::Summary(family), _)) => Some(*family), - _ => None, - }) - .collect() - }; - let offered: Vec> = self.targets.iter().map(families).collect(); - // Per target, its summary choices no query reading its aggregate can - // use; a target no query reaches, or whose composition fails, keeps - // every choice. - let mut unusable_by_own_queries: Vec> = vec![Vec::new(); self.targets.len()]; - for (index, target) in self.targets.iter().enumerate() { - let aggregate = Rc::as_ptr(self.aggregate(target)); - let queries: Vec = (0..self.roots.len()) - .filter(|&q| reached[q].contains(&aggregate) && q < query_targets.len()) - .collect(); - if queries.is_empty() { - continue; - } - let roots: Vec = queries.iter().map(|&q| self.roots[q].clone()).collect(); - for choice in 0..target.choice_count() { - let Some((HowToComputeAggregateMeasure::Summary(_), _)) = - target.computation_for(choice) - else { - continue; - }; - let mut choices = vec![0; self.targets.len()]; - choices[index] = choice; - let Ok(composed) = compose(&self, &roots, &choices) else { - continue; - }; - if !queries - .iter() - .zip(&composed) - .any(|(&q, root)| output_can_meet_target(root, &query_targets[q], data, model)) - { - unusable_by_own_queries[index].push(choice); - } - } - } - // The families each target's own queries can use. - let usable_families: Vec> = self - .targets - .iter() - .enumerate() - .map(|(index, target)| { - (0..target.choice_count()) - .filter(|choice| !unusable_by_own_queries[index].contains(choice)) - .filter_map(|choice| match target.computation_for(choice) { - Some((HowToComputeAggregateMeasure::Summary(family), _)) => Some(*family), - _ => None, - }) - .collect() - }) - .collect(); - // Whether Pass 2 may reuse target `index`'s `family` for another - // target's aggregate whose own queries can use it: a reused state - // composes through that target's queries as its own would. - let reusable = |index: usize, family: &SketchAlgorithm| { - let aggregate = self.targets[index].aggregate_group; - self.targets.iter().enumerate().any(|(other, target)| { - target.aggregate_group != aggregate - && offered[other].contains(family) - && usable_families[other].contains(family) - && sources[other] - .iter() - .any(|source| sources[index].contains(source)) - }) - }; - let mut unusable: Vec<(usize, usize)> = Vec::new(); - for (index, choices) in unusable_by_own_queries.iter().enumerate() { - for &choice in choices { - let Some((HowToComputeAggregateMeasure::Summary(family), _)) = - self.targets[index].computation_for(choice) - else { - continue; - }; - if !reusable(index, family) { - unusable.push((index, choice)); - } - } - } - for (index, target) in self.targets.iter_mut().enumerate() { - let own = target.ways_to_compute.len(); - let pruned_choices: Vec = unusable - .iter() - .filter(|(i, _)| *i == index) - .map(|(_, choice)| *choice) - .collect(); - if pruned_choices.is_empty() { - continue; - } - let own_pruned: Vec = pruned_choices - .iter() - .filter(|&&choice| choice < own) - .map(|&choice| target.ways_to_compute[choice].clone()) - .collect(); - let fused_pruned: Vec = pruned_choices - .iter() - .filter(|&&choice| choice >= own) - .filter_map(|&choice| { - let fused = target.fused_computation.as_ref()?; - fused.ways_to_compute.get(choice - own).cloned() - }) - .collect(); - prune_own(&mut target.ways_to_compute, |way| own_pruned.contains(way)); - if let Some(fused) = &mut target.fused_computation { - fused - .ways_to_compute - .retain(|way| !fused_pruned.contains(way)); - } - } - self - } -} - -/// The sources every scan below `node` reads, each once. -fn sources_read(node: &Rc) -> Vec { - let mut sources = Vec::new(); - for node in OperatorNode::reachable(node) { - if let Some(NonASAPOp::Scan { source, .. }) = node.non_asap() { - if !sources.contains(source) { - sources.push(source.clone()); - } - } - } - sources -} - -/// Whether a query's composed output `root` can meet `target` at some size -/// of its summaries: its guarantee is exact, or it has one in a metric -/// `model` says can meet `target` ([`AccuracyModel::metric_can_meet`]; an -/// exact target accepts only exact) whose error, and failure probability -/// when `target` sets one, are known once the statistics Pass 1 left -/// symbolic are bound and the guarantee is stated over the values the -/// target's scope covers, as plan selection states it. The sign of a -/// metric's samples is bound as plan selection binds it, proven only when -/// `data` declares the metric a counter or non-negative -/// ([`declared_non_negative`]). Classic HLL's failure probability is bound -/// from `data`'s declared distinct bound for its source at the largest -/// precision the contract covers: a bound outside the contract there (no -/// certificate, failure probability 1) is outside it at every size, so it -/// stays unbound. Every other statistic (row and series counts, stream -/// norms, an undeclared distinct bound, operand domains) is assumed -/// bindable, so no candidate is pruned for one plan selection may bind. -/// The number of values a whole-result target covers is `data`'s -/// ([`values_per_result`]): when it is not bound, a per-value guarantee -/// that may fail never meets a whole-result failure probability, whatever -/// the size (sizing splits δ over those values). -pub(crate) fn output_can_meet_target( - root: &QueryRoot, - target: &AccuracyTarget, - data: &DataWorkload, - model: &dyn AccuracyModel, -) -> bool { - let bind = |guarantee: &AccuracyGuarantee| { - guarantee.bind(&|statistic| { - if let Some(metric) = parse_raw_data_samples_sign_statistic(statistic) { - return declared_non_negative(data, metric).then_some(1.0); - } - if let Some((_, relative_error, source)) = - hll::parse_classic_failure_statistic(statistic) - { - // At the largest precision the contract covers: no - // certificate there, none at any size. - let declared = data - .sources - .get(source) - .and_then(|facts| facts.max_distinct_per_evaluation); - if let Some(bound) = declared { - let best = - hll::classic_failure_probability(hll::MAX_PRECISION, relative_error, bound); - return (best < 1.0).then_some(best); - } - } - Some(1.0) - }) - }; - let guarantee = match root_guarantee(root) { - Some(guarantee) => Some(bind(&guarantee)), - None => bind_guarantees(root, &bind), - }; - let Some(guarantee) = guarantee else { - return false; - }; - if guarantee.is_exact() { - return true; - } - let values = values_per_result(root, series_read(root, data), data); - let guarantee = guarantee.over_scope(target.accuracy_scope, values); - model.metric_can_meet(guarantee.spec.accuracy_metric, target) - && guarantee.spec.error.evaluate().is_some() - && (target.failure_probability.is_none() - || guarantee.spec.failure_probability.evaluate().is_some()) -} - -/// The indices of `targets` some target's fused computation absorbs. -#[cfg(any(test, feature = "test-support"))] -fn absorbed_targets(targets: &[ComputationTarget]) -> impl Iterator + '_ { - targets - .iter() - .filter_map(|target| target.fused_computation.as_ref()) - .flat_map(|fused| fused.absorbed_targets.iter().copied()) -} - +/// Why Pass 1 could not add a group's candidates. #[derive(Debug, Error)] pub enum PerComputationCandidateError { #[error(transparent)] Structure(#[from] SchemaDerivationError), #[error("logical candidate input already contains ASAP operators")] ContainsAsap, - /// `root` (the query's index in the workload) and `measure` locate the target; - /// `computation_candidates_for_intent` alone leaves them `None`. + /// `root` (the first query reaching the aggregate) and `measure` locate + /// the target; `computation_candidates_for_intent` alone leaves them + /// `None`. #[error( "approximate accuracy of root {}, measure {} requires epsilon and delta in (0, 1)", root.as_deref().unwrap_or("?"), @@ -561,8 +176,14 @@ pub enum PerComputationCandidateError { root: Option, measure: Option, }, - #[error("choice must name one listed candidate per target")] - InvalidChoice, + /// A candidate Pass 1 built breaks the node rules or does not output + /// its group's result: a bug in the rule that built it. + #[error(transparent)] + Candidate(#[from] CandidateError), + /// A candidate Pass 1 built has logical properties that cannot go + /// together: a bug in the rule that built it. + #[error(transparent)] + LogicalProperties(#[from] LogicalPropertiesError), #[error("unsupported substitution in logical ASAP-aware optimization: {0}")] Unsupported(&'static str), } @@ -623,28 +244,23 @@ pub(super) fn computation_candidates_for_intent( /// Whether an `avg` measure's input may be null, so `count` rows differ from /// counted values. An unresolvable input is treated as possibly null. -fn nullable_avg_input(node: &OperatorNode, intent: &AggIntent) -> bool { - let Some(NonASAPOp::Aggregate { child, .. }) = node.non_asap() else { - return true; - }; +fn nullable_avg_input(input: &Schema, intent: &AggIntent) -> bool { let column = match intent.input_cols().first() { Some(column) => Some(*column), - None => resolve_column_ref(&ColumnRef::SampleValue, &child.schema).ok(), + None => resolve_column_ref(&ColumnRef::SampleValue, input).ok(), }; column - .and_then(|column| child.schema.fields.get(column)) + .and_then(|column| input.fields.get(column)) .is_none_or(|field| field.nullable) } -/// Whether `having` over `aggregate`'s output reads only `by(...)` group keys. -/// A global aggregate (no group keys) never qualifies: its one output row -/// exists even when no input row does, so no row predicate can stand in. -fn key_only_having(aggregate: &OperatorNode, having: &Predicate) -> bool { - let keys = match aggregate.non_asap() { - Some(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(by), - .. - }) if !by.is_without() => by.len(), +/// Whether `having` over an aggregate's output reads only its `by(...)` +/// group keys. A global aggregate (no group keys) never qualifies: its one +/// output row exists even when no input row does, so no row predicate can +/// stand in. +fn key_only_having(reduction: &Reduction, having: &Predicate) -> bool { + let keys = match reduction { + Reduction::Reduce(by) if !by.is_without() => by.len(), _ => 0, }; keys > 0 @@ -652,137 +268,74 @@ fn key_only_having(aggregate: &OperatorNode, having: &Predicate) -> bool { && having.0.columns_referenced().iter().all(|id| *id < keys) } -fn placement(below: bool) -> HavingPlacement { - if below { - HavingPlacement::BelowAggregate - } else { - HavingPlacement::AboveOutput - } +/// The consumer `Filter` groups directly above `aggregate_group` (its +/// `HAVING` as the SQL frontend lowers it), with their predicates. A +/// `Filter` that reads the aggregate from a scalar expression stays a plain +/// consumer. +fn having_filter_groups( + memo: &Memo, + aggregate_group: MemoGroupId, +) -> impl Iterator)> { + memo.groups()[aggregate_group.0 as usize] + .parent_groups() + .iter() + .filter_map(move |&parent| match expression(memo, parent) { + Operator::NonASAP(NonASAPOp::Filter { child, pred }) if *child == aggregate_group => { + Some((parent, pred)) + } + _ => None, + }) } -/// Each memo group's expression as an operator-node tree, by group id: the -/// trees target discovery still walks. `Memo::logical_node_trees` is -/// deleted; discovery reads the groups in #1207 PR 3. -fn logical_node_trees(memo: &Memo) -> Vec> { - let _ = memo; - todo!("#1207 PR 3: discover targets over the memo groups, not node trees") +/// Whether `aggregate_group` has a `HAVING`: its own `having` or a consumer +/// `Filter` group directly above it ([`having_filter_groups`]). +fn has_having(memo: &Memo, aggregate_group: MemoGroupId) -> bool { + matches!( + expression(memo, aggregate_group), + Operator::NonASAP(NonASAPOp::Aggregate { + having: Some(_), + .. + }) + ) || having_filter_groups(memo, aggregate_group).next().is_some() } -/// Discover one target per measure of each eligible aggregate memo group of -/// `dag`, including operator plans read by scalar roots: Pass 1 walks the -/// groups, so an aggregate several queries read is one target per measure. -/// An aggregate measure is eligible when it has a realization besides exact -/// execution, even if every such realization is pruned; the prune reasons -/// are reported on the target. Measure filters and `HAVING` keep every -/// candidate; the target records how they are applied. A group's consumers -/// are its parent groups and the query roots that read it; a consumer -/// `Filter`'s key-only `HAVING` is pushed below only when every consumer of -/// the group applies the same predicate, so every candidate of a shared -/// aggregate computes the group's own rows (#1089). -pub fn generate_per_computation_candidates( +/// Discover one target per measure of each eligible aggregate group of +/// `memo`, nested aggregates and those read by scalar roots included: +/// Pass 1 walks the groups, so an aggregate several queries read is one +/// target per measure. An aggregate measure is eligible when it has a +/// realization besides exact execution, even if every such realization is +/// pruned. The summaries the deployment does not support +/// (`supported_summaries`, the deployment's +/// `DeploymentCapabilities::supported_summaries`; `None` supports every +/// one) are dropped: a target's own, and a fused computation's (with all +/// of them dropped, the fusion is not offered). Measure filters and +/// `HAVING` keep every candidate. +pub(crate) fn computation_targets( memo: &Memo, -) -> Result { - let logical_nodes: Rc<[Rc]> = logical_node_trees(memo).into(); - let group_of: HashMap<*const OperatorNode, MemoGroupId> = logical_nodes + supported_summaries: Option<&[SupportedSummary]>, +) -> Result, PerComputationCandidateError> { + if memo + .groups() .iter() - .enumerate() - .map(|(g, node)| (Rc::as_ptr(node), MemoGroupId(g as u32))) - .collect(); - // Each query's root over the groups' logical nodes, in workload order: - // a group several queries read is one node. - let node = |g: &MemoGroupId| Rc::clone(&logical_nodes[g.0 as usize]); - let roots: Vec = memo - .query_roots - .iter() - .map(|root| match root { - QueryRoot::Operator(g) => QueryRoot::Operator(node(g)), - QueryRoot::Scalar(expr) => QueryRoot::Scalar(expr.map_operator_refs(&mut |g| node(g))), - }) - .collect(); - let mut seen = HashSet::new(); - // Each aggregate group with the first query that reaches it. - let mut aggregates = Vec::new(); - // Every consumer of each group's node: `Some(parent)`, or `None` for a - // query root or scalar reference. - let mut consumers: HashMap<*const OperatorNode, Vec>>> = HashMap::new(); - for (query, root) in roots.iter().enumerate() { - root.validate_structure()?; - let operators = match root { - QueryRoot::Operator(node) => vec![node], - QueryRoot::Scalar(expr) => expr.operator_refs(), - }; - for root in operators { - if root.contains_asap() { - return Err(PerComputationCandidateError::ContainsAsap); - } - consumers.entry(Rc::as_ptr(root)).or_default().push(None); - for node in OperatorNode::reachable(root) { - if !seen.insert(Rc::as_ptr(&node)) { - continue; - } - for child in node.children() { - consumers - .entry(Rc::as_ptr(child)) - .or_default() - .push(Some(Rc::clone(&node))); - } - if matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. })) { - aggregates.push((query, node)); - } - } - } + .any(|group| matches!(group.expression, Operator::ASAP(_))) + { + return Err(PerComputationCandidateError::ContainsAsap); } let mut targets = Vec::new(); - for (query, node) in aggregates { - let Some(NonASAPOp::Aggregate { - measures, - filters, - having, - .. - }) = node.non_asap() + for (index, group) in memo.groups().iter().enumerate() { + let Operator::NonASAP(NonASAPOp::Aggregate { + child, measures, .. + }) = &group.expression else { continue; }; - let aggregate_group = group_of[&Rc::as_ptr(&node)]; - let mut entries: Vec = having - .iter() - .map(|having| HavingEntry { - filter: None, - predicate: having.clone(), - placement: placement(key_only_having(&node, having)), - }) - .collect(); - let parents = &consumers[&Rc::as_ptr(&node)]; - // Only a `Filter` whose input is this aggregate is its `HAVING`; one - // that reads it from a scalar expression stays a plain consumer. - let mut filters_above: Vec<(&Rc, &Predicate)> = Vec::new(); - for parent in parents.iter().flatten() { - if let Some(NonASAPOp::Filter { child, pred }) = parent.non_asap() { - if Rc::ptr_eq(child, &node) - && !filters_above - .iter() - .any(|(filter, _)| Rc::ptr_eq(filter, parent)) - { - filters_above.push((parent, pred)); - } - } - } - for (filter, predicate) in &filters_above { - // Every consumer of the group, across queries, applies it. - let shared_by_all = filters_above.len() == parents.len() - && filters_above.iter().all(|(_, other)| other == predicate); - entries.push(HavingEntry { - filter: Some(Rc::clone(filter)), - predicate: (*predicate).clone(), - placement: placement(shared_by_all && key_only_having(&node, predicate)), - }); - } + let aggregate_group = MemoGroupId(index as u32); for (measure, intent) in measures.iter().enumerate() { let mut choices = computation_candidates_for_intent(intent).map_err(|error| match error { PerComputationCandidateError::InvalidAccuracy { .. } => { PerComputationCandidateError::InvalidAccuracy { - root: Some(query.to_string()), + root: group.reached_by_queries().first().map(usize::to_string), measure: Some(measure), } } @@ -793,13 +346,16 @@ pub fn generate_per_computation_candidates( { continue; } - if matches!(intent, AggIntent::Avg { .. }) && nullable_avg_input(&node, intent) { + if matches!(intent, AggIntent::Avg { .. }) + && nullable_avg_input(schema(memo, *child), intent) + { choices.prune(|choice| { matches!(choice, HowToComputeAggregateMeasure::ExactAccumulator(_)) }); } if matches!(intent, AggIntent::Count { .. }) - && node.children()[0].result_kind == OperatorResultKind::Relation + && memo.groups()[child.0 as usize].group_properties.result_kind + == OperatorResultKind::Relation { choices.prune(|choice| matches!(choice, HowToComputeAggregateMeasure::Summary(_))); } @@ -807,28 +363,52 @@ pub fn generate_per_computation_candidates( aggregate_group, measure_index: measure, ways_to_compute: choices.candidates, - measure_filter: filters.get(measure).cloned().flatten(), - having_filters: entries.clone(), fused_computation: None, }); } } - add_whole_expression_targets(&mut targets, &logical_nodes); - prune_topk_heaps_over_one_row_per_item(&mut targets, &logical_nodes); - add_nested_sum_targets(&mut targets, &logical_nodes); + add_whole_expression_targets(memo, &mut targets); + prune_topk_heaps_over_one_row_per_item(memo, &mut targets); + add_nested_distributive_targets(memo, &mut targets); close_absorptions(&mut targets); - Ok(PerComputationCandidates { - roots, - logical_nodes, - targets, - }) + without_unsupported_summaries(memo, &mut targets, supported_summaries); + Ok(targets) } -/// Adds to each fused computation's `absorbed_targets` the targets its absorbed -/// targets' own fused computations absorb, transitively: none of them is -/// computed when the fused computation is picked, so enumeration keeps only -/// their first choice ([`PerComputationCandidates::absorbed_choice_varies`]) -/// and [`PerComputationCandidates::combinations`] counts exactly those. +/// Drops from `targets` the summary candidates the executor does not +/// support (`supported_summaries`; `None` supports every one): a target's +/// own, and its fused computation's. The readout is the statistic the +/// measure's estimate reads (the outer measure's for a fused computation). +fn without_unsupported_summaries( + memo: &Memo, + targets: &mut [ComputationTarget], + supported_summaries: Option<&[SupportedSummary]>, +) { + for target in targets.iter_mut() { + let Operator::NonASAP(NonASAPOp::Aggregate { measures, .. }) = + expression(memo, target.aggregate_group) + else { + continue; + }; + let Some(readout) = sketch_statistic(&measures[target.measure_index]).map(|s| s.kind()) + else { + continue; + }; + let unsupported = |candidate: &HowToComputeAggregateMeasure| { + matches!(candidate, HowToComputeAggregateMeasure::Summary(family) + if !summary_supported(supported_summaries, family, readout)) + }; + prune_own(&mut target.ways_to_compute, unsupported); + if let Some(fused) = &mut target.fused_computation { + fused.ways_to_compute.retain(|way| !unsupported(way)); + } + } +} + +/// Adds to each fused computation's `absorbed_groups` the groups its +/// absorbed targets' own fused computations replace, transitively: the +/// fused computation replaces all their operators, so its builds list all +/// their groups in `fused_groups`. fn close_absorptions(targets: &mut [ComputationTarget]) { loop { let mut changed = false; @@ -836,12 +416,18 @@ fn close_absorptions(targets: &mut [ComputationTarget]) { let Some(fused) = &targets[t].fused_computation else { continue; }; - let beneath: Vec = fused - .absorbed_targets + let beneath: Vec = fused + .absorbed_groups .iter() - .filter_map(|&a| targets[a].fused_computation.as_ref()) - .flat_map(|inner| inner.absorbed_targets.iter().copied()) - .filter(|b| !fused.absorbed_targets.contains(b)) + .filter_map(|&group| { + targets + .iter() + .find(|target| target.aggregate_group == group)? + .fused_computation + .as_ref() + }) + .flat_map(|inner| inner.absorbed_groups.iter().copied()) + .filter(|b| !fused.absorbed_groups.contains(b)) .collect(); if let Some(fused) = targets[t] .fused_computation @@ -849,8 +435,8 @@ fn close_absorptions(targets: &mut [ComputationTarget]) { .filter(|_| !beneath.is_empty()) { for b in beneath { - if !fused.absorbed_targets.contains(&b) { - fused.absorbed_targets.push(b); + if !fused.absorbed_groups.contains(&b) { + fused.absorbed_groups.push(b); } } changed = true; @@ -862,117 +448,140 @@ fn close_absorptions(targets: &mut [ComputationTarget]) { } } -/// Nested-sum merge: a grouped `sum` over the value of a `sum` target -/// beneath it gets a fused computation ([`ComputationTarget::fused_computation`]) -/// with the one candidate `ExactAccumulator([Sum])`: one exact `Sum` -/// accumulator over the inner sum's input, grouped by the outer keys (the -/// sum of the inner groups' sums is the sum of their rows), absorbing the -/// inner target. Offered whatever else reads the inner sum: it is one more -/// candidate of the outer sum's group, and the inner sum's group keeps its -/// own candidates for its other consumers. Applied when neither has a filter -/// or `HAVING` -/// ([`nested_sum_input`]), and when the outer sum is not itself absorbed by -/// a merge and has no fused computation yet, and the inner sum has none, so -/// merges do not stack. An outer sum a whole-expression top-k absorbs -/// (`topk(k, sum by (G) (sum_over_time(m[W])))`) keeps its merge for the -/// top-k's own candidates. -fn add_nested_sum_targets(targets: &mut [ComputationTarget], logical_nodes: &[Rc]) { - let aggregate = |target: &ComputationTarget| &logical_nodes[target.aggregate_group.0 as usize]; +/// Nested distributive merge: a grouped `sum`, `min` or `max` over the +/// value of a target of the same reduction beneath it (`sum` of `sum`, +/// `min` of `min`, `max` of `max`) gets a fused computation +/// ([`ComputationTarget::fused_computation`]) with the one candidate +/// `ExactAccumulator([kind])`: one exact accumulator of that kind over the +/// inner aggregate's input, grouped by the outer keys (the sum, min or max +/// of the inner groups' values is that of their rows), absorbing the inner +/// target. Offered whatever else reads the inner aggregate: its group keeps +/// its own candidates for its other consumers. Applied when neither has a +/// filter or `HAVING` ([`nested_distributive_input`]), and when the outer +/// aggregate is not itself absorbed by a merge and has no fused computation +/// yet, and the inner one has none, so merges do not stack. An outer sum a +/// whole-expression top-k absorbs (`topk(k, sum by (G) +/// (sum_over_time(m[W])))`) keeps its merge for the top-k's own candidates. +fn add_nested_distributive_targets(memo: &Memo, targets: &mut [ComputationTarget]) { // The targets a merge absorbs. - let mut absorbed: HashSet = HashSet::new(); + let mut absorbed: Vec = Vec::new(); for t in 0..targets.len() { let target = &targets[t]; if absorbed.contains(&t) || target.fused_computation.is_some() - || !target.having_filters.is_empty() + || has_having(memo, target.aggregate_group) { continue; } - let Some(NonASAPOp::Aggregate { child, .. }) = aggregate(target).non_asap() else { + let Operator::NonASAP(NonASAPOp::Aggregate { child, .. }) = + expression(memo, target.aggregate_group) + else { continue; }; let Some(inner) = targets .iter() - .position(|other| Rc::ptr_eq(aggregate(other), child)) + .position(|other| other.aggregate_group == *child) else { continue; }; - // Merges do not stack: the inner sum must not have a fused + // Merges do not stack: the inner aggregate must not have a fused // computation of its own. - if !targets[inner].having_filters.is_empty() - || targets[inner].fused_computation.is_some() - || nested_sum_input(aggregate(target)).is_none() - { + if has_having(memo, *child) || targets[inner].fused_computation.is_some() { continue; } - absorbed.insert(inner); - let merge = HowToComputeAggregateMeasure::ExactAccumulator(vec![ExactKind::Sum]); + let Some((_, _, _, kind)) = nested_distributive_input(memo, target.aggregate_group) else { + continue; + }; + absorbed.push(inner); targets[t].fused_computation = Some(FusedComputation { - absorbed_targets: vec![inner], - ways_to_compute: vec![merge], + absorbed_groups: vec![*child], + ways_to_compute: vec![HowToComputeAggregateMeasure::ExactAccumulator(vec![kind])], }); } } -/// The input rows, the inner sum and the outer grouping (moved onto the -/// input rows by column, [`Reduction::partitions_over`]) of a nested-sum merge of -/// `target`, or `None` when it does not apply: `target` is a grouped `sum` -/// (not by `without`) of the value column of a per-series or grouped `sum` -/// directly beneath it, neither filtered nor with `HAVING`, and not grouped -/// by the metric name a per-series inner sum drops. -fn nested_sum_input(target: &OperatorNode) -> Option<(Rc, AggIntent, Reduction)> { - use asap_types::ir::operator::non_asap::any_measure_filtered; - let Some(NonASAPOp::Aggregate { +/// The accumulator kind and input column of a distributive measure, one +/// whose value over a union of groups is that of their values: `sum`, `min` +/// and `max`. +fn distributive(intent: &AggIntent) -> Option<(ExactKind, Option)> { + match intent { + AggIntent::Sum { col } => Some((ExactKind::Sum, *col)), + AggIntent::Min { col } => Some((ExactKind::Min, *col)), + AggIntent::Max { col } => Some((ExactKind::Max, *col)), + _ => None, + } +} + +/// The input rows, the inner measure, the outer grouping (moved onto the +/// input rows by column, [`Reduction::partitions_over`]) and the +/// accumulator kind of a nested distributive merge of `target`, or `None` +/// when it does not apply: `target` is a grouped `sum`, `min` or `max` (not +/// by `without`) of the value column of a per-series or grouped aggregate of +/// the same reduction directly beneath it ([`distributive`]), neither +/// filtered nor with its own `having`, and not grouped by the metric name a +/// per-series inner aggregate drops. +fn nested_distributive_input( + memo: &Memo, + target: MemoGroupId, +) -> Option<(MemoGroupId, AggIntent, Reduction, ExactKind)> { + let Operator::NonASAP(NonASAPOp::Aggregate { child: inner, reduction: reduction @ Reduction::Reduce(by), measures, filters, having: None, .. - }) = target.non_asap() + }) = expression(memo, target) else { return None; }; - let [AggIntent::Sum { col }] = measures.as_slice() else { + let [outer] = measures.as_slice() else { return None; }; - let Some(NonASAPOp::Aggregate { + let (kind, col) = distributive(outer)?; + let Operator::NonASAP(NonASAPOp::Aggregate { child: rows, reduction: inner_reduction, measures: inner_measures, filters: inner_filters, having: None, .. - }) = inner.non_asap() + }) = expression(memo, *inner) else { return None; }; - let [inner_sum @ AggIntent::Sum { .. }] = inner_measures.as_slice() else { + let [inner_measure] = inner_measures.as_slice() else { return None; }; - if by.is_without() || any_measure_filtered(filters) || any_measure_filtered(inner_filters) { + if distributive(inner_measure).map(|(inner_kind, _)| inner_kind) != Some(kind) + || by.is_without() + || any_measure_filtered(filters) + || any_measure_filtered(inner_filters) + { return None; } - // The inner sum's value column: a per-series sum keeps its labels and - // carries the value as the sample value; a grouped one ends with it. + // The inner aggregate's value column: a per-series one keeps its labels + // and carries the value as the sample value; a grouped one ends with it. let value = match inner_reduction { - Reduction::PerEntity => resolve_column_ref(&ColumnRef::SampleValue, &inner.schema).ok()?, + Reduction::PerEntity => { + resolve_column_ref(&ColumnRef::SampleValue, schema(memo, *inner)).ok()? + } Reduction::Reduce(keys) if !keys.is_without() => keys.len(), Reduction::Reduce(_) => return None, }; - let summed = match col { - None => resolve_column_ref(&ColumnRef::SampleValue, &inner.schema).ok()?, - Some(column) => *column, + let reduced = match col { + None => resolve_column_ref(&ColumnRef::SampleValue, schema(memo, *inner)).ok()?, + Some(column) => column, }; - if summed != value || by.keys().contains(&value) { + if reduced != value || by.keys().contains(&value) { return None; } - // A per-series sum drops the metric name, which the raw rows keep: - // grouping them by it would name groups the nested sums leave unnamed. + // A per-series aggregate drops the metric name, which the raw rows + // keep: grouping them by it would name groups the nested aggregates + // leave unnamed. if matches!(inner_reduction, Reduction::PerEntity) && by.keys().iter().any(|&key| { - inner - .schema + schema(memo, *inner) .fields .get(key) .is_some_and(|f| f.name == "__name__") @@ -980,67 +589,55 @@ fn nested_sum_input(target: &OperatorNode) -> Option<(Rc, AggInten { return None; } - let partitions = reduction.partitions_over(&inner.schema, &rows.schema)?; - Some((Rc::clone(rows), inner_sum.clone(), partitions)) + let partitions = reduction.partitions_over(schema(memo, *inner), schema(memo, *rows))?; + Some((*rows, inner_measure.clone(), partitions, kind)) } /// Whole-expression top-k (Example 1's Q2): a top-k over a per-item `sum` /// or `count` that is itself a target gets a fused computation -/// ([`ComputationTarget::fused_computation`]) absorbing the inner target, whose -/// candidates are the top-k's heap summaries (Count-Min and CountSketch -/// with a heap), each one heap over the inner aggregate's input, keyed by -/// the ranked item and weighted by the summed value. Those heaps over the -/// exact per-item result leave the top-k's own candidates: the fused -/// computation replaces them. Over a grouped sum of per-series sums or -/// counts of raw data samples, the heap reads those samples and absorbs the -/// per-series target too. Offered whatever else reads the inner aggregate: -/// it is one more candidate of the top-k's group, and the absorbed groups -/// keep their own candidates for their other consumers. Applied when neither -/// has a filter or `HAVING`, and when the keyed-additive rule realizes it -/// ([`whole_expression_input`]). -fn add_whole_expression_targets( - targets: &mut [ComputationTarget], - logical_nodes: &[Rc], -) { - let aggregate = |target: &ComputationTarget| &logical_nodes[target.aggregate_group.0 as usize]; +/// ([`ComputationTarget::fused_computation`]) absorbing the inner target, +/// whose candidates are the top-k's heap summaries (Count-Min and +/// CountSketch with a heap), each one heap over the inner aggregate's +/// input, keyed by the ranked item and weighted by the summed value. Those +/// heaps over the exact per-item result leave the top-k's own candidates: +/// the fused computation replaces them. Over a grouped sum of per-series +/// sums or counts of raw data samples, the heap reads those samples and +/// absorbs the per-series target too. Offered whatever else reads the inner +/// aggregate: the absorbed groups keep their own candidates for their other +/// consumers. Applied when neither has a filter or `HAVING`, and when the +/// keyed-additive rule realizes it ([`whole_expression_input`]). +fn add_whole_expression_targets(memo: &Memo, targets: &mut [ComputationTarget]) { for t in 0..targets.len() { let target = &targets[t]; - let Some(NonASAPOp::Aggregate { child, .. }) = aggregate(target).non_asap() else { - continue; - }; - let Some(inner) = targets - .iter() - .position(|other| Rc::ptr_eq(aggregate(other), child)) + let Operator::NonASAP(NonASAPOp::Aggregate { child, .. }) = + expression(memo, target.aggregate_group) else { continue; }; - if target.measure_filter.is_some() - || !target.having_filters.is_empty() - || targets[inner].measure_filter.is_some() - || !targets[inner].having_filters.is_empty() - { + if !targets.iter().any(|other| other.aggregate_group == *child) { + continue; + } + // Measure filters: `whole_expression_input` refuses them. + if has_having(memo, target.aggregate_group) || has_having(memo, *child) { continue; } - let Some((input, ..)) = whole_expression_input(aggregate(target)) else { + let Some((input, ..)) = whole_expression_input(memo, target.aggregate_group) else { continue; }; // A heap over the raw data samples beneath a grouped sum of per-series // sums or counts also absorbs the per-series target. - let mut absorbs = vec![inner]; - if let Some(NonASAPOp::Aggregate { + let mut absorbs = vec![*child]; + if let Operator::NonASAP(NonASAPOp::Aggregate { child: per_series, .. - }) = aggregate(&targets[inner]).non_asap() + }) = expression(memo, *child) { - let reads_below = per_series - .children() - .iter() - .any(|&rows| Rc::ptr_eq(rows, &input)); - if reads_below { - absorbs.extend( - targets - .iter() - .position(|other| Rc::ptr_eq(aggregate(other), per_series)), - ); + let reads_below = expression(memo, *per_series).children().contains(&&input); + if reads_below + && targets + .iter() + .any(|other| other.aggregate_group == *per_series) + { + absorbs.push(*per_series); } } let (heaps, own): (Vec<_>, Vec<_>) = @@ -1058,7 +655,7 @@ fn add_whole_expression_targets( let target = &mut targets[t]; target.ways_to_compute = own; target.fused_computation = Some(FusedComputation { - absorbed_targets: absorbs, + absorbed_groups: absorbs, ways_to_compute: heaps, }); } @@ -1067,14 +664,11 @@ fn add_whole_expression_targets( /// Prunes the own heap candidates of each PromQL top-k: the heap reads /// one row per item, so exact is no costlier; a fused whole-expression /// heap ([`add_whole_expression_targets`]) has already moved out of them. -fn prune_topk_heaps_over_one_row_per_item( - targets: &mut [ComputationTarget], - logical_nodes: &[Rc], -) { +fn prune_topk_heaps_over_one_row_per_item(memo: &Memo, targets: &mut [ComputationTarget]) { for target in targets.iter_mut() { - let Some(NonASAPOp::Aggregate { + let Operator::NonASAP(NonASAPOp::Aggregate { measures, child, .. - }) = logical_nodes[target.aggregate_group.0 as usize].non_asap() + }) = expression(memo, target.aggregate_group) else { continue; }; @@ -1086,7 +680,7 @@ fn prune_topk_heaps_over_one_row_per_item( } // A PromQL top-k ranks an instant vector: one row per series or // group. (A SQL top-k keeps its heaps.) - if !reads_only_time_series(child) { + if !reads_only_time_series(memo, *child) { continue; } prune_own(&mut target.ways_to_compute, |candidate| { @@ -1096,11 +690,16 @@ fn prune_topk_heaps_over_one_row_per_item( } } -/// Whether every scan below `node` reads a time series (PromQL rows). -fn reads_only_time_series(node: &OperatorNode) -> bool { - match node.non_asap() { - Some(NonASAPOp::Scan { source, .. }) => matches!(source, Source::TimeSeries { .. }), - _ => node.children().iter().all(|c| reads_only_time_series(c)), +/// Whether every scan below `input` reads a time series (PromQL rows). +fn reads_only_time_series(memo: &Memo, input: MemoGroupId) -> bool { + match expression(memo, input) { + Operator::NonASAP(NonASAPOp::Scan { source, .. }) => { + matches!(source, Source::TimeSeries { .. }) + } + operator => operator + .children() + .into_iter() + .all(|&child| reads_only_time_series(memo, child)), } } @@ -1110,24 +709,25 @@ fn reads_only_time_series(node: &OperatorNode) -> bool { /// column, as [`summary_update`] does. The partitions are `target`'s, moved /// onto the input the heap reads ([`Reduction::partitions_over`]). fn whole_expression_input( - target: &OperatorNode, -) -> Option<(Rc, SummaryUpdate, Reduction)> { - use crate::realization::realize_keyed_additive_summary_input; - let Some(NonASAPOp::Aggregate { + memo: &Memo, + target: MemoGroupId, +) -> Option<(MemoGroupId, SummaryUpdate, Reduction)> { + let Operator::NonASAP(NonASAPOp::Aggregate { child, reduction, measures, filters, having: None, .. - }) = target.non_asap() + }) = expression(memo, target) else { return None; }; + let child = *child; let [intent @ AggIntent::TopK { .. }] = measures.as_slice() else { return None; }; - if asap_types::ir::operator::non_asap::any_measure_filtered(filters) { + if any_measure_filtered(filters) { return None; } // The rule reads only the family's algorithm, and rejects Count-Min over @@ -1138,22 +738,23 @@ fn whole_expression_input( SketchAlgorithm::CountSketchWithHeap, GroupingStrategy::default(), ); - let realized = realize_keyed_additive_summary_input(intent, &family, reduction, child)?; + let realized = realize_keyed_additive_summary_input(memo, intent, &family, reduction, child)?; let mut input = realized.input; let per_series = matches!( - child.non_asap(), - Some(NonASAPOp::Aggregate { + expression(memo, child), + Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, .. }) ); - let partitions = reduction.partitions_over(&child.schema, &realized.child.schema)?; - if per_series && realized.child.schema.has_promql_series_identity() { + let partitions = + reduction.partitions_over(schema(memo, child), schema(memo, realized.child))?; + if per_series && schema(memo, realized.child).has_promql_series_identity() { input.item = Some(asap_types::ir::schema::topk::series_identity_item( - &realized.child.schema, + schema(memo, realized.child), partitions.group_keys().map_or(&[][..], |keys| keys.keys()), )); - } else if realized.child.schema.closed + } else if schema(memo, realized.child).closed && matches!(input.item, Some(SummaryInputExpr::EntityIdentity(_))) { // An encoded label set needs an open PromQL schema. PromQL rows that @@ -1182,537 +783,398 @@ fn with_raw_data_samples_requirement( update } -/// Build one whole-workload candidate (end of Pass 1): `choice[i]` picks -/// `inventory.targets[i]`'s candidate, or past its own candidates its fused -/// computation's ([`ComputationTarget::computation_for`]). An aggregate with -/// a chosen candidate other than `Exact` is replaced measure by measure: an -/// exact accumulator by `SummaryAgg` → `FinalizeExactAccumulator`, a summary -/// by `SummaryAgg` → `SummaryEstimate`, `avg` by its `sum` and `count` -/// accumulators divided, a fused computation by one state over the absorbed -/// target's input, and any other measure by its own single-measure -/// aggregate; several measures are joined back on the group keys. A summary -/// is sized here for its measure's accuracy requirement. A measure filter -/// becomes `SummaryAgg.filter`; -/// `HAVING` is applied as its target records. A sub-DAG shared between roots -/// stays shared. Every node carries the guarantee `asap_accuracy::propagation::composed_guarantee` -/// gives it. -#[cfg(any(test, feature = "test-support"))] -pub fn compose_logical_candidate( - inventory: &PerComputationCandidates, - choice: &[usize], -) -> Result, PerComputationCandidateError> { - compose(inventory, &inventory.roots, choice) -} - -/// [`compose_logical_candidate`] of `roots` (some of `inventory.roots`), -/// each summary sized for its measure's whole budget. -fn compose( - inventory: &PerComputationCandidates, - roots: &[QueryRoot], - choice: &[usize], -) -> Result, PerComputationCandidateError> { - if choice.len() != inventory.targets.len() { - return Err(PerComputationCandidateError::InvalidChoice); +/// Adds Pass 1's candidates to `memo` (Stage 0's, without candidates): +/// every group's own expression, then, for each aggregate group with +/// `targets`, each way to compute it. A consumer `Filter` group whose +/// key-only `HAVING` is pushed below the aggregate gets the candidates that +/// compute its filtered rows. Every group and candidate is added through +/// `Memo::find_or_add_group` and `Memo::add_candidate`. +pub(crate) fn add_pass1_candidates( + memo: &mut Memo, + targets: &[ComputationTarget], +) -> Result<(), PerComputationCandidateError> { + // Stage 0's groups: a consumer `Filter` among them is a query's + // `HAVING`; the `Filter` groups Pass 1 adds are parts of its candidates. + let stage0_groups = memo.groups().len(); + for index in 0..stage0_groups { + let group = MemoGroupId(index as u32); + let own = expression(memo, group).clone(); + add_candidate(memo, group, own, None, Vec::new())?; } - let mut chosen: Chosen = HashMap::new(); - for (target, &index) in inventory.targets.iter().zip(choice) { - let (candidate, absorbs) = target - .computation_for(index) - .ok_or(PerComputationCandidateError::InvalidChoice)?; - chosen - .entry(Rc::as_ptr(inventory.aggregate(target))) + let mut targets_of: BTreeMap> = BTreeMap::new(); + for target in targets { + targets_of + .entry(target.aggregate_group) .or_default() - .push(ChosenComputation { - target, - candidate, - fused: absorbs.is_some(), - }); + .push(target); } - // Consumer `Filter`s whose key-only `HAVING` moves below a substituted - // aggregate: each is replaced by its input. - let pushed_below: HashSet<_> = chosen - .values() - .filter(|targets| substituted(targets)) - .flat_map(|targets| &targets[0].target.having_filters) - .filter(|entry| entry.placement == HavingPlacement::BelowAggregate) - .filter_map(|entry| entry.filter.as_ref().map(Rc::as_ptr)) - .collect(); - compose_roots(roots, &chosen, &pushed_below, &Sizing::default()) -} - -/// One measure of an aggregate: the aggregate and the measure's index. -type MeasureKey = (*const OperatorNode, usize); - -/// How [`compose_logical_candidate`] sizes summaries in one composition. -#[derive(Default)] -struct Sizing { - /// Every sketch summary state built, with the measure it sizes. - built: std::cell::RefCell, MeasureKey)>>, -} - -/// Every root of `roots` with the chosen candidates substituted, each -/// summary sized as `sizing` says, and every node with its guarantee. -fn compose_roots( - roots: &[QueryRoot], - chosen: &Chosen<'_>, - pushed_below: &HashSet<*const OperatorNode>, - sizing: &Sizing, -) -> Result, PerComputationCandidateError> { - let (mut cache, mut guaranteed) = (HashMap::new(), HashMap::new()); - let mut rewrite = |node: &Rc| { - let node = rewrite(node, chosen, pushed_below, sizing, &mut cache)?; - with_guarantees(&node, &mut guaranteed).map_err(PerComputationCandidateError::from) - }; - let roots = roots - .iter() - .map(|root| { - let root = match root { - QueryRoot::Operator(node) => QueryRoot::Operator(rewrite(node)?), - QueryRoot::Scalar(expr) => { - let mut rewritten = HashMap::new(); - for node in expr.operator_refs() { - rewritten.insert(Rc::as_ptr(node), rewrite(node)?); - } - QueryRoot::Scalar( - expr.map_operator_refs(&mut |node| rewritten[&Rc::as_ptr(node)].clone()), - ) - } + for (&aggregate_group, group_targets) in &targets_of { + let Operator::NonASAP(NonASAPOp::Aggregate { + reduction, having, .. + }) = expression(memo, aggregate_group) + else { + continue; + }; + let (below, above): (Vec<_>, Vec<_>) = having + .iter() + .cloned() + .partition(|having| key_only_having(reduction, having)); + // Each consumer `Filter` group whose `HAVING` is key-only gets the + // candidates that compute its rows with the predicate pushed below. + let pushed_down: Vec<(MemoGroupId, Predicate)> = + having_filter_groups(memo, aggregate_group) + .filter(|&(filter, pred)| { + (filter.0 as usize) < stage0_groups && key_only_having(reduction, pred) + }) + .map(|(filter, pred)| (filter, pred.clone())) + .collect(); + let ways = AggregateWays { + aggregate_group, + result_group: aggregate_group, + below: &below, + above: &above, + targets: group_targets, + }; + add_aggregate_candidates(memo, &ways)?; + for (filter, pred) in pushed_down { + let mut with_consumer = below.clone(); + with_consumer.push(pred); + let filtered = AggregateWays { + result_group: filter, + below: &with_consumer, + ..ways }; - // A substitution this module cannot express is an error, never an - // invalid DAG. - root.validate_structure()?; - Ok(root) - }) - .collect::, PerComputationCandidateError>>()?; - // The built states, as the roots hold them: with their guarantees. - for (state, _) in sizing.built.borrow_mut().iter_mut() { - if let Some(with_guarantee) = guaranteed.get(&Rc::as_ptr(state)) { - *state = with_guarantee.clone(); + add_aggregate_candidates(memo, &filtered)?; } } - Ok(roots) -} - -/// One target's chosen computation in a whole-workload choice. -struct ChosenComputation<'a> { - /// The target whose measure it computes. - target: &'a ComputationTarget, - /// How it is computed. - candidate: &'a HowToComputeAggregateMeasure, - /// Whether the candidate is the target's fused computation's, which - /// absorbs the targets beneath; `false` for one of its own candidates. - fused: bool, + Ok(()) } -/// Each aggregate's targets with their chosen computations. -type Chosen<'a> = HashMap<*const OperatorNode, Vec>>; -type NodeToRewrittenNodeCache = HashMap<*const OperatorNode, Rc>; - -/// Whether any measure of the aggregate is substituted by something other than -/// its plain exact computation. -fn substituted(targets: &[ChosenComputation<'_>]) -> bool { - targets - .iter() - .any(|chosen| *chosen.candidate != HowToComputeAggregateMeasure::Exact) +/// The ways to compute one result from an aggregate group's targets. +struct AggregateWays<'a> { + /// The aggregate group whose measures are computed. + aggregate_group: MemoGroupId, + /// The group the candidates compute: the aggregate group, or a consumer + /// `Filter` group above it whose key-only `HAVING` goes below. + result_group: MemoGroupId, + /// Key-only predicates over the aggregate's output, applied to its + /// input rows (their key columns renumbered to the input's). + below: &'a [Predicate], + /// Predicates applied as `Filter`s above the output. + above: &'a [Predicate], + /// The aggregate's targets, one per measure that has one. + targets: &'a [&'a ComputationTarget], } -fn rewrite( - node: &Rc, - chosen: &Chosen<'_>, - pushed_below: &HashSet<*const OperatorNode>, - sizing: &Sizing, - cache: &mut NodeToRewrittenNodeCache, -) -> Result, PerComputationCandidateError> { - if let Some(done) = cache.get(&Rc::as_ptr(node)) { - return Ok(done.clone()); - } - for child in node.children() { - rewrite(child, chosen, pushed_below, sizing, cache)?; - } - let changed = node - .children() - .iter() - .any(|child| !Rc::ptr_eq(child, &cache[&Rc::as_ptr(child)])); - let rebuilt = match (chosen.get(&Rc::as_ptr(node)), node.non_asap()) { - (Some(targets), _) if substituted(targets) => substitute(node, targets, sizing, cache)?, - (_, Some(NonASAPOp::Filter { child, .. })) if pushed_below.contains(&Rc::as_ptr(node)) => { - cache[&Rc::as_ptr(child)].clone() - } - _ if changed => Rc::new(node.with_new_children(|child| cache[&Rc::as_ptr(child)].clone())?), - _ => node.clone(), - }; - cache.insert(Rc::as_ptr(node), rebuilt.clone()); - Ok(rebuilt) +/// One measure of an aggregate, computed on its own over `input`. +struct SingleMeasure<'a> { + input: MemoGroupId, + reduction: &'a Reduction, + intent: &'a AggIntent, + filter: Option<&'a Predicate>, + /// The metric whose raw data samples, or plain sums of them, are every + /// value the aggregate reads: a heap-sketch weight read from them + /// carries the open requirement `WeightDomain::RawDataSamplesOfMetric`. + raw_data_samples_of: Option<&'a str>, } -/// Replace an aggregate whose measures are `targets`' (or exact, for a -/// measure that is no target) by its substitution, with the same output -/// columns. -fn substitute( - target: &Rc, - targets: &[ChosenComputation<'_>], - sizing: &Sizing, - cache: &NodeToRewrittenNodeCache, -) -> Result, PerComputationCandidateError> { - let Some(NonASAPOp::Aggregate { +/// Adds `ways.result_group`'s candidates that compute it from +/// `ways.aggregate_group`'s measures: for a single measure, each of its +/// target's ways and fused ways; for several, one candidate joining the +/// measures' own groups (a single-measure aggregate each, with its own +/// candidates) on the group keys. +fn add_aggregate_candidates( + memo: &mut Memo, + ways: &AggregateWays<'_>, +) -> Result<(), PerComputationCandidateError> { + let Operator::NonASAP(NonASAPOp::Aggregate { child, reduction, measures, + output_names, filters, .. - }) = target.non_asap() + }) = expression(memo, ways.aggregate_group).clone() else { return Err(PerComputationCandidateError::Unsupported( "target is not an aggregate", )); }; - // A key-only `HAVING` is a row predicate over the input: rows of a - // dropped group never reach the state. - let having = &targets[0].target.having_filters; - // Operator refs in a predicate (a subquery) are this aggregate's - // children, already rewritten. - let rewritten = |pred: &Predicate| { - Predicate( - pred.0 - .map_operator_refs(&mut |node| cache[&Rc::as_ptr(node)].clone()), - ) + let keys: Vec = reduction + .group_keys() + .map_or_else(Vec::new, |by| by.keys().to_vec()); + let mut input = child; + for predicate in ways.below { + let pred = Predicate(predicate.0.map_columns(&mut |column| keys[column])); + input = add_group( + memo, + Operator::NonASAP(NonASAPOp::Filter { pred, child: input }), + )?; + } + let raw_data_samples = raw_data_samples_of_metric(memo, child); + let measure = |index: usize| SingleMeasure { + input, + reduction: &reduction, + intent: &measures[index], + filter: filters.get(index).and_then(Option::as_ref), + raw_data_samples_of: raw_data_samples.as_deref(), }; - // The metric whose raw data samples, or plain sums of them, are every - // value the aggregate reads: a heap-sketch weight read from them carries - // the open requirement `WeightDomain::RawDataSamplesOfMetric`, which - // plan selection confirms. - let raw_data_samples = raw_data_samples_of_metric(child); - let mut input = cache[&Rc::as_ptr(child)].clone(); - for entry in having { - if entry.placement == HavingPlacement::BelowAggregate { - let keys = reduction.group_keys().map_or(&[][..], |by| by.keys()); - let pred = Predicate(entry.predicate.0.map_columns(&mut |column| keys[column])); - input = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Filter { - pred, - child: input, - }))?; + let target_of = |index: usize| { + ways.targets + .iter() + .find(|target| target.measure_index == index) + }; + if measures.len() == 1 { + let Some(target) = target_of(0) else { + return Ok(()); + }; + for way in &target.ways_to_compute { + if *way == HowToComputeAggregateMeasure::Exact { + continue; + } + unless_unsupported( + measure_output(memo, &measure(0), way, ways.result_group).and_then(|output| { + add_output_candidate(memo, ways.result_group, output, ways.above) + }), + )?; } - } - let parts = measures - .iter() - .enumerate() - .map(|(measure, intent)| { - // `fused`: the candidate is the target's fused computation's, - // which absorbs the target beneath. - let (candidate, raw_data_samples_of, fused) = targets - .iter() - .find(|chosen| chosen.target.measure_index == measure) - .map_or( - (&HowToComputeAggregateMeasure::Exact, None, false), - |chosen| (chosen.candidate, raw_data_samples.as_deref(), chosen.fused), - ); - let filter = filters.get(measure).and_then(Option::as_ref).map(rewritten); - let key = (Rc::as_ptr(target), measure); - if fused { - return match candidate { - HowToComputeAggregateMeasure::ExactAccumulator(_) => nested_sum(target, cache), - HowToComputeAggregateMeasure::Summary(algorithm) => whole_expression( - target, - intent, - algorithm, - raw_data_samples_of, - (sizing, key), - cache, - ), - HowToComputeAggregateMeasure::Exact => { - Err(PerComputationCandidateError::Unsupported( - "plain exact execution of a fused computation", - )) - } - }; + if let Some(fused) = &target.fused_computation { + for way in &fused.ways_to_compute { + unless_unsupported(add_fused_candidate( + memo, + ways, + &measure(0), + way, + &fused.absorbed_groups, + ))?; } - substitute_measure( - target, - intent, - candidate, - filter, - &input, - raw_data_samples_of, - (sizing, key), - ) - }) - .collect::, _>>()?; - let mut output = match <[_; 1]>::try_from(parts) { - Ok([part]) => with_names_of(target, part)?, - Err(parts) => join_measures(target, parts)?, - }; - for entry in having { - if entry.placement == HavingPlacement::AboveOutput && entry.filter.is_none() { - output = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Filter { - pred: rewritten(&entry.predicate), - child: output, - }))?; } + return Ok(()); } - Ok(output) -} - -/// `output` with `target`'s column names: an estimate or accumulator names -/// its value after the operation (`quantile`, `sum`), not the aggregate's -/// output column. -fn with_names_of( - target: &OperatorNode, - output: Rc, -) -> Result, PerComputationCandidateError> { - let names = |node: &OperatorNode| -> Vec { - node.schema - .fields - .iter() - .map(|field| field.name.clone()) - .collect() - }; - // A top-k's ranked rows may carry their item columns after the - // aggregate's ([`ranked_as_topk`]). - if names(&output).starts_with(&names(target)) { - return Ok(output); + // The measures are joined back on their group keys: SQL rows grouped + // `by` keys (PromQL aggregates have one measure each). + if !matches!(&reduction, Reduction::Reduce(by) if !by.is_without()) + || memo.groups()[input.0 as usize].group_properties.result_kind + != OperatorResultKind::Relation + { + return Ok(()); } - if output.schema.fields.len() != target.schema.fields.len() { - return Err(PerComputationCandidateError::Unsupported( - "substituted output with other columns than the aggregate", - )); + let mut parts = Vec::with_capacity(measures.len()); + for (index, intent) in measures.iter().enumerate() { + let part = add_group( + memo, + Operator::NonASAP(NonASAPOp::Aggregate { + child: input, + reduction: reduction.clone(), + measures: vec![intent.clone()], + output_names: vec![output_names.get(index).cloned().unwrap_or_default()], + filters: vec![filters.get(index).cloned().flatten()], + having: None, + }), + )?; + if let Some(target) = target_of(index) { + for way in &target.ways_to_compute { + if *way == HowToComputeAggregateMeasure::Exact { + continue; + } + unless_unsupported( + measure_output(memo, &measure(index), way, part) + .and_then(|output| add_output_candidate(memo, part, output, &[])), + )?; + } + } + parts.push(part); } - let cols = names(target) - .into_iter() - .enumerate() - .map(|(column, name)| ProjectItem { - alias: Some(name), - expr: ScalarExpr::Column(column), - }) - .collect(); - Ok(OperatorNode::new_shared(Operator::NonASAP( - NonASAPOp::Project { - cols, - child: output, - }, - ))?) + let output = join_measures(memo, &reduction, ways.result_group, parts)?; + add_output_candidate(memo, ways.result_group, output, ways.above) } -/// A whole-expression top-k for `target` ([`whole_expression_input`]): one -/// heap summary of `algorithm` over the absorbed aggregate's rewritten -/// input, partitioned as `target`. -fn whole_expression( - target: &OperatorNode, - intent: &AggIntent, - algorithm: &SketchAlgorithm, - raw_data_samples_of: Option<&str>, - (sizing, key): (&Sizing, MeasureKey), - cache: &NodeToRewrittenNodeCache, -) -> Result, PerComputationCandidateError> { - let (input, update, partitions) = whole_expression_input(target).ok_or( - PerComputationCandidateError::Unsupported("whole-expression top-k input"), - )?; - let state = OperatorNode::new_shared(Operator::ASAP(ASAPOp::SummaryAgg { - child: cache[&Rc::as_ptr(&input)].clone(), - input: with_raw_data_samples_requirement(update, raw_data_samples_of), - family: FieldDataType::Sketch(*algorithm, GroupingStrategy::default()), - reduction: partitions, - grouping: GroupingStrategy::default(), - filter: None, - }))?; - sizing.built.borrow_mut().push((state.clone(), key)); - let estimate = OperatorNode::new_shared(Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input: state, - query: sketch_statistic(intent).ok_or(PerComputationCandidateError::Unsupported( - "sketch evaluation", - ))?, - }))?; - ranked_as_topk(target, estimate) +/// `added`, with a way Pass 1 cannot build (`Unsupported`) not offered: +/// its checks run before the groups it reads are added, so the memo is +/// unchanged, except for the rare checks that need a group's schema (a +/// top-k estimate without its item columns, a per-series `avg` that is not +/// Float64), which leave groups no query reaches. +fn unless_unsupported( + added: Result<(), PerComputationCandidateError>, +) -> Result<(), PerComputationCandidateError> { + match added { + Err(PerComputationCandidateError::Unsupported(_)) => Ok(()), + added => added, + } } -/// A heap's top-k `estimate` (`[partitions.., items.., topk]`, one row per -/// ranked item) in `target`'s columns: its `[keys.., topk_k]`, with -/// `topk_k` the estimated Float64 weight, then the item columns. This is -/// the exact top-k's physical shape (its ranked rows' value, then the -/// columns naming each ranked item: PromQL labels, SQL group keys), so -/// consumers of `[keys.., topk_k]` find those columns where they were. -fn ranked_as_topk( - target: &OperatorNode, - estimate: Rc, -) -> Result, PerComputationCandidateError> { - let Some(NonASAPOp::Aggregate { reduction, .. }) = target.non_asap() else { - return Err(PerComputationCandidateError::Unsupported( - "top-k target is not an aggregate", - )); - }; - let ranked_index = reduction.group_keys().map_or(0, |keys| keys.len()); - let fields = &estimate.schema.fields; - let estimated = fields.len() - 1; - let cols = target - .schema - .fields - .iter() - .enumerate() - .map(|(index, field)| { - let source = if index == ranked_index { - estimated - } else { - fields[..estimated] - .iter() - .position(|input| input.name == field.name) - .ok_or(PerComputationCandidateError::Unsupported( - "top-k estimate without a ranked-row identity column", - ))? +/// Adds the fused computation `way` of `ways`' single measure: a whole- +/// expression heap (a summary way) or a nested distributive merge (an +/// exact accumulator way). Its builds list `absorbed_groups`, the groups of the +/// operators it replaces, in `fused_groups`. +fn add_fused_candidate( + memo: &mut Memo, + ways: &AggregateWays<'_>, + measure: &SingleMeasure<'_>, + way: &HowToComputeAggregateMeasure, + absorbed_groups: &[MemoGroupId], +) -> Result<(), PerComputationCandidateError> { + match way { + HowToComputeAggregateMeasure::Summary(algorithm) => { + let (rows, update, partitions) = whole_expression_input(memo, ways.aggregate_group) + .ok_or(PerComputationCandidateError::Unsupported( + "whole-expression top-k input", + ))?; + let query = sketch_statistic(measure.intent).ok_or( + PerComputationCandidateError::Unsupported("sketch evaluation"), + )?; + let state = add_state_group( + memo, + Operator::ASAP(ASAPOp::SummaryAgg { + child: rows, + input: with_raw_data_samples_requirement(update, measure.raw_data_samples_of), + family: FieldDataType::Sketch(*algorithm, GroupingStrategy::default()), + reduction: partitions, + grouping: GroupingStrategy::default(), + filter: None, + }), + absorbed_groups, + )?; + let estimate = add_group( + memo, + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: state, + query, + }), + )?; + let output = ranked_as_topk(memo, measure.reduction, ways.result_group, estimate)?; + add_candidate(memo, ways.result_group, output, None, Vec::new())?; + } + HowToComputeAggregateMeasure::ExactAccumulator(_) => { + // The merged build computes the outer aggregate's own + // accumulator state, from the rows below the inner aggregate: a + // candidate of that state group (#1203, fused builds). + let (rows, inner, partitions, kind) = + nested_distributive_input(memo, ways.aggregate_group).ok_or( + PerComputationCandidateError::Unsupported("nested distributive merge input"), + )?; + let read = measure_input(measure.intent, memo, measure.input, measure.filter)?; + let outer_state = accumulator_state(memo, kind, measure.intent, measure, read)?; + let below_inner = SingleMeasure { + input: rows, + reduction: &partitions, + intent: &inner, + filter: None, + raw_data_samples_of: None, }; - Ok(ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(source), - }) - }) - .collect::, PerComputationCandidateError>>()?; - Ok(OperatorNode::new_shared(Operator::NonASAP( - NonASAPOp::Project { - cols, - child: estimate, - }, - ))?) -} - -/// A nested-sum merge for `target` ([`nested_sum_input`]): one exact `Sum` -/// accumulator over the absorbed sum's rewritten input, grouped as -/// `target`, finalized. -fn nested_sum( - target: &OperatorNode, - cache: &NodeToRewrittenNodeCache, -) -> Result, PerComputationCandidateError> { - let (rows, intent, reduction) = nested_sum_input(target).ok_or( - PerComputationCandidateError::Unsupported("nested-sum merge input"), - )?; - let state = exact_accumulator( - ExactKind::Sum, - &intent, - &cache[&Rc::as_ptr(&rows)], - reduction, - )?; - Ok(OperatorNode::new_shared(Operator::ASAP( - ASAPOp::FinalizeExactAccumulator { child: state }, - ))?) -} - -/// An unfiltered exact accumulator of `kind` for `intent` over `input`, -/// grouped by `reduction`. -pub(crate) fn exact_accumulator( - kind: ExactKind, - intent: &AggIntent, - input: &Rc, - reduction: Reduction, -) -> Result, PerComputationCandidateError> { - let family = FieldDataType::exact(kind); - let read = measure_input(intent, input, None)?; - Ok(OperatorNode::new_shared(Operator::ASAP( - ASAPOp::SummaryAgg { - input: summary_update(intent, &family, &reduction, &input.schema, read)?, - child: Rc::clone(input), - family, - reduction, - grouping: GroupingStrategy::default(), - filter: None, - }, - ))?) + let read = measure_input(&inner, memo, rows, None)?; + let merged = accumulator_build(memo, kind, &inner, &below_inner, read)?; + add_candidate(memo, outer_state, merged, None, absorbed_groups.to_vec())?; + } + HowToComputeAggregateMeasure::Exact => { + return Err(PerComputationCandidateError::Unsupported( + "plain exact execution of a fused computation", + )) + } + } + Ok(()) } -/// One measure of `target` over `input`: its group keys, then its value. -fn substitute_measure( - target: &OperatorNode, - intent: &AggIntent, - candidate: &HowToComputeAggregateMeasure, - filter: Option, - input: &Rc, - raw_data_samples_of: Option<&str>, - (sizing, key): (&Sizing, MeasureKey), -) -> Result, PerComputationCandidateError> { - let Some(NonASAPOp::Aggregate { - reduction, - output_names, - .. - }) = target.non_asap() - else { - unreachable!("substitute checked the aggregate") - }; - let state = |family: FieldDataType, intent: &AggIntent, read: SummaryInputExpr| { - Ok::<_, PerComputationCandidateError>(OperatorNode::new_shared(Operator::ASAP( - ASAPOp::SummaryAgg { - child: input.clone(), - input: with_raw_data_samples_requirement( - summary_update(intent, &family, reduction, &input.schema, read)?, - raw_data_samples_of, - ), - family, - reduction: reduction.clone(), - grouping: GroupingStrategy::default(), - filter: filter.clone(), - }, - ))?) - }; - let finalized = |kind: &ExactKind, intent: &AggIntent, read| { - let state = state(FieldDataType::exact(*kind), intent, read)?; - Ok::<_, PerComputationCandidateError>(OperatorNode::new_shared(Operator::ASAP( - ASAPOp::FinalizeExactAccumulator { child: state }, - ))?) - }; - Ok(match candidate { +/// The operator that computes `measure` by `way` (not `Exact`) for +/// `result_group`: its value after its group keys, its column names not +/// yet the group's ([`add_output_candidate`] renames them). Every group it +/// reads is added with its candidates. +fn measure_output( + memo: &mut Memo, + measure: &SingleMeasure<'_>, + way: &HowToComputeAggregateMeasure, + result_group: MemoGroupId, +) -> Result, PerComputationCandidateError> { + Ok(match way { HowToComputeAggregateMeasure::Exact => { - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { - child: input.clone(), - reduction: reduction.clone(), - measures: vec![intent.clone()], - // One name per measure, as the target names this one (an - // empty name is the intent's synthetic one). - output_names: vec![output_names.get(key.1).cloned().unwrap_or_default()], - filters: vec![filter.clone()], - having: None, - }))? + return Err(PerComputationCandidateError::Unsupported( + "plain exact execution is the group's own expression", + )) } HowToComputeAggregateMeasure::ExactAccumulator(kinds) if kinds.len() == 1 => { - let read = measure_input(intent, input, filter.as_ref())?; - finalized(&kinds[0], intent, read)? + let read = measure_input(measure.intent, memo, measure.input, measure.filter)?; + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: accumulator_state(memo, kinds[0], measure.intent, measure, read)?, + }) } HowToComputeAggregateMeasure::Summary(algorithm) => { - let family = FieldDataType::Sketch(*algorithm, GroupingStrategy::default()); - let summary_input = state( - family, - intent, - measure_input(intent, input, filter.as_ref())?, + let query = sketch_statistic(measure.intent).ok_or( + PerComputationCandidateError::Unsupported("sketch evaluation"), )?; - sizing.built.borrow_mut().push((summary_input.clone(), key)); - let estimate = OperatorNode::new_shared(Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query: sketch_statistic(intent).ok_or( - PerComputationCandidateError::Unsupported("sketch evaluation"), + let family = FieldDataType::Sketch(*algorithm, GroupingStrategy::default()); + let read = measure_input(measure.intent, memo, measure.input, measure.filter)?; + let update = with_raw_data_samples_requirement( + summary_update( + measure.intent, + &family, + measure.reduction, + schema(memo, measure.input), + read, )?, - }))?; - match intent { - AggIntent::TopK { .. } => ranked_as_topk(target, estimate)?, + measure.raw_data_samples_of, + ); + let state = add_state_group( + memo, + Operator::ASAP(ASAPOp::SummaryAgg { + child: measure.input, + input: update, + family, + reduction: measure.reduction.clone(), + grouping: GroupingStrategy::default(), + filter: measure.filter.cloned(), + }), + &[], + )?; + let estimate = Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: state, + query, + }); + match measure.intent { + AggIntent::TopK { .. } => { + let estimate = add_group(memo, estimate)?; + ranked_as_topk(memo, measure.reduction, result_group, estimate)? + } _ => estimate, } } HowToComputeAggregateMeasure::ExactAccumulator(kinds) => { let (AggIntent::Avg { col }, [ExactKind::Sum, ExactKind::Count]) = - (intent, kinds.as_slice()) + (measure.intent, kinds.as_slice()) else { return Err(PerComputationCandidateError::Unsupported( "several exact states other than avg's sum then count", )); }; - let sum_intent = AggIntent::Sum { col: *col }; // The count counts the averaged column, as `avg` does: it is // non-null (a nullable input prunes this candidate), so it counts // the rows the sum reads. - let read = measure_input(&sum_intent, input, filter.as_ref())?; - let sum = finalized(&ExactKind::Sum, &sum_intent, read.clone())?; - let count = finalized( - &ExactKind::Count, - &AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }, - read, + let sum_intent = AggIntent::Sum { col: *col }; + let count_intent = AggIntent::Count { + accuracy: AccuracyTarget::exact(), + }; + let read = measure_input(&sum_intent, memo, measure.input, measure.filter)?; + let sum = accumulator_state(memo, ExactKind::Sum, &sum_intent, measure, read.clone())?; + let count = accumulator_state(memo, ExactKind::Count, &count_intent, measure, read)?; + let sum = add_group( + memo, + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: sum }), )?; - if input.result_kind == OperatorResultKind::Relation { - return sql_avg(reduction, filter.is_some(), sum, count); + let count = add_group( + memo, + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: count }), + )?; + if memo.groups()[measure.input.0 as usize] + .group_properties + .result_kind + == OperatorResultKind::Relation + { + return sql_avg( + memo, + measure.reduction, + measure.filter.is_some(), + sum, + count, + ); } // `avg` is Float64 whatever the summed type, as `Avg`'s output is, // and a PromQL binary operator reads Float64 sample values. A @@ -1720,15 +1182,16 @@ fn substitute_measure( // (a count is Int64). A per-series sum keeps the input's labels, // so its value is the sample-value column; a per-series count is // Float64. - let (sum, count) = match reduction { + let (sum, count) = match measure.reduction { Reduction::Reduce(by) => ( - last_as_float64(by.len(), sum)?, - last_as_float64(by.len(), count)?, + last_as_float64(memo, by.len(), sum)?, + last_as_float64(memo, by.len(), count)?, ), Reduction::PerEntity => { - let value = resolve_column_ref(&ColumnRef::SampleValue, &sum.schema) + let sum_schema = schema(memo, sum); + let value = resolve_column_ref(&ColumnRef::SampleValue, sum_schema) .ok() - .and_then(|column| sum.schema.fields.get(column)); + .and_then(|column| sum_schema.fields.get(column)); if value.map(|field| &field.dtype) != Some(&FieldDataType::Plain(DataType::Float64)) { @@ -1739,32 +1202,340 @@ fn substitute_measure( (sum, count) } }; - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::BinaryOp { + Operator::NonASAP(NonASAPOp::BinaryOp { operator: BinaryOperator { checked_relative_division: false, // A per-series window may hold no finite sum to divide. - checked_finite_division: matches!(reduction, Reduction::PerEntity), + checked_finite_division: matches!(measure.reduction, Reduction::PerEntity), kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), vector_match: None, }, return_bool: false, lhs: sum, rhs: count, - }))? + }) } }) } -/// `node`, a grouped aggregate's `keys` group keys and then its value, with -/// the value cast to Float64 unless it already is one. +/// The accumulator-state group of `kind` for `intent` (`measure`'s own, or +/// one of its states, such as `avg`'s `sum`), reading `read` from +/// `measure`'s input, with its build candidate. +fn accumulator_state( + memo: &mut Memo, + kind: ExactKind, + intent: &AggIntent, + measure: &SingleMeasure<'_>, + read: SummaryInputExpr, +) -> Result { + let build = accumulator_build(memo, kind, intent, measure, read)?; + add_state_group(memo, build, &[]) +} + +/// The accumulator build of `kind` for `intent` over `measure`'s input, +/// filtered and grouped as `measure`. +fn accumulator_build( + memo: &Memo, + kind: ExactKind, + intent: &AggIntent, + measure: &SingleMeasure<'_>, + read: SummaryInputExpr, +) -> Result, PerComputationCandidateError> { + let family = FieldDataType::exact(kind); + Ok(Operator::ASAP(ASAPOp::SummaryAgg { + input: summary_update( + intent, + &family, + measure.reduction, + schema(memo, measure.input), + read, + )?, + child: measure.input, + family, + reduction: measure.reduction.clone(), + grouping: GroupingStrategy::default(), + filter: measure.filter.cloned(), + })) +} + +/// Adds `output` as a candidate of `result_group`, then the `above` filters +/// over it. When its column names differ from the group's, +/// `output` becomes its own group, read through a `Project` that names its +/// columns as the group does ([`renaming_project`]). With `above` +/// predicates the output (named as the group) is a group, read by one +/// `Filter` group per predicate but the last, whose `Filter` is the +/// candidate. +fn add_output_candidate( + memo: &mut Memo, + result_group: MemoGroupId, + output: Operator, + above: &[Predicate], +) -> Result<(), PerComputationCandidateError> { + let Some((last, filters)) = above.split_last() else { + match add_candidate(memo, result_group, output.clone(), None, Vec::new()) { + Err(PerComputationCandidateError::Candidate( + CandidateError::OutputDiffersFromGroup { .. }, + )) => {} + added => return added.map(|_| ()), + } + let output = add_group(memo, output)?; + let renamed = renaming_project(memo, output, result_group)?; + add_candidate(memo, result_group, renamed, None, Vec::new())?; + return Ok(()); + }; + let mut named = add_group(memo, output)?; + if schema(memo, named) != schema(memo, result_group) { + let renamed = renaming_project(memo, named, result_group)?; + named = add_group(memo, renamed)?; + } + for pred in filters { + named = add_group( + memo, + Operator::NonASAP(NonASAPOp::Filter { + pred: pred.clone(), + child: named, + }), + )?; + } + add_candidate( + memo, + result_group, + Operator::NonASAP(NonASAPOp::Filter { + pred: last.clone(), + child: named, + }), + None, + Vec::new(), + )?; + Ok(()) +} + +/// A `Project` over `output` that names its columns as `result_group`'s. `output` must have the group's number of columns, +/// and the group's schema must be closed (list every column its rows carry). +fn renaming_project( + memo: &Memo, + output: MemoGroupId, + result_group: MemoGroupId, +) -> Result, PerComputationCandidateError> { + let (from, to) = (schema(memo, output), schema(memo, result_group)); + // An open schema's rows carry columns it does not list (a PromQL + // series whose identity is not realized as a column): a `Project` lists + // its columns, so it would drop them, and its rows are not the group's. + if !to.closed { + return Err(PerComputationCandidateError::Unsupported( + "a rename drops the columns an open schema does not list", + )); + } + if from.fields.len() != to.fields.len() { + return Err(PerComputationCandidateError::Unsupported( + "substituted output with other columns than the aggregate", + )); + } + let cols = to + .fields + .iter() + .enumerate() + .map(|(column, field)| ProjectItem { + alias: Some(field.name.clone()), + expr: ScalarExpr::Column(column), + }) + .collect(); + Ok(Operator::NonASAP(NonASAPOp::Project { + cols, + child: output, + })) +} + +/// A heap's top-k `estimate` group (`[partitions.., items.., topk]`, one +/// row per ranked item) in `result_group`'s columns, as a `Project`: its +/// `[keys.., topk_k]`, with `topk_k` the estimated Float64 weight, then the +/// item columns. This is the exact top-k's physical shape (its ranked rows' +/// value, then the columns naming each ranked item: PromQL labels, SQL +/// group keys), so consumers of `[keys.., topk_k]` find those columns where +/// they were. +fn ranked_as_topk( + memo: &Memo, + reduction: &Reduction, + result_group: MemoGroupId, + estimate: MemoGroupId, +) -> Result, PerComputationCandidateError> { + let ranked_index = reduction.group_keys().map_or(0, |keys| keys.len()); + let fields = &schema(memo, estimate).fields; + let estimated = fields.len() - 1; + let to = schema(memo, result_group); + let cols = to + .fields + .iter() + .enumerate() + .map(|(index, field)| { + let source = if index == ranked_index { + estimated + } else { + fields[..estimated] + .iter() + .position(|input| input.name == field.name) + .ok_or(PerComputationCandidateError::Unsupported( + "top-k estimate without a ranked-row identity column", + ))? + }; + // The estimated weight is Float64; an exact top-k of a SQL + // count ranks Int64 values, so its column is cast to them. + let expr = match (&fields[source].dtype, &field.dtype) { + (FieldDataType::Plain(from), FieldDataType::Plain(to)) if from != to => { + ScalarExpr::Cast { + expr: Box::new(ScalarExpr::Column(source)), + to: to.clone(), + try_cast: false, + } + } + _ => ScalarExpr::Column(source), + }; + Ok(ProjectItem { + alias: Some(field.name.clone()), + expr, + }) + }) + .collect::, PerComputationCandidateError>>()?; + Ok(Operator::NonASAP(NonASAPOp::Project { + cols, + child: estimate, + })) +} + +/// The group of `operator`, whatever adds it, with `operator` as its +/// candidate: an intermediate result is its own group. +fn add_group( + memo: &mut Memo, + operator: Operator, +) -> Result { + let group = memo.find_or_add_group(operator.clone())?; + add_candidate(memo, group, operator, None, Vec::new())?; + Ok(group) +} + +/// The state group of `build` (a `SummaryAgg`) with its build candidates: +/// one per configuration of a sketch family's predefined list +/// (`parameter_list`), or the one exact accumulator build. Each lists +/// `fused_groups`. +fn add_state_group( + memo: &mut Memo, + build: Operator, + fused_groups: &[MemoGroupId], +) -> Result { + let group = memo.find_or_add_group(build.clone())?; + let configurations: Vec> = match &build { + Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(algorithm, _), + .. + }) => parameter_list(*algorithm) + .iter() + .cloned() + .map(Some) + .collect(), + _ => vec![None], + }; + for parameters in configurations { + add_candidate( + memo, + group, + build.clone(), + parameters, + fused_groups.to_vec(), + )?; + } + Ok(group) +} + +/// Adds the candidate `operator` with `parameters` to `group`, with its +/// first input requirements ([`input_requirements`]). +fn add_candidate( + memo: &mut Memo, + group: MemoGroupId, + operator: Operator, + parameters: Option, + fused_groups: Vec, +) -> Result<(), PerComputationCandidateError> { + let expression = CandidateExpression { operator }; + let logical_properties = LogicalProperties::new(&expression, parameters, None)?; + let input_requirements = input_requirements(memo, &expression.operator); + memo.add_candidate( + group, + CandidateForMemoGroup { + expression, + logical_properties, + input_requirements, + fused_groups, + origin: OriginOfCandidateForMemoGroup::Pass1, + }, + )?; + Ok(()) +} + +/// $r^{\log}_{c,i}$ of a Pass 1 candidate `operator`, one per input group: +/// a top-k readout of a heap state needs a heap of at least `k` items (the +/// weakest listed width and depth, `heap_size = k`); every other input has +/// no parameter floor. Pass 1's consumers of state groups propagate error +/// linearly, so no validity cap sets a floor here (concepts doc, section 4). +pub(crate) fn input_requirements( + memo: &Memo, + operator: &Operator, +) -> Vec { + let floor = match operator { + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input, + query: SketchStatistic::TopK { k }, + }) => heap_floor(expression(memo, *summary_input), *k), + _ => None, + }; + operator + .children() + .into_iter() + .enumerate() + .map(|(index, _)| LogicalRequirements::new(if index == 0 { floor.clone() } else { None })) + .collect() +} + +/// The parameter floor of a top-k readout of `k` items over a heap state +/// `build`: the family's weakest listed width and depth with +/// `heap_size = k`. `None` when `build` is no heap family's build. +fn heap_floor(build: &Operator, k: usize) -> Option { + let Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(algorithm, _), + .. + }) = build + else { + return None; + }; + let heap_size = u32::try_from(k).unwrap_or(u32::MAX); + match parameter_list(*algorithm).first()? { + SketchParams::CmsWithHeap { width, depth, .. } => Some(SketchParams::CmsWithHeap { + width: *width, + depth: *depth, + heap_size, + }), + SketchParams::CountSketchWithHeap { width, depth, .. } => { + Some(SketchParams::CountSketchWithHeap { + width: *width, + depth: *depth, + heap_size, + }) + } + _ => None, + } +} + +/// `group`, a grouped aggregate's `keys` group keys and then its value, +/// with the value cast to Float64 unless it already is one (a `Project` +/// group over it). fn last_as_float64( + memo: &mut Memo, keys: usize, - node: Rc, -) -> Result, PerComputationCandidateError> { - if node.schema.fields.last().map(|field| &field.dtype) + group: MemoGroupId, +) -> Result { + if schema(memo, group).fields.last().map(|field| &field.dtype) == Some(&FieldDataType::Plain(DataType::Float64)) { - return Ok(node); + return Ok(group); } let mut cols = key_items(keys); cols.push(ProjectItem { @@ -1775,21 +1546,23 @@ fn last_as_float64( try_cast: false, }, }); - Ok(OperatorNode::new_shared(Operator::NonASAP( - NonASAPOp::Project { cols, child: node }, - ))?) + add_group( + memo, + Operator::NonASAP(NonASAPOp::Project { cols, child: group }), + ) } -/// SQL `avg` from its finalized `sum` and `count`: joined on the group keys -/// (NULL keys match), then `sum / count` as Float64. A group with no row -/// passing the measure filter, or an empty global input, counts 0 and its -/// `avg` is NULL. +/// SQL `avg` from its finalized `sum` and `count` groups: joined on the +/// group keys (NULL keys match), then `sum / count` as Float64. A group +/// with no row passing the measure filter, or an empty global input, counts +/// 0 and its `avg` is NULL. fn sql_avg( + memo: &mut Memo, reduction: &Reduction, filtered: bool, - sum: Rc, - count: Rc, -) -> Result, PerComputationCandidateError> { + sum: MemoGroupId, + count: MemoGroupId, +) -> Result, PerComputationCandidateError> { let Reduction::Reduce(by) = reduction else { return Err(PerComputationCandidateError::Unsupported( "SQL avg without `by` keys", @@ -1835,22 +1608,23 @@ fn sql_avg( alias: None, expr: avg, }); - Ok(OperatorNode::new_shared(Operator::NonASAP( - NonASAPOp::Project { - cols, - child: join_on_keys(keys, sum, count)?, - }, - ))?) + let joined = add_group(memo, join_on_keys(memo, keys, sum, count))?; + Ok(Operator::NonASAP(NonASAPOp::Project { + cols, + child: joined, + })) } /// `left` joined with `right` on their leading `keys` columns; NULL keys /// match each other, as grouping treats them. fn join_on_keys( + memo: &Memo, keys: usize, - left: Rc, - right: Rc, -) -> Result, PerComputationCandidateError> { - let width = left.schema.fields.len(); + left: MemoGroupId, + right: MemoGroupId, +) -> Operator { + let width = schema(memo, left).fields.len(); + let right_schema = schema(memo, right); let column = |id| Box::new(ScalarExpr::Column(id)); let matches = (0..keys) .map(|key| { @@ -1860,7 +1634,7 @@ fn join_on_keys( right: column(width + key), semantics: ExprSemantics::Sql, }; - if right.schema.fields[key].nullable { + if right_schema.fields[key].nullable { ScalarExpr::BoolOr(vec![ equal, ScalarExpr::BoolAnd(vec![ @@ -1880,29 +1654,25 @@ fn join_on_keys( ), _ => (JoinKind::Inner, ScalarExpr::BoolAnd(matches)), }; - Ok(OperatorNode::new_shared(Operator::NonASAP( - NonASAPOp::Join { - kind, - pred: Predicate(pred), - left, - right, - }, - ))?) + Operator::NonASAP(NonASAPOp::Join { + kind, + pred: Predicate(pred), + left, + right, + }) } -/// Join single-measure `parts` (each: group keys, then one value) on their -/// group keys and project `target`'s output columns. Every part has the same -/// groups, so an inner join keeps them all; NULL keys match each other, as -/// grouping treats them. +/// Joins single-measure `parts` (each a group: group keys, then one value) +/// on their group keys, and projects `result_group`'s output columns. Every +/// part has the same groups, so an inner join keeps them all; NULL keys +/// match each other, as grouping treats them. fn join_measures( - target: &OperatorNode, - parts: Vec>, -) -> Result, PerComputationCandidateError> { - let Some(NonASAPOp::Aggregate { - reduction: Reduction::Reduce(by), - .. - }) = target.non_asap() - else { + memo: &mut Memo, + reduction: &Reduction, + result_group: MemoGroupId, + parts: Vec, +) -> Result, PerComputationCandidateError> { + let Reduction::Reduce(by) = reduction else { return Err(PerComputationCandidateError::Unsupported( "multi-measure aggregate without `by` keys", )); @@ -1917,27 +1687,25 @@ fn join_measures( let mut parts = parts.into_iter(); let mut joined = parts.next().expect("an aggregate has measures"); for part in parts { - joined = join_on_keys(keys, joined, part)?; + let join = join_on_keys(memo, keys, joined, part); + joined = add_group(memo, join)?; } + let to = schema(memo, result_group); let mut cols = key_items(keys); - cols.extend( - (0..target.schema.fields.len() - keys).map(|measure| ProjectItem { - alias: None, - expr: ScalarExpr::Column(measure * width + keys), - }), - ); - for (item, field) in cols.iter_mut().zip(&target.schema.fields) { + cols.extend((0..to.fields.len() - keys).map(|measure| ProjectItem { + alias: None, + expr: ScalarExpr::Column(measure * width + keys), + })); + for (item, field) in cols.iter_mut().zip(&to.fields) { item.alias = Some(field.name.clone()); } - Ok(OperatorNode::new_shared(Operator::NonASAP( - NonASAPOp::Project { - cols, - child: joined, - }, - ))?) + Ok(Operator::NonASAP(NonASAPOp::Project { + cols, + child: joined, + })) } -fn key_items(keys: usize) -> Vec { +fn key_items(keys: usize) -> Vec> { (0..keys) .map(|key| ProjectItem { alias: None, @@ -1951,17 +1719,21 @@ fn key_items(keys: usize) -> Vec { /// included, reads its input column (the sample value when it names none). fn measure_input( intent: &AggIntent, - input: &Rc, - filter: Option<&Predicate>, + memo: &Memo, + input: MemoGroupId, + filter: Option<&Predicate>, ) -> Result { - let schema = &input.schema; + let schema = schema(memo, input); let read = match intent { - AggIntent::Count { .. } if input.result_kind == OperatorResultKind::Relation => { + AggIntent::Count { .. } + if memo.groups()[input.0 as usize].group_properties.result_kind + == OperatorResultKind::Relation => + { SummaryInputExpr::Column(counted_column(schema, filter)?) } // A top-k ranks its child's aggregate output, not a column of its own. AggIntent::TopK { .. } => SummaryInputExpr::Column( - crate::realization::ranked_value(input) + crate::realization::ranked_value(memo, input) .map_err(|_| { PerComputationCandidateError::Unsupported("Top-K has no ranked column") })? @@ -1985,9 +1757,9 @@ fn measure_input( /// a non-nullable one, Float64 first (the runtime's numeric update). fn counted_column( schema: &Schema, - filter: Option<&Predicate>, + filter: Option<&Predicate>, ) -> Result { - fn not_null(expr: &ScalarExpr) -> Vec { + fn not_null(expr: &ScalarExpr) -> Vec { match expr { ScalarExpr::IsNotNull(inner) => match **inner { ScalarExpr::Column(column) => vec![column], @@ -2113,128 +1885,3 @@ fn summary_update( }, }) } - -#[cfg(test)] -mod tests { - - use super::*; - - use asap_accuracy::test_support::{ - estimate_bound_over_raw_data_samples, estimate_has_bound, local_guarantee, - raw_data_samples_sign_statistic, - }; - - use asap_types::ir::properties::AccuracyTarget; - use asap_types::ir::schema::SketchStatistic; - /// Approximate count keeps one exact option, its accumulator, beside the - /// universal summary; plain exact execution is not offered. - #[test] - fn approximate_count_keeps_one_exact_accumulator_and_universal_choices() { - let choices = computation_candidates_for_intent(&AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.05).failure_probability(0.01), - }) - .unwrap() - .candidates; - assert!(!choices.contains(&HowToComputeAggregateMeasure::Exact)); - assert_eq!( - choices[0], - HowToComputeAggregateMeasure::ExactAccumulator(vec![ExactKind::Count]) - ); - assert!(choices.contains(&HowToComputeAggregateMeasure::Summary( - SketchAlgorithm::UnivMon - ))); - } - - /// Count Sketch's signed counters give no total, so Pass 1 offers no - /// Count Sketch for a count, and every count candidate it offers - /// answers the total. - #[test] - fn a_count_offers_no_count_sketch() { - let choices = computation_candidates_for_intent(&AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.05).failure_probability(0.01), - }) - .unwrap() - .candidates; - assert!(!choices.contains(&HowToComputeAggregateMeasure::Summary( - SketchAlgorithm::CountSketch - ))); - let total = sketch_statistic(&AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }) - .unwrap(); - for choice in &choices { - if let HowToComputeAggregateMeasure::Summary(algorithm) = choice { - assert!( - asap_types::ir::schema::FamilyCapability::of_per_instance_sketch(algorithm) - .answers(&total), - "{algorithm:?}" - ); - } - } - } - - /// A Count-Min estimate has a bound only over weights proven - /// non-negative: an open requirement (`RawDataSamplesOfMetric`) is not a proof. - #[test] - fn count_min_estimate_has_no_bound_over_an_open_requirement() { - let family = FieldDataType::Sketch(SketchAlgorithm::Cms, GroupingStrategy::default()); - let query = SketchStatistic::PointCount { - key: ColumnRef::SampleValue, - value: None, - }; - let with = |weight_domain| SummaryUpdate { - item: Some(SummaryInputExpr::Column(ColumnRef::SampleValue)), - weight: SummaryInputExpr::Column(ColumnRef::SampleValue), - weight_domain, - }; - assert!(!estimate_has_bound( - &family, - &with(WeightDomain::RawDataSamplesOfMetric { metric: "m".into() }), - &query - )); - assert!(!estimate_has_bound( - &family, - &with(WeightDomain::UnknownOrSigned), - &query - )); - assert!(estimate_has_bound( - &family, - &with(WeightDomain::NonNegative { - proof: NonNegativeWeightProof::CounterRawDataSamples - }), - &query - )); - } - - /// A Count-Min estimate over samples of `m` gets its bound gated on - /// `m`'s sign: unknown until plan selection binds it, the local bound - /// once bound to 1. - #[test] - fn count_min_estimate_over_raw_data_samples_is_gated_on_the_metric() { - let family = FieldDataType::Sketch(SketchAlgorithm::Cms, GroupingStrategy::default()); - let query = SketchStatistic::PointCount { - key: ColumnRef::SampleValue, - value: None, - }; - let update = SummaryUpdate { - item: Some(SummaryInputExpr::Column(ColumnRef::SampleValue)), - weight: SummaryInputExpr::Column(ColumnRef::SampleValue), - weight_domain: WeightDomain::RawDataSamplesOfMetric { metric: "m".into() }, - }; - let gated = estimate_bound_over_raw_data_samples(&family, &update, &query).expect("gated"); - assert_eq!(gated.spec.error.evaluate(), None); - let local = local_guarantee(&family, &query).unwrap(); - let statistic = raw_data_samples_sign_statistic("m"); - // Every other statistic (a stream norm) bound alike. - let others = |s: &str| Some(if s == statistic { 1.0 } else { 1_000.0 }); - let bound = gated.bind(&others).spec.error.evaluate(); - assert!(bound.is_some()); - assert_eq!(bound, local.bind(&others).spec.error.evaluate()); - let unconfirmed = gated - .bind(&|s| (s != statistic).then_some(1_000.0)) - .spec - .error - .evaluate(); - assert_eq!(unconfirmed, None); - } -} diff --git a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs index c8311f580..0b83b9a3e 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs @@ -1,19 +1,991 @@ -//! Frontend-to-Pass-1 acceptance: candidate discovery precedes empirical selection. -use crate::per_computation_candidates::computation_candidates_for_intent; +//! Pass 1's tests. Each builds its input memo through `Memo::find_or_add_group` +//! and `Memo::add_query`, as Stage 0 does, and reads the candidates +//! [`pass1_memo`](crate::pass1_memo) adds; no other stage crate is called. +use std::fmt::Write as _; +use std::time::Duration; + +use asap_types::ir::operator::operator_properties::{BinaryOpKind, Reduction, Source}; use asap_types::ir::operator::AggIntent; use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::schema::SketchAlgorithm; +use asap_types::ir::scalar::expr_ir::ArithmeticOpKind; +use asap_types::ir::scalar::{ColumnRef, CompareOpKind, ScalarValue}; +use asap_types::ir::schema::{ + DataType, ExactKind, Field, FieldDataType, Schema, SketchAlgorithm, SketchParams, + SketchStatistic, SummaryInputExpr, SummaryUpdate, WeightDomain, PROMQL_SERIES_IDENTITY, +}; +use asap_types::ir::{ + ASAPOp, BinaryOperator, ExprSemantics, NonASAPOp, Operator, Predicate, ScalarExpr, + TimeRangeKind, +}; +use asap_types::stages::{ + parameter_list, CandidateForMemoGroup, HowToComputeAggregateMeasure, Memo, MemoGroupId, +}; + +use crate::pass1_memo; +use crate::per_computation_candidates::{ + computation_candidates_for_intent, computation_targets, PerComputationCandidateError, +}; +use crate::test_fixtures::{ + aggregate_group, group, metric_schema, query, scan_group, scan_operator, +}; + +/// A compact printout of every group and its candidates: the expression's +/// operator, then per candidate its operator, input groups, θ and input +/// floors. +fn memo_printout(memo: &Memo) -> String { + let mut out = String::new(); + for (index, group) in memo.groups().iter().enumerate() { + let _ = writeln!(out, "G{index}: {}", label(&group.expression)); + for (position, candidate) in group.candidates.iter().enumerate() { + let inputs: Vec = candidate + .expression + .operator + .children() + .iter() + .map(|g| format!("G{}", g.0)) + .collect(); + let mut line = format!( + " [{position}] {} inputs=[{}]", + label(&candidate.expression.operator), + inputs.join(", ") + ); + if let Some(parameters) = candidate.logical_properties.parameters() { + let _ = write!(line, " θ={parameters:?}"); + } + for (input, requirement) in candidate.input_requirements.iter().enumerate() { + if let Some(floor) = requirement.parameter_floor_for_required_accuracy() { + let _ = write!(line, " floor[{input}]={floor:?}"); + } + } + if !candidate.fused_groups.is_empty() { + let _ = write!(line, " fused_groups={:?}", candidate.fused_groups); + } + let _ = writeln!(out, "{line}"); + } + } + out +} + +fn label(operator: &Operator) -> String { + match operator { + Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) => format!("SummaryAgg[{family:?}]"), + Operator::ASAP(ASAPOp::SummaryEstimate { query, .. }) => { + format!("SummaryEstimate[{query:?}]") + } + Operator::NonASAP(NonASAPOp::Aggregate { measures, .. }) => { + let names: Vec = measures + .iter() + .map(|m| { + format!("{m:?}") + .split([' ', '{']) + .next() + .unwrap_or("") + .to_string() + }) + .collect(); + format!("Aggregate[{}]", names.join(", ")) + } + operator => operator.kind_name().to_string(), + } +} -use asap_types::stages::HowToComputeAggregateMeasure; +fn candidates(memo: &Memo, group: MemoGroupId) -> &[CandidateForMemoGroup] { + &memo.groups()[group.0 as usize].candidates +} + +/// The groups of `memo` whose expression matches `pattern`. +fn groups_where(memo: &Memo, pattern: impl Fn(&Operator) -> bool) -> Vec { + memo.groups() + .iter() + .enumerate() + .filter(|(_, group)| pattern(&group.expression)) + .map(|(index, _)| MemoGroupId(index as u32)) + .collect() +} + +/// The state group of `family` in `memo`, and its build's update. +fn state_group(memo: &Memo, family: SketchAlgorithm) -> (MemoGroupId, SummaryUpdate) { + let found = groups_where( + memo, + |expression| matches!(expression, Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(f, _), .. }) if *f == family), + ); + let [state] = found[..] else { + panic!( + "one {family:?} state group: {found:?}\n{}", + memo_printout(memo) + ) + }; + let Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) = + &memo.groups()[state.0 as usize].expression + else { + unreachable!() + }; + (state, input.clone()) +} fn approximate() -> AccuracyTarget { AccuracyTarget::max_error(0.05).failure_probability(0.01) } + +fn quantile(q: f64) -> AggIntent { + AggIntent::Quantile { + col: None, + q, + accuracy: approximate(), + } +} + +fn sum() -> AggIntent { + AggIntent::Sum { col: None } +} + +fn count() -> AggIntent { + AggIntent::Count { + accuracy: approximate(), + } +} + +fn topk(k: usize) -> AggIntent { + AggIntent::TopK { + k, + accuracy: approximate(), + } +} + +/// `metric[5m]` (`Range`) or `metric` (`Instant`, which a cross-series +/// aggregate reads): a selector over `metric{labels}`. +fn selector(memo: &mut Memo, kind: TimeRangeKind, metric: &str, labels: &[&str]) -> MemoGroupId { + let scan = scan_group(memo, metric, labels); + group( + memo, + Operator::NonASAP(NonASAPOp::TimeRange { + range: Duration::from_secs(300), + kind, + child: scan, + }), + ) +} + +/// `column op literal` over an aggregate's output. +fn compare(column: usize, op: CompareOpKind, value: ScalarValue) -> Predicate { + Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(column)), + op, + right: Box::new(ScalarExpr::Literal(value)), + semantics: ExprSemantics::Promql, + }) +} + +/// `Filter(pred)` over `child`, added as Stage 0 adds a consumer `Filter`. +fn filter(memo: &mut Memo, child: MemoGroupId, pred: Predicate) -> MemoGroupId { + group(memo, Operator::NonASAP(NonASAPOp::Filter { pred, child })) +} + +// ── Running example (concepts doc, section 1) ────────────────────────────── + +/// The doc's running example: query A reads p95 of `latency`, query B +/// reads p99 / p50 of it. KLL state group holds one build per θ of KLL's +/// list, DDSketch's state group likewise; the p95, p99 and p50 groups each +/// read either state through their own readout groups. The metric rule +/// (B's relative ratio cannot read KLL's rank error) drops edges in PR 4, +/// so Pass 1 offers both families to every quantile group. +#[test] +fn running_example_gives_one_build_per_theta_and_separate_readouts() { + let mut memo = Memo::default(); + let g1 = selector(&mut memo, TimeRangeKind::Instant, "latency", &[]); + let g3 = aggregate_group( + &mut memo, + g1, + Reduction::by(vec![]), + vec![quantile(0.95)], + None, + ); + let g4 = aggregate_group( + &mut memo, + g1, + Reduction::by(vec![]), + vec![quantile(0.99)], + None, + ); + let g5 = aggregate_group( + &mut memo, + g1, + Reduction::by(vec![]), + vec![quantile(0.5)], + None, + ); + let g6 = group( + &mut memo, + Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), + vector_match: None, + }, + return_bool: false, + lhs: g4, + rhs: g5, + }), + ); + query(&mut memo, g3); + query(&mut memo, g6); + let memo = pass1_memo(memo, None).unwrap(); + println!("{}", memo_printout(&memo)); + let (kll, _) = state_group(&memo, SketchAlgorithm::Kll); + let (ddsketch, _) = state_group(&memo, SketchAlgorithm::DDSketch); + for (state, family) in [ + (kll, SketchAlgorithm::Kll), + (ddsketch, SketchAlgorithm::DDSketch), + ] { + let thetas: Vec = candidates(&memo, state) + .iter() + .map(|candidate| candidate.logical_properties.parameters().unwrap().clone()) + .collect(); + assert_eq!(thetas, parameter_list(family)); + // θ is a property, never an operator argument: every build has the + // group's own expression. + assert!(candidates(&memo, state).iter().all(|candidate| { + candidate.expression.operator == memo.groups()[state.0 as usize].expression + })); + } + // Each quantile group: its exact aggregate and one readout per family, + // each through a readout group of its own over the state group. + for (result, q) in [(g3, 0.95), (g4, 0.99), (g5, 0.5)] { + let readouts: Vec = candidates(&memo, result)[1..] + .iter() + .map(|candidate| *candidate.expression.operator.children()[0]) + .collect(); + assert_eq!(readouts.len(), 2, "G{}", result.0); + for (readout, state) in readouts.iter().zip([kll, ddsketch]) { + assert_eq!( + memo.groups()[readout.0 as usize].expression, + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: state, + query: SketchStatistic::Quantile { q }, + }) + ); + assert!(candidates(&memo, *readout)[0] + .logical_properties + .parameters() + .is_none()); + } + } + // The ratio's group keeps its one candidate, its own expression. + assert_eq!(candidates(&memo, g6).len(), 1); +} + +// ── Series identity ──────────────────────────────────────────────────────── + +/// `quantile_over_time(0.9, latency[5m])` over series whose identity is +/// realized as a column (as the planner does before Stage 0): each readout +/// candidate outputs the per-series aggregate's exact schema, the +/// `$promql_series_identity` column included, so every readout keeps the +/// series it answers for. +#[test] +fn per_series_readouts_keep_the_series_identity() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Range, "latency", &["job"]); + let per_series = aggregate_group( + &mut memo, + rows, + Reduction::PerEntity, + vec![quantile(0.9)], + None, + ); + query(&mut memo, per_series); + let memo = pass1_memo(memo, None).unwrap(); + let output = &memo.groups()[per_series.0 as usize].group_properties.schema; + assert!(output.closed && output.has_promql_series_identity()); + let readouts = &candidates(&memo, per_series)[1..]; + assert_eq!(readouts.len(), 2, "{}", memo_printout(&memo)); + for readout in readouts { + let Operator::NonASAP(NonASAPOp::Project { child, cols, .. }) = + &readout.expression.operator + else { + panic!("a renaming Project: {:?}", readout.expression.operator) + }; + let estimate = &memo.groups()[child.0 as usize].group_properties.schema; + assert!(estimate.has_promql_series_identity()); + let identity = output + .fields + .iter() + .position(|field| field.name == PROMQL_SERIES_IDENTITY) + .unwrap(); + let read = estimate + .fields + .iter() + .position(|field| field.name == PROMQL_SERIES_IDENTITY) + .unwrap(); + assert_eq!(cols[identity].expr, ScalarExpr::Column(read)); + } +} + +/// Without a realized identity the per-series aggregate's schema is open: +/// its rows carry labels it does not list. A renaming `Project` would drop +/// them, so no readout is offered; the group keeps its own expression. +#[test] +fn an_open_per_series_aggregate_offers_no_renamed_readout() { + let mut memo = Memo::default(); + let scan = group( + &mut memo, + scan_operator("latency", metric_schema(&["job"], vec![])), + ); + let rows = group( + &mut memo, + Operator::NonASAP(NonASAPOp::TimeRange { + range: Duration::from_secs(300), + kind: TimeRangeKind::Range, + child: scan, + }), + ); + let per_series = aggregate_group( + &mut memo, + rows, + Reduction::PerEntity, + vec![quantile(0.9)], + None, + ); + query(&mut memo, per_series); + let memo = pass1_memo(memo, None).unwrap(); + assert!( + !memo.groups()[per_series.0 as usize] + .group_properties + .schema + .closed + ); + assert_eq!(candidates(&memo, per_series).len(), 1); +} + +// ── Target discovery ─────────────────────────────────────────────────────── + +/// Each measure of a multi-measure aggregate is its own target; a measure +/// whose only realization is its exact accumulator is still a target. +#[test] +fn multi_measure_aggregate_has_one_target_per_measure() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let both = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![2]), + vec![sum(), quantile(0.9)], + None, + ); + query(&mut memo, both); + let targets = computation_targets(&memo, None).unwrap(); + let measures: Vec<(MemoGroupId, usize)> = targets + .iter() + .map(|target| (target.aggregate_group, target.measure_index)) + .collect(); + assert_eq!(measures, vec![(both, 0), (both, 1)]); +} + +/// An aggregate with no realization besides exact execution is not a target. +#[test] +fn aggregate_without_realization_is_not_a_target() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &[]); + let quantile_exact = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![]), + vec![AggIntent::Quantile { + col: None, + q: 0.5, + accuracy: AccuracyTarget::exact(), + }], + None, + ); + query(&mut memo, quantile_exact); + let memo = pass1_memo(memo, None).unwrap(); + assert_eq!(candidates(&memo, quantile_exact).len(), 1); +} + +/// An aggregate several queries read, or a scalar root reads twice, is one +/// target per measure: Pass 1 walks the groups. +#[test] +fn an_aggregate_read_by_several_roots_is_one_target() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &[]); + let p = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![]), + vec![quantile(0.9)], + None, + ); + query(&mut memo, p); + query(&mut memo, p); + assert_eq!(computation_targets(&memo, None).unwrap().len(), 1); +} + +/// A memo whose groups already hold ASAP operators, and an approximate +/// accuracy target outside (0, 1), fail at the stage boundary. +#[test] +fn asap_input_and_invalid_accuracy_are_rejected() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &[]); + let state = group( + &mut memo, + Operator::ASAP(ASAPOp::SummaryAgg { + child: rows, + family: FieldDataType::exact(ExactKind::Sum), + input: SummaryUpdate::column(ColumnRef::SampleValue), + reduction: Reduction::PerEntity, + grouping: Default::default(), + filter: None, + }), + ); + query(&mut memo, state); + assert!(matches!( + pass1_memo(memo, None), + Err(PerComputationCandidateError::ContainsAsap) + )); + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &[]); + let invalid = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![]), + vec![AggIntent::Quantile { + col: None, + q: 0.5, + accuracy: AccuracyTarget::max_error(1.5), + }], + None, + ); + query(&mut memo, invalid); + assert!(matches!( + pass1_memo(memo, None), + Err(PerComputationCandidateError::InvalidAccuracy { .. }) + )); +} + +/// The summaries the deployment does not support are not offered. +#[test] +fn unsupported_summaries_are_not_offered() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Range, "m", &[]); + let p = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![]), + vec![quantile(0.9)], + None, + ); + query(&mut memo, p); + let memo = pass1_memo(memo, Some(&[])).unwrap(); + assert_eq!(candidates(&memo, p).len(), 1); + assert!(groups_where(&memo, |e| matches!(e, Operator::ASAP(_))).is_empty()); +} + +// ── Candidate composition ────────────────────────────────────────────────── + +/// Every group has its own expression as a candidate, with no θ and no +/// floor: plain exact execution. +#[test] +fn every_group_offers_its_own_expression() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let s = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![sum()], None); + query(&mut memo, s); + let stage0 = memo.groups().len(); + let memo = pass1_memo(memo, None).unwrap(); + for index in 0..stage0 { + let group = &memo.groups()[index]; + let own = &group.candidates[0]; + assert_eq!(own.expression.operator, group.expression); + assert!(own.logical_properties.parameters().is_none()); + assert!(own + .input_requirements + .iter() + .all(|r| r.parameter_floor_for_required_accuracy().is_none())); + } +} + +/// An exact accumulator is a state group (`SummaryAgg` of the exact +/// family, no θ) read by a `FinalizeExactAccumulator` group, which the +/// aggregate's group reads through a renaming `Project`. +#[test] +fn exact_accumulator_is_a_state_group_and_a_finalize() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let s = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![sum()], None); + query(&mut memo, s); + let memo = pass1_memo(memo, None).unwrap(); + let states = groups_where(&memo, |e| { + matches!( + e, + Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(ExactKind::Sum, _), + .. + }) + ) + }); + let [state] = states[..] else { + panic!("{}", memo_printout(&memo)) + }; + assert_eq!(candidates(&memo, state).len(), 1); + assert!(candidates(&memo, state)[0] + .logical_properties + .parameters() + .is_none()); + // The aggregate reads it through a `FinalizeExactAccumulator`, renamed + // to the aggregate's columns when its names differ. + let finalize = Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: state }); + let finalize_groups = groups_where(&memo, |e| *e == finalize); + assert!(candidates(&memo, s).iter().any(|c| { + c.expression.operator == finalize + || c.expression + .operator + .children() + .iter() + .any(|g| finalize_groups.contains(g)) + })); +} + +/// A multi-measure aggregate's candidate joins the measures' own groups +/// (one single-measure aggregate each, with its own candidates) on the +/// group keys. +#[test] +fn multi_measure_candidate_joins_single_measure_groups() { + let mut memo = Memo::default(); + // A SQL table `flows(src Utf8, bytes Float64)`. + let rows = group( + &mut memo, + Operator::NonASAP(NonASAPOp::Scan { + source: Source::Table { + table_ref: "flows".into(), + }, + predicates: vec![], + schema: Schema::new(vec![ + Field::plain("src", DataType::Utf8, false), + Field::plain("bytes", DataType::Float64, false), + ]), + }), + ); + let both = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![0]), + vec![ + AggIntent::Sum { col: Some(1) }, + AggIntent::Quantile { + col: Some(1), + q: 0.9, + accuracy: approximate(), + }, + ], + None, + ); + query(&mut memo, both); + let memo = pass1_memo(memo, None).unwrap(); + let single = groups_where( + &memo, + |e| matches!(e, Operator::NonASAP(NonASAPOp::Aggregate { measures, .. }) if measures.len() == 1), + ); + assert_eq!(single.len(), 2, "{}", memo_printout(&memo)); + // The quantile measure's group has its exact aggregate and two readouts. + assert!(single.iter().any(|g| candidates(&memo, *g).len() == 3)); + let joins = groups_where(&memo, |e| { + matches!(e, Operator::NonASAP(NonASAPOp::Join { .. })) + }); + assert_eq!(joins.len(), 1); + assert_eq!(candidates(&memo, both).len(), 2); +} + +/// Pass 1's candidates leave every input's floor empty except a top-k +/// readout's (the heap size it needs): the consumers of state groups +/// propagate error linearly, so no validity cap applies. +#[test] +fn only_a_topk_readout_has_a_parameter_floor() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Range, "m", &["job"]); + let per_series = aggregate_group(&mut memo, rows, Reduction::PerEntity, vec![sum()], None); + let by_job = aggregate_group( + &mut memo, + per_series, + Reduction::by(vec![2]), + vec![sum()], + None, + ); + let ranked = aggregate_group( + &mut memo, + by_job, + Reduction::by(vec![]), + vec![topk(15)], + None, + ); + let samples = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let p = aggregate_group( + &mut memo, + samples, + Reduction::by(vec![]), + vec![quantile(0.9)], + None, + ); + query(&mut memo, ranked); + query(&mut memo, p); + let memo = pass1_memo(memo, None).unwrap(); + for (index, group) in memo.groups().iter().enumerate() { + for candidate in &group.candidates { + let floors: Vec<_> = candidate + .input_requirements + .iter() + .filter_map(|r| r.parameter_floor_for_required_accuracy()) + .collect(); + match &candidate.expression.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { + query: SketchStatistic::TopK { .. }, + .. + }) => { + let [floor] = floors[..] else { + panic!("G{index}: {floors:?}") + }; + assert!(matches!( + floor, + SketchParams::CmsWithHeap { heap_size: 15, .. } + | SketchParams::CountSketchWithHeap { heap_size: 15, .. } + )); + } + _ => assert!(floors.is_empty(), "G{index}"), + } + } + } +} + +// ── HAVING placement ─────────────────────────────────────────────────────── + +/// The aggregate's input with a key-only predicate pushed below it: a +/// `Filter` group over `rows`. +fn pushed_below(memo: &Memo, rows: MemoGroupId) -> Vec { + groups_where( + memo, + |e| matches!(e, Operator::NonASAP(NonASAPOp::Filter { child, .. }) if *child == rows), + ) +} + +/// A key-only `HAVING` (a consumer `Filter` over the group keys) gets +/// candidates in the `Filter`'s group that push it below the aggregate: a +/// `Filter` on the input rows, which the states read. The aggregate's own +/// group keeps its unfiltered candidates for its other consumers. +#[test] +fn key_only_having_is_pushed_below_into_the_filter_group() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let by_job = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![2]), + vec![quantile(0.9)], + None, + ); + let having = filter( + &mut memo, + by_job, + compare(0, CompareOpKind::Eq, ScalarValue::Utf8("a".into())), + ); + query(&mut memo, having); + query(&mut memo, by_job); + let memo = pass1_memo(memo, None).unwrap(); + let below = pushed_below(&memo, rows); + assert_eq!(below.len(), 1, "{}", memo_printout(&memo)); + // The Filter group: its own expression, then one readout per family + // over states built from the filtered rows. + assert_eq!(candidates(&memo, having).len(), 3); + assert_eq!(candidates(&memo, by_job).len(), 3); +} + +/// A `HAVING` over an aggregate value stays above the output: nothing is +/// pushed below, and the `Filter` group keeps its own expression only. +#[test] +fn value_having_stays_above() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let by_job = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![2]), + vec![quantile(0.9)], + None, + ); + let having = filter( + &mut memo, + by_job, + compare(1, CompareOpKind::Gt, ScalarValue::Float64(10.0)), + ); + query(&mut memo, having); + let memo = pass1_memo(memo, None).unwrap(); + assert!(pushed_below(&memo, rows).is_empty()); + assert_eq!(candidates(&memo, having).len(), 1); +} + +/// A global aggregate's `HAVING` stays above even when it is constant: the +/// one output row exists with no input row. +#[test] +fn global_aggregate_having_stays_above() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &[]); + let p = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![]), + vec![quantile(0.9)], + None, + ); + let having = filter( + &mut memo, + p, + Predicate(ScalarExpr::Literal(ScalarValue::Boolean(false))), + ); + query(&mut memo, having); + let memo = pass1_memo(memo, None).unwrap(); + assert!(pushed_below(&memo, rows).is_empty()); +} + +/// The aggregate's own value `having` is a `Filter` above each readout: +/// the aggregate group's summary candidates are `Filter`s over a readout. +#[test] +fn own_value_having_filters_each_readout() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let by_job = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![2]), + vec![quantile(0.9)], + Some(compare(1, CompareOpKind::Gt, ScalarValue::Float64(10.0))), + ); + query(&mut memo, by_job); + let memo = pass1_memo(memo, None).unwrap(); + let kinds: Vec<&str> = candidates(&memo, by_job) + .iter() + .map(|c| c.expression.operator.kind_name()) + .collect(); + assert_eq!(kinds, ["Aggregate", "Filter", "Filter"]); +} + +// ── Fused heaps, whole-expression top-k, ranked items, raw-data signs ────── + +/// `topk(5, sum by (job) (sum_over_time(m[5m])))`: the top-k gets heaps +/// over the range's raw data samples, keyed by `job` (the ranked item), +/// weighted by the sample value with the open requirement that the samples +/// of `m` are non-negative (`RawDataSamplesOfMetric`). Each heap build +/// lists the absorbed sums' groups in `fused_groups`, and the top-k's own +/// heaps over its one-row-per-item input are pruned. +#[test] +fn whole_expression_topk_heaps_read_the_raw_samples() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Range, "m", &["job"]); + let per_series = aggregate_group(&mut memo, rows, Reduction::PerEntity, vec![sum()], None); + let by_job = aggregate_group( + &mut memo, + per_series, + Reduction::by(vec![2]), + vec![sum()], + None, + ); + let ranked = aggregate_group( + &mut memo, + by_job, + Reduction::by(vec![]), + vec![topk(5)], + None, + ); + query(&mut memo, ranked); + let memo = pass1_memo(memo, None).unwrap(); + for family in [ + SketchAlgorithm::CmsWithHeap, + SketchAlgorithm::CountSketchWithHeap, + ] { + let (state, update) = state_group(&memo, family); + assert_eq!( + *memo.groups()[state.0 as usize].expression.children()[0], + rows + ); + assert_eq!( + update.item, + Some(SummaryInputExpr::Column(ColumnRef::Named("job".into()))) + ); + assert_eq!( + update.weight_domain, + WeightDomain::RawDataSamplesOfMetric { metric: "m".into() } + ); + for build in candidates(&memo, state) { + let mut fused = build.fused_groups.clone(); + fused.sort(); + assert_eq!(fused, vec![per_series, by_job]); + } + assert_eq!(candidates(&memo, state).len(), parameter_list(family).len()); + } +} + +/// A top-k over a `count` ranks the counted items with unit weight, proven +/// non-negative. +#[test] +fn count_ranked_topk_counts_with_unit_weight() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let counts = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![count()], None); + let ranked = aggregate_group( + &mut memo, + counts, + Reduction::by(vec![]), + vec![topk(5)], + None, + ); + query(&mut memo, ranked); + let memo = pass1_memo(memo, None).unwrap(); + let (_, update) = state_group(&memo, SketchAlgorithm::CmsWithHeap); + assert_eq!(update.weight, SummaryInputExpr::Constant(1.0)); + assert!(matches!( + update.weight_domain, + WeightDomain::NonNegative { .. } + )); +} + +/// The ranked item over several group keys is their tuple; the top-k's own +/// partition keys partition the heap and stay out of the tuple. +#[test] +fn ranked_item_is_the_keys_outside_the_partition() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Range, "m", &["job", "host"]); + let per_series = aggregate_group(&mut memo, rows, Reduction::PerEntity, vec![sum()], None); + let by_both = aggregate_group( + &mut memo, + per_series, + Reduction::by(vec![2, 3]), + vec![sum()], + None, + ); + let ranked = aggregate_group( + &mut memo, + by_both, + Reduction::by(vec![0]), + vec![topk(5)], + None, + ); + query(&mut memo, ranked); + let memo = pass1_memo(memo, None).unwrap(); + let (_, update) = state_group(&memo, SketchAlgorithm::CountSketchWithHeap); + assert_eq!( + update.item, + Some(SummaryInputExpr::Column(ColumnRef::Named("host".into()))) + ); +} + +// ── Nested distributive merges ───────────────────────────────────────────── + +/// `sum by (job) (sum_over_time(m[5m]))`, and likewise `min` of `min` and +/// `max` of `max`, gets a merged build: one exact accumulator of that kind +/// over the raw samples grouped by `job`, a candidate of the outer +/// aggregate's own accumulator state group, listing the inner aggregate's +/// group in `fused_groups`. +#[test] +fn nested_distributive_aggregate_merges_into_the_outer_state_group() { + for (intent, kind) in [ + (sum(), ExactKind::Sum), + (AggIntent::Min { col: None }, ExactKind::Min), + (AggIntent::Max { col: None }, ExactKind::Max), + ] { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Range, "m", &["job"]); + let per_series = aggregate_group( + &mut memo, + rows, + Reduction::PerEntity, + vec![intent.clone()], + None, + ); + let by_job = aggregate_group( + &mut memo, + per_series, + Reduction::by(vec![2]), + vec![intent], + None, + ); + query(&mut memo, by_job); + let memo = pass1_memo(memo, None).unwrap(); + let outer_state = groups_where( + &memo, + |e| matches!(e, Operator::ASAP(ASAPOp::SummaryAgg { child, family: FieldDataType::ExactAggregate(state, _), .. }) if *child == per_series && *state == kind), + ); + let [outer_state] = outer_state[..] else { + panic!("{kind:?}: {}", memo_printout(&memo)) + }; + let builds = candidates(&memo, outer_state); + assert_eq!(builds.len(), 2, "{kind:?}: {}", memo_printout(&memo)); + let merged = &builds[1]; + assert_eq!(merged.expression.operator.children(), vec![&rows]); + assert_eq!(merged.fused_groups, vec![per_series]); + } +} + +/// The merge needs the same distributive aggregate twice: a `sum` over a +/// `max`, or a `min` over a `max`, gets none. +#[test] +fn nested_merge_needs_the_same_distributive_aggregate() { + for outer in [sum(), AggIntent::Min { col: None }] { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Range, "m", &["job"]); + let per_series = aggregate_group( + &mut memo, + rows, + Reduction::PerEntity, + vec![AggIntent::Max { col: None }], + None, + ); + let by_job = aggregate_group( + &mut memo, + per_series, + Reduction::by(vec![2]), + vec![outer], + None, + ); + query(&mut memo, by_job); + let memo = pass1_memo(memo, None).unwrap(); + assert!(memo + .groups() + .iter() + .flat_map(|g| &g.candidates) + .all(|c| c.fused_groups.is_empty())); + } +} + +// ── Stream norms (update weights) ────────────────────────────────────────── + +/// A Count-Min count adds one per row (bounded by the row count); a sum's +/// state reads the raw value column (bounded by the source's norm). +#[test] +fn update_weights_follow_the_measure() { + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let counts = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![count()], None); + query(&mut memo, counts); + let memo = pass1_memo(memo, None).unwrap(); + let (_, update) = state_group(&memo, SketchAlgorithm::Cms); + assert_eq!(update.weight, SummaryInputExpr::Constant(1.0)); + let mut memo = Memo::default(); + let rows = selector(&mut memo, TimeRangeKind::Instant, "m", &["job"]); + let sums = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![sum()], None); + query(&mut memo, sums); + let memo = pass1_memo(memo, None).unwrap(); + let state = groups_where( + &memo, + |e| matches!(e, Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) if input.weight == SummaryInputExpr::Column(ColumnRef::SampleValue)), + ); + assert_eq!(state.len(), 1); +} + +// ── Intents ──────────────────────────────────────────────────────────────── + fn candidates_for(intent: AggIntent) -> Vec { computation_candidates_for_intent(&intent) .unwrap() .candidates } + fn algorithms(choices: &[HowToComputeAggregateMeasure]) -> Vec { choices .iter() @@ -69,10 +1041,7 @@ fn frequency_statistics_keep_universal_choices() { /// TopK keeps both declared heap substrates without choosing an implementation. #[test] fn topk_keeps_both_specialized_heap_choices() { - let choices = candidates_for(AggIntent::TopK { - k: 10, - accuracy: approximate(), - }); + let choices = candidates_for(topk(10)); assert_eq!(choices[0], HowToComputeAggregateMeasure::Exact); assert_eq!( algorithms(&choices), @@ -86,11 +1055,34 @@ fn topk_keeps_both_specialized_heap_choices() { /// Summary candidates are unsized, so they do not depend on the requested error. #[test] fn summary_candidates_are_unsized() { - let loose = candidates_for(AggIntent::Count { - accuracy: approximate(), - }); let tight = candidates_for(AggIntent::Count { accuracy: AccuracyTarget::max_error(0.0001).failure_probability(0.0001), }); - assert_eq!(loose, tight); + assert_eq!(candidates_for(count()), tight); +} + +/// Approximate count lists one exact option, its accumulator, beside the +/// summaries (the plain aggregate is the group's own expression). +#[test] +fn approximate_count_lists_its_accumulator_and_summaries() { + let choices = candidates_for(count()); + assert!(!choices.contains(&HowToComputeAggregateMeasure::Exact)); + assert_eq!( + choices[0], + HowToComputeAggregateMeasure::ExactAccumulator(vec![ExactKind::Count]) + ); + assert!(choices.contains(&HowToComputeAggregateMeasure::Summary( + SketchAlgorithm::UnivMon + ))); +} + +/// Count Sketch's signed counters give no total, so Pass 1 offers no Count +/// Sketch for a count. +#[test] +fn a_count_offers_no_count_sketch() { + assert!( + !candidates_for(count()).contains(&HowToComputeAggregateMeasure::Summary( + SketchAlgorithm::CountSketch + )) + ); } diff --git a/crates/stage1-pass1-per-computation-candidates/src/realization.rs b/crates/stage1-pass1-per-computation-candidates/src/realization.rs index 114cba7ab..d64a58710 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/realization.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/realization.rs @@ -4,19 +4,19 @@ //! keyed-additive rule for a heap sketch that reads a larger sub-DAG. use asap_types::ir::operator::agg_intent::AggIntent; +use asap_types::ir::operator::non_asap::any_measure_filtered; +use asap_types::ir::operator::operator_properties::Source; use asap_types::ir::operator::Reduction; use asap_types::ir::scalar::column_resolution::{resolve_column_ref, ResolveError}; use asap_types::ir::scalar::expr_ir::ColumnRef; -use asap_types::ir::schema::topk::ranked_column; +use asap_types::ir::schema::topk::ranked_column_of_group; use asap_types::ir::schema::ColumnId; use asap_types::ir::schema::{ EntityIdentity, FamilyCapability, Field, FieldDataType, NonNegativeWeightProof, Schema, SketchAlgorithm, SummaryInputExpr, SummaryUpdate, WeightDomain, }; -use asap_types::ir::{NonASAPOp, OperatorNode}; -#[cfg(test)] -use asap_types::ir::{Operator, ScalarExpr}; -use std::rc::Rc; +use asap_types::ir::{NonASAPOp, Operator}; +use asap_types::stages::{Memo, MemoGroupId}; use asap_accuracy::sizing::sketch_statistic; @@ -81,15 +81,17 @@ pub(crate) fn summarised_column(intent: &AggIntent, child_schema: &Schema) -> Co } } -/// [`ranked_column`] as a reference: the sample value when it is that -/// column, else the column by name. +/// The column a top-k ranks group `child` by ([`ranked_column_of_group`]), +/// also as a reference: the sample value when it is that column, else the +/// column by name. pub(crate) fn ranked_value( - child: &Rc, + memo: &Memo, + child: MemoGroupId, ) -> Result<(ColumnId, ColumnRef), ResolveError> { - let ranked = ranked_column(child)?; - let column = match resolve_column_ref(&ColumnRef::SampleValue, &child.schema) { + let ranked = ranked_column_of_group(memo, child)?; + let column = match resolve_column_ref(&ColumnRef::SampleValue, schema(memo, child)) { Ok(value) if value == ranked => ColumnRef::SampleValue, - _ => child.schema.fields[ranked].column_ref(), + _ => schema(memo, child).fields[ranked].column_ref(), }; Ok((ranked, column)) } @@ -127,7 +129,7 @@ pub(crate) fn summarised_input( /// input value. Composite realizations can instead consume a larger /// logical sub-DAG and bind a different key or value. pub(crate) struct PhysicalSummaryInput { - pub(crate) child: Rc, + pub(crate) child: MemoGroupId, pub(crate) input: SummaryUpdate, } @@ -137,10 +139,11 @@ pub(crate) struct PhysicalSummaryInput { /// when the rule does not apply or the shape is unsupported (each reason is /// noted where it returns). pub(crate) fn realize_keyed_additive_summary_input( + memo: &Memo, intent: &AggIntent, family: &FieldDataType, output_reduction: &Reduction, - child: &Rc, + child: MemoGroupId, ) -> Option { if !matches!(intent, AggIntent::TopK { .. }) { return None; @@ -155,28 +158,29 @@ pub(crate) fn realize_keyed_additive_summary_input( ) { return None; } - let Some(NonASAPOp::Aggregate { + let Operator::NonASAP(NonASAPOp::Aggregate { reduction, measures, filters, having: None, child: raw_child, .. - }) = child.non_asap() + }) = expression(memo, child) else { return None; }; + let raw_child = *raw_child; // The heap sketch consumes every raw row; a filtered measure counts only // some of them (#466). - if asap_types::ir::operator::non_asap::any_measure_filtered(filters) { + if any_measure_filtered(filters) { return None; } // A grouped sum of per-series `rate`/`increase`/`irate` values: the // metric they derive from, whose declared type decides their sign. - let derivative_of = match (measures.as_slice(), raw_child.non_asap()) { + let derivative_of = match (measures.as_slice(), expression(memo, raw_child)) { ( [AggIntent::Sum { .. }], - Some(NonASAPOp::Aggregate { + Operator::NonASAP(NonASAPOp::Aggregate { measures: derivative, child: samples, .. @@ -186,9 +190,7 @@ pub(crate) fn realize_keyed_additive_summary_input( [AggIntent::Rate | AggIntent::Increase | AggIntent::IRate] ) => { - Some(asap_accuracy::propagation::raw_data_samples_of_metric( - samples, - )) + Some(raw_data_samples_of_metric(memo, *samples)) } _ => None, }; @@ -196,7 +198,7 @@ pub(crate) fn realize_keyed_additive_summary_input( // `sum by (G) (sum_over_time(m[W]))` or `... count_over_time ...`: the // grouped sum of per-series sums (counts) of raw data samples is their sum // (count) by `G`, so the heap reads those samples. - let samples = summed_per_series_samples(child); + let samples = summed_per_series_samples(memo, child); // A raw data sample's count or a count's row weighs one. let counted = match &samples { Some((_, inner)) => matches!(inner, AggIntent::Count { .. }), @@ -209,7 +211,11 @@ pub(crate) fn realize_keyed_additive_summary_input( } [AggIntent::Sum { col }] => SummaryInputExpr::Column(match col { None => ColumnRef::SampleValue, - Some(index) => match raw_child.schema.fields.get(*index).map(Field::column_ref) { + Some(index) => match schema(memo, raw_child) + .fields + .get(*index) + .map(Field::column_ref) + { Some(column) => column, None => { // Unsupported: sum-ranked Top-K value column is outside the raw input schema. @@ -239,7 +245,12 @@ pub(crate) fn realize_keyed_additive_summary_input( Reduction::PerEntity => vec![], Reduction::Reduce(keys) => keys .iter() - .filter_map(|index| child.schema.fields.get(*index).map(Field::column_ref)) + .filter_map(|index| { + schema(memo, child) + .fields + .get(*index) + .map(Field::column_ref) + }) .collect(), }; let item = match reduction { @@ -249,7 +260,12 @@ pub(crate) fn realize_keyed_additive_summary_input( Reduction::Reduce(keys) if !keys.is_without() && !keys.is_empty() => { let Some(columns) = keys .iter() - .map(|index| raw_child.schema.fields.get(*index).map(Field::column_ref)) + .map(|index| { + schema(memo, raw_child) + .fields + .get(*index) + .map(Field::column_ref) + }) .collect::>>() else { // Unsupported: ranked item column is outside the raw input schema. @@ -279,7 +295,7 @@ pub(crate) fn realize_keyed_additive_summary_input( } }; Some(PhysicalSummaryInput { - child: samples.map_or_else(|| Rc::clone(raw_child), |(rows, _)| rows), + child: samples.map_or(raw_child, |(rows, _)| rows), input: SummaryUpdate { item: Some(item), weight, @@ -294,30 +310,32 @@ pub(crate) fn realize_keyed_additive_summary_input( /// `count` of raw data samples (`sum by (G) (sum_over_time(m[W]))`), neither /// filtered nor with `HAVING`; `None` otherwise. The grouped sum is then the /// sum (count) of those samples by `G`. -fn summed_per_series_samples(grouped: &OperatorNode) -> Option<(Rc, AggIntent)> { - use asap_types::ir::operator::non_asap::any_measure_filtered; - let Some(NonASAPOp::Aggregate { +fn summed_per_series_samples( + memo: &Memo, + grouped: MemoGroupId, +) -> Option<(MemoGroupId, AggIntent)> { + let Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::Reduce(by), measures, filters, having: None, child: per_series, .. - }) = grouped.non_asap() + }) = expression(memo, grouped) else { return None; }; let [AggIntent::Sum { col }] = measures.as_slice() else { return None; }; - let Some(NonASAPOp::Aggregate { + let Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures: inner, filters: inner_filters, having: None, child: rows, .. - }) = per_series.non_asap() + }) = expression(memo, *per_series) else { return None; }; @@ -328,53 +346,106 @@ fn summed_per_series_samples(grouped: &OperatorNode) -> Option<(Rc if by.is_without() || any_measure_filtered(filters) || any_measure_filtered(inner_filters) - || matches!(rows.non_asap(), Some(NonASAPOp::Aggregate { .. })) + || matches!( + expression(memo, *rows), + Operator::NonASAP(NonASAPOp::Aggregate { .. }) + ) { return None; } // The grouped sum reads the per-series value. - let value = resolve_column_ref(&ColumnRef::SampleValue, &per_series.schema).ok()?; + let value = resolve_column_ref(&ColumnRef::SampleValue, schema(memo, *per_series)).ok()?; if col.is_some_and(|col| col != value) || by.keys().contains(&value) { return None; } if by.keys().iter().any(|&key| { - per_series - .schema + schema(memo, *per_series) .fields .get(key) .is_none_or(|field| field.name == "__name__") }) { return None; } - Some((Rc::clone(rows), inner.clone())) + Some((*rows, inner.clone())) +} + +/// The expression of group `group`. +pub(crate) fn expression(memo: &Memo, group: MemoGroupId) -> &Operator { + &memo.groups()[group.0 as usize].expression +} + +/// The output schema of group `group`. +pub(crate) fn schema(memo: &Memo, group: MemoGroupId) -> &Schema { + &memo.groups()[group.0 as usize].group_properties.schema +} + +/// The metric whose raw data samples, or plain sums of them, are every value +/// group `group` outputs, or `None`: walking down the groups' expressions +/// through a time range, an `offset`-only time shift (it moves the window, +/// not the samples) and a plain `sum`, to a time-series scan. Any other +/// operator (rate and arithmetic included) ends it. Whether the metric's +/// samples are non-negative is plan selection's question +/// ([`WeightDomain::RawDataSamplesOfMetric`]). +pub(crate) fn raw_data_samples_of_metric(memo: &Memo, group: MemoGroupId) -> Option { + let Operator::NonASAP(operator) = expression(memo, group) else { + return None; + }; + match operator { + NonASAPOp::Scan { + source: Source::TimeSeries { metric }, + .. + } => Some(metric.clone()), + NonASAPOp::TimeRange { child, .. } => raw_data_samples_of_metric(memo, *child), + NonASAPOp::TimeShift { shift, child } if shift.at.is_none() => { + raw_data_samples_of_metric(memo, *child) + } + NonASAPOp::Aggregate { + measures, child, .. + } if matches!(measures.as_slice(), [AggIntent::Sum { col: None }]) => { + raw_data_samples_of_metric(memo, *child) + } + _ => None, + } } #[cfg(test)] mod tests { use super::*; - use crate::test_fixtures::metric_scan; + use crate::test_fixtures::{group, metric_schema, scan_group}; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::scalar::{CompareOpKind, ScalarValue}; use asap_types::ir::schema::GroupingStrategy; - use asap_types::ir::{ExprSemantics, Predicate}; + use asap_types::ir::{ExprSemantics, Predicate, ScalarExpr}; /// A heap sketch consumes every raw row, so the keyed input rule refuses /// a TopK over a filtered count. #[test] fn keyed_heap_input_refuses_a_filtered_count() { - let count_by_job = |filter: Option| { - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![2]), - measures: vec![AggIntent::Count { - accuracy: AccuracyTarget::exact(), - }], - output_names: vec![], - filters: vec![filter], - having: None, - child: metric_scan(&["job"]), - })) - .unwrap() + let mut memo = Memo::default(); + let rows = scan_group(&mut memo, "m", &["job"]); + let mut count_by_job = |filter: Option>| { + group( + &mut memo, + Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![2]), + measures: vec![AggIntent::Count { + accuracy: AccuracyTarget::exact(), + }], + output_names: vec![String::new()], + filters: vec![filter], + having: None, + child: rows, + }), + ) }; + let unfiltered = count_by_job(None); + let value_above_one = Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(1)), + op: CompareOpKind::Gt, + right: Box::new(ScalarExpr::Literal(ScalarValue::Float64(1.0))), + semantics: ExprSemantics::Promql, + }); + let filtered = count_by_job(Some(value_above_one)); let intent = AggIntent::TopK { k: 5, accuracy: AccuracyTarget::max_error(0.01), @@ -383,34 +454,22 @@ mod tests { FieldDataType::Sketch(SketchAlgorithm::CmsWithHeap, GroupingStrategy::default()); let output = Reduction::Reduce(asap_types::ir::operator::operator_properties::GroupKeys::none()); - assert!(realize_keyed_additive_summary_input( - &intent, - &family, - &output, - &count_by_job(None) - ) - .is_some()); - let value_above_one = Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(1)), - op: CompareOpKind::Gt, - right: Box::new(ScalarExpr::Literal(ScalarValue::Float64(1.0))), - semantics: ExprSemantics::Promql, - }); - assert!(realize_keyed_additive_summary_input( - &intent, - &family, - &output, - &count_by_job(Some(value_above_one)) - ) - .is_none()); + assert!( + realize_keyed_additive_summary_input(&memo, &intent, &family, &output, unfiltered) + .is_some() + ); + assert!( + realize_keyed_additive_summary_input(&memo, &intent, &family, &output, filtered) + .is_none() + ); } /// A summary reads the intent's own input columns: one column by name, /// none as the sample value, several as one hashed tuple. #[test] fn summarised_input_reads_the_intents_columns() { - // `metric_scan(&["job"])` → columns [ts=0, value=1, job=2]. - let scan = metric_scan(&["job"]); + // `metric_schema(&["job"], ..)` → columns [ts=0, value=1, job=2]. + let schema = metric_schema(&["job"], vec![]); let column = |name: &str| SummaryInputExpr::Column(ColumnRef::Named(name.into())); let cases = [ (vec![2], column("job")), @@ -427,7 +486,7 @@ mod tests { accuracy: AccuracyTarget::max_error(0.01), }; assert_eq!( - summarised_input(&intent, &scan.schema), + summarised_input(&intent, &schema), Some(want), "wrong summarised input for cols={cols:?}" ); diff --git a/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs index a69c984be..00bbc112a 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs @@ -1,22 +1,16 @@ -//! Fixtures this crate's own tests share. Every builder returns an -//! `Rc` whose schema is derived by `OperatorNode::new_shared`, -//! so a fixture is exactly what a front end would hand the planner. +//! Fixtures this crate's own tests share. The memo builders add Stage 0's +//! groups through `Memo::find_or_add_group` and `Memo::add_query`, as +//! Stage 0 does, so no test calls another stage crate. -use std::rc::Rc; - -use asap_types::ir::operator::operator_properties::Source; +use asap_types::ir::operator::operator_properties::{Reduction, Source}; +use asap_types::ir::operator::AggIntent; +use asap_types::ir::schema::schema_support::with_promql_series_identity; use asap_types::ir::schema::{ColumnId, DataType, Field, Schema}; -use asap_types::ir::{NonASAPOp, OperatorNode}; - -/// A `TimeSeries("m")` scan over `[ts(0), value(1), labels...]`, time index 0, -/// no unique key. -pub fn metric_scan(labels: &[&str]) -> Rc { - metric_scan_with_keys(labels, vec![]) -} +use asap_types::ir::{NonASAPOp, Operator, OperatorNode, Predicate, QueryRoot}; +use asap_types::stages::{Memo, MemoGroupId}; -/// [`metric_scan`] with explicit `unique_keys` (a `[[0]]` key makes CSE -/// willing to hoist the scan). -pub fn metric_scan_with_keys(labels: &[&str], unique_keys: Vec>) -> Rc { +/// `[ts(0), value(1), labels...]`, time index 0, with `unique_keys`. +pub fn metric_schema(labels: &[&str], unique_keys: Vec>) -> Schema { let mut columns = vec![ Field::plain("ts", DataType::Timestamp, false), Field::plain("value", DataType::Float64, false), @@ -26,24 +20,60 @@ pub fn metric_scan_with_keys(labels: &[&str], unique_keys: Vec>) - .iter() .map(|n| Field::plain(*n, DataType::Utf8, true)), ); - scan("m", Schema::with_time_index(columns, 0, unique_keys)) + Schema::with_time_index(columns, 0, unique_keys) } -/// A predicate-free `TimeSeries(metric)` scan with the given schema. -pub fn scan(metric: &str, schema: Schema) -> Rc { - scan_from( - Source::TimeSeries { +/// A predicate-free `TimeSeries(metric)` scan with `schema`. +pub fn scan_operator(metric: &str, schema: Schema) -> Operator { + Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { metric: metric.into(), }, + predicates: vec![], schema, + }) +} + +/// The group of `operator`, added as Stage 0 adds it. +pub fn group(memo: &mut Memo, operator: Operator) -> MemoGroupId { + memo.find_or_add_group(operator).unwrap() +} + +/// The group of a `TimeSeries(metric)` scan over [`metric_schema`] with its +/// series identity realized as the planner realizes it before Stage 0 +/// (`with_promql_series_identity`): a closed schema whose last column is +/// `$promql_series_identity`, keyed by it and the timestamp. +pub fn scan_group(memo: &mut Memo, metric: &str, labels: &[&str]) -> MemoGroupId { + let open = + OperatorNode::new_shared(scan_operator(metric, metric_schema(labels, vec![]))).unwrap(); + let realized = with_promql_series_identity(&open).unwrap(); + group(memo, scan_operator(metric, realized.schema.clone())) +} + +/// The group of an `Aggregate` of `measures` (named by their intents) over +/// `child`, grouped by `reduction`, unfiltered, with `having`. +pub fn aggregate_group( + memo: &mut Memo, + child: MemoGroupId, + reduction: Reduction, + measures: Vec, + having: Option>, +) -> MemoGroupId { + let count = measures.len(); + group( + memo, + Operator::NonASAP(NonASAPOp::Aggregate { + child, + reduction, + measures, + output_names: vec![String::new(); count], + filters: vec![None; count], + having, + }), ) } -pub fn scan_from(source: Source, schema: Schema) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source, - predicates: vec![], - schema, - })) - .unwrap() +/// Adds a query whose root is group `root`. +pub fn query(memo: &mut Memo, root: MemoGroupId) -> usize { + memo.add_query(QueryRoot::Operator(root)) } diff --git a/crates/stage1-pass1-per-computation-candidates/src/test_support.rs b/crates/stage1-pass1-per-computation-candidates/src/test_support.rs deleted file mode 100644 index f5952cf2c..000000000 --- a/crates/stage1-pass1-per-computation-candidates/src/test_support.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! The Pass 1 internals other crates' tests call (feature `test-support`): -//! Pass 1's working form and single-candidate composition. - -pub use crate::per_computation_candidates::{ - compose_logical_candidate, generate_per_computation_candidates, -}; diff --git a/crates/types/src/ir/operator/asap.rs b/crates/types/src/ir/operator/asap.rs index c4490bb2d..c2d6fd930 100644 --- a/crates/types/src/ir/operator/asap.rs +++ b/crates/types/src/ir/operator/asap.rs @@ -1012,10 +1012,10 @@ fn topk_schema_of_build( let complete_item_identity = matches!( input.item.as_ref()?, SummaryInputExpr::Column(_) | SummaryInputExpr::Tuple(_) - ) || child_schema.closed - || columns.iter().any(|&index| { - child_schema.fields[index].name == crate::ir::schema::PROMQL_SERIES_IDENTITY - }); + ) || crate::ir::schema::topk::item_identity_is_complete( + child_schema, + columns.iter().copied(), + ); let mut fields: Vec = state_schema .fields .iter() @@ -1032,12 +1032,12 @@ fn topk_schema_of_build( } // One row per (partition, item), not one row per partition. Preserve this // key through the ranked-row projection used by the logical TopK result. - let unique_keys = if complete_item_identity && !fields.is_empty() { - vec![(0..fields.len()).collect()] + fields.push(Field::plain("topk", DataType::Float64, false)); + let unique_keys = if complete_item_identity { + crate::ir::schema::topk::ranked_rows_unique_keys(fields.len(), fields.len() - 1) } else { Vec::new() }; - fields.push(Field::plain("topk", DataType::Float64, false)); Some(Schema { fields, unique_keys, diff --git a/crates/types/src/ir/operator/non_asap.rs b/crates/types/src/ir/operator/non_asap.rs index f064cf622..2fe109e60 100644 --- a/crates/types/src/ir/operator/non_asap.rs +++ b/crates/types/src/ir/operator/non_asap.rs @@ -587,11 +587,17 @@ impl NonASAPOp { let name = crate::ir::schema::topk::topk_output_name(*k, output_names); let cols = crate::ir::schema::topk::ranked_row_projection(child, reduction, &name)?; - return NonASAPOp::Project { + let mut output = NonASAPOp::Project { cols, child: child.clone(), } - .output_schema(); + .output_schema()?; + output.unique_keys = crate::ir::schema::topk::exact_topk_unique_keys( + child, + reduction.group_keys().map_or(0, |keys| keys.len()), + &output, + ); + return Ok(output); } let mut output = aggregate_output_schema(child.schema(), reduction, measures, output_names)?; @@ -1301,7 +1307,7 @@ fn default_proj_name( } /// Whether any aggregate measure has its own input predicate. -pub fn any_measure_filtered(filters: &[Option]) -> bool { +pub fn any_measure_filtered(filters: &[Option>]) -> bool { filters.iter().any(Option::is_some) } diff --git a/crates/types/src/ir/schema/topk.rs b/crates/types/src/ir/schema/topk.rs index f2e4cb0d7..547b7bc74 100644 --- a/crates/types/src/ir/schema/topk.rs +++ b/crates/types/src/ir/schema/topk.rs @@ -5,6 +5,7 @@ use crate::ir::scalar::column_resolution::{resolve_column_ref, ResolveError}; use crate::ir::scalar::{ColumnRef, ProjectItem}; use crate::ir::schema::{ColumnId, DataType, FieldDataType, PROMQL_SERIES_IDENTITY}; use crate::ir::{ASAPOp, NonASAPOp, Operator, ScalarExpr}; +use crate::stages::{Memo, MemoGroupAsInput, MemoGroupId}; /// The column a top-k ranks `child` by. Over a one-measure grouped /// aggregate (the SQL heavy-hitter shape and PromQL `topk(k, sum by ..)`) it @@ -13,9 +14,40 @@ use crate::ir::{ASAPOp, NonASAPOp, Operator, ScalarExpr}; /// `FinalizeExactAccumulator` or `SummaryEstimate`, under the `Project` /// restoring its names (a renaming) and a value `HAVING`'s `Filter`. Over /// per-series rows (PromQL `topk by (..) (k, rows)`) it is the sample value. -pub fn ranked_column(child: &I) -> Result { +pub(crate) fn ranked_column(child: &I) -> Result { + match ranked_source(child) { + RankedSource::TopK(reduction) => Ok(reduction.group_keys().map_or(0, |keys| keys.len())), + RankedSource::OneRowPerItem(Reduction::Reduce(_)) => Ok(child.schema().fields.len() - 1), + RankedSource::OneRowPerItem(Reduction::PerEntity) | RankedSource::Rows => { + resolve_column_ref(&ColumnRef::SampleValue, child.schema()) + } + } +} + +/// [`ranked_column`] of memo group `group`: the column a top-k reading the +/// group ranks it by. +pub fn ranked_column_of_group(memo: &Memo, group: MemoGroupId) -> Result { + ranked_column(&MemoGroupAsInput { + memo, + group_id: group, + }) +} + +/// What a top-k's input rows are, seen through a renaming `Project`, a +/// value `HAVING`'s `Filter`, a finalize or a summary readout. +enum RankedSource { + /// Another top-k's ranked rows, grouped by this reduction. + TopK(Reduction), + /// One row per item: a one-measure aggregate (or a summary build), per + /// group of this reduction or per series. + OneRowPerItem(Reduction), + /// Any other rows, possibly several per item. + Rows, +} + +fn ranked_source(child: &I) -> RankedSource { let mut node = child.clone(); - let reduction = loop { + loop { match node.operator() { // Only a renaming keeps the measure last. Operator::NonASAP(NonASAPOp::Project { cols, child, .. }) @@ -35,23 +67,73 @@ pub fn ranked_column(child: &I) -> Result node = child, - Operator::ASAP(ASAPOp::SummaryAgg { reduction, .. }) => break Some(reduction), + Operator::ASAP(ASAPOp::SummaryAgg { reduction, .. }) => { + return RankedSource::OneRowPerItem(reduction) + } Operator::NonASAP(NonASAPOp::Aggregate { reduction, measures, .. }) if measures.len() == 1 => { if matches!(measures[0], crate::ir::operator::AggIntent::TopK { .. }) { - return Ok(reduction.group_keys().map_or(0, |keys| keys.len())); + return RankedSource::TopK(reduction); } - break Some(reduction); + return RankedSource::OneRowPerItem(reduction); } - _ => break None, + _ => return RankedSource::Rows, } - }; - match reduction { - Some(Reduction::Reduce(_)) => Ok(child.schema().fields.len() - 1), - _ => resolve_column_ref(&ColumnRef::SampleValue, child.schema()), + } +} + +/// Whether the item columns `item_columns` of `input` identify each item +/// completely: `input` lists every column its rows carry (it is closed), or +/// the items include the realized PromQL series identity. An open PromQL +/// label set may carry unlisted labels, so its listed columns cannot. +pub(crate) fn item_identity_is_complete( + input: &crate::ir::schema::Schema, + item_columns: impl IntoIterator, +) -> bool { + input.closed + || item_columns + .into_iter() + .any(|index| input.fields[index].name == PROMQL_SERIES_IDENTITY) +} + +/// The unique key of ranked rows (one row per partition and item) with +/// `columns` columns, the ranked value at `value`: every other column. The +/// one rule for a top-k's ranked rows, exact or a heap's readout. +pub(crate) fn ranked_rows_unique_keys(columns: usize, value: ColumnId) -> Vec> { + let key: Vec = (0..columns).filter(|&column| column != value).collect(); + if key.is_empty() { + Vec::new() + } else { + vec![key] + } +} + +/// The unique keys of an exact top-k's ranked rows (`output`, the +/// projection [`ranked_row_projection`] gives, its ranked value after the +/// `partitions` keys) over `child`: one row per (partition, item) when +/// `child` has one row per item whose identity is complete +/// ([`item_identity_is_complete`]); else the projection's own keys. +pub(crate) fn exact_topk_unique_keys( + child: &I, + partitions: usize, + output: &crate::ir::schema::Schema, +) -> Vec> { + let item_columns = (0..child.schema().fields.len()).filter(|&index| { + output + .fields + .iter() + .skip(partitions + 1) + .any(|field| field.name == child.schema().fields[index].name) + }); + if matches!(ranked_source(child), RankedSource::OneRowPerItem(_)) + && item_identity_is_complete(child.schema(), item_columns) + { + ranked_rows_unique_keys(output.fields.len(), partitions) + } else { + output.unique_keys.clone() } } @@ -161,3 +243,137 @@ fn reads_only_time_series(node: &I) -> bool { operator => operator.children().into_iter().all(reads_only_time_series), } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use crate::ir::operator::operator_properties::{Reduction, Source}; + use crate::ir::operator::AggIntent; + use crate::ir::properties::AccuracyTarget; + use crate::ir::scalar::ColumnRef; + use crate::ir::schema::schema_support::with_promql_series_identity; + use crate::ir::schema::{ + DataType, Field, FieldDataType, GroupingStrategy, Schema, SketchAlgorithm, SketchStatistic, + SummaryUpdate, WeightDomain, + }; + use crate::ir::{ + ASAPOp, NonASAPOp, Operator, OperatorNode, ProjectItem, ScalarExpr, TimeRangeKind, + }; + use crate::stages::{Memo, MemoGroupId}; + + fn schema_of(memo: &Memo, group: MemoGroupId) -> &Schema { + &memo.groups()[group.0 as usize].group_properties.schema + } + + /// `topk by (job) (10, sum_over_time(m[1m]))` over series whose identity + /// is realized: the exact top-k's group and a heap's ranked readout of + /// the same rows (a `Project` naming the estimate's columns as the + /// top-k's) derive the same unique keys, one row per (job, series). + #[test] + fn exact_topk_and_heap_readout_derive_the_same_unique_keys() { + let open = 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![], + ); + fn scan(schema: Schema) -> Operator { + Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { metric: "m".into() }, + predicates: vec![], + schema, + }) + } + let realized = with_promql_series_identity(&OperatorNode::new_shared(scan(open)).unwrap()) + .unwrap() + .schema + .clone(); + let mut memo = Memo::default(); + let rows = memo.find_or_add_group(scan(realized)).unwrap(); + let window = memo + .find_or_add_group(Operator::NonASAP(NonASAPOp::TimeRange { + range: Duration::from_secs(60), + kind: TimeRangeKind::Range, + child: rows, + })) + .unwrap(); + let per_series = memo + .find_or_add_group(Operator::NonASAP(NonASAPOp::Aggregate { + child: window, + reduction: Reduction::PerEntity, + measures: vec![AggIntent::Sum { col: None }], + output_names: vec![String::new()], + filters: vec![None], + having: None, + })) + .unwrap(); + let topk = AggIntent::TopK { + k: 10, + accuracy: AccuracyTarget::max_error(0.01), + }; + let exact = memo + .find_or_add_group(Operator::NonASAP(NonASAPOp::Aggregate { + child: per_series, + reduction: Reduction::by(vec![2]), + measures: vec![topk], + output_names: vec![String::new()], + filters: vec![None], + having: None, + })) + .unwrap(); + let heap = memo + .find_or_add_group(Operator::ASAP(ASAPOp::SummaryAgg { + child: window, + family: FieldDataType::Sketch( + SketchAlgorithm::CountSketchWithHeap, + GroupingStrategy::default(), + ), + input: SummaryUpdate { + item: Some(super::series_identity_item(schema_of(&memo, window), &[2])), + weight: crate::ir::schema::SummaryInputExpr::Column(ColumnRef::SampleValue), + weight_domain: WeightDomain::UnknownOrSigned, + }, + reduction: Reduction::by(vec![2]), + grouping: GroupingStrategy::default(), + filter: None, + })) + .unwrap(); + let estimate = memo + .find_or_add_group(Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: heap, + query: SketchStatistic::TopK { k: 10 }, + })) + .unwrap(); + let estimated = schema_of(&memo, estimate).fields.len() - 1; + let cols = schema_of(&memo, exact) + .fields + .iter() + .enumerate() + .map(|(index, field)| ProjectItem { + alias: Some(field.name.clone()), + expr: ScalarExpr::Column(if index == 1 { + estimated + } else { + schema_of(&memo, estimate) + .fields + .iter() + .position(|input| input.name == field.name) + .unwrap() + }), + }) + .collect(); + let readout = memo + .find_or_add_group(Operator::NonASAP(NonASAPOp::Project { + cols, + child: estimate, + })) + .unwrap(); + let exact_keys = &schema_of(&memo, exact).unique_keys; + assert!(!exact_keys.is_empty()); + assert_eq!(exact_keys, &schema_of(&memo, readout).unique_keys); + } +} diff --git a/crates/types/src/stages/candidates.rs b/crates/types/src/stages/candidates.rs index ffc57336a..90166186e 100644 --- a/crates/types/src/stages/candidates.rs +++ b/crates/types/src/stages/candidates.rs @@ -321,8 +321,12 @@ pub struct CandidateForMemoGroup { /// $r^{\log}_{c,i}$, one per input group: per child of /// `expression.operator`, in [`Operator::children`] order. pub input_requirements: Vec, - /// The groups a fused computation also implements, beyond the group - /// whose candidate it is; empty otherwise. + /// A fused candidate computes, as one operator, a sub-DAG pattern of + /// several operators in one appearance of the sub-expression (a top-k + /// over an aggregate as one heap summary; a sum over a sum as one + /// aggregate): the groups of the operators it replaces. Empty otherwise. + /// Reuse across appearances is Pass 2's, not fusion (concepts doc §3, + /// "Fused groups"). pub fused_groups: Vec, /// Which rule added it. Not in the formulation: kept for /// `stage_pipeline --text` and the viewer (approved 2026-11-05). diff --git a/crates/types/src/stages/mod.rs b/crates/types/src/stages/mod.rs index 873c276a6..49842eb39 100644 --- a/crates/types/src/stages/mod.rs +++ b/crates/types/src/stages/mod.rs @@ -24,9 +24,7 @@ pub use candidates::{ pub use logical_memo_candidates::{LogicalASAPMemoCandidates, LogicalEdge}; pub(crate) use memo::MemoGroupAsInput; pub use memo::{queries_rooted_at, root_groups, GroupProperties, MemoGroup, MemoGroupId}; -pub use per_computation_logical_memo_candidates::{ - ComputationTarget, FusedComputation, HavingEntry, HavingPlacement, HowToComputeAggregateMeasure, -}; +pub use per_computation_logical_memo_candidates::HowToComputeAggregateMeasure; pub use physical::{ output_references, producer_satisfies_physical_requirements, DagOfSelectedCandidates, ExecutionContext, ExecutionContextId, PhysicalASAPMemoCandidates, PhysicalCandidate, diff --git a/crates/types/src/stages/per_computation_logical_memo_candidates.rs b/crates/types/src/stages/per_computation_logical_memo_candidates.rs index 137360946..e92a6cc34 100644 --- a/crates/types/src/stages/per_computation_logical_memo_candidates.rs +++ b/crates/types/src/stages/per_computation_logical_memo_candidates.rs @@ -1,135 +1,25 @@ -//! The computation-target types Stage 1, Pass 1 builds its candidates from. +//! How Stage 1, Pass 1 computes an aggregate measure. -use std::rc::Rc; - -use super::MemoGroupId; use crate::ir::schema::{ExactKind, SketchAlgorithm}; -use crate::ir::{OperatorNode, Predicate}; /// How one Pass 1 target is computed: exact or a summary. /// -/// Each target has exactly one exact option: `ExactAccumulator` when the -/// intent has an exact mergeable state and it can be built, otherwise -/// `Exact`. The plain form would duplicate "accumulator, not materialized"; -/// Stage 2/3 decide materialization and sharing. Which computation is -/// computed is the target's ([`ComputationTarget`]) or its fused -/// computation's ([`FusedComputation`]). +/// Each target lists one exact option: `ExactAccumulator` when the intent +/// has an exact mergeable state and it can be built, otherwise `Exact`. +/// The plain exact computation is always offered too, as the aggregate +/// group's own expression. #[derive(Debug, Clone, PartialEq)] pub enum HowToComputeAggregateMeasure { - /// Plain exact execution of the original sub-DAG; the exact option only - /// when the intent has no exact accumulator form or that form is pruned. + /// Plain exact execution: the aggregate group's own expression; the + /// listed exact option only when the intent has no exact accumulator + /// form or that form is pruned. Exact, /// One or more exact mergeable accumulator states, by accumulator kind, /// finalized and combined into the measure: one state for `sum`, /// `count`, `min`, `max`, `rate`, `irate` and `increase`; `[Sum, Count]`, /// divided, for `avg`. ExactAccumulator(Vec), - /// An unsized summary family; sized for the measure's accuracy when Pass 1 - /// attaches it to its memo group. + /// A summary family: its state group gets one build candidate per + /// configuration of the family's predefined list (`parameter_list`). Summary(SketchAlgorithm), } - -/// Where a target's `HAVING` is applied. Composition (#575) applies it. -/// -/// Only the adjacent form is recorded for push-down: the aggregate's own -/// `having`, or a `Filter` directly above the aggregate (the form the SQL -/// frontend lowers `HAVING` to). A value predicate anywhere above an -/// approximate output gets `guarantee = None` through guarantee propagation -/// (#575); multi-level push-down is tracked in #664. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HavingPlacement { - /// The predicate reads only group keys and every consumer of the - /// aggregate applies it, so it is pushed below the aggregate as a row - /// predicate (a `Filter` on its input, which drops the group). - BelowAggregate, - /// The predicate reads aggregate values, so it stays a `Filter` above the - /// finalize/estimate output; approximate candidates of this target carry - /// `guarantee = None`, since membership near the threshold may change. - AboveOutput, -} - -/// One `HAVING` over a target's aggregate output. -#[derive(Debug, Clone, PartialEq)] -pub struct HavingEntry { - /// The consumer `Filter` that applies it; `None` for the aggregate's own - /// `having`, which every consumer reads. - pub filter: Option>, - pub predicate: Predicate, - pub placement: HavingPlacement, -} - -/// One measure of an aggregate with at least one realization besides exact -/// execution. The target retains source, grouping, filters, input -/// expressions and evaluation context. -#[derive(Debug, Clone)] -pub struct ComputationTarget { - /// The memo group of the `Aggregate` the measure belongs to. - pub aggregate_group: MemoGroupId, - /// Index into the aggregate's measures. - pub measure_index: usize, - /// The measure's unranked ways to compute it, the exact option first. - pub ways_to_compute: Vec, - /// The measure's own filter; only rows that pass it update the state - /// (`SummaryAgg.filter`). - pub measure_filter: Option, - /// The aggregate's own `having`, then one entry per consumer `Filter` - /// directly above the aggregate. - pub having_filters: Vec, - /// The fused computation of this measure together with the target - /// directly beneath it, offered beside this target's own candidates; - /// `None` when no fusion applies. - pub fused_computation: Option, -} - -/// A target's measure computed together with the targets beneath it, which -/// it absorbs (they are not computed): the whole-expression top-k over a -/// per-item `sum` or `count` (`Summary` of a heap family), or the nested -/// sum, a grouped `sum` over a `sum` (`ExactAccumulator([Sum])`). It has the -/// outer target's aggregate, measure, filter and `HAVING`. -#[derive(Debug, Clone, PartialEq)] -pub struct FusedComputation { - /// Indices, into Pass 1's computation-target list, of the targets it - /// absorbs: the one directly beneath, then, for a whole-expression - /// heap over the raw data samples beneath a grouped sum of per-series sums or - /// counts (`topk(k, sum by (G) (sum_over_time(m[W])))`), the per-series - /// target beneath that, then the targets any of these targets' own fused - /// computations absorb. None of them is computed when this is picked. - pub absorbed_targets: Vec, - /// How the fused computation is computed: exact or a summary. Empty - /// when Pass 1 pruned every one (today only for a family the deployment - /// does not support); the fusion is then not offered. - pub ways_to_compute: Vec, -} - -impl ComputationTarget { - /// How many values this target's digit of a whole-workload choice - /// takes: its own candidates, then its fused computation's. - pub fn choice_count(&self) -> usize { - self.ways_to_compute.len() - + self - .fused_computation - .as_ref() - .map_or(0, |fused| fused.ways_to_compute.len()) - } - - /// How the measure is computed when this target's digit of a - /// whole-workload choice is `index`: the candidate, and the targets it - /// absorbs when the candidate is the fused computation's (past this - /// target's own candidates). `None` past [`Self::choice_count`]. - pub fn computation_for( - &self, - index: usize, - ) -> Option<(&HowToComputeAggregateMeasure, Option<&[usize]>)> { - let own = self.ways_to_compute.len(); - match self.ways_to_compute.get(index) { - Some(candidate) => Some((candidate, None)), - None => { - let fused = self.fused_computation.as_ref()?; - Some(( - fused.ways_to_compute.get(index - own)?, - Some(fused.absorbed_targets.as_slice()), - )) - } - } - } -} diff --git a/docs/design_docs/proposals/planner-layering.md b/docs/design_docs/proposals/planner-layering.md index 93cb4441d..7b61d75e2 100644 --- a/docs/design_docs/proposals/planner-layering.md +++ b/docs/design_docs/proposals/planner-layering.md @@ -524,8 +524,8 @@ candidates is in `crates/stage3-plan-selection/src/` (`select.rs`, `groups/`, `m | Step | Entry point | Path | |---|---|---| | Stage 0 | `memo_of_queries` → `DagOfLogicalMemoGroups`, `MemoGroup`, `MemoGroupId` | `stage0.rs` (the types: `asap_types::stages`); hash-consing is `share_common_sub_dags` in `stage0/cse.rs` | -| Pass 1 | `pass1_memo` → `PerComputationLogicalMemoCandidates` (its `Memo` and its `PrunedCandidate`s; `ComputationTarget` is Pass 1's working type, in `asap_types::stages`), the one entry: inside it, the computation targets (walking the memo groups), the summaries the deployment does not support and those no query can use pruned, the candidates attached to the groups (`try_populate_pass1`), then the accuracy-scoped sizes | `pass1/logical_memo_candidates.rs`; `pass1/per_computation_candidates.rs`; `pass1/memo_population.rs` | -| Memo types | `DagOfLogicalMemoGroups`, `PerComputationLogicalMemoCandidates`, `HowToComputeAggregateMeasure`, Pass 1's working types (`ComputationTarget`, `FusedComputation`, `HavingEntry`, `HavingPlacement`), `Memo`, `CandidateForMemoGroup`, `OriginOfCandidateForMemoGroup`, `LogicalASAPMemoCandidates`, `CandidateGenerationTimeout`, `root_groups`, `queries_rooted_at` | `crates/types/src/stages/` (`asap_types::stages`) | +| Pass 1 | `pass1_memo` → `PerComputationLogicalMemoCandidates` (its `Memo` and its `PrunedCandidate`s; `ComputationTarget` is Pass 1's private working type), the one entry: inside it, the computation targets (walking the memo groups), the summaries the deployment does not support and those no query can use pruned, the candidates attached to the groups (`try_populate_pass1`), then the accuracy-scoped sizes | `pass1/logical_memo_candidates.rs`; `pass1/per_computation_candidates.rs`; `pass1/memo_population.rs` | +| Memo types | `DagOfLogicalMemoGroups`, `PerComputationLogicalMemoCandidates`, `HowToComputeAggregateMeasure`, `Memo`, `CandidateForMemoGroup`, `OriginOfCandidateForMemoGroup`, `LogicalASAPMemoCandidates`, `CandidateGenerationTimeout`, `root_groups`, `queries_rooted_at` | `crates/types/src/stages/` (`asap_types::stages`) | | Pass 2 | `generate_logical_asap_memo_candidates` → `LogicalASAPMemoCandidates`, from Pass 1's output. It calls in this order: 1. window composition for every legal tumbling-window width (`add_local_window_composition`); 2–9. the rules in `pass2/memo/rules/`: scan widening, summary inputs, top-k reuse, finer aggregate, rollup, window rollup, summary results, state configurations; 10. `add_shared_summary_state_groups`, the shared builds | `pass2/logical_memo_candidates.rs` (`LogicalASAPMemoCandidates` itself: `asap_types::stages`); `pass2/memo/window_candidates.rs`, `pass2/memo/rules/`, `pass2/memo/state_groups.rs`, `pass2/memo/shared_configurations.rs` | | Memo to candidate DAGs | `enumerate`, `build_logical_asap_dags_from_chosen_candidates`, `ChosenCandidatePerMemoGroup`, `AllChosenCandidatesPerMemoGroup`, `ErrorBuildingLogicalASAPDagsFromChosenCandidates` | `pass2/memo/enumeration.rs` (re-exported at the crate root) | | Production memo build and search | The facade's `run_planning` builds the DAG of logical memo groups and runs Pass 1 (`pass1_memo`) once; then `generate_logical_asap_memo_candidates`, `generate_physical_asap_memo_candidates` and Stage 3's `select_plan`, once each, and builds the selected plan with Stage 2's `AssignmentRealizer::realize`. Behind `select_plan`: the group selection MILP (#1095), `groups/` and `milp/` | `crates/planner/src/run.rs`; `crates/stage3-plan-selection/src/select.rs` |