From 2f294938b856cc8f51356545287488d997c017d4 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:46:08 +0000 Subject: [PATCH 01/15] refactor(stage1-pass1): per-computation candidates over memo groups (#1207 PR 3) --- Cargo.lock | 2 - crates/accuracy/src/propagation.rs | 101 +- crates/accuracy/src/test_support.rs | 28 +- crates/devtools/Cargo.toml | 2 - crates/devtools/src/bin/sketch_coverage.rs | 19 +- crates/devtools/src/candidate_labels.rs | 33 - crates/devtools/src/planning_stages.rs | 11 +- crates/executor/Cargo.toml | 2 - crates/executor/tests/common/mod.rs | 32 - crates/integration-tests/Cargo.toml | 2 +- crates/integration-tests/src/lib.rs | 56 +- .../tests/per_computation_candidates_sql.rs | 143 +- .../tests/stage1_logical_asap_optimization.rs | 499 ++-- crates/planner/src/run.rs | 12 +- .../Cargo.toml | 4 - .../src/lib.rs | 52 +- .../src/logical_memo_candidates.rs | 43 +- .../src/per_computation_candidates.rs | 2388 +++++++---------- .../src/per_computation_candidates_tests.rs | 143 +- .../src/realization.rs | 67 +- .../src/test_fixtures.rs | 92 +- .../src/test_support.rs | 6 - crates/types/src/ir/operator/non_asap.rs | 2 +- crates/types/src/stages/candidates.rs | 35 +- crates/types/src/stages/mod.rs | 5 +- ...per_computation_logical_memo_candidates.rs | 87 +- 26 files changed, 1642 insertions(+), 2224 deletions(-) delete mode 100644 crates/stage1-pass1-per-computation-candidates/src/test_support.rs 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..16d7fbf8b 100644 --- a/crates/accuracy/src/propagation.rs +++ b/crates/accuracy/src/propagation.rs @@ -6,6 +6,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use asap_types::ir::operator::input::OperatorInput; use asap_types::ir::operator::operator_properties::{BinaryOpKind, JoinKind, Reduction, Source}; use asap_types::ir::operator::tumbling_window_chain::merged_chain; use asap_types::ir::operator::AggIntent; @@ -39,26 +40,30 @@ use crate::PropagationStats; type NodeToRewrittenNodeCache = HashMap<*const OperatorNode, Rc>; /// The metric whose raw data samples, or plain sums of them, are every value -/// `node` outputs, or `None`. Only a time-series scan, a time range over it, -/// an `offset`-only time shift (it moves the window, not the samples) and a -/// plain `sum` keep that; any other operator (rate and arithmetic included) -/// ends it. The metric's type is not read here: whether its +/// `operator` outputs, or `None`. Only a time-series scan, a time range over +/// it, an `offset`-only time shift (it moves the window, not the samples) +/// and a plain `sum` keep that; any other operator (rate and arithmetic +/// included) ends it. The metric's type is not read here: whether its /// samples are non-negative is plan selection's question -/// ([`WeightDomain::RawDataSamplesOfMetric`]). -pub fn raw_data_samples_of_metric(node: &OperatorNode) -> Option { - match node.non_asap()? { +/// ([`WeightDomain::RawDataSamplesOfMetric`]). It reads a node's operator +/// and a memo group's expression alike ([`OperatorInput`]). +pub fn raw_data_samples_of_metric(operator: &Operator) -> Option { + let Operator::NonASAP(operator) = operator else { + return None; + }; + match operator { NonASAPOp::Scan { source: Source::TimeSeries { metric }, .. } => Some(metric.clone()), - NonASAPOp::TimeRange { child, .. } => raw_data_samples_of_metric(child), + NonASAPOp::TimeRange { child, .. } => raw_data_samples_of_metric(&child.operator()), NonASAPOp::TimeShift { shift, child } if shift.at.is_none() => { - raw_data_samples_of_metric(child) + raw_data_samples_of_metric(&child.operator()) } NonASAPOp::Aggregate { measures, child, .. } if matches!(measures.as_slice(), [AggIntent::Sum { col: None }]) => { - raw_data_samples_of_metric(child) + raw_data_samples_of_metric(&child.operator()) } _ => None, } @@ -717,7 +722,7 @@ pub(crate) fn estimates_raw_data_samples_of(node: &OperatorNode) -> Option raw_data_samples_of_metric(node), + _ => raw_data_samples_of_metric(&node.operator), } } @@ -1605,3 +1610,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..83a9aa62c 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -288,17 +288,8 @@ 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 @@ -372,7 +363,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..bbf8e7988 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, @@ -76,71 +72,49 @@ fn promql(query: &str) -> QueryRoot { .remove(0) } -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() } -/// 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 +/// The one `Aggregate` group of `memo`. +fn root_group(memo: &Memo) -> MemoGroupId { + let [group] = aggregate_groups(memo)[..] else { + panic!("one aggregate group expected") + }; + group +} + +/// 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 +125,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 +142,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 +179,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 +311,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 +425,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 +455,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 +486,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, _) @@ -655,12 +549,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 +588,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 +610,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..82fac5723 100644 --- a/crates/planner/src/run.rs +++ b/crates/planner/src/run.rs @@ -121,19 +121,9 @@ 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. let pass1 = pass1_memo( initial, - &accuracy_targets, - accuracy.model, - accuracy.evidence, - data, models .deployment_capabilities .supported_summaries 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..2c71a6875 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 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..28b274470 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,22 +3,19 @@ //! 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 asap_types::ir::schema::SupportedSummary; +use asap_types::stages::Memo; use crate::per_computation_candidates::{ - generate_per_computation_candidates, PerComputationCandidateError, + add_pass1_candidates, computation_targets, 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. + /// A query's memo cannot be planned: a group's expression is already an + /// ASAP operator, a measure's accuracy target is invalid, or a + /// candidate Pass 1 built is rejected by the memo. #[error(transparent)] PerComputation(#[from] PerComputationCandidateError), } @@ -29,25 +26,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 θ") + 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..37cd14e1a 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,141 +1,83 @@ -//! 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 sum (one exact `Sum` state +//! over the rows below a `sum` of a `sum`): their builds list the groups +//! they absorb in `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 target records the +//! measure filter (applied as `SummaryAgg.filter`) and one [`HavingEntry`] +//! per `HAVING`: the aggregate's own, and each consumer `Filter` group +//! directly above the aggregate group. A key-only predicate is pushed below +//! the aggregate by the candidates that compute the filtered result; 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::input::OperatorInput; +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, ComputationTarget, + FusedComputation, HavingEntry, HavingPlacement, 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::propagation::raw_data_samples_of_metric; 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::{non_asap, realize_keyed_additive_summary_input, summary_candidates}; /// Unranked candidates of one measure and the candidates pruned for it. #[derive(Debug, Clone, PartialEq)] @@ -173,385 +115,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 +134,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 +202,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 @@ -660,129 +234,81 @@ fn placement(below: bool) -> HavingPlacement { } } -/// 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") +/// The expression of group `group`. +fn expression(memo: &Memo, group: MemoGroupId) -> &Operator { + &memo.groups()[group.0 as usize].expression +} + +/// The output schema of group `group`. +fn schema(memo: &Memo, group: MemoGroupId) -> &Schema { + &memo.groups()[group.0 as usize].group_properties.schema } -/// 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; the target records how they are applied: +/// the aggregate's own `having`, then each consumer `Filter` group whose +/// input is the aggregate group, key-only predicates below the aggregate. +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 { + for (index, group) in memo.groups().iter().enumerate() { + let Operator::NonASAP(NonASAPOp::Aggregate { + child, + reduction, measures, filters, having, .. - }) = node.non_asap() + }) = &group.expression else { continue; }; - let aggregate_group = group_of[&Rc::as_ptr(&node)]; + let aggregate_group = MemoGroupId(index as u32); let mut entries: Vec = having .iter() .map(|having| HavingEntry { filter: None, predicate: having.clone(), - placement: placement(key_only_having(&node, having)), + placement: placement(key_only_having(reduction, 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 &parent in group.parent_groups() { + if let Operator::NonASAP(NonASAPOp::Filter { child, pred }) = expression(memo, parent) { + if *child == aggregate_group { + entries.push(HavingEntry { + filter: Some(parent), + predicate: pred.clone(), + placement: placement(key_only_having(reduction, 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)), - }); - } 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 +319,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(_))); } @@ -813,22 +342,48 @@ pub fn generate_per_computation_candidates( }); } } - 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_sum_targets(memo, &mut targets); close_absorptions(&mut targets); - Ok(PerComputationCandidates { - roots, - logical_nodes, - targets, - }) + without_unsupported_summaries(memo, &mut targets, supported_summaries); + Ok(targets) +} + +/// 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_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. +/// Adds to each fused computation's `absorbed_targets` the targets its +/// absorbed targets' own fused computations absorb, transitively: none of +/// them is read when the fused computation is picked, so its builds list +/// all their groups in `fused_groups`. fn close_absorptions(targets: &mut [ComputationTarget]) { loop { let mut changed = false; @@ -867,19 +422,16 @@ fn close_absorptions(targets: &mut [ComputationTarget]) { /// 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]; +/// inner target. Offered whatever else reads the inner sum: 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(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) @@ -888,12 +440,14 @@ fn add_nested_sum_targets(targets: &mut [ComputationTarget], logical_nodes: &[Rc { 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; }; @@ -901,11 +455,11 @@ fn add_nested_sum_targets(targets: &mut [ComputationTarget], logical_nodes: &[Rc // computation of its own. if !targets[inner].having_filters.is_empty() || targets[inner].fused_computation.is_some() - || nested_sum_input(aggregate(target)).is_none() + || nested_sum_input(&memo.group_as_input(target.aggregate_group)).is_none() { continue; } - absorbed.insert(inner); + absorbed.push(inner); let merge = HowToComputeAggregateMeasure::ExactAccumulator(vec![ExactKind::Sum]); targets[t].fused_computation = Some(FusedComputation { absorbed_targets: vec![inner], @@ -915,24 +469,26 @@ fn add_nested_sum_targets(targets: &mut [ComputationTarget], logical_nodes: &[Rc } /// 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; +/// 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: &I) -> Option<(I, AggIntent, Reduction)> { let Some(NonASAPOp::Aggregate { child: inner, - reduction: reduction @ Reduction::Reduce(by), + reduction: reduction @ Reduction::Reduce(_), measures, filters, having: None, .. - }) = target.non_asap() + }) = non_asap(target) else { return None; }; + let Reduction::Reduce(by) = &reduction else { + return None; + }; let [AggIntent::Sum { col }] = measures.as_slice() else { return None; }; @@ -943,25 +499,25 @@ fn nested_sum_input(target: &OperatorNode) -> Option<(Rc, AggInten filters: inner_filters, having: None, .. - }) = inner.non_asap() + }) = non_asap(&inner) else { return None; }; let [inner_sum @ AggIntent::Sum { .. }] = inner_measures.as_slice() else { return None; }; - if by.is_without() || any_measure_filtered(filters) || any_measure_filtered(inner_filters) { + if 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. - let value = match inner_reduction { - Reduction::PerEntity => resolve_column_ref(&ColumnRef::SampleValue, &inner.schema).ok()?, + let value = match &inner_reduction { + Reduction::PerEntity => resolve_column_ref(&ColumnRef::SampleValue, inner.schema()).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()?, + None => resolve_column_ref(&ColumnRef::SampleValue, inner.schema()).ok()?, Some(column) => *column, }; if summed != value || by.keys().contains(&value) { @@ -972,7 +528,7 @@ fn nested_sum_input(target: &OperatorNode) -> Option<(Rc, AggInten if matches!(inner_reduction, Reduction::PerEntity) && by.keys().iter().any(|&key| { inner - .schema + .schema() .fields .get(key) .is_some_and(|f| f.name == "__name__") @@ -980,37 +536,34 @@ 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(inner.schema(), rows.schema())?; + Some((rows, inner_sum.clone(), partitions)) } /// 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 { + 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; }; @@ -1021,25 +574,26 @@ fn add_whole_expression_targets( { continue; } - let Some((input, ..)) = whole_expression_input(aggregate(target)) else { + let Some((input, ..)) = + whole_expression_input(&memo.group_as_input(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 { + if let Operator::NonASAP(NonASAPOp::Aggregate { child: per_series, .. - }) = aggregate(&targets[inner]).non_asap() + }) = expression(memo, targets[inner].aggregate_group) { - let reads_below = per_series + let reads_below = expression(memo, *per_series) .children() - .iter() - .any(|&rows| Rc::ptr_eq(rows, &input)); + .contains(&&input.group_id()); if reads_below { absorbs.extend( targets .iter() - .position(|other| Rc::ptr_eq(aggregate(other), per_series)), + .position(|other| other.aggregate_group == *per_series), ); } } @@ -1067,14 +621,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 +637,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.group_as_input(*child)) { continue; } prune_own(&mut target.ways_to_compute, |candidate| { @@ -1096,11 +647,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(input: &I) -> bool { + match input.operator() { + Operator::NonASAP(NonASAPOp::Scan { source, .. }) => { + matches!(source, Source::TimeSeries { .. }) + } + operator => operator + .children() + .into_iter() + .all(|child| reads_only_time_series(child)), } } @@ -1109,10 +665,7 @@ fn reads_only_time_series(node: &OperatorNode) -> bool { /// does not apply. Rows that carry the full series identity rank it as a /// 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; +fn whole_expression_input(target: &I) -> Option<(I, SummaryUpdate, Reduction)> { let Some(NonASAPOp::Aggregate { child, reduction, @@ -1120,14 +673,14 @@ fn whole_expression_input( filters, having: None, .. - }) = target.non_asap() + }) = non_asap(target) else { return None; }; 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 +691,22 @@ 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(intent, &family, &reduction, &child)?; let mut input = realized.input; let per_series = matches!( - child.non_asap(), + non_asap(&child), Some(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(child.schema(), realized.child.schema())?; + if per_series && realized.child.schema().has_promql_series_identity() { input.item = Some(asap_types::ir::schema::topk::series_identity_item( - &realized.child.schema, + realized.child.schema(), partitions.group_keys().map_or(&[][..], |keys| keys.keys()), )); - } else if realized.child.schema.closed + } else if realized.child.schema().closed && matches!(input.item, Some(SummaryInputExpr::EntityIdentity(_))) { // An encoded label set needs an open PromQL schema. PromQL rows that @@ -1182,537 +735,392 @@ 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> { + for index in 0..memo.groups().len() { + 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()), - ) - } - }; - // 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(); + for (&aggregate_group, group_targets) in &targets_of { + let entries = &group_targets[0].having_filters; + let own = |placement: HavingPlacement| -> Vec> { + entries + .iter() + .filter(|entry| entry.filter.is_none() && entry.placement == placement) + .map(|entry| entry.predicate.clone()) + .collect() + }; + let (below, above) = ( + own(HavingPlacement::BelowAggregate), + own(HavingPlacement::AboveOutput), + ); + let ways = AggregateWays { + aggregate_group, + result_group: aggregate_group, + below: &below, + above: &above, + targets: group_targets, + all_targets: targets, + }; + add_aggregate_candidates(memo, &ways)?; + for entry in entries { + if let (Some(filter), HavingPlacement::BelowAggregate) = (entry.filter, entry.placement) + { + let mut with_consumer = below.clone(); + with_consumer.push(entry.predicate.clone()); + let filtered = AggregateWays { + result_group: filter, + below: &with_consumer, + ..ways + }; + 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], + /// Every target, for the groups a fused computation absorbs. + all_targets: &'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.group_as_input(child).operator()); + 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 + if let Some(fused) = &target.fused_computation { + let absorbed_groups: Vec = fused + .absorbed_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", - )) - } - }; + .map(|&absorbed| ways.all_targets[absorbed].aggregate_group) + .collect(); + for way in &fused.ways_to_compute { + unless_unsupported(add_fused_candidate( + memo, + ways, + &measure(0), + way, + &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. + if !matches!(&reduction, Reduction::Reduce(by) if !by.is_without()) { + 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) -} - -/// 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", - ))? - }; - 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 }, - ))?) +/// `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, + } } -/// 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, - }, - ))?) +/// Adds the fused computation `way` of `ways`' single measure: a whole- +/// expression heap (a summary way) or a nested-sum merge (an exact +/// accumulator way). Its builds list `absorbed_groups` in `fused_groups`. +fn add_fused_candidate( + memo: &mut Memo, + ways: &AggregateWays<'_>, + measure: &SingleMeasure<'_>, + way: &HowToComputeAggregateMeasure, + absorbed_groups: &[MemoGroupId], +) -> Result<(), PerComputationCandidateError> { + let target = memo.group_as_input(ways.aggregate_group); + match way { + HowToComputeAggregateMeasure::Summary(algorithm) => { + let (rows, update, partitions) = whole_expression_input(&target).ok_or( + PerComputationCandidateError::Unsupported("whole-expression top-k input"), + )?; + let rows = rows.group_id(); + 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 sum's own accumulator + // state, from the rows below the inner sum: a candidate of that + // state group (#1203, fused builds). + let (rows, inner_sum, partitions) = nested_sum_input(&target).ok_or( + PerComputationCandidateError::Unsupported("nested-sum merge input"), + )?; + let rows = rows.group_id(); + let outer_state = accumulator_state(memo, ExactKind::Sum, measure)?; + let merged = + exact_accumulator_build(memo, ExactKind::Sum, &inner_sum, rows, partitions)?; + 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(), - }, - ))?) +/// 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> { + let finalized = |memo: &mut Memo, kind: ExactKind, measure: &SingleMeasure<'_>| { + let state = accumulator_state(memo, kind, measure)?; + Ok::<_, PerComputationCandidateError>(Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: state, + })) }; - 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 { + 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)? + finalized(memo, kinds[0], measure)? } HowToComputeAggregateMeasure::Summary(algorithm) => { + let query = sketch_statistic(measure.intent).ok_or( + PerComputationCandidateError::Unsupported("sketch evaluation"), + )?; let family = FieldDataType::Sketch(*algorithm, GroupingStrategy::default()); - let summary_input = state( - family, - intent, - measure_input(intent, input, filter.as_ref())?, + let read = measure_input( + measure.intent, + &memo.group_as_input(measure.input), + measure.filter, )?; - 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 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.group_as_input(measure.input), + measure.filter, )?; - if input.result_kind == OperatorResultKind::Relation { - return sql_avg(reduction, filter.is_some(), sum, count); + let sum = finalized_with(memo, ExactKind::Sum, &sum_intent, measure, read.clone())?; + let count = finalized_with(memo, ExactKind::Count, &count_intent, measure, read)?; + let sum = add_group(memo, sum)?; + let count = add_group(memo, 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 +1128,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 +1148,383 @@ 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 `measure` (its own intent and +/// input), with its build candidate. +fn accumulator_state( + memo: &mut Memo, + kind: ExactKind, + measure: &SingleMeasure<'_>, +) -> Result { + let read = measure_input( + measure.intent, + &memo.group_as_input(measure.input), + measure.filter, + )?; + let build = accumulator_build(memo, kind, measure.intent, measure, read)?; + add_state_group(memo, build, &[]) +} + +/// `FinalizeExactAccumulator` over the accumulator state of `kind` for +/// `intent` (one of `measure`'s states, such as `avg`'s `sum`), reading +/// `read` from `measure`'s input. +fn finalized_with( + memo: &mut Memo, + kind: ExactKind, + intent: &AggIntent, + measure: &SingleMeasure<'_>, + read: SummaryInputExpr, +) -> Result, PerComputationCandidateError> { + let build = accumulator_build(memo, kind, intent, measure, read)?; + let state = add_state_group(memo, build, &[])?; + Ok(Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: state, + })) +} + +/// 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(), + })) +} + +/// An unfiltered exact accumulator build of `kind` for `intent` over +/// `input`, grouped by `reduction`. +fn exact_accumulator_build( + memo: &Memo, + kind: ExactKind, + intent: &AggIntent, + input: MemoGroupId, + reduction: Reduction, +) -> Result, PerComputationCandidateError> { + let family = FieldDataType::exact(kind); + let read = measure_input::<_, MemoGroupId>(intent, &memo.group_as_input(input), None)?; + Ok(Operator::ASAP(ASAPOp::SummaryAgg { + input: summary_update(intent, &family, &reduction, schema(memo, input), read)?, + child: input, + family, + reduction, + grouping: GroupingStrategy::default(), + filter: None, + })) +} + +/// Adds `output` as a candidate of `result_group`, then the `above` filters +/// over it. When its column names or qualifiers 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, and qualifies them, +/// as `result_group`'s. `output` must have the group's number of columns, +/// and the group's columns one qualifier (or none). +fn renaming_project( + memo: &Memo, + output: MemoGroupId, + result_group: MemoGroupId, +) -> Result, PerComputationCandidateError> { + let (from, to) = (schema(memo, output), schema(memo, result_group)); + if from.fields.len() != to.fields.len() { + return Err(PerComputationCandidateError::Unsupported( + "substituted output with other columns than the aggregate", + )); + } + // One qualifier for all columns when the group's share one; a key's + // table qualifier is not compared (`Memo::add_candidate`). + let qualifier = to.fields.first().and_then(|field| field.table.clone()); + let qualifier = qualifier.filter(|table| { + to.fields + .iter() + .all(|field| field.table.as_ref() == Some(table)) + }); + 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, + qualifier, + 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, + qualifier: None, + 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 +1535,27 @@ 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, + qualifier: None, + 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 +1601,24 @@ 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, + qualifier: None, + 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 +1628,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 +1648,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 +1681,26 @@ 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, + qualifier: to.fields.first().and_then(|field| field.table.clone()), + child: joined, + })) } -fn key_items(keys: usize) -> Vec { +fn key_items(keys: usize) -> Vec> { (0..keys) .map(|key| ProjectItem { alias: None, @@ -1949,14 +1712,14 @@ fn key_items(keys: usize) -> Vec { /// What a measure's state reads from each row of `input`, resolvable there. /// A SQL `count` reads [`counted_column`]; any other measure, a PromQL count /// included, reads its input column (the sample value when it names none). -fn measure_input( +fn measure_input( intent: &AggIntent, - input: &Rc, - filter: Option<&Predicate>, + input: &I, + filter: Option<&Predicate>, ) -> Result { - let schema = &input.schema; + let schema = input.schema(); let read = match intent { - AggIntent::Count { .. } if input.result_kind == OperatorResultKind::Relation => { + AggIntent::Count { .. } if input.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. @@ -1983,11 +1746,11 @@ fn measure_input( /// so it is a column non-null on every counted row: one the measure filter /// proves non-null (`count(col)` lowers to the filter `col IS NOT NULL`), else /// a non-nullable one, Float64 first (the runtime's numeric update). -fn counted_column( +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 +1876,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..85b531140 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,96 +1,73 @@ -//! 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. +use std::fmt::Write as _; + +use asap_types::ir::operator::operator_properties::Reduction; use asap_types::ir::operator::AggIntent; use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::schema::SketchAlgorithm; - -use asap_types::stages::HowToComputeAggregateMeasure; +use asap_types::ir::{ASAPOp, Operator}; +use asap_types::stages::{Memo, MemoGroupId}; -fn approximate() -> AccuracyTarget { - AccuracyTarget::max_error(0.05).failure_probability(0.01) -} -fn candidates_for(intent: AggIntent) -> Vec { - computation_candidates_for_intent(&intent) - .unwrap() - .candidates -} -fn algorithms(choices: &[HowToComputeAggregateMeasure]) -> Vec { - choices - .iter() - .filter_map(|choice| match choice { - HowToComputeAggregateMeasure::Summary(algorithm) => Some(*algorithm), - _ => None, - }) - .collect() -} +use crate::pass1_memo; +use crate::test_fixtures::{aggregate_group, query, scan_group}; -/// Example 2 preserves specialized distinct summaries and the universal alternative. -#[test] -fn cardinality_keeps_exact_specialized_and_universal_candidates() { - let choices = candidates_for(AggIntent::Cardinality { - cols: vec![0], - accuracy: approximate(), - }); - assert_eq!(choices[0], HowToComputeAggregateMeasure::Exact); - assert_eq!( - algorithms(&choices), - vec![ - SketchAlgorithm::Hll, - SketchAlgorithm::Kmv, - SketchAlgorithm::UnivMon - ] - ); - let tuple = candidates_for(AggIntent::Cardinality { - cols: vec![0, 1], - accuracy: approximate(), - }); - assert!(!algorithms(&tuple).contains(&SketchAlgorithm::UnivMon)); +/// A compact printout of every group and its candidates. +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 _ = writeln!( + out, + " [{position}] {} inputs=[{}] θ={:?} floors={:?} fused={:?}", + label(&candidate.expression.operator), + inputs.join(", "), + candidate.logical_properties.parameters(), + candidate + .input_requirements + .iter() + .map(|r| r.parameter_floor_for_required_accuracy().cloned()) + .collect::>(), + candidate.fused_groups, + ); + } + } + out } -/// Frequency moments retain exact execution and a universal sketch without certification. -#[test] -fn frequency_statistics_keep_universal_choices() { - for intent in [ - AggIntent::FrequencyL2 { - col: Some(0), - accuracy: approximate(), - }, - AggIntent::FrequencyEntropy { - col: Some(0), - accuracy: approximate(), - }, - ] { - let choices = candidates_for(intent); - assert_eq!(choices[0], HowToComputeAggregateMeasure::Exact); - assert_eq!(algorithms(&choices), vec![SketchAlgorithm::UnivMon]); +fn label(operator: &Operator) -> String { + match operator { + Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) => format!("SummaryAgg[{family:?}]"), + Operator::ASAP(ASAPOp::SummaryEstimate { query, .. }) => { + format!("SummaryEstimate[{query:?}]") + } + operator => operator.kind_name().to_string(), } } -/// 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(), - }); - assert_eq!(choices[0], HowToComputeAggregateMeasure::Exact); - assert_eq!( - algorithms(&choices), - vec![ - SketchAlgorithm::CmsWithHeap, - SketchAlgorithm::CountSketchWithHeap - ] - ); +fn quantile(q: f64) -> AggIntent { + AggIntent::Quantile { + col: None, + q, + accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), + } } -/// Summary candidates are unsized, so they do not depend on the requested error. +/// The running example (concepts doc, section 1). #[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); +fn running_example() { + let mut memo = Memo::default(); + let g1 = scan_group(&mut memo, "latency", &[]); + let g3 = aggregate_group(&mut memo, g1, Reduction::by(vec![]), vec![quantile(0.95)]); + query(&mut memo, g3); + let memo = pass1_memo(memo, None).unwrap(); + println!("{}", memo_printout(&memo)); } diff --git a/crates/stage1-pass1-per-computation-candidates/src/realization.rs b/crates/stage1-pass1-per-computation-candidates/src/realization.rs index 114cba7ab..042b8b3ee 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/realization.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/realization.rs @@ -4,6 +4,7 @@ //! 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::input::OperatorInput; 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; @@ -13,10 +14,7 @@ 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_accuracy::sizing::sketch_statistic; @@ -83,13 +81,13 @@ 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. -pub(crate) fn ranked_value( - child: &Rc, +pub(crate) fn ranked_value( + child: &I, ) -> Result<(ColumnId, ColumnRef), ResolveError> { let ranked = ranked_column(child)?; - let column = match resolve_column_ref(&ColumnRef::SampleValue, &child.schema) { + let column = match resolve_column_ref(&ColumnRef::SampleValue, child.schema()) { Ok(value) if value == ranked => ColumnRef::SampleValue, - _ => child.schema.fields[ranked].column_ref(), + _ => child.schema().fields[ranked].column_ref(), }; Ok((ranked, column)) } @@ -126,8 +124,8 @@ pub(crate) fn summarised_input( /// consume the logical aggregate's immediate child and summarize its declared /// 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) struct PhysicalSummaryInput { + pub(crate) child: I, pub(crate) input: SummaryUpdate, } @@ -136,12 +134,12 @@ pub(crate) struct PhysicalSummaryInput { /// it does not consume an independently materialized Count result. `None` /// when the rule does not apply or the shape is unsupported (each reason is /// noted where it returns). -pub(crate) fn realize_keyed_additive_summary_input( +pub(crate) fn realize_keyed_additive_summary_input( intent: &AggIntent, family: &FieldDataType, output_reduction: &Reduction, - child: &Rc, -) -> Option { + child: &I, +) -> Option> { if !matches!(intent, AggIntent::TopK { .. }) { return None; } @@ -162,18 +160,18 @@ pub(crate) fn realize_keyed_additive_summary_input( having: None, child: raw_child, .. - }) = child.non_asap() + }) = non_asap(child) else { return None; }; // 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 asap_types::ir::operator::non_asap::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(), non_asap(&raw_child)) { ( [AggIntent::Sum { .. }], Some(NonASAPOp::Aggregate { @@ -187,7 +185,7 @@ pub(crate) fn realize_keyed_additive_summary_input( ) => { Some(asap_accuracy::propagation::raw_data_samples_of_metric( - samples, + &samples.operator(), )) } _ => None, @@ -209,7 +207,7 @@ 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 raw_child.schema().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 +237,7 @@ 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| child.schema().fields.get(*index).map(Field::column_ref)) .collect(), }; let item = match reduction { @@ -249,7 +247,7 @@ 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| raw_child.schema().fields.get(*index).map(Field::column_ref)) .collect::>>() else { // Unsupported: ranked item column is outside the raw input schema. @@ -279,7 +277,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_else(|| raw_child.clone(), |(rows, _)| rows), input: SummaryUpdate { item: Some(item), weight, @@ -294,7 +292,7 @@ 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)> { +fn summed_per_series_samples(grouped: &I) -> Option<(I, AggIntent)> { use asap_types::ir::operator::non_asap::any_measure_filtered; let Some(NonASAPOp::Aggregate { reduction: Reduction::Reduce(by), @@ -303,7 +301,7 @@ fn summed_per_series_samples(grouped: &OperatorNode) -> Option<(Rc having: None, child: per_series, .. - }) = grouped.non_asap() + }) = non_asap(grouped) else { return None; }; @@ -317,7 +315,7 @@ fn summed_per_series_samples(grouped: &OperatorNode) -> Option<(Rc having: None, child: rows, .. - }) = per_series.non_asap() + }) = non_asap(&per_series) else { return None; }; @@ -326,27 +324,35 @@ fn summed_per_series_samples(grouped: &OperatorNode) -> Option<(Rc return None; }; if by.is_without() - || any_measure_filtered(filters) - || any_measure_filtered(inner_filters) - || matches!(rows.non_asap(), Some(NonASAPOp::Aggregate { .. })) + || any_measure_filtered(&filters) + || any_measure_filtered(&inner_filters) + || matches!(non_asap(&rows), Some(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, per_series.schema()).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() .fields .get(key) .is_none_or(|field| field.name == "__name__") }) { return None; } - Some((Rc::clone(rows), inner.clone())) + Some((rows, inner.clone())) +} + +/// `input`'s operator when it is not an ASAP one. +pub(crate) fn non_asap(input: &I) -> Option> { + match input.operator() { + Operator::NonASAP(operator) => Some(operator), + Operator::ASAP(_) => None, + } } #[cfg(test)] @@ -357,6 +363,7 @@ mod tests { use asap_types::ir::scalar::{CompareOpKind, ScalarValue}; use asap_types::ir::schema::GroupingStrategy; use asap_types::ir::{ExprSemantics, Predicate}; + use asap_types::ir::{OperatorNode, ScalarExpr}; /// A heap sketch consumes every raw row, so the keyed input rule refuses /// a TopK over a filtered count. 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..5c08dddcd 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,17 @@ -//! 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::{ColumnId, DataType, Field, Schema}; -use asap_types::ir::{NonASAPOp, OperatorNode}; +use asap_types::ir::{NonASAPOp, Operator, OperatorNode, Predicate, QueryRoot}; +use asap_types::stages::{Memo, MemoGroupId}; -/// 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![]) -} - -/// [`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 +21,69 @@ 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 `TimeSeries("m")` scan node over [`metric_schema`], no unique key. +pub fn metric_scan(labels: &[&str]) -> Rc { + OperatorNode::new_shared(scan_operator("m", metric_schema(labels, vec![]))).unwrap() +} + +/// 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`]. +pub fn scan_group(memo: &mut Memo, metric: &str, labels: &[&str]) -> MemoGroupId { + group(memo, scan_operator(metric, metric_schema(labels, vec![]))) +} + +/// The group of an `Aggregate` of `measures` (named by their intents) over +/// `child`, grouped by `reduction`, unfiltered and without `HAVING`. +pub fn aggregate_group( + memo: &mut Memo, + child: MemoGroupId, + reduction: Reduction, + measures: Vec, +) -> MemoGroupId { + aggregate_group_with(memo, child, reduction, measures, None) +} + +/// [`aggregate_group`] with the aggregate's own `having`. +pub fn aggregate_group_with( + 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/non_asap.rs b/crates/types/src/ir/operator/non_asap.rs index 12eba05c3..d55db1fb3 100644 --- a/crates/types/src/ir/operator/non_asap.rs +++ b/crates/types/src/ir/operator/non_asap.rs @@ -1305,7 +1305,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/stages/candidates.rs b/crates/types/src/stages/candidates.rs index ffc57336a..8a2936a4c 100644 --- a/crates/types/src/stages/candidates.rs +++ b/crates/types/src/stages/candidates.rs @@ -9,8 +9,8 @@ use super::hash_consing::{expression_hash, same_expression, same_value}; use super::{GroupProperties, MemoGroup, MemoGroupAsInput, MemoGroupId}; use crate::ir::properties::summary_coverage::SummaryCoverage; use crate::ir::schema::{ - FamilyCapability, FieldDataType, GroupingStrategy, HydraKind, HydraParams, SketchAlgorithm, - SketchParams, + FamilyCapability, FieldDataType, GroupingStrategy, HydraKind, HydraParams, Schema, + SketchAlgorithm, SketchParams, }; use crate::ir::ASAPOp; use crate::ir::Operator; @@ -128,7 +128,7 @@ impl Memo { let output = derive_group_properties(self, &candidate.expression.operator) .map_err(CandidateError::Schema)?; let group_properties = &self.groups[group.0 as usize].group_properties; - if output.schema != group_properties.schema + if !same_output_columns(&output.schema, &group_properties.schema) || output.result_kind != group_properties.result_kind { return Err(CandidateError::OutputDiffersFromGroup { group }); @@ -196,6 +196,15 @@ impl Memo { } } + /// Group `group_id` read as an operator's input: the node rules and the + /// rules that read an input's operator take it as they take a node. + pub fn group_as_input(&self, group_id: MemoGroupId) -> MemoGroupAsInput<'_> { + MemoGroupAsInput { + memo: self, + group_id, + } + } + /// The candidate `id`. pub fn candidate(&self, id: CandidateForMemoGroupId) -> &CandidateForMemoGroup { &self.groups[id.group.0 as usize].candidates[id.index_in_group] @@ -289,6 +298,26 @@ pub enum CandidateError { OutputDiffersFromGroup { group: MemoGroupId }, } +/// Whether `left` and `right` are one output: the same columns (names, +/// types, nullability), time index and unique keys. Two schema fields +/// that no operator can set per column are not compared: +/// - each field's table qualifier, which only resolves a qualified name (a +/// `Project` sets one qualifier for all columns, while a grouped SQL +/// aggregate's keys keep their table and its measures have none); +/// - `closed`, whether the columns are all the row carries (a `Project` +/// that names a readout's value as a PromQL per-series aggregate names it +/// closes the schema the aggregate leaves open). +fn same_output_columns(left: &Schema, right: &Schema) -> bool { + left.fields.len() == right.fields.len() + && left + .fields + .iter() + .zip(&right.fields) + .all(|(l, r)| l.name == r.name && l.dtype == r.dtype && l.nullable == r.nullable) + && left.time_index == right.time_index + && left.unique_keys == right.unique_keys +} + /// Lists `parent` among `group`'s parents, once. fn add_parent(group: &mut MemoGroup, parent: MemoGroupId) { if !group.parents.contains(&parent) { diff --git a/crates/types/src/stages/mod.rs b/crates/types/src/stages/mod.rs index 873c276a6..1c035a4f5 100644 --- a/crates/types/src/stages/mod.rs +++ b/crates/types/src/stages/mod.rs @@ -22,8 +22,9 @@ pub use candidates::{ LogicalPropertiesError, LogicalRequirements, Memo, OriginOfCandidateForMemoGroup, WindowLayout, }; 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 memo::{ + queries_rooted_at, root_groups, GroupProperties, MemoGroup, MemoGroupAsInput, MemoGroupId, +}; pub use per_computation_logical_memo_candidates::{ ComputationTarget, FusedComputation, HavingEntry, HavingPlacement, HowToComputeAggregateMeasure, }; 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..0159deac3 100644 --- a/crates/types/src/stages/per_computation_logical_memo_candidates.rs +++ b/crates/types/src/stages/per_computation_logical_memo_candidates.rs @@ -1,60 +1,56 @@ //! The computation-target types Stage 1, Pass 1 builds its candidates from. -use std::rc::Rc; - use super::MemoGroupId; use crate::ir::schema::{ExactKind, SketchAlgorithm}; -use crate::ir::{OperatorNode, Predicate}; +use crate::ir::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. Which computation is computed is the target's +/// ([`ComputationTarget`]) or its fused computation's ([`FusedComputation`]). #[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. +/// Where a target's `HAVING` is applied by the candidates Pass 1 adds. /// /// 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. +/// `having`, or a `Filter` group directly above the aggregate group (the +/// form the SQL frontend lowers `HAVING` to). 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). + /// The predicate reads only group keys, so the candidates that compute + /// the filtered result push it 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. + /// The predicate reads aggregate values (or the aggregate is global), so + /// it stays a `Filter` above the finalize or readout output. 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, + /// The consumer `Filter` group that applies it; `None` for the + /// aggregate's own `having`, which every consumer reads. + pub filter: Option, + pub predicate: Predicate, pub placement: HavingPlacement, } @@ -71,7 +67,7 @@ pub struct ComputationTarget { pub ways_to_compute: Vec, /// The measure's own filter; only rows that pass it update the state /// (`SummaryAgg.filter`). - pub measure_filter: Option, + pub measure_filter: Option>, /// The aggregate's own `having`, then one entry per consumer `Filter` /// directly above the aggregate. pub having_filters: Vec, @@ -100,36 +96,3 @@ pub struct FusedComputation { /// 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()), - )) - } - } - } -} From cc14aba162c5720599787e12ee6bcacc6ebd56ff Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:50:32 +0000 Subject: [PATCH 02/15] test(stage1-pass1): Pass 1 tests over memos built through the Memo methods (#1207 PR 3) --- .../src/per_computation_candidates.rs | 8 +- .../src/per_computation_candidates_tests.rs | 883 +++++++++++++++++- 2 files changed, 867 insertions(+), 24 deletions(-) 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 37cd14e1a..cb6c320b9 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 @@ -904,8 +904,12 @@ fn add_aggregate_candidates( } return Ok(()); } - // The measures are joined back on their group keys. - if !matches!(&reduction, Reduction::Reduce(by) if !by.is_without()) { + // 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(()); } let mut parts = Vec::with_capacity(measures.len()); 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 85b531140..849e42c10 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,18 +1,35 @@ //! 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. +//! [`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::Reduction; +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::{ASAPOp, Operator}; -use asap_types::stages::{Memo, MemoGroupId}; +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, +}; +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::test_fixtures::{aggregate_group, query, scan_group}; +use crate::per_computation_candidates::{ + computation_candidates_for_intent, computation_targets, PerComputationCandidateError, +}; +use crate::test_fixtures::{aggregate_group, aggregate_group_with, group, query, scan_group}; +use crate::{pass1_memo, Pass1Error}; -/// A compact printout of every group and its candidates. +/// 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() { @@ -25,19 +42,23 @@ fn memo_printout(memo: &Memo) -> String { .iter() .map(|g| format!("G{}", g.0)) .collect(); - let _ = writeln!( - out, - " [{position}] {} inputs=[{}] θ={:?} floors={:?} fused={:?}", + let mut line = format!( + " [{position}] {} inputs=[{}]", label(&candidate.expression.operator), - inputs.join(", "), - candidate.logical_properties.parameters(), - candidate - .input_requirements - .iter() - .map(|r| r.parameter_floor_for_required_accuracy().cloned()) - .collect::>(), - candidate.fused_groups, + 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 @@ -49,25 +70,843 @@ fn label(operator: &Operator) -> String { 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(), } } +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: AccuracyTarget::max_error(0.01).failure_probability(0.01), + accuracy: approximate(), } } -/// The running example (concepts doc, section 1). +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]`: a range selector over `metric{labels}`. +fn range(memo: &mut Memo, metric: &str, labels: &[&str]) -> MemoGroupId { + let scan = scan_group(memo, metric, labels); + group( + memo, + Operator::NonASAP(NonASAPOp::TimeRange { + range: Duration::from_secs(300), + kind: TimeRangeKind::Range, + child: scan, + }), + ) +} + +/// `metric`: an instant selector over `metric{labels}`, which a +/// cross-series aggregate reads. +fn instant(memo: &mut Memo, metric: &str, labels: &[&str]) -> MemoGroupId { + let scan = scan_group(memo, metric, labels); + group( + memo, + Operator::NonASAP(NonASAPOp::TimeRange { + range: Duration::from_secs(300), + kind: TimeRangeKind::Instant, + child: scan, + }), + ) +} + +/// A SQL table `flows(src Utf8, bytes Float64)`. +fn table_group(memo: &mut Memo) -> MemoGroupId { + group( + 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), + ]), + }), + ) +} + +/// `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 })) +} + +/// `lhs / rhs` as a PromQL binary operator. +fn divide(memo: &mut Memo, lhs: MemoGroupId, rhs: MemoGroupId) -> MemoGroupId { + group( + 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, + rhs, + }), + ) +} + +// ── 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() { +fn running_example_gives_one_build_per_theta_and_separate_readouts() { let mut memo = Memo::default(); - let g1 = scan_group(&mut memo, "latency", &[]); + let g1 = instant(&mut memo, "latency", &[]); let g3 = aggregate_group(&mut memo, g1, Reduction::by(vec![]), vec![quantile(0.95)]); + let g4 = aggregate_group(&mut memo, g1, Reduction::by(vec![]), vec![quantile(0.99)]); + let g5 = aggregate_group(&mut memo, g1, Reduction::by(vec![]), vec![quantile(0.5)]); + let g6 = divide(&mut memo, g4, 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); +} + +// ── 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 = instant(&mut memo, "m", &["job"]); + let both = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![2]), + vec![sum(), quantile(0.9)], + ); + 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 = instant(&mut memo, "m", &[]); + let quantile_exact = aggregate_group( + &mut memo, + rows, + Reduction::by(vec![]), + vec![AggIntent::Quantile { + col: None, + q: 0.5, + accuracy: AccuracyTarget::exact(), + }], + ); + 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 = instant(&mut memo, "m", &[]); + let p = aggregate_group(&mut memo, rows, Reduction::by(vec![]), vec![quantile(0.9)]); + 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 = instant(&mut memo, "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(Pass1Error::PerComputation( + PerComputationCandidateError::ContainsAsap + )) + )); + let mut memo = Memo::default(); + let rows = instant(&mut memo, "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), + }], + ); + query(&mut memo, invalid); + assert!(matches!( + pass1_memo(memo, None), + Err(Pass1Error::PerComputation( + 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 = range(&mut memo, "m", &[]); + let p = aggregate_group(&mut memo, rows, Reduction::by(vec![]), vec![quantile(0.9)]); + 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 = instant(&mut memo, "m", &["job"]); + let s = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![sum()]); + 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 = instant(&mut memo, "m", &["job"]); + let s = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![sum()]); + 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(); + let rows = table_group(&mut memo); + 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(), + }, + ], + ); + 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 = range(&mut memo, "m", &["job"]); + let per_series = aggregate_group(&mut memo, rows, Reduction::PerEntity, vec![sum()]); + let by_job = aggregate_group(&mut memo, per_series, Reduction::by(vec![2]), vec![sum()]); + let ranked = aggregate_group(&mut memo, by_job, Reduction::by(vec![]), vec![topk(15)]); + let samples = instant(&mut memo, "m", &["job"]); + let p = aggregate_group( + &mut memo, + samples, + Reduction::by(vec![]), + vec![quantile(0.9)], + ); + 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 = instant(&mut memo, "m", &["job"]); + let by_job = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![quantile(0.9)]); + 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 = instant(&mut memo, "m", &["job"]); + let by_job = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![quantile(0.9)]); + 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 = instant(&mut memo, "m", &[]); + let p = aggregate_group(&mut memo, rows, Reduction::by(vec![]), vec![quantile(0.9)]); + 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 = instant(&mut memo, "m", &["job"]); + let by_job = aggregate_group_with( + &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 = range(&mut memo, "m", &["job"]); + let per_series = aggregate_group(&mut memo, rows, Reduction::PerEntity, vec![sum()]); + let by_job = aggregate_group(&mut memo, per_series, Reduction::by(vec![2]), vec![sum()]); + let ranked = aggregate_group(&mut memo, by_job, Reduction::by(vec![]), vec![topk(5)]); + 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 = instant(&mut memo, "m", &["job"]); + let counts = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![count()]); + let ranked = aggregate_group(&mut memo, counts, Reduction::by(vec![]), vec![topk(5)]); + 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 = range(&mut memo, "m", &["job", "host"]); + let per_series = aggregate_group(&mut memo, rows, Reduction::PerEntity, vec![sum()]); + let by_both = aggregate_group( + &mut memo, + per_series, + Reduction::by(vec![2, 3]), + vec![sum()], + ); + let ranked = aggregate_group(&mut memo, by_both, Reduction::by(vec![0]), vec![topk(5)]); + 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-sum merges ────────────────────────────────────────────────────── + +/// `sum by (job) (sum_over_time(m[5m]))` gets a merged build: one exact +/// `Sum` build over the raw samples grouped by `job`, a candidate of the +/// outer sum's own accumulator state group, listing the inner sum's group +/// in `fused_groups`. +#[test] +fn nested_sum_merges_into_the_outer_state_group() { + let mut memo = Memo::default(); + let rows = range(&mut memo, "m", &["job"]); + let per_series = aggregate_group(&mut memo, rows, Reduction::PerEntity, vec![sum()]); + let by_job = aggregate_group(&mut memo, per_series, Reduction::by(vec![2]), vec![sum()]); + 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(ExactKind::Sum, _), .. }) if *child == per_series), + ); + let [outer_state] = outer_state[..] else { + panic!("{}", memo_printout(&memo)) + }; + let builds = candidates(&memo, outer_state); + assert_eq!(builds.len(), 2, "{}", 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 a sum over a sum's value: an outer sum over an inner +/// `max` gets none. +#[test] +fn nested_sum_merge_needs_a_sum_of_sums() { + let mut memo = Memo::default(); + let rows = range(&mut memo, "m", &["job"]); + let per_series = aggregate_group( + &mut memo, + rows, + Reduction::PerEntity, + vec![AggIntent::Max { col: None }], + ); + let by_job = aggregate_group(&mut memo, per_series, Reduction::by(vec![2]), vec![sum()]); + 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 = instant(&mut memo, "m", &["job"]); + let counts = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![count()]); + 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 = instant(&mut memo, "m", &["job"]); + let sums = aggregate_group(&mut memo, rows, Reduction::by(vec![2]), vec![sum()]); + 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() + .filter_map(|choice| match choice { + HowToComputeAggregateMeasure::Summary(algorithm) => Some(*algorithm), + _ => None, + }) + .collect() +} + +/// Example 2 preserves specialized distinct summaries and the universal alternative. +#[test] +fn cardinality_keeps_exact_specialized_and_universal_candidates() { + let choices = candidates_for(AggIntent::Cardinality { + cols: vec![0], + accuracy: approximate(), + }); + assert_eq!(choices[0], HowToComputeAggregateMeasure::Exact); + assert_eq!( + algorithms(&choices), + vec![ + SketchAlgorithm::Hll, + SketchAlgorithm::Kmv, + SketchAlgorithm::UnivMon + ] + ); + let tuple = candidates_for(AggIntent::Cardinality { + cols: vec![0, 1], + accuracy: approximate(), + }); + assert!(!algorithms(&tuple).contains(&SketchAlgorithm::UnivMon)); +} + +/// Frequency moments retain exact execution and a universal sketch without certification. +#[test] +fn frequency_statistics_keep_universal_choices() { + for intent in [ + AggIntent::FrequencyL2 { + col: Some(0), + accuracy: approximate(), + }, + AggIntent::FrequencyEntropy { + col: Some(0), + accuracy: approximate(), + }, + ] { + let choices = candidates_for(intent); + assert_eq!(choices[0], HowToComputeAggregateMeasure::Exact); + assert_eq!(algorithms(&choices), vec![SketchAlgorithm::UnivMon]); + } +} + +/// TopK keeps both declared heap substrates without choosing an implementation. +#[test] +fn topk_keeps_both_specialized_heap_choices() { + let choices = candidates_for(topk(10)); + assert_eq!(choices[0], HowToComputeAggregateMeasure::Exact); + assert_eq!( + algorithms(&choices), + vec![ + SketchAlgorithm::CmsWithHeap, + SketchAlgorithm::CountSketchWithHeap + ] + ); +} + +/// Summary candidates are unsized, so they do not depend on the requested error. +#[test] +fn summary_candidates_are_unsized() { + let tight = candidates_for(AggIntent::Count { + accuracy: AccuracyTarget::max_error(0.0001).failure_probability(0.0001), + }); + 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 + )) + ); } From 5e3c6cf5d0b4d470be60f2cc2fb9c38d4f0ae0b3 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:01:08 +0000 Subject: [PATCH 03/15] refactor(stage1-pass1): exact output check; readouts over realized series identity (#1207 PR 3) --- .../tests/stage1_logical_asap_optimization.rs | 11 ++- .../src/per_computation_candidates.rs | 10 ++- .../src/per_computation_candidates_tests.rs | 76 ++++++++++++++++++- .../src/test_fixtures.rs | 11 ++- crates/types/src/stages/candidates.rs | 26 +------ 5 files changed, 105 insertions(+), 29 deletions(-) diff --git a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs index bbf8e7988..90291a379 100644 --- a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs +++ b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs @@ -67,9 +67,18 @@ 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, + } } /// Pass 1's memo of the one query `root`. 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 cb6c320b9..462ec9c54 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 @@ -1302,13 +1302,21 @@ fn add_output_candidate( /// A `Project` over `output` that names its columns, and qualifies them, /// as `result_group`'s. `output` must have the group's number of columns, -/// and the group's columns one qualifier (or none). +/// 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", 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 849e42c10..106e8787f 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 @@ -11,7 +11,7 @@ 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, + SketchStatistic, SummaryInputExpr, SummaryUpdate, WeightDomain, PROMQL_SERIES_IDENTITY, }; use asap_types::ir::{ ASAPOp, BinaryOperator, ExprSemantics, NonASAPOp, Operator, Predicate, ScalarExpr, @@ -24,7 +24,9 @@ use asap_types::stages::{ use crate::per_computation_candidates::{ computation_candidates_for_intent, computation_targets, PerComputationCandidateError, }; -use crate::test_fixtures::{aggregate_group, aggregate_group_with, group, query, scan_group}; +use crate::test_fixtures::{ + aggregate_group, aggregate_group_with, group, metric_schema, query, scan_group, scan_operator, +}; use crate::{pass1_memo, Pass1Error}; /// A compact printout of every group and its candidates: the expression's @@ -290,6 +292,76 @@ fn running_example_gives_one_build_per_theta_and_separate_readouts() { 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 = range(&mut memo, "latency", &["job"]); + let per_series = aggregate_group(&mut memo, rows, Reduction::PerEntity, vec![quantile(0.9)]); + 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)]); + 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 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 5c08dddcd..97043654b 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs @@ -6,6 +6,7 @@ use std::rc::Rc; 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, Operator, OperatorNode, Predicate, QueryRoot}; use asap_types::stages::{Memo, MemoGroupId}; @@ -45,9 +46,15 @@ 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`]. +/// 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 { - group(memo, scan_operator(metric, metric_schema(labels, vec![]))) + 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 diff --git a/crates/types/src/stages/candidates.rs b/crates/types/src/stages/candidates.rs index 8a2936a4c..a95139bb2 100644 --- a/crates/types/src/stages/candidates.rs +++ b/crates/types/src/stages/candidates.rs @@ -9,8 +9,8 @@ use super::hash_consing::{expression_hash, same_expression, same_value}; use super::{GroupProperties, MemoGroup, MemoGroupAsInput, MemoGroupId}; use crate::ir::properties::summary_coverage::SummaryCoverage; use crate::ir::schema::{ - FamilyCapability, FieldDataType, GroupingStrategy, HydraKind, HydraParams, Schema, - SketchAlgorithm, SketchParams, + FamilyCapability, FieldDataType, GroupingStrategy, HydraKind, HydraParams, SketchAlgorithm, + SketchParams, }; use crate::ir::ASAPOp; use crate::ir::Operator; @@ -128,7 +128,7 @@ impl Memo { let output = derive_group_properties(self, &candidate.expression.operator) .map_err(CandidateError::Schema)?; let group_properties = &self.groups[group.0 as usize].group_properties; - if !same_output_columns(&output.schema, &group_properties.schema) + if output.schema != group_properties.schema || output.result_kind != group_properties.result_kind { return Err(CandidateError::OutputDiffersFromGroup { group }); @@ -298,26 +298,6 @@ pub enum CandidateError { OutputDiffersFromGroup { group: MemoGroupId }, } -/// Whether `left` and `right` are one output: the same columns (names, -/// types, nullability), time index and unique keys. Two schema fields -/// that no operator can set per column are not compared: -/// - each field's table qualifier, which only resolves a qualified name (a -/// `Project` sets one qualifier for all columns, while a grouped SQL -/// aggregate's keys keep their table and its measures have none); -/// - `closed`, whether the columns are all the row carries (a `Project` -/// that names a readout's value as a PromQL per-series aggregate names it -/// closes the schema the aggregate leaves open). -fn same_output_columns(left: &Schema, right: &Schema) -> bool { - left.fields.len() == right.fields.len() - && left - .fields - .iter() - .zip(&right.fields) - .all(|(l, r)| l.name == r.name && l.dtype == r.dtype && l.nullable == r.nullable) - && left.time_index == right.time_index - && left.unique_keys == right.unique_keys -} - /// Lists `parent` among `group`'s parents, once. fn add_parent(group: &mut MemoGroup, parent: MemoGroupId) { if !group.parents.contains(&parent) { From 246110d68534c1196dfa9155d2be3c82f6f87d55 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:55:38 +0000 Subject: [PATCH 04/15] fix(types): an exact top-k's ranked rows derive the heap readout's unique key (#1207 PR 3) One rule for a top-k's ranked rows, exact or a heap's readout: one row per (partition, item) when the item identity is complete (ranked_rows_unique_keys, item_identity_is_complete). The exact top-k's group derived no key over per-series rows, so a heap readout candidate did not output the group's schema. --- crates/types/src/ir/operator/asap.rs | 14 +- crates/types/src/ir/operator/non_asap.rs | 10 +- crates/types/src/ir/schema/topk.rs | 224 ++++++++++++++++++++++- 3 files changed, 230 insertions(+), 18 deletions(-) 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 d55db1fb3..f98240fc2 100644 --- a/crates/types/src/ir/operator/non_asap.rs +++ b/crates/types/src/ir/operator/non_asap.rs @@ -591,11 +591,17 @@ impl NonASAPOp { .unwrap_or_else(|| format!("topk_{k}")); 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)?; diff --git a/crates/types/src/ir/schema/topk.rs b/crates/types/src/ir/schema/topk.rs index 6600fd1ca..9a48eddf7 100644 --- a/crates/types/src/ir/schema/topk.rs +++ b/crates/types/src/ir/schema/topk.rs @@ -14,8 +14,30 @@ use crate::ir::{ASAPOp, NonASAPOp, Operator, ScalarExpr}; /// 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 { + 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()) + } + } +} + +/// 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 +57,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() } } @@ -151,3 +223,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); + } +} From d4bde077a4c3422e79b1834b67c46472d2ce862b Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:55:38 +0000 Subject: [PATCH 05/15] refactor(stage1-pass1): read memo groups directly; fused_groups as the replaced operators' groups (#1207 PR 3) --- crates/accuracy/src/propagation.rs | 27 +-- .../tests/stage1_logical_asap_optimization.rs | 5 +- .../src/per_computation_candidates.rs | 169 ++++++------- .../src/realization.rs | 226 ++++++++++++------ .../src/test_fixtures.rs | 7 - crates/types/src/stages/candidates.rs | 17 +- crates/types/src/stages/mod.rs | 5 +- ...per_computation_logical_memo_candidates.rs | 15 +- 8 files changed, 258 insertions(+), 213 deletions(-) diff --git a/crates/accuracy/src/propagation.rs b/crates/accuracy/src/propagation.rs index 16d7fbf8b..da54aab98 100644 --- a/crates/accuracy/src/propagation.rs +++ b/crates/accuracy/src/propagation.rs @@ -6,7 +6,6 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use asap_types::ir::operator::input::OperatorInput; use asap_types::ir::operator::operator_properties::{BinaryOpKind, JoinKind, Reduction, Source}; use asap_types::ir::operator::tumbling_window_chain::merged_chain; use asap_types::ir::operator::AggIntent; @@ -40,30 +39,26 @@ use crate::PropagationStats; type NodeToRewrittenNodeCache = HashMap<*const OperatorNode, Rc>; /// The metric whose raw data samples, or plain sums of them, are every value -/// `operator` outputs, or `None`. Only a time-series scan, a time range over -/// it, an `offset`-only time shift (it moves the window, not the samples) -/// and a plain `sum` keep that; any other operator (rate and arithmetic -/// included) ends it. The metric's type is not read here: whether its +/// `node` outputs, or `None`. Only a time-series scan, a time range over it, +/// an `offset`-only time shift (it moves the window, not the samples) and a +/// plain `sum` keep that; any other operator (rate and arithmetic included) +/// ends it. The metric's type is not read here: whether its /// samples are non-negative is plan selection's question -/// ([`WeightDomain::RawDataSamplesOfMetric`]). It reads a node's operator -/// and a memo group's expression alike ([`OperatorInput`]). -pub fn raw_data_samples_of_metric(operator: &Operator) -> Option { - let Operator::NonASAP(operator) = operator else { - return None; - }; - match operator { +/// ([`WeightDomain::RawDataSamplesOfMetric`]). +pub fn raw_data_samples_of_metric(node: &OperatorNode) -> Option { + match node.non_asap()? { NonASAPOp::Scan { source: Source::TimeSeries { metric }, .. } => Some(metric.clone()), - NonASAPOp::TimeRange { child, .. } => raw_data_samples_of_metric(&child.operator()), + NonASAPOp::TimeRange { child, .. } => raw_data_samples_of_metric(child), NonASAPOp::TimeShift { shift, child } if shift.at.is_none() => { - raw_data_samples_of_metric(&child.operator()) + raw_data_samples_of_metric(child) } NonASAPOp::Aggregate { measures, child, .. } if matches!(measures.as_slice(), [AggIntent::Sum { col: None }]) => { - raw_data_samples_of_metric(&child.operator()) + raw_data_samples_of_metric(child) } _ => None, } @@ -722,7 +717,7 @@ pub(crate) fn estimates_raw_data_samples_of(node: &OperatorNode) -> Option raw_data_samples_of_metric(&node.operator), + _ => raw_data_samples_of_metric(node), } } diff --git a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs index 90291a379..dbc5d4ce1 100644 --- a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs +++ b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs @@ -521,10 +521,7 @@ fn count_updates(memo: &Memo) -> 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, 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 462ec9c54..b1588565e 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 @@ -20,8 +20,10 @@ //! 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 sum (one exact `Sum` state -//! over the rows below a `sum` of a `sum`): their builds list the groups -//! they absorb in `fused_groups`. +//! over the rows below a `sum` of a `sum`): 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"). //! //! Every intermediate result is its own group, and every candidate is one //! operator over input groups. A candidate outputs its group's schema @@ -49,7 +51,6 @@ //! operator is rejected. No cost model and no accuracy target is read. use std::collections::BTreeMap; -use asap_types::ir::operator::input::OperatorInput; 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; @@ -73,11 +74,13 @@ use asap_types::stages::{ }; use thiserror::Error; -use asap_accuracy::propagation::raw_data_samples_of_metric; use asap_accuracy::sizing::{accuracy_budget, sizing_target, sketch_statistic}; use asap_types::deployment_capabilities::summary_supported; -use crate::realization::{non_asap, realize_keyed_additive_summary_input, summary_candidates}; +use crate::realization::{ + non_asap, raw_data_samples_of_metric, realize_keyed_additive_summary_input, schema, + summary_candidates, +}; /// Unranked candidates of one measure and the candidates pruned for it. #[derive(Debug, Clone, PartialEq)] @@ -239,11 +242,6 @@ fn expression(memo: &Memo, group: MemoGroupId) -> &Operator { &memo.groups()[group.0 as usize].expression } -/// The output schema of group `group`. -fn schema(memo: &Memo, group: MemoGroupId) -> &Schema { - &memo.groups()[group.0 as usize].group_properties.schema -} - /// 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 @@ -381,9 +379,9 @@ fn without_unsupported_summaries( } /// Adds to each fused computation's `absorbed_targets` the targets its -/// absorbed targets' own fused computations absorb, transitively: none of -/// them is read when the fused computation is picked, so its builds list -/// all their groups in `fused_groups`. +/// absorbed targets' own fused computations absorb, 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; @@ -455,7 +453,7 @@ fn add_nested_sum_targets(memo: &Memo, targets: &mut [ComputationTarget]) { // computation of its own. if !targets[inner].having_filters.is_empty() || targets[inner].fused_computation.is_some() - || nested_sum_input(&memo.group_as_input(target.aggregate_group)).is_none() + || nested_sum_input(memo, target.aggregate_group).is_none() { continue; } @@ -474,7 +472,10 @@ fn add_nested_sum_targets(memo: &Memo, targets: &mut [ComputationTarget]) { /// 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: &I) -> Option<(I, AggIntent, Reduction)> { +fn nested_sum_input( + memo: &Memo, + target: MemoGroupId, +) -> Option<(MemoGroupId, AggIntent, Reduction)> { let Some(NonASAPOp::Aggregate { child: inner, reduction: reduction @ Reduction::Reduce(_), @@ -482,7 +483,7 @@ fn nested_sum_input(target: &I) -> Option<(I, AggIntent, Reduc filters, having: None, .. - }) = non_asap(target) + }) = non_asap(memo, target) else { return None; }; @@ -499,7 +500,7 @@ fn nested_sum_input(target: &I) -> Option<(I, AggIntent, Reduc filters: inner_filters, having: None, .. - }) = non_asap(&inner) + }) = non_asap(memo, inner) else { return None; }; @@ -512,12 +513,14 @@ fn nested_sum_input(target: &I) -> Option<(I, AggIntent, Reduc // 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. 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()?, + None => resolve_column_ref(&ColumnRef::SampleValue, schema(memo, inner)).ok()?, Some(column) => *column, }; if summed != value || by.keys().contains(&value) { @@ -527,8 +530,7 @@ fn nested_sum_input(target: &I) -> Option<(I, AggIntent, Reduc // grouping them by it would name groups the nested sums 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__") @@ -536,7 +538,7 @@ fn nested_sum_input(target: &I) -> Option<(I, AggIntent, Reduc { return None; } - let partitions = reduction.partitions_over(inner.schema(), rows.schema())?; + let partitions = reduction.partitions_over(schema(memo, inner), schema(memo, rows))?; Some((rows, inner_sum.clone(), partitions)) } @@ -574,9 +576,7 @@ fn add_whole_expression_targets(memo: &Memo, targets: &mut [ComputationTarget]) { continue; } - let Some((input, ..)) = - whole_expression_input(&memo.group_as_input(target.aggregate_group)) - 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 @@ -586,9 +586,7 @@ fn add_whole_expression_targets(memo: &Memo, targets: &mut [ComputationTarget]) child: per_series, .. }) = expression(memo, targets[inner].aggregate_group) { - let reads_below = expression(memo, *per_series) - .children() - .contains(&&input.group_id()); + let reads_below = expression(memo, *per_series).children().contains(&&input); if reads_below { absorbs.extend( targets @@ -637,7 +635,7 @@ fn prune_topk_heaps_over_one_row_per_item(memo: &Memo, targets: &mut [Computatio } // 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(&memo.group_as_input(*child)) { + if !reads_only_time_series(memo, *child) { continue; } prune_own(&mut target.ways_to_compute, |candidate| { @@ -648,15 +646,15 @@ fn prune_topk_heaps_over_one_row_per_item(memo: &Memo, targets: &mut [Computatio } /// Whether every scan below `input` reads a time series (PromQL rows). -fn reads_only_time_series(input: &I) -> bool { - match input.operator() { +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(child)), + .all(|&child| reads_only_time_series(memo, child)), } } @@ -665,7 +663,10 @@ fn reads_only_time_series(input: &I) -> bool { /// does not apply. Rows that carry the full series identity rank it as a /// 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: &I) -> Option<(I, SummaryUpdate, Reduction)> { +fn whole_expression_input( + memo: &Memo, + target: MemoGroupId, +) -> Option<(MemoGroupId, SummaryUpdate, Reduction)> { let Some(NonASAPOp::Aggregate { child, reduction, @@ -673,7 +674,7 @@ fn whole_expression_input(target: &I) -> Option<(I, SummaryUpd filters, having: None, .. - }) = non_asap(target) + }) = non_asap(memo, target) else { return None; }; @@ -691,22 +692,23 @@ fn whole_expression_input(target: &I) -> Option<(I, SummaryUpd 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!( - non_asap(&child), + non_asap(memo, child), Some(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 @@ -859,7 +861,7 @@ fn add_aggregate_candidates( Operator::NonASAP(NonASAPOp::Filter { pred, child: input }), )?; } - let raw_data_samples = raw_data_samples_of_metric(&memo.group_as_input(child).operator()); + let raw_data_samples = raw_data_samples_of_metric(memo, child); let measure = |index: usize| SingleMeasure { input, reduction: &reduction, @@ -958,7 +960,8 @@ fn unless_unsupported( /// Adds the fused computation `way` of `ways`' single measure: a whole- /// expression heap (a summary way) or a nested-sum merge (an exact -/// accumulator way). Its builds list `absorbed_groups` in `fused_groups`. +/// 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<'_>, @@ -966,13 +969,12 @@ fn add_fused_candidate( way: &HowToComputeAggregateMeasure, absorbed_groups: &[MemoGroupId], ) -> Result<(), PerComputationCandidateError> { - let target = memo.group_as_input(ways.aggregate_group); match way { HowToComputeAggregateMeasure::Summary(algorithm) => { - let (rows, update, partitions) = whole_expression_input(&target).ok_or( - PerComputationCandidateError::Unsupported("whole-expression top-k input"), - )?; - let rows = rows.group_id(); + 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"), )?; @@ -1002,10 +1004,10 @@ fn add_fused_candidate( // The merged build computes the outer sum's own accumulator // state, from the rows below the inner sum: a candidate of that // state group (#1203, fused builds). - let (rows, inner_sum, partitions) = nested_sum_input(&target).ok_or( - PerComputationCandidateError::Unsupported("nested-sum merge input"), - )?; - let rows = rows.group_id(); + let (rows, inner_sum, partitions) = nested_sum_input(memo, ways.aggregate_group) + .ok_or(PerComputationCandidateError::Unsupported( + "nested-sum merge input", + ))?; let outer_state = accumulator_state(memo, ExactKind::Sum, measure)?; let merged = exact_accumulator_build(memo, ExactKind::Sum, &inner_sum, rows, partitions)?; @@ -1050,11 +1052,7 @@ fn measure_output( PerComputationCandidateError::Unsupported("sketch evaluation"), )?; let family = FieldDataType::Sketch(*algorithm, GroupingStrategy::default()); - let read = measure_input( - measure.intent, - &memo.group_as_input(measure.input), - measure.filter, - )?; + let read = measure_input(measure.intent, memo, measure.input, measure.filter)?; let update = with_raw_data_samples_requirement( summary_update( measure.intent, @@ -1104,11 +1102,7 @@ fn measure_output( let count_intent = AggIntent::Count { accuracy: AccuracyTarget::exact(), }; - let read = measure_input( - &sum_intent, - &memo.group_as_input(measure.input), - measure.filter, - )?; + let read = measure_input(&sum_intent, memo, measure.input, measure.filter)?; let sum = finalized_with(memo, ExactKind::Sum, &sum_intent, measure, read.clone())?; let count = finalized_with(memo, ExactKind::Count, &count_intent, measure, read)?; let sum = add_group(memo, sum)?; @@ -1175,11 +1169,7 @@ fn accumulator_state( kind: ExactKind, measure: &SingleMeasure<'_>, ) -> Result { - let read = measure_input( - measure.intent, - &memo.group_as_input(measure.input), - measure.filter, - )?; + let read = measure_input(measure.intent, memo, measure.input, measure.filter)?; let build = accumulator_build(memo, kind, measure.intent, measure, read)?; add_state_group(memo, build, &[]) } @@ -1237,7 +1227,7 @@ fn exact_accumulator_build( reduction: Reduction, ) -> Result, PerComputationCandidateError> { let family = FieldDataType::exact(kind); - let read = measure_input::<_, MemoGroupId>(intent, &memo.group_as_input(input), None)?; + let read = measure_input(intent, memo, input, None)?; Ok(Operator::ASAP(ASAPOp::SummaryAgg { input: summary_update(intent, &family, &reduction, schema(memo, input), read)?, child: input, @@ -1249,7 +1239,7 @@ fn exact_accumulator_build( } /// Adds `output` as a candidate of `result_group`, then the `above` filters -/// over it. When its column names or qualifiers differ from the group's, +/// 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 @@ -1300,8 +1290,7 @@ fn add_output_candidate( Ok(()) } -/// A `Project` over `output` that names its columns, and qualifies them, -/// as `result_group`'s. `output` must have the group's number of columns, +/// 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, @@ -1322,14 +1311,6 @@ fn renaming_project( "substituted output with other columns than the aggregate", )); } - // One qualifier for all columns when the group's share one; a key's - // table qualifier is not compared (`Memo::add_candidate`). - let qualifier = to.fields.first().and_then(|field| field.table.clone()); - let qualifier = qualifier.filter(|table| { - to.fields - .iter() - .all(|field| field.table.as_ref() == Some(table)) - }); let cols = to .fields .iter() @@ -1341,7 +1322,6 @@ fn renaming_project( .collect(); Ok(Operator::NonASAP(NonASAPOp::Project { cols, - qualifier, child: output, })) } @@ -1398,7 +1378,6 @@ fn ranked_as_topk( .collect::, PerComputationCandidateError>>()?; Ok(Operator::NonASAP(NonASAPOp::Project { cols, - qualifier: None, child: estimate, })) } @@ -1549,11 +1528,7 @@ fn last_as_float64( }); add_group( memo, - Operator::NonASAP(NonASAPOp::Project { - cols, - qualifier: None, - child: group, - }), + Operator::NonASAP(NonASAPOp::Project { cols, child: group }), ) } @@ -1616,7 +1591,6 @@ fn sql_avg( let joined = add_group(memo, join_on_keys(memo, keys, sum, count))?; Ok(Operator::NonASAP(NonASAPOp::Project { cols, - qualifier: None, child: joined, })) } @@ -1707,7 +1681,6 @@ fn join_measures( } Ok(Operator::NonASAP(NonASAPOp::Project { cols, - qualifier: to.fields.first().and_then(|field| field.table.clone()), child: joined, })) } @@ -1724,19 +1697,23 @@ fn key_items(keys: usize) -> Vec> { /// What a measure's state reads from each row of `input`, resolvable there. /// A SQL `count` reads [`counted_column`]; any other measure, a PromQL count /// included, reads its input column (the sample value when it names none). -fn measure_input( +fn measure_input( intent: &AggIntent, - input: &I, - 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") })? @@ -1758,11 +1735,11 @@ fn measure_input( /// so it is a column non-null on every counted row: one the measure filter /// proves non-null (`count(col)` lowers to the filter `col IS NOT NULL`), else /// a non-nullable one, Float64 first (the runtime's numeric update). -fn counted_column( +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], diff --git a/crates/stage1-pass1-per-computation-candidates/src/realization.rs b/crates/stage1-pass1-per-computation-candidates/src/realization.rs index 042b8b3ee..c8a62c606 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/realization.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/realization.rs @@ -4,17 +4,17 @@ //! 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::input::OperatorInput; +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::ColumnId; use asap_types::ir::schema::{ EntityIdentity, FamilyCapability, Field, FieldDataType, NonNegativeWeightProof, Schema, SketchAlgorithm, SummaryInputExpr, SummaryUpdate, WeightDomain, }; -use asap_types::ir::{NonASAPOp, Operator}; +use asap_types::ir::{NonASAPOp, Operator, ScalarExpr}; +use asap_types::stages::{Memo, MemoGroupId}; use asap_accuracy::sizing::sketch_statistic; @@ -81,17 +81,57 @@ 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. -pub(crate) fn ranked_value( - child: &I, +pub(crate) fn ranked_value( + 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(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)) } +/// The column a top-k ranks group `child` by: over a one-measure grouped +/// aggregate (under a renaming `Project` or a value `HAVING`'s `Filter`), +/// that measure, the last column after its group keys; over per-series +/// rows, the sample value. The rule of `asap_types`' `ranked_column`, over +/// the operators a Stage 0 group holds. +fn ranked_column(memo: &Memo, child: MemoGroupId) -> Result { + let mut group = child; + let reduction = loop { + match non_asap(memo, group) { + // Only a renaming keeps the measure last. + Some(NonASAPOp::Project { cols, child: input }) + if cols.len() == schema(memo, input).fields.len() + && cols + .iter() + .enumerate() + .all(|(i, item)| matches!(item.expr, ScalarExpr::Column(c) if c == i)) => + { + group = input + } + Some(NonASAPOp::Filter { child: input, .. }) => group = input, + Some(NonASAPOp::Aggregate { + reduction, + measures, + .. + }) if measures.len() == 1 => { + if matches!(measures[0], AggIntent::TopK { .. }) { + return Ok(reduction.group_keys().map_or(0, |keys| keys.len())); + } + break Some(reduction); + } + _ => break None, + } + }; + match reduction { + Some(Reduction::Reduce(_)) => Ok(schema(memo, child).fields.len() - 1), + _ => resolve_column_ref(&ColumnRef::SampleValue, schema(memo, child)), + } +} + /// What the summary consumes per input row. An intent that reads one column (or /// none) feeds that column; `COUNT(DISTINCT a, b)` feeds the whole tuple as one /// item, so the distinct-count sketch hashes `(a, b)` rather than `a` — the @@ -124,8 +164,8 @@ pub(crate) fn summarised_input( /// consume the logical aggregate's immediate child and summarize its declared /// 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: I, +pub(crate) struct PhysicalSummaryInput { + pub(crate) child: MemoGroupId, pub(crate) input: SummaryUpdate, } @@ -134,12 +174,13 @@ pub(crate) struct PhysicalSummaryInput { /// it does not consume an independently materialized Count result. `None` /// when the rule does not apply or the shape is unsupported (each reason is /// noted where it returns). -pub(crate) fn realize_keyed_additive_summary_input( +pub(crate) fn realize_keyed_additive_summary_input( + memo: &Memo, intent: &AggIntent, family: &FieldDataType, output_reduction: &Reduction, - child: &I, -) -> Option> { + child: MemoGroupId, +) -> Option { if !matches!(intent, AggIntent::TopK { .. }) { return None; } @@ -160,7 +201,7 @@ pub(crate) fn realize_keyed_additive_summary_input( having: None, child: raw_child, .. - }) = non_asap(child) + }) = non_asap(memo, child) else { return None; }; @@ -171,7 +212,7 @@ pub(crate) fn realize_keyed_additive_summary_input( } // 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(), non_asap(&raw_child)) { + let derivative_of = match (measures.as_slice(), non_asap(memo, raw_child)) { ( [AggIntent::Sum { .. }], Some(NonASAPOp::Aggregate { @@ -184,9 +225,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.operator(), - )) + Some(raw_data_samples_of_metric(memo, samples)) } _ => None, }; @@ -194,7 +233,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 { .. }), @@ -207,7 +246,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. @@ -237,7 +280,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 { @@ -247,7 +295,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. @@ -277,7 +330,7 @@ pub(crate) fn realize_keyed_additive_summary_input( } }; Some(PhysicalSummaryInput { - child: samples.map_or_else(|| raw_child.clone(), |(rows, _)| rows), + child: samples.map_or(raw_child, |(rows, _)| rows), input: SummaryUpdate { item: Some(item), weight, @@ -292,7 +345,10 @@ 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: &I) -> Option<(I, AggIntent)> { +fn summed_per_series_samples( + memo: &Memo, + grouped: MemoGroupId, +) -> Option<(MemoGroupId, AggIntent)> { use asap_types::ir::operator::non_asap::any_measure_filtered; let Some(NonASAPOp::Aggregate { reduction: Reduction::Reduce(by), @@ -301,7 +357,7 @@ fn summed_per_series_samples(grouped: &I) -> Option<(I, AggInt having: None, child: per_series, .. - }) = non_asap(grouped) + }) = non_asap(memo, grouped) else { return None; }; @@ -315,7 +371,7 @@ fn summed_per_series_samples(grouped: &I) -> Option<(I, AggInt having: None, child: rows, .. - }) = non_asap(&per_series) + }) = non_asap(memo, per_series) else { return None; }; @@ -326,18 +382,17 @@ fn summed_per_series_samples(grouped: &I) -> Option<(I, AggInt if by.is_without() || any_measure_filtered(&filters) || any_measure_filtered(&inner_filters) - || matches!(non_asap(&rows), Some(NonASAPOp::Aggregate { .. })) + || matches!(non_asap(memo, rows), Some(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__") @@ -348,40 +403,83 @@ fn summed_per_series_samples(grouped: &I) -> Option<(I, AggInt } /// `input`'s operator when it is not an ASAP one. -pub(crate) fn non_asap(input: &I) -> Option> { - match input.operator() { - Operator::NonASAP(operator) => Some(operator), +/// Group `group`'s expression when it is not an ASAP operator. +pub(crate) fn non_asap(memo: &Memo, group: MemoGroupId) -> Option> { + match &memo.groups()[group.0 as usize].expression { + Operator::NonASAP(operator) => Some(operator.clone()), Operator::ASAP(_) => None, } } +/// 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 { + match non_asap(memo, group)? { + NonASAPOp::Scan { + source: Source::TimeSeries { metric }, + .. + } => Some(metric), + 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::{OperatorNode, 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), @@ -390,34 +488,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")), @@ -434,7 +520,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 97043654b..229aa44bc 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs @@ -2,8 +2,6 @@ //! 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::{Reduction, Source}; use asap_types::ir::operator::AggIntent; use asap_types::ir::schema::schema_support::with_promql_series_identity; @@ -25,11 +23,6 @@ pub fn metric_schema(labels: &[&str], unique_keys: Vec>) -> Schema Schema::with_time_index(columns, 0, unique_keys) } -/// A `TimeSeries("m")` scan node over [`metric_schema`], no unique key. -pub fn metric_scan(labels: &[&str]) -> Rc { - OperatorNode::new_shared(scan_operator("m", metric_schema(labels, vec![]))).unwrap() -} - /// A predicate-free `TimeSeries(metric)` scan with `schema`. pub fn scan_operator(metric: &str, schema: Schema) -> Operator { Operator::NonASAP(NonASAPOp::Scan { diff --git a/crates/types/src/stages/candidates.rs b/crates/types/src/stages/candidates.rs index a95139bb2..90166186e 100644 --- a/crates/types/src/stages/candidates.rs +++ b/crates/types/src/stages/candidates.rs @@ -196,15 +196,6 @@ impl Memo { } } - /// Group `group_id` read as an operator's input: the node rules and the - /// rules that read an input's operator take it as they take a node. - pub fn group_as_input(&self, group_id: MemoGroupId) -> MemoGroupAsInput<'_> { - MemoGroupAsInput { - memo: self, - group_id, - } - } - /// The candidate `id`. pub fn candidate(&self, id: CandidateForMemoGroupId) -> &CandidateForMemoGroup { &self.groups[id.group.0 as usize].candidates[id.index_in_group] @@ -330,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 1c035a4f5..873c276a6 100644 --- a/crates/types/src/stages/mod.rs +++ b/crates/types/src/stages/mod.rs @@ -22,9 +22,8 @@ pub use candidates::{ LogicalPropertiesError, LogicalRequirements, Memo, OriginOfCandidateForMemoGroup, WindowLayout, }; pub use logical_memo_candidates::{LogicalASAPMemoCandidates, LogicalEdge}; -pub use memo::{ - queries_rooted_at, root_groups, GroupProperties, MemoGroup, MemoGroupAsInput, MemoGroupId, -}; +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, }; 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 0159deac3..0155371c9 100644 --- a/crates/types/src/stages/per_computation_logical_memo_candidates.rs +++ b/crates/types/src/stages/per_computation_logical_memo_candidates.rs @@ -77,11 +77,14 @@ pub struct ComputationTarget { 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`. +/// 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 +/// the nested sum, a grouped `sum` over a `sum` (`ExactAccumulator([Sum])`). +/// Its build candidates list the replaced targets' groups in +/// `fused_groups`. 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 @@ -89,7 +92,7 @@ pub struct FusedComputation { /// 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. + /// computations absorb: the operators it replaces. 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 From d63c3f38f98ed585ae75648d2297d11edec1e461 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:27:05 +0000 Subject: [PATCH 06/15] =?UTF-8?q?refactor(accuracy):=20local=20guarantee?= =?UTF-8?q?=20of=20=CE=B8;=20delete=20node-tree=20guarantee=20propagation?= =?UTF-8?q?=20(#1207=20PR=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit local_guarantee reads the candidate's parameters (LogicalProperties::parameters) and is public: Pass 2 derives each listed θ's error from it. Node trees carry no θ since PR 0, so a node's guarantee cannot be composed any more: the node-tree propagation (propagation.rs with bind_guarantees and raw_data_samples_of_metric), its operator rules (composition.rs, function_rules.rs), the budget split (budget.rs) and PropagationStats are deleted. Error propagation is per edge through each consumer's error contract (LogicalRequirements::error_propagation). --- crates/accuracy/Cargo.toml | 9 +- crates/accuracy/src/budget.rs | 106 -- crates/accuracy/src/composition.rs | 1339 ------------- crates/accuracy/src/estimators/mod.rs | 60 +- crates/accuracy/src/evidence.rs | 40 +- crates/accuracy/src/function_rules.rs | 41 - crates/accuracy/src/lib.rs | 30 +- crates/accuracy/src/model.rs | 17 +- crates/accuracy/src/propagation.rs | 1681 ----------------- crates/accuracy/src/test_support.rs | 66 - .../tests/promql_numeric_regressions.rs | 50 - .../src/memo/rules/summary_inputs.rs | 12 - 12 files changed, 64 insertions(+), 3387 deletions(-) delete mode 100644 crates/accuracy/src/budget.rs delete mode 100644 crates/accuracy/src/composition.rs delete mode 100644 crates/accuracy/src/function_rules.rs delete mode 100644 crates/accuracy/src/propagation.rs delete mode 100644 crates/stage1-pass2-reuse-candidates/src/memo/rules/summary_inputs.rs diff --git a/crates/accuracy/Cargo.toml b/crates/accuracy/Cargo.toml index 03965917f..8fe0cd014 100644 --- a/crates/accuracy/Cargo.toml +++ b/crates/accuracy/Cargo.toml @@ -4,9 +4,8 @@ version = "0.1.0" edition = "2021" # The accuracy model shared by the planning stages: per-family estimators, -# guarantee composition and propagation through a DAG, sizing, budget -# split, the target check and the -# deployment's accuracy model and evidence traits. Depends only on +# sizing, the target check and the deployment's accuracy model and +# evidence traits. Depends only on # `asap-types`, never on a stage crate, so every stage can use it. [dependencies] asap_sketchlib = { workspace = true } @@ -14,8 +13,8 @@ asap-types = { path = "../types" } serde_json = "1" [features] -# Exposes `test_support`: estimator, sizing, composition and propagation -# helpers other crates' tests call. +# Exposes `test_support`: estimator, sizing and target-check helpers other +# crates' tests call. test-support = [] [dev-dependencies] diff --git a/crates/accuracy/src/budget.rs b/crates/accuracy/src/budget.rs deleted file mode 100644 index 562529301..000000000 --- a/crates/accuracy/src/budget.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Split an end-to-end accuracy target equally among the approximate inputs -//! of one composition (#669, part b). -//! -//! When `n` approximate values compose into one output, each is sized for an -//! equal share of the output's target, so the composed bound meets the -//! target: `ε_i = ε / n` for an additively composed metric (no slack), -//! `ε_i = 1 − (1 + ε)^{−1/n}` for a relative one, and `δ_i = δ / n` (the -//! union bound). Relative errors compose through products and quotients: -//! each input contributes a factor of at most `1 + ε_i` as a factor and -//! `1/(1 − ε_i)` as a divisor, so the true error of any mix stays within -//! `(1 − ε_i)^{−n} − 1 = ε`. The composed bound Stage 3 checks meets it for -//! one product or quotient of two inputs (a ratio's `(a + b)/(1 − b)`), not -//! for a nested quotient such as `x / (y / z)`, whose bound composes each -//! step's slack. A lone approximate denominator over an exact numerator is -//! sized for the whole target, so its quotient's bound `ε/(1 − ε)` misses -//! it. Only equal splits are made. -use super::*; - -/// The target of one of `approximate_inputs` approximate inputs that -/// compose into a value with `target`, split equally in `metric`'s -/// composition (see the module doc). `None` for an exact target or an ε -/// that is not positive and finite: no approximate input meets it. One -/// input (or none) gets `target` itself. -pub fn per_input_target( - target: &AccuracyTarget, - metric: AccuracyMetric, - approximate_inputs: usize, -) -> Option { - if target.is_exact() || !(target.max_error.is_finite() && target.max_error > 0.0) { - return None; - } - if approximate_inputs <= 1 { - return Some(target.clone()); - } - let fraction = 1.0 / approximate_inputs.max(1) as f64; - let max_error = match metric { - AccuracyMetric::Relative => 1.0 - (1.0 + target.max_error).powf(-fraction), - _ => target.max_error * fraction, - }; - Some(AccuracyTarget { - max_error, - failure_probability: target.failure_probability.map(|delta| delta * fraction), - ..target.clone() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Additive metrics split ε and δ by `n`; the `n` shares add up to the - /// target. - #[test] - fn an_additive_target_splits_epsilon_and_delta_equally() { - let target = AccuracyTarget::max_error(0.1).failure_probability(0.02); - let share = per_input_target(&target, AccuracyMetric::ABSOLUTE, 2).unwrap(); - assert_eq!( - share, - AccuracyTarget::max_error(0.05).failure_probability(0.01) - ); - assert_eq!( - per_input_target(&target, AccuracyMetric::Rank, 1), - Some(target.clone()) - ); - assert_eq!( - per_input_target(&AccuracyTarget::max_error(0.1), AccuracyMetric::Rank, 2) - .unwrap() - .failure_probability, - None - ); - } - - /// A relative target splits so that a quotient meets it too: - /// `(1 − ε_i)^{−n} = 1 + ε`. For ε = 0.05 over two inputs each gets - /// 0.0241, and both a ratio `(a + b)/(1 − b)` and a product - /// `(1 + a)(1 + b) − 1` of them stay within 0.05; the product-only split - /// `(1 + ε)^{1/2} − 1 = 0.0247` would give the ratio 0.0507. - #[test] - fn a_relative_target_splits_so_a_quotient_meets_it() { - let target = AccuracyTarget::max_error(0.05); - let share = per_input_target(&target, AccuracyMetric::Relative, 2) - .unwrap() - .max_error; - assert!((share - (1.0 - 1.05f64.powf(-0.5))).abs() < 1e-12); - let ratio = (share + share) / (1.0 - share); - let product = (1.0 + share) * (1.0 + share) - 1.0; - assert!(ratio <= 0.05 && product <= 0.05, "{ratio} {product}"); - assert_eq!( - per_input_target(&target, AccuracyMetric::Relative, 1), - Some(target) - ); - } - - /// No approximate input meets an exact target. - #[test] - fn an_exact_target_has_no_share() { - assert_eq!( - per_input_target(&AccuracyTarget::exact(), AccuracyMetric::ABSOLUTE, 2), - None - ); - assert_eq!( - per_input_target(&AccuracyTarget::max_error(0.0), AccuracyMetric::ABSOLUTE, 2), - None - ); - } -} diff --git a/crates/accuracy/src/composition.rs b/crates/accuracy/src/composition.rs deleted file mode 100644 index 3776f485b..000000000 --- a/crates/accuracy/src/composition.rs +++ /dev/null @@ -1,1339 +0,0 @@ -//! Registered propagation rules for computations over uncertain inputs. -//! Stage 1 composes each candidate's guarantees with them -//! (`per_computation_candidates`); a rule that needs a statistic leaves an -//! `Unknown` leaf named by one of the constants below, which Stage 3 binds. -use super::*; - -/// Rows an exact aggregation folds (per group): bounded by the series -/// count of the sources the query reads. -pub const INPUT_ROW_COUNT: &str = "input_row_count"; -/// Rows one group of a SQL fold over an approximate relation folds: not -/// bounded by the series count of the sources it reads, so plan selection -/// leaves it unbound. -pub(crate) const GROUP_ROW_COUNT: &str = "group_row_count"; -/// Whether every input value is non-negative. -pub(crate) const VALUES_NON_NEGATIVE: &str = "values_non_negative"; -/// The absolute bound of a product of two estimates, -/// `|x|·e_y + |y|·e_x + e_x·e_y`, given enforced operand domains. -pub const PRODUCT_OPERAND_DOMAINS: &str = "product_operand_domains"; -/// The absolute bound of a quotient with an approximate denominator, -/// `(|x|·e_y + |y|·e_x) / (|y|·(|y| − e_y))`, given enforced operand domains -/// with the denominator's bounded away from zero. -pub(crate) const QUOTIENT_OPERAND_DOMAINS: &str = "quotient_operand_domains"; -/// `‖f‖₁` of the stream a Count-Min bound is normalized by. -pub const STREAM_L1_NORM: &str = "stream_l1_norm"; -/// `‖f‖₂` of the stream a Count Sketch bound is normalized by. -pub(crate) const STREAM_L2_NORM: &str = "stream_l2_norm"; -/// Rows of one source's stream a summary folds: with a constant update -/// weight `c`, `‖f‖₁ = |c| · rows` and `‖f‖₂ ≤ ‖f‖₁`. -pub(crate) const STREAM_ROW_COUNT: &str = "stream_row_count"; - -/// Which stream norm bounds a Count-Min or Count Sketch estimate, given its -/// summary's update weight. -#[derive(Debug, Clone, PartialEq)] -pub enum StreamNorm { - /// The weight is the source's raw value column: the source's norm - /// (`source_norm_statistic`). - Source(String), - /// A constant weight (a count): `|weight|` times the source's row count. - ConstantWeight { weight: f64, source: String }, -} - -/// The statistic for `norm` ([`STREAM_L1_NORM`] or [`STREAM_L2_NORM`]) of -/// one source's stream, e.g. `stream_l1_norm[m]`. -pub(crate) fn source_norm_statistic(norm: &str, source: &str) -> String { - format!("{norm}[{source}]") -} - -/// Whether every value folded is non-negative, for values that are raw -/// samples of `metric` or plain sums of them, e.g. -/// `values_non_negative[m]`: an indicator, 1 when proven. Plan selection -/// binds it to 1 when the data workload declares `metric` a counter or -/// non-negative, and leaves it unbound otherwise. -pub(crate) fn raw_data_samples_sign_statistic(metric: &str) -> String { - format!("{VALUES_NON_NEGATIVE}[{metric}]") -} - -/// The metric a `raw_data_samples_sign_statistic` names. -pub fn parse_raw_data_samples_sign_statistic(statistic: &str) -> Option<&str> { - statistic - .strip_prefix(VALUES_NON_NEGATIVE)? - .strip_prefix('[')? - .strip_suffix(']') -} - -/// The norm and source a `source_norm_statistic` names. -pub fn parse_source_norm_statistic(statistic: &str) -> Option<(&'static str, &str)> { - [STREAM_L1_NORM, STREAM_L2_NORM] - .into_iter() - .find_map(|norm| { - let source = statistic - .strip_prefix(norm)? - .strip_prefix('[')? - .strip_suffix(']')?; - Some((norm, source)) - }) -} - -fn additive( - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], - local: &AccuracyGuarantee, - rule: &str, -) -> AccuracyGuarantee { - let mut terms: Vec = inputs.iter().map(|g| g.spec.error.clone()).collect(); - terms.push(local.spec.error.clone()); - let mut deltas: Vec = inputs - .iter() - .map(|g| g.spec.failure_probability.clone()) - .collect(); - deltas.push(local.spec.failure_probability.clone()); - AccuracyGuarantee::new( - local.spec.accuracy_metric, - BoundExpr::Sum { terms }, - ProbabilityExpr::UnionBound { terms: deltas }, - composed_provenance(op, inputs, local, rule), - ) -} - -/// `(1 + ε_total) = Π (1 + ε_i)` ⇒ for two factors -/// `ε_in + ε_out + ε_in·ε_out`; written out as the sum of all -/// cross-products so the expression DAG is exact for any input count. -fn multiplicative( - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], - local: &AccuracyGuarantee, -) -> AccuracyGuarantee { - let factors: Vec<&BoundExpr> = inputs - .iter() - .map(|g| &g.spec.error) - .chain(std::iter::once(&local.spec.error)) - .collect(); - // Every non-empty subset's product: Π(1+ε_i) − 1 = Σ_{S≠∅} Π_{i∈S} ε_i. - let mut terms = Vec::new(); - for mask in 1..(1u32 << factors.len()) { - let subset: Vec = factors - .iter() - .enumerate() - .filter(|(i, _)| mask & (1 << i) != 0) - .map(|(_, b)| (*b).clone()) - .collect(); - terms.push(if subset.len() == 1 { - subset.into_iter().next().expect("one element") - } else { - BoundExpr::Product { factors: subset } - }); - } - let mut deltas: Vec = inputs - .iter() - .map(|g| g.spec.failure_probability.clone()) - .collect(); - deltas.push(local.spec.failure_probability.clone()); - AccuracyGuarantee::new( - AccuracyMetric::Relative, - BoundExpr::Sum { terms }, - ProbabilityExpr::UnionBound { terms: deltas }, - composed_provenance(op, inputs, local, "relative_cross_term_union_bound"), - ) -} - -fn lipschitz( - op: &CompositionOperator, - constant: f64, - inputs: &[AccuracyGuarantee], - local: Option<&AccuracyGuarantee>, -) -> AccuracyGuarantee { - let input = &inputs[0]; - let scaled = BoundExpr::Scaled { - factor: constant, - inner: Box::new(input.spec.error.clone()), - }; - let (bound, delta) = match local { - Some(local) => ( - BoundExpr::Sum { - terms: vec![scaled, local.spec.error.clone()], - }, - ProbabilityExpr::UnionBound { - terms: vec![ - input.spec.failure_probability.clone(), - local.spec.failure_probability.clone(), - ], - }, - ), - None => (scaled, input.spec.failure_probability.clone()), - }; - let exact_local = AccuracyGuarantee::exact("deterministic Lipschitz transformation"); - AccuracyGuarantee::new( - AccuracyMetric::ABSOLUTE, - bound, - delta, - composed_provenance( - op, - inputs, - local.unwrap_or(&exact_local), - "lipschitz_union_bound", - ), - ) -} - -/// Exact `sum` over approximate inputs: `B ≤ Σ B_i`, `δ ≤ Σ δ_i`. The -/// planner composes one *per-value* child guarantee over an unknown -/// number of input rows, so both the bound and the union bound scale by -/// `stats.input_row_count` — an [`BoundExpr::Unknown`] leaf when it is -/// not supplied. Each input's normalized bound is first converted to -/// absolute units via the statistic its metric is normalized by (also -/// unknown unless supplied); a `Rank` input has no such conversion. -fn exact_sum( - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], - stats: &PropagationStats, -) -> Result { - if all_relative(inputs) { - return relative_fold(op, inputs, stats, true, "exact_sum_relative"); - } - let mut terms = Vec::with_capacity(inputs.len()); - let mut deltas = Vec::with_capacity(inputs.len()); - let mut provenance = Vec::new(); - for (i, input) in inputs.iter().enumerate() { - let absolute = - absolute_bound(input).ok_or_else(|| AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: inputs.iter().map(|g| g.spec.accuracy_metric).collect(), - local_metric: None, - reason: format!( - "input {i} carries a {:?} guarantee, which has no registered \ - conversion to an absolute value error", - input.spec.accuracy_metric - ), - })?; - if let BoundExpr::Product { factors } = &absolute { - for f in factors { - if let BoundExpr::Unknown { statistic } = f { - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: statistic.clone(), - }); - } - } - } - terms.push(absolute); - deltas.push(input.spec.failure_probability.clone()); - } - let count = row_count(stats, &mut provenance); - let exact_local = AccuracyGuarantee::exact("ExactAggregate(Sum)"); - provenance.extend(composed_provenance( - op, - inputs, - &exact_local, - "exact_sum_union_bound", - )); - Ok(AccuracyGuarantee::new( - AccuracyMetric::ABSOLUTE, - BoundExpr::Product { - factors: vec![count.clone(), BoundExpr::Sum { terms }], - }, - ProbabilityExpr::Scaled { - count, - inner: Box::new(ProbabilityExpr::UnionBound { terms: deltas }), - }, - provenance, - )) -} - -/// Exact arithmetic mean over values with absolute-error guarantees. -/// Averaging cannot amplify the largest absolute input error. The event -/// that every row respects its bound is still protected conservatively -/// by a union bound over the input row count. -fn exact_average( - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], - stats: &PropagationStats, -) -> Result { - if all_relative(inputs) { - return relative_fold(op, inputs, stats, true, "exact_average_relative"); - } - if inputs - .iter() - .any(|input| input.spec.accuracy_metric != AccuracyMetric::ABSOLUTE) - { - return Err(AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: inputs.iter().map(|g| g.spec.accuracy_metric).collect(), - local_metric: None, - reason: "exact average requires absolute-error input guarantees".into(), - }); - } - let mut provenance = Vec::new(); - let count = row_count(stats, &mut provenance); - let exact_local = AccuracyGuarantee::exact("ExactAggregate(Average)"); - provenance.extend(composed_provenance( - op, - inputs, - &exact_local, - "exact_average_union_bound", - )); - Ok(AccuracyGuarantee::new( - AccuracyMetric::ABSOLUTE, - BoundExpr::Max { - terms: inputs.iter().map(|g| g.spec.error.clone()).collect(), - }, - ProbabilityExpr::Scaled { - count, - inner: Box::new(ProbabilityExpr::UnionBound { - terms: inputs - .iter() - .map(|g| g.spec.failure_probability.clone()) - .collect(), - }), - }, - provenance, - )) -} - -/// Exact `max`/`min` over approximate inputs of one shared metric: the -/// returned value's error is at most the largest input bound (order -/// statistics are monotone under a uniform perturbation), with -/// probability by the union bound over every input row. This bounds the -/// returned *value*; it does not identify the true winning key. -fn exact_extremum( - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], - stats: &PropagationStats, -) -> Result { - if all_relative(inputs) { - return relative_fold(op, inputs, stats, false, "exact_extremum_relative"); - } - let metric = inputs[0].spec.accuracy_metric; - if inputs - .iter() - .any(|g| g.spec.accuracy_metric != AccuracyMetric::ABSOLUTE) - { - return Err(AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: inputs.iter().map(|g| g.spec.accuracy_metric).collect(), - local_metric: None, - reason: "exact max/min requires absolute-error input guarantees".into(), - }); - } - let mut provenance = Vec::new(); - let count = row_count(stats, &mut provenance); - let exact_local = AccuracyGuarantee::exact("ExactAggregate(Max)"); - provenance.extend(composed_provenance( - op, - inputs, - &exact_local, - "exact_extremum_union_bound", - )); - Ok(AccuracyGuarantee::new( - metric, - BoundExpr::Max { - terms: inputs.iter().map(|g| g.spec.error.clone()).collect(), - }, - ProbabilityExpr::Scaled { - count, - inner: Box::new(ProbabilityExpr::UnionBound { - terms: inputs - .iter() - .map(|g| g.spec.failure_probability.clone()) - .collect(), - }), - }, - provenance, - )) -} - -/// An exact sum, mean, max or min of values each within relative `α_i` -/// is within relative `max α_i`. A max or min holds whatever the signs; -/// a sum or mean only over non-negative values (no cancellation), so -/// with `needs_sign` and the sign unknown the bound is the symbolic -/// [`VALUES_NON_NEGATIVE`]. The failure probability is the union bound -/// over every input row. -fn relative_fold( - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], - stats: &PropagationStats, - needs_sign: bool, - rule: &str, -) -> Result { - if needs_sign && stats.values_non_negative == Some(false) { - return Err(AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: inputs.iter().map(|g| g.spec.accuracy_metric).collect(), - local_metric: None, - reason: "relative error of a sum over signed values is unbounded".into(), - }); - } - let mut provenance = Vec::new(); - let count = row_count(stats, &mut provenance); - let bound = if needs_sign && stats.values_non_negative.is_none() { - match &stats.sign_statistic { - // The sign of one metric's samples: the bound, gated on it. - Some(statistic) => { - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: statistic.clone(), - }); - BoundExpr::Product { - factors: vec![ - BoundExpr::Max { - terms: inputs.iter().map(|g| g.spec.error.clone()).collect(), - }, - BoundExpr::Unknown { - statistic: statistic.clone(), - }, - ], - } - } - None => { - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: VALUES_NON_NEGATIVE.into(), - }); - BoundExpr::Unknown { - statistic: VALUES_NON_NEGATIVE.into(), - } - } - } - } else { - BoundExpr::Max { - terms: inputs.iter().map(|g| g.spec.error.clone()).collect(), - } - }; - provenance.extend(composed_provenance( - op, - inputs, - &AccuracyGuarantee::exact("exact aggregation"), - rule, - )); - Ok(AccuracyGuarantee::new( - AccuracyMetric::Relative, - bound, - ProbabilityExpr::Scaled { - count, - inner: Box::new(ProbabilityExpr::UnionBound { - terms: inputs - .iter() - .map(|g| g.spec.failure_probability.clone()) - .collect(), - }), - }, - provenance, - )) -} - -/// Exact division of two relative-value estimates (an exact operand has -/// bound 0). If the numerator is within relative `a` and the denominator -/// within `b < 1`, the ratio is within -/// `max((1 + a)/(1 − b) − 1, 1 − (1 − a)/(1 + b)) = (a + b)/(1 − b)`, and -/// fails with the union bound of their failure probabilities. No operand -/// domain is needed: a relative bound below 1 keeps a zero denominator at -/// zero and a numerator's sign, so the true and estimated ratios are then -/// the same non-finite value. Both bounds must be constants: a symbolic one -/// has no rule. -/// Float64 overflow and underflow of the quotient are not bounded, as for a -/// product. -fn exact_division( - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], -) -> Result { - let unsupported = |reason: String| AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: inputs.iter().map(|g| g.spec.accuracy_metric).collect(), - local_metric: None, - reason, - }; - if inputs.len() != 2 - || inputs.iter().any(|input| { - input.spec.accuracy_metric != AccuracyMetric::Relative && !input.is_exact() - }) - { - return Err(unsupported( - "division needs two relative-value or exact guarantees".into(), - )); - } - let Some(numerator) = inputs[0].spec.error.evaluate() else { - return Err(unsupported( - "numerator relative bound is unavailable".into(), - )); - }; - let Some(denominator) = inputs[1].spec.error.evaluate() else { - return Err(unsupported( - "denominator relative bound is unavailable".into(), - )); - }; - if !(numerator.is_finite() && numerator >= 0.0 && (0.0..1.0).contains(&denominator)) { - return Err(unsupported( - "division needs finite non-negative bounds and a denominator bound below one".into(), - )); - } - Ok(AccuracyGuarantee::new( - AccuracyMetric::Relative, - BoundExpr::Constant { - value: (numerator + denominator) / (1.0 - denominator), - }, - ProbabilityExpr::UnionBound { - terms: inputs - .iter() - .map(|input| input.spec.failure_probability.clone()) - .collect(), - }, - composed_provenance( - op, - inputs, - &AccuracyGuarantee::exact("exact division"), - "relative_division_union_bound", - ), - )) -} - -/// Whether `guarantee` is a sketch's distinct-count estimate. -fn estimates_distinct_count(guarantee: &AccuracyGuarantee) -> bool { - let cardinality = format!("{:?}", asap_types::ir::schema::SketchStatistic::Cardinality); - guarantee.provenance.iter().any(|source| { - matches!(source, GuaranteeSource::SketchEvaluation { query, .. } if *query == cardinality) - }) -} - -/// Every input carries a relative-value guarantee. -fn all_relative(inputs: &[AccuracyGuarantee]) -> bool { - !inputs.is_empty() - && inputs - .iter() - .all(|g| g.spec.accuracy_metric == AccuracyMetric::Relative) -} - -/// `stats.input_row_count` as a bound factor, or an `Unknown` leaf named by -/// `stats.row_count_statistic` (recorded in `provenance`) when absent. -fn row_count(stats: &PropagationStats, provenance: &mut Vec) -> BoundExpr { - match stats.input_row_count { - Some(n) => BoundExpr::Constant { value: n as f64 }, - None => { - let statistic = stats.row_count_statistic.unwrap_or(INPUT_ROW_COUNT); - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: statistic.into(), - }); - BoundExpr::Unknown { - statistic: statistic.into(), - } - } - } -} - -/// `input` with a Count-Min ([`AccuracyMetric::ABSOLUTE_STREAM_L1`]) or Count Sketch -/// ([`AccuracyMetric::ABSOLUTE_STREAM_L2`]) bound restated as an additive absolute -/// error: the normalized bound times its stream norm, a symbolic statistic -/// until bound. The norm is the one `norm` names ([`StreamNorm`]), which -/// plan selection can bind; without one (a weight computed from the -/// values) it is a statistic nothing binds. Any other guarantee is -/// returned unchanged. -pub(crate) fn additive_as_absolute( - input: AccuracyGuarantee, - norm: Option<&StreamNorm>, -) -> AccuracyGuarantee { - if !matches!( - input.spec.accuracy_metric, - AccuracyMetric::ABSOLUTE_STREAM_L1 | AccuracyMetric::ABSOLUTE_STREAM_L2 - ) || input.is_exact() - { - return input; - } - let mut bound = absolute_bound(&input).expect("additive metrics convert to absolute errors"); - if let (Some(norm), BoundExpr::Product { factors }) = (norm, &mut bound) { - for factor in factors { - if let BoundExpr::Unknown { statistic } = factor { - *factor = match norm { - StreamNorm::Source(source) => BoundExpr::Unknown { - statistic: source_norm_statistic(statistic, source), - }, - StreamNorm::ConstantWeight { weight, source } => BoundExpr::Product { - factors: vec![ - BoundExpr::Constant { - value: weight.abs(), - }, - BoundExpr::Unknown { - statistic: source_norm_statistic(STREAM_ROW_COUNT, source), - }, - ], - }, - }; - } - } - } - let mut provenance = input.provenance; - unknown_statistics(&bound, &mut |statistic| { - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: statistic.into(), - }) - }); - let mut absolute = AccuracyGuarantee { - provenance, - ..input - }; - absolute.spec.accuracy_metric = AccuracyMetric::ABSOLUTE; - absolute.spec.error = bound; - absolute -} - -/// Every `Unknown` statistic in `bound`. -fn unknown_statistics(bound: &BoundExpr, found: &mut dyn FnMut(&str)) { - match bound { - BoundExpr::Unknown { statistic } => found(statistic), - BoundExpr::Sum { terms } - | BoundExpr::Product { factors: terms } - | BoundExpr::Max { terms } => { - for term in terms { - unknown_statistics(term, found); - } - } - BoundExpr::Scaled { inner, .. } => unknown_statistics(inner, found), - _ => {} - } -} - -/// `input`'s bound converted to absolute value units, multiplying a -/// normalized metric by the (unknown) statistic it is normalized by. `None` -/// for a metric with no such conversion (`Rank`). -fn absolute_bound(input: &AccuracyGuarantee) -> Option { - let normalizer = match input.spec.accuracy_metric { - AccuracyMetric::ABSOLUTE => return Some(input.spec.error.clone()), - AccuracyMetric::Relative => "true_value_magnitude", - AccuracyMetric::ABSOLUTE_STREAM_L1 => STREAM_L1_NORM, - AccuracyMetric::ABSOLUTE_STREAM_L2 => STREAM_L2_NORM, - // `Rank` has no distribution-free conversion to a value error; a - // metric this crate does not know has no registered conversion. - AccuracyMetric::Rank | _ => return None, - }; - if input.spec.error.is_zero() { - return Some(BoundExpr::Zero); - } - Some(BoundExpr::Product { - factors: vec![ - input.spec.error.clone(), - BoundExpr::Unknown { - statistic: normalizer.into(), - }, - ], - }) -} - -fn composed_provenance( - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], - local: &AccuracyGuarantee, - rule: &str, -) -> Vec { - let mut provenance: Vec = inputs - .iter() - .enumerate() - .map(|(input_index, g)| GuaranteeSource::ChildGuarantee { - input_index, - guarantee: Box::new(g.clone()), - }) - .collect(); - provenance.extend(local.provenance.iter().cloned()); - provenance.push(GuaranteeSource::CompositionStep { - operator: op.clone(), - rule: rule.into(), - }); - provenance -} - -/// The built-in composition rule: `inputs`' guarantees (in the parent's -/// child order) composed with the parent's own `local` guarantee under `op`, -/// given the statistics in `stats` (all unknown in Stage 1; plan selection -/// binds some). `Err` is the fail-closed answer: no registered rule, or a -/// missing input guarantee. It means "no rule", not "infeasible": Pass 1 -/// keeps the candidate with `guarantee = None`. -pub(crate) fn propagate( - op: &CompositionOperator, - inputs: &[AccuracyGuarantee], - local: Option<&AccuracyGuarantee>, - stats: &PropagationStats, -) -> Result { - // Exact input: only the local guarantee remains (or the value is exact). - if inputs.iter().all(AccuracyGuarantee::is_exact) - && !matches!(op, CompositionOperator::TopKSelection) - { - return Ok(match local { - Some(local) => { - let mut out = local.clone(); - out.provenance - .extend(inputs.iter().enumerate().map(|(input_index, g)| { - GuaranteeSource::ChildGuarantee { - input_index, - guarantee: Box::new(g.clone()), - } - })); - out.provenance.push(GuaranteeSource::CompositionStep { - operator: op.clone(), - rule: "exact_input".into(), - }); - out - } - None => { - let mut out = AccuracyGuarantee::exact(format!("{op:?} over exact inputs")); - out.provenance.push(GuaranteeSource::CompositionStep { - operator: op.clone(), - rule: "exact_input".into(), - }); - out - } - }); - } - - let input_metrics: Vec = - inputs.iter().map(|g| g.spec.accuracy_metric).collect(); - let unsupported = |reason: String| AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: input_metrics.clone(), - local_metric: local.map(|g| g.spec.accuracy_metric), - reason, - }; - // An exact input is compatible with every metric; only approximate - // inputs constrain the rule. - let approximate: Vec<&AccuracyGuarantee> = inputs.iter().filter(|g| !g.is_exact()).collect(); - let same_metric = - |metric: AccuracyMetric| approximate.iter().all(|g| g.spec.accuracy_metric == metric); - - match op { - CompositionOperator::CheckedRelativeDivision => { - if inputs.len() != 2 || local.is_some() || !same_metric(AccuracyMetric::Relative) { - return Err(unsupported( - "checked division requires two exact/relative-value operands".into(), - )); - } - let a = inputs[0] - .spec - .error - .evaluate() - .ok_or_else(|| unsupported("unknown numerator bound".into()))?; - let b = inputs[1] - .spec - .error - .evaluate() - .ok_or_else(|| unsupported("unknown denominator bound".into()))?; - if !(0.0..1.0).contains(&b) || a < 0.0 || !a.is_finite() { - return Err(unsupported("invalid relative division bounds".into())); - } - Ok(AccuracyGuarantee::new( - AccuracyMetric::Relative, - BoundExpr::Constant { - value: (a + b) / (1.0 - b) + 4.0 * f64::EPSILON, - }, - ProbabilityExpr::UnionBound { - terms: inputs - .iter() - .map(|g| g.spec.failure_probability.clone()) - .collect(), - }, - composed_provenance( - op, - inputs, - &AccuracyGuarantee::exact("checked floating-point division"), - "checked_relative_division_union_bound", - ), - )) - } - CompositionOperator::ApproximateAggregate => { - let local = local.ok_or_else(|| { - unsupported("approximate operator has no local guarantee to compose".into()) - })?; - if !same_metric(local.spec.accuracy_metric) { - return Err(unsupported(format!( - "no registered cross-metric rule from {input_metrics:?} to {:?}", - local.spec.accuracy_metric - ))); - } - // A distinct count over approximate values is not within the - // inputs' relative error: no rule, as for any summary whose - // estimate is not a value of its inputs. - if estimates_distinct_count(local) { - return Err(unsupported( - "no registered rule for a distinct count over approximate values".into(), - )); - } - match local.spec.accuracy_metric { - AccuracyMetric::ABSOLUTE => Ok(additive(op, inputs, local, "additive_union_bound")), - AccuracyMetric::Relative => { - if stats.values_non_negative == Some(false) { - return Err(unsupported( - "relative-error composition cannot use a known signed input".into(), - )); - } - if stats.values_non_negative.is_none() { - let mut provenance = composed_provenance( - op, - inputs, - local, - "relative_value_sign_unverified", - ); - provenance.extend(stats.evidence_provenance.clone()); - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: VALUES_NON_NEGATIVE.into(), - }); - return Ok(AccuracyGuarantee::new( - AccuracyMetric::Relative, - BoundExpr::Unknown { - statistic: VALUES_NON_NEGATIVE.into(), - }, - ProbabilityExpr::Unknown { - statistic: VALUES_NON_NEGATIVE.into(), - }, - provenance, - )); - } - Ok(multiplicative(op, inputs, local)) - } - AccuracyMetric::Rank - | AccuracyMetric::ABSOLUTE_STREAM_L1 - | AccuracyMetric::ABSOLUTE_STREAM_L2 - | _ => Err(unsupported(format!( - "no registered same-metric composition rule for {:?} over {:?}", - local.spec.accuracy_metric, local.spec.accuracy_metric - ))), - } - } - CompositionOperator::Lipschitz { constant } => { - if !(constant.is_finite() && *constant >= 0.0) { - return Err(unsupported(format!( - "Lipschitz constant {constant} is not a finite non-negative number" - ))); - } - if inputs.len() != 1 || !same_metric(AccuracyMetric::ABSOLUTE) { - return Err(unsupported( - "Lipschitz rule is registered for exactly one absolute-error input".into(), - )); - } - if local.is_some_and(|g| g.spec.accuracy_metric != AccuracyMetric::ABSOLUTE) { - return Err(unsupported( - "Lipschitz rule needs an absolute-error local guarantee".into(), - )); - } - Ok(lipschitz(op, *constant, inputs, local)) - } - CompositionOperator::ExactSum => exact_sum(op, inputs, stats), - CompositionOperator::ExactAverage => exact_average(op, inputs, stats), - CompositionOperator::ExactExtremum => exact_extremum(op, inputs, stats), - CompositionOperator::ExactDivision => exact_division(op, inputs), - CompositionOperator::CounterRate - | CompositionOperator::InstantCounterRate - | CompositionOperator::CounterIncrease => Err(unsupported( - "counter reset detection and boundary extrapolation have no distribution-free \ - accuracy bound over approximate samples; exact samples remain exact" - .into(), - )), - CompositionOperator::TopKSelection => { - let selected = stats.topk_selected_lower_bound; - let excluded = stats.topk_excluded_upper_bound; - let delta = stats.topk_interval_failure_probability; - if selected.is_some_and(|value| !value.is_finite()) - || excluded.is_some_and(|value| !value.is_finite()) - || delta.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) - || selected - .zip(excluded) - .is_some_and(|(lower, upper)| lower <= upper) - { - return Err(unsupported( - "top-k confidence intervals overlap or contain invalid evidence".into(), - )); - } - let mut provenance = inputs - .iter() - .enumerate() - .map(|(input_index, guarantee)| GuaranteeSource::ChildGuarantee { - input_index, - guarantee: Box::new(guarantee.clone()), - }) - .collect::>(); - provenance.extend(stats.evidence_provenance.clone()); - if let Some(local) = local { - provenance.extend(local.provenance.clone()); - } - for (name, missing) in [ - ("topk_selected_lower_bound", selected.is_none()), - ("topk_excluded_upper_bound", excluded.is_none()), - ("topk_interval_failure_probability", delta.is_none()), - ] { - if missing { - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: name.into(), - }); - } - } - provenance.push(GuaranteeSource::CompositionStep { - operator: op.clone(), - rule: "topk_membership_margin_certificate".into(), - }); - let certified = selected.is_some() && excluded.is_some() && delta.is_some(); - // A certified margin makes the returned key set the true one, so - // each returned value keeps its score's error; without one, - // membership and hence the values are unknown. - let metric = approximate - .first() - .map_or(AccuracyMetric::ABSOLUTE, |score| score.spec.accuracy_metric); - if !same_metric(metric) { - return Err(unsupported( - "top-k selection needs scores of one metric".into(), - )); - } - let error = if certified { - BoundExpr::Max { - terms: inputs.iter().map(|g| g.spec.error.clone()).collect(), - } - } else { - BoundExpr::Unknown { - statistic: "topk_membership_margin".into(), - } - }; - Ok(AccuracyGuarantee::new( - metric, - error, - delta.map_or_else( - || ProbabilityExpr::Unknown { - statistic: "topk_interval_failure_probability".into(), - }, - |value| ProbabilityExpr::Constant { value }, - ), - provenance, - )) - } - // An operator this crate does not know has no registered rule. - _ => Err(unsupported("no registered rule for this operator".into())), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A source-norm statistic names its norm and source, whatever - /// characters the source holds; any other statistic is not one. - #[test] - fn source_norm_statistics_round_trip() { - for source in ["m", "a[b]", "]x[", "t.c", ""] { - for norm in [STREAM_L1_NORM, STREAM_L2_NORM] { - let statistic = source_norm_statistic(norm, source); - assert_eq!( - parse_source_norm_statistic(&statistic), - Some((norm, source)) - ); - } - } - for other in [ - INPUT_ROW_COUNT, - STREAM_L1_NORM, - "stream_l3_norm[m]", - "stream_l1_norm[m", - ] { - assert_eq!(parse_source_norm_statistic(other), None, "{other}"); - } - } - fn abs(bound: f64, delta: f64) -> AccuracyGuarantee { - AccuracyGuarantee::new( - AccuracyMetric::ABSOLUTE, - BoundExpr::Constant { value: bound }, - ProbabilityExpr::Constant { value: delta }, - vec![], - ) - } - fn rel(bound: f64) -> AccuracyGuarantee { - AccuracyGuarantee::new( - AccuracyMetric::Relative, - BoundExpr::Constant { value: bound }, - ProbabilityExpr::Zero, - vec![], - ) - } - fn with_metric(metric: AccuracyMetric, bound: f64) -> AccuracyGuarantee { - let mut g = abs(bound, 0.0); - g.spec.accuracy_metric = metric; - g - } - #[test] - fn checked_division_propagates_value_bounds_and_rejects_rank_bounds() { - let op = CompositionOperator::CheckedRelativeDivision; - let inputs = [rel(0.01), rel(0.01)]; - let g = propagate(&op, &inputs, None, &Default::default()).unwrap(); - assert!((g.spec.error.evaluate().unwrap() - 0.02 / 0.99).abs() < 1e-14); - let mut rank = inputs[0].clone(); - rank.spec.accuracy_metric = AccuracyMetric::Rank; - assert!(propagate(&op, &[rank.clone(), rank], None, &Default::default()).is_err()); - assert!(propagate(&op, &[rel(0.01), rel(1.0)], None, &Default::default()).is_err()); - } - /// A ratio of relative bounds `a`, `b` is within `(a + b)/(1 − b)`, - /// the larger of its over- and under-estimate bounds, with the union - /// bound on failure; no operand domain is needed. A denominator bound of - /// 1 or more, or a bound that is not finite and non-negative, has no - /// rule. - #[test] - fn relative_division_bounds_the_ratio_without_operand_domains() { - let mut numerator = rel(0.02); - numerator.spec.failure_probability = ProbabilityExpr::Constant { value: 0.001 }; - let got = propagate( - &CompositionOperator::ExactDivision, - &[numerator, rel(0.03)], - None, - &PropagationStats::default(), - ) - .unwrap(); - let (a, b): (f64, f64) = (0.02, 0.03); - let over = (1.0 + a) / (1.0 - b) - 1.0; - let under = 1.0 - (1.0 - a) / (1.0 + b); - assert!((got.spec.error.evaluate().unwrap() - over.max(under)).abs() < 1e-14); - assert!((got.spec.failure_probability.evaluate().unwrap() - 0.001).abs() < 1e-15); - for bound in [1., f64::NAN, f64::INFINITY, -0.1] { - assert!(propagate( - &CompositionOperator::ExactDivision, - &[rel(0.01), rel(bound)], - None, - &PropagationStats::default() - ) - .is_err()); - } - let rank = with_metric(AccuracyMetric::Rank, 0.01); - assert!(propagate( - &CompositionOperator::ExactDivision, - &[rank.clone(), rank], - None, - &PropagationStats::default() - ) - .is_err()); - } - #[test] - fn exact_child_contributes_zero_error() { - let local = abs(0.05, 0.01); - let out = propagate( - &CompositionOperator::ApproximateAggregate, - &[AccuracyGuarantee::exact("sum")], - Some(&local), - &PropagationStats::default(), - ) - .unwrap(); - assert_eq!(out.spec.error.evaluate(), Some(0.05)); - assert_eq!(out.spec.failure_probability.evaluate(), Some(0.01)); - assert_eq!(out.spec.accuracy_metric, AccuracyMetric::ABSOLUTE); - } - #[test] - fn additive_bounds_and_delta_union_bound_compose() { - let out = propagate( - &CompositionOperator::ApproximateAggregate, - &[abs(0.02, 0.01)], - Some(&abs(0.03, 0.02)), - &PropagationStats::default(), - ) - .unwrap(); - assert!((out.spec.error.evaluate().unwrap() - 0.05).abs() < 1e-12); - // Union bound, not 1 − (1−0.01)(1−0.02) = 0.0298. - assert!((out.spec.failure_probability.evaluate().unwrap() - 0.03).abs() < 1e-12); - assert!(out.provenance.iter().any(|s| matches!( - s, - GuaranteeSource::CompositionStep { rule, .. } if rule == "additive_union_bound" - ))); - } - #[test] - fn relative_error_includes_the_cross_term() { - let stats = PropagationStats { - values_non_negative: Some(true), - ..Default::default() - }; - let out = propagate( - &CompositionOperator::ApproximateAggregate, - &[rel(0.1)], - Some(&rel(0.2)), - &stats, - ) - .unwrap(); - // 0.1 + 0.2 + 0.1·0.2 = 0.32, not 0.3. - assert!((out.spec.error.evaluate().unwrap() - 0.32).abs() < 1e-12); - assert_eq!(out.spec.accuracy_metric, AccuracyMetric::Relative); - } - #[test] - fn relative_error_without_sign_knowledge_remains_symbolic() { - let unknown = propagate( - &CompositionOperator::ApproximateAggregate, - &[rel(0.1)], - Some(&rel(0.2)), - &PropagationStats::default(), - ) - .unwrap(); - assert!(unknown.has_unknown()); - let signed = propagate( - &CompositionOperator::ApproximateAggregate, - &[rel(0.1)], - Some(&rel(0.2)), - &PropagationStats { - values_non_negative: Some(false), - ..Default::default() - }, - ); - assert!(signed.is_err()); - } - #[test] - fn incompatible_metrics_are_rejected_not_treated_as_exact() { - // HLL cardinality error under a CMS frequency guarantee. - let err = propagate( - &CompositionOperator::ApproximateAggregate, - &[with_metric(AccuracyMetric::Relative, 0.01)], - Some(&with_metric(AccuracyMetric::ABSOLUTE_STREAM_L1, 0.01)), - &PropagationStats::default(), - ) - .unwrap_err(); - assert!(matches!( - err, - AccuracyError::UnsupportedComposition { - input_metrics, - local_metric: Some(AccuracyMetric::ABSOLUTE_STREAM_L1), - .. - } if input_metrics == vec![AccuracyMetric::Relative] - )); - // Quantile rank error under value-additive logic. - let err = propagate( - &CompositionOperator::ApproximateAggregate, - &[with_metric(AccuracyMetric::Rank, 0.01)], - Some(&abs(0.01, 0.0)), - &PropagationStats::default(), - ) - .unwrap_err(); - assert!(matches!(err, AccuracyError::UnsupportedComposition { .. })); - } - #[test] - fn same_metric_rank_over_rank_has_no_registered_rule() { - let err = propagate( - &CompositionOperator::ApproximateAggregate, - &[with_metric(AccuracyMetric::Rank, 0.01)], - Some(&with_metric(AccuracyMetric::Rank, 0.01)), - &PropagationStats::default(), - ) - .unwrap_err(); - assert!(matches!(err, AccuracyError::UnsupportedComposition { .. })); - } - #[test] - fn lipschitz_scales_the_input_bound() { - let out = propagate( - &CompositionOperator::Lipschitz { constant: 3.0 }, - &[abs(0.1, 0.01)], - Some(&abs(0.05, 0.02)), - &PropagationStats::default(), - ) - .unwrap(); - assert!((out.spec.error.evaluate().unwrap() - 0.35).abs() < 1e-12); - assert!((out.spec.failure_probability.evaluate().unwrap() - 0.03).abs() < 1e-12); - } - #[test] - fn exact_sum_over_approximate_sums_bounds_and_keeps_unknown_row_count_unknown() { - let out = propagate( - &CompositionOperator::ExactSum, - &[abs(0.1, 0.01)], - None, - &PropagationStats::default(), - ) - .unwrap(); - assert_eq!(out.spec.accuracy_metric, AccuracyMetric::ABSOLUTE); - assert_eq!( - out.spec.error.evaluate(), - None, - "unknown row count stays unknown" - ); - assert!(out.provenance.iter().any(|s| matches!( - s, - GuaranteeSource::UnavailableStatistic { statistic } if statistic == "input_row_count" - ))); - assert!(!satisfies(&out, &AccuracyTarget::max_error(1.0))); - - let known = PropagationStats { - input_row_count: Some(4), - ..Default::default() - }; - let out = propagate( - &CompositionOperator::ExactSum, - &[abs(0.1, 0.01)], - None, - &known, - ) - .unwrap(); - assert!((out.spec.error.evaluate().unwrap() - 0.4).abs() < 1e-12); - assert!((out.spec.failure_probability.evaluate().unwrap() - 0.04).abs() < 1e-12); - } - #[test] - fn exact_extremum_takes_the_max_bound() { - let known = PropagationStats { - input_row_count: Some(2), - ..Default::default() - }; - let out = propagate( - &CompositionOperator::ExactExtremum, - &[abs(0.1, 0.01), abs(0.3, 0.01)], - None, - &known, - ) - .unwrap(); - assert!((out.spec.error.evaluate().unwrap() - 0.3).abs() < 1e-12); - assert!((out.spec.failure_probability.evaluate().unwrap() - 0.04).abs() < 1e-12); - } - /// `max`/`min` of values each within relative `α_i` is within relative - /// `max α_i` of the true extremum, whatever the signs. - #[test] - fn exact_extremum_keeps_a_relative_value_bound() { - let out = propagate( - &CompositionOperator::ExactExtremum, - &[rel(0.01), rel(0.02)], - None, - &PropagationStats { - input_row_count: Some(3), - ..Default::default() - }, - ) - .unwrap(); - assert_eq!(out.spec.accuracy_metric, AccuracyMetric::Relative); - assert_eq!(out.spec.error.evaluate(), Some(0.02)); - assert_eq!(out.spec.failure_probability.evaluate(), Some(0.0)); - } - /// A sum or mean of relative-error values keeps the relative bound only - /// over values known non-negative (cancellation otherwise); with the sign - /// unknown, the bound stays symbolic, and a known sign change rejects. - #[test] - fn exact_sum_and_average_keep_a_relative_bound_only_without_a_sign_change() { - for op in [ - CompositionOperator::ExactSum, - CompositionOperator::ExactAverage, - ] { - let with = |values_non_negative| { - propagate( - &op, - &[rel(0.01)], - None, - &PropagationStats { - values_non_negative, - input_row_count: Some(3), - ..Default::default() - }, - ) - }; - let known = with(Some(true)).unwrap(); - assert_eq!(known.spec.accuracy_metric, AccuracyMetric::Relative); - assert_eq!(known.spec.error.evaluate(), Some(0.01), "{op:?}"); - let unknown = with(None).unwrap(); - assert_eq!(unknown.spec.accuracy_metric, AccuracyMetric::Relative); - assert_eq!( - unknown.spec.error, - BoundExpr::Unknown { - statistic: VALUES_NON_NEGATIVE.into() - } - ); - assert!(with(Some(false)).is_err(), "{op:?}"); - } - } - /// Over the samples of one metric, an unknown sign is named by that - /// metric ([`raw_data_samples_sign_statistic`]) and gates the bound: binding it - /// to 1 (proven) gives the relative bound, and left unbound it stays - /// unknown. - #[test] - fn a_sign_over_one_metrics_samples_gates_the_relative_bound() { - let statistic = raw_data_samples_sign_statistic("m"); - assert_eq!(parse_raw_data_samples_sign_statistic(&statistic), Some("m")); - assert_eq!( - parse_raw_data_samples_sign_statistic(VALUES_NON_NEGATIVE), - None - ); - for op in [ - CompositionOperator::ExactSum, - CompositionOperator::ExactAverage, - ] { - let out = propagate( - &op, - &[rel(0.01)], - None, - &PropagationStats { - sign_statistic: Some(statistic.clone()), - input_row_count: Some(3), - ..Default::default() - }, - ) - .unwrap(); - assert_eq!(out.spec.error.evaluate(), None, "{op:?}"); - let proven = out.bind(&|s| (s == statistic).then_some(1.0)); - assert_eq!(proven.spec.error.evaluate(), Some(0.01), "{op:?}"); - let other = out.bind(&|s| (s == raw_data_samples_sign_statistic("n")).then_some(1.0)); - assert_eq!(other.spec.error.evaluate(), None, "{op:?}"); - } - } - - #[test] - fn exact_average_has_its_own_absolute_error_rule() { - let out = propagate( - &CompositionOperator::ExactAverage, - &[abs(0.25, 0.01)], - None, - &PropagationStats { - input_row_count: Some(4), - ..PropagationStats::default() - }, - ) - .unwrap(); - assert_eq!(out.spec.accuracy_metric, AccuracyMetric::ABSOLUTE); - assert_eq!(out.spec.error.evaluate(), Some(0.25)); - assert_eq!(out.spec.failure_probability.evaluate(), Some(0.04)); - } - #[test] - fn topk_selection_requires_a_separated_margin_certificate() { - let unknown = propagate( - &CompositionOperator::TopKSelection, - &[abs(0.1, 0.01)], - None, - &PropagationStats::default(), - ) - .unwrap(); - assert!(unknown.has_unknown()); - - let certified = propagate( - &CompositionOperator::TopKSelection, - &[abs(0.1, 0.01)], - None, - &PropagationStats { - topk_selected_lower_bound: Some(101.0), - topk_excluded_upper_bound: Some(100.0), - topk_interval_failure_probability: Some(0.005), - ..Default::default() - }, - ) - .unwrap(); - assert_eq!(certified.spec.accuracy_metric, AccuracyMetric::ABSOLUTE); - assert_eq!(certified.spec.error.evaluate(), Some(0.1)); - assert_eq!(certified.spec.failure_probability.evaluate(), Some(0.005)); - - let overlapping = propagate( - &CompositionOperator::TopKSelection, - &[abs(0.1, 0.01)], - None, - &PropagationStats { - topk_selected_lower_bound: Some(100.0), - topk_excluded_upper_bound: Some(100.0), - topk_interval_failure_probability: Some(0.005), - ..Default::default() - }, - ); - assert!(overlapping.is_err()); - - let partial = propagate( - &CompositionOperator::TopKSelection, - &[abs(0.1, 0.01)], - None, - &PropagationStats { - topk_selected_lower_bound: Some(101.0), - topk_interval_failure_probability: Some(0.005), - ..Default::default() - }, - ) - .unwrap(); - assert!(partial.has_unknown()); - assert_eq!(partial.spec.failure_probability.evaluate(), Some(0.005)); - - let invalid_partial = propagate( - &CompositionOperator::TopKSelection, - &[abs(0.1, 0.01)], - None, - &PropagationStats { - topk_selected_lower_bound: Some(f64::NAN), - ..Default::default() - }, - ); - assert!(invalid_partial.is_err()); - } -} diff --git a/crates/accuracy/src/estimators/mod.rs b/crates/accuracy/src/estimators/mod.rs index 75684c1e3..131e09d49 100644 --- a/crates/accuracy/src/estimators/mod.rs +++ b/crates/accuracy/src/estimators/mod.rs @@ -12,7 +12,6 @@ pub mod hll; pub(crate) mod kll; pub mod univmon; -#[allow(dead_code)] // Read again for the error bound of each listed θ (`parameter_list`), in #1207 PRs 3–4. pub(super) fn sketch_guarantee( algorithm: &SketchAlgorithm, params: &SketchParams, @@ -58,28 +57,27 @@ fn bounded_guarantee( ) } -/// The built-in guarantee of reading `query` out of a summary of family -/// `family` built over an **exact** input, derived from the family's -/// committed parameters by inverting the same sizing formulas -/// [`size_params`] uses. Exact for a plain value -/// or an exact aggregate. `None` when a sketch does not answer `query` (its -/// family-capability table row), and when the family has no error model -/// (`SummarySample`, `Wavelet`, `StatModel`, and sketch evaluations without a -/// certified bound): Pass 1 then leaves the guarantee `None` and keeps the -/// candidate for plan selection to decide. -pub(crate) fn local_guarantee( +/// The built-in guarantee of reading `statistic` out of a summary of family +/// `family` with parameters `parameters` ($\boldsymbol\theta$, a candidate's +/// `LogicalProperties::parameters`) built over an **exact** input, derived +/// by inverting the same sizing formulas [`size_params`] uses. Exact for a +/// plain value or an exact aggregate. `None` when a sketch does not answer +/// `statistic` (its family-capability table row), when a sketch has no +/// parameters, and when the family has no error model (`SummarySample`, +/// `Wavelet`, `StatModel`, and sketch evaluations without a certified +/// bound). +pub fn local_guarantee( family: &FieldDataType, - query: &SketchStatistic, + parameters: Option<&SketchParams>, + statistic: &SketchStatistic, ) -> Option { match family { FieldDataType::Plain(_) => Some(AccuracyGuarantee::exact("Plain value")), FieldDataType::ExactAggregate(kind, _) => Some(AccuracyGuarantee::exact(format!( "ExactAggregate({kind:?})" ))), - FieldDataType::Sketch(..) if FamilyCapability::of(family).answers(query) => { - todo!( - "#1207 PR 4: the guarantee of θ from the candidate's LogicalProperties::parameters" - ) + FieldDataType::Sketch(algorithm, _) if FamilyCapability::of(family).answers(statistic) => { + sketch_guarantee(algorithm, parameters?, statistic) } FieldDataType::Sketch(..) => None, // No error model is registered for these families. @@ -550,23 +548,43 @@ mod tests { } /// A sketch read for a statistic its table row does not answer (an HLL - /// for a total) has no guarantee; one it answers keeps its guarantee, - /// and a plain value or exact aggregate stays exact. + /// for a total) has no guarantee, nor one without θ; one it answers + /// has the guarantee of its θ, and a plain value or exact aggregate + /// stays exact. #[test] - fn local_guarantee_needs_a_statistic_the_family_answers() { + fn local_guarantee_needs_a_statistic_the_family_answers_and_its_parameters() { use asap_types::ir::scalar::expr_ir::ColumnRef; use asap_types::ir::schema::{DataType, ExactKind, ExactParams, GroupingStrategy}; + use asap_types::stages::parameter_list; let hll = FieldDataType::Sketch(SketchAlgorithm::Hll, GroupingStrategy::default()); let total = SketchStatistic::PointCount { key: ColumnRef::SampleValue, value: None, }; - assert!(local_guarantee(&hll, &total).is_none()); + let hll_parameters = ¶meter_list(SketchAlgorithm::Hll)[0]; + assert!(local_guarantee(&hll, Some(hll_parameters), &total).is_none()); + let kll = FieldDataType::Sketch(SketchAlgorithm::Kll, GroupingStrategy::default()); + let median = SketchStatistic::Quantile { q: 0.5 }; + assert!(local_guarantee(&kll, None, &median).is_none()); + let errors: Vec = parameter_list(SketchAlgorithm::Kll) + .iter() + .map(|parameters| { + let guarantee = local_guarantee(&kll, Some(parameters), &median).unwrap(); + assert_eq!(guarantee.spec.accuracy_metric, AccuracyMetric::Rank); + guarantee.spec.error.evaluate().unwrap() + }) + .collect(); + // The list runs weakest first: each listed k bounds the rank error + // more tightly than the one before. + assert!( + errors.windows(2).all(|pair| pair[1] < pair[0]), + "{errors:?}" + ); for exact in [ FieldDataType::Plain(DataType::Float64), FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), ] { - assert!(local_guarantee(&exact, &total).is_some(), "{exact:?}"); + assert!(local_guarantee(&exact, None, &total).is_some(), "{exact:?}"); } } } diff --git a/crates/accuracy/src/evidence.rs b/crates/accuracy/src/evidence.rs index 7a66c62ea..802006f7f 100644 --- a/crates/accuracy/src/evidence.rs +++ b/crates/accuracy/src/evidence.rs @@ -1,5 +1,4 @@ -//! Scoped source contracts and statistics the accuracy rules consult. -use super::*; +//! Scoped source contracts the accuracy rules consult. /// An enforced domain for the true values of one operand of an exact /// arithmetic operation, in every evaluation: they are finite and lie in @@ -12,40 +11,3 @@ pub struct OperandDomain { pub lower: f64, pub upper: f64, } - -/// Statistics a propagation rule may consult. Every field is optional and -/// defaults to "unknown": a rule that needs a missing statistic emits a -/// [`BoundExpr::Unknown`] leaf (or rejects) rather than guessing. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct PropagationStats { - /// Provenance for supplied evidence (source, observation identity, etc.). - pub(crate) evidence_provenance: Vec, - /// Whether every input value is known to be non-negative — required by - /// the multiplicative relative-error rule, which is unsound across a - /// sign change. - pub(crate) values_non_negative: Option, - /// The statistic an unknown sign is named by when the folded values are - /// one metric's raw data samples or plain sums of them - /// ([`raw_data_samples_sign_statistic`](crate::composition::raw_data_samples_sign_statistic)): - /// the relative bound is then gated on it, and plan selection binds it - /// to 1 when the metric is declared a counter. `None`: the bare - /// `values_non_negative`, which nothing binds. - pub(crate) sign_statistic: Option, - /// Number of input rows an exact aggregation consumes (e.g. the number - /// of groups a function folds), for exact aggregate union bounds - /// bound over per-input failures. - pub(crate) input_row_count: Option, - /// The statistic an unknown row count is named by (default - /// `input_row_count`), so a fold whose rows the data workload's input - /// cardinality does not bound can leave a statistic nothing binds. - pub(crate) row_count_statistic: Option<&'static str>, - /// Lower confidence bound of the kth selected TopK item, after widening - /// the interval by the sketch's own estimation error. - pub(crate) topk_selected_lower_bound: Option, - /// Greatest upper confidence bound among excluded TopK items, after - /// widening the interval by the sketch's own estimation error. - pub(crate) topk_excluded_upper_bound: Option, - /// Union-bound failure probability of all intervals used by the margin - /// certificate. - pub(crate) topk_interval_failure_probability: Option, -} diff --git a/crates/accuracy/src/function_rules.rs b/crates/accuracy/src/function_rules.rs deleted file mode 100644 index fd5eb81d4..000000000 --- a/crates/accuracy/src/function_rules.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! The accuracy composition rule of each exact function applied to an -//! approximate input, shared by Pass 1's guarantee composition and the -//! accuracy algebra. -use asap_types::ir::operator::AggIntent; -use asap_types::ir::properties::CompositionOperator; - -/// The composition operator that propagates an approximate input's guarantee -/// through `intent`; `None` for a function with no registered rule over -/// approximate input, whose callers must fail closed. -pub(crate) fn approximate_input_composition(intent: &AggIntent) -> Option { - Some(match intent { - AggIntent::Sum { .. } => CompositionOperator::ExactSum, - AggIntent::Min { .. } | AggIntent::Max { .. } => CompositionOperator::ExactExtremum, - AggIntent::Avg { .. } => CompositionOperator::ExactAverage, - AggIntent::Rate => CompositionOperator::CounterRate, - AggIntent::IRate => CompositionOperator::InstantCounterRate, - AggIntent::Increase => CompositionOperator::CounterIncrease, - _ => return None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn counter_functions_have_distinct_definition_rules() { - assert_eq!( - approximate_input_composition(&AggIntent::Rate), - Some(CompositionOperator::CounterRate) - ); - assert_eq!( - approximate_input_composition(&AggIntent::IRate), - Some(CompositionOperator::InstantCounterRate) - ); - assert_eq!( - approximate_input_composition(&AggIntent::Increase), - Some(CompositionOperator::CounterIncrease) - ); - } -} diff --git a/crates/accuracy/src/lib.rs b/crates/accuracy/src/lib.rs index 92b623583..2608e3064 100644 --- a/crates/accuracy/src/lib.rs +++ b/crates/accuracy/src/lib.rs @@ -1,35 +1,30 @@ //! `asap-accuracy` — the accuracy model the planning stages share (Pass 1, //! Pass 2 and Stage 3): the planner's built-in analytical accuracy rules -//! (estimators, composition, guarantee propagation through a DAG, sizing, -//! budget split and target check) and the deployment's accuracy model and -//! evidence traits. +//! (estimators, sizing and the target check) and the deployment's accuracy +//! model and evidence traits. //! -//! Estimator models derive local guarantees, composition propagates them, -//! `propagation` composes them through every node of a DAG, `sizing` sizes -//! a summary for its target, the budget split sizes each approximate input -//! of a composition for an equal share of its target, and `evidence` holds -//! the scoped contracts and statistics the rules consult. The deployment's accuracy model -//! ([`AccuracyModel`]) and evidence provider ([`AccuracyEvidenceProvider`]) -//! are read only by plan selection (Stage 3). +//! Estimator models derive the local guarantee of reading a statistic from +//! a summary with given parameters ([`estimators::local_guarantee`]), +//! `sizing` names the statistic an intent reads and its target, and +//! `evidence` holds the scoped contracts the rules consult. How a +//! guarantee propagates through an operator is its error contract +//! (`LogicalRequirements::error_propagation` in `asap-types`), applied per +//! edge. The deployment's accuracy model ([`AccuracyModel`]) and evidence +//! provider ([`AccuracyEvidenceProvider`]) are read only by plan selection +//! (Stage 3). //! See `docs/design_docs/concepts/accuracy-models.md` for the design. //! //! It depends only on `asap-types`, never on a stage crate, so every stage //! can use it. -mod budget; -pub mod composition; pub mod estimators; mod evidence; -mod function_rules; mod model; -pub mod propagation; pub mod sizing; #[cfg(feature = "test-support")] pub mod test_support; -pub use budget::per_input_target; pub use evidence::OperandDomain; -pub(crate) use evidence::PropagationStats; pub use model::{ AccuracyEvidenceProvider, AccuracyModel, BuiltinAccuracyModel, IgnoreAccuracyGuarantees, NoAccuracyEvidence, @@ -39,8 +34,7 @@ pub use model::{ use asap_types::ir::properties::AccuracyScope; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::properties::{ - AccuracyError, AccuracyGuarantee, AccuracyMetric, BoundExpr, CompositionOperator, - GuaranteeSource, ProbabilityExpr, + AccuracyGuarantee, AccuracyMetric, BoundExpr, GuaranteeSource, ProbabilityExpr, }; use asap_types::ir::schema::{FieldDataType, SketchAlgorithm, SketchParams, SketchStatistic}; diff --git a/crates/accuracy/src/model.rs b/crates/accuracy/src/model.rs index 865694196..5bd9f8872 100644 --- a/crates/accuracy/src/model.rs +++ b/crates/accuracy/src/model.rs @@ -1,13 +1,14 @@ //! The deployment's accuracy model, read only by plan selection (#509 //! Stage 3). //! -//! Stage 1 composes each candidate's guarantee with the planner's built-in -//! analytical rules (this crate's [`composition`](crate::composition)) and -//! keeps a candidate it has no rule for with `guarantee = None`. Stage 3 decides, -//! with the model here, whether a guarantee meets its query's target (after -//! the data workload and the deployment's evidence, -//! [`AccuracyEvidenceProvider`], bind the statistics Stage 1 left unknown), -//! and whether the deployment accepts an output directly, whatever its +//! Each candidate's local guarantee comes from its parameters +//! ([`local_guarantee`](crate::estimators::local_guarantee)) and travels +//! per edge through each consumer's error contract +//! (`LogicalRequirements::error_propagation`). Stage 3 decides, with the +//! model here, whether a guarantee meets its query's target (after the +//! data workload and the deployment's evidence, +//! [`AccuracyEvidenceProvider`], bind the statistics left unknown), and +//! whether the deployment accepts an output directly, whatever its //! guarantee says. use crate::OperandDomain; @@ -21,8 +22,6 @@ use asap_types::ir::{OperatorNode, QueryRoot}; /// registered cross-metric conversion, say) or its own measurements /// implements this trait and passes it in /// `asap_planner::PlanningModels::accuracy`. -/// Guarantees are composed with the built-in rules only -/// (`propagate`). pub trait AccuracyModel { /// Whether `guarantee` meets `target`: a bound within its ε, and within /// its δ when it states one, over the values the target's scope covers diff --git a/crates/accuracy/src/propagation.rs b/crates/accuracy/src/propagation.rs deleted file mode 100644 index da54aab98..000000000 --- a/crates/accuracy/src/propagation.rs +++ /dev/null @@ -1,1681 +0,0 @@ -//! Guarantee propagation: the theoretical guarantee of every node of a -//! composed DAG, composed with the built-in analytical rules -//! (`crate::composition::propagate`) through operators, and the helpers -//! Pass 2 and plan selection (Stage 3) use to read, bind and recompose -//! those guarantees and to resize the summaries they depend on. -use std::collections::{HashMap, HashSet}; -use std::rc::Rc; - -use asap_types::ir::operator::operator_properties::{BinaryOpKind, JoinKind, Reduction, Source}; -use asap_types::ir::operator::tumbling_window_chain::merged_chain; -use asap_types::ir::operator::AggIntent; -use asap_types::ir::properties::guarantee::{ - AccuracyGuarantee, AccuracyMetric, BoundExpr, CompositionOperator, GuaranteeSource, - ProbabilityExpr, -}; -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::{ - ExactKind, FamilyCapability, FieldDataType, GroupingStrategy, MergedError, - NonNegativeWeightProof, SketchAlgorithm, SketchStatistic, SummaryInputExpr, SummaryUpdate, - WeightDomain, -}; -use asap_types::ir::{ - ASAPOp, BinaryOperator, NonASAPOp, Operator, OperatorNode, ProjectItem, QueryRoot, ScalarExpr, - SchemaDerivationError, -}; - -use crate::composition::{ - additive_as_absolute, propagate, raw_data_samples_sign_statistic, StreamNorm, GROUP_ROW_COUNT, - PRODUCT_OPERAND_DOMAINS, QUOTIENT_OPERAND_DOMAINS, -}; -use crate::estimators::local_guarantee; - -use crate::function_rules::approximate_input_composition; -use crate::PropagationStats; - -/// A rebuilt node per original node, keyed by the original's address. -type NodeToRewrittenNodeCache = HashMap<*const OperatorNode, Rc>; - -/// The metric whose raw data samples, or plain sums of them, are every value -/// `node` outputs, or `None`. Only a time-series scan, a time range over it, -/// an `offset`-only time shift (it moves the window, not the samples) and a -/// plain `sum` keep that; any other operator (rate and arithmetic included) -/// ends it. The metric's type is not read here: whether its -/// samples are non-negative is plan selection's question -/// ([`WeightDomain::RawDataSamplesOfMetric`]). -pub fn raw_data_samples_of_metric(node: &OperatorNode) -> Option { - match node.non_asap()? { - NonASAPOp::Scan { - source: Source::TimeSeries { metric }, - .. - } => Some(metric.clone()), - NonASAPOp::TimeRange { child, .. } => raw_data_samples_of_metric(child), - NonASAPOp::TimeShift { shift, child } if shift.at.is_none() => { - raw_data_samples_of_metric(child) - } - NonASAPOp::Aggregate { - measures, child, .. - } if matches!(measures.as_slice(), [AggIntent::Sum { col: None }]) => { - raw_data_samples_of_metric(child) - } - _ => None, - } -} - -/// The approximate inputs a query's value composes: every sketch summary -/// state reachable from `root`, each once, when the value carries a -/// guarantee that is not exact ([`root_guarantee`]), including compositions -/// conditional on source-sign facts supplied later. Empty when neither the -/// current nor conditional contract composes the inputs, or the result is -/// exact. Pass 1 sizes each for its share of failure probability too. -pub fn approximate_inputs(root: &QueryRoot) -> Vec> { - // Sizing may depend on a source-domain proof supplied only at Stage 3. - // Count the inputs of that conditional composition too, without attaching - // the hypothetical proof to the candidate or accepting its accuracy. - let guarantee = root_guarantee(root).or_else(|| { - bind_guarantees(root, &|guarantee| { - guarantee.bind(&|statistic| { - crate::composition::parse_raw_data_samples_sign_statistic(statistic).map(|_| 1.0) - }) - }) - }); - if guarantee.is_none_or(|g| g.is_exact()) { - return Vec::new(); - } - let operators = match root { - QueryRoot::Operator(node) => vec![node], - QueryRoot::Scalar(expr) => expr.operator_refs(), - }; - let mut seen = HashSet::new(); - operators - .into_iter() - .flat_map(OperatorNode::reachable) - .filter(|node| { - matches!( - node.asap(), - Some( - ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(..), - .. - } | ASAPOp::SummaryTumblingWindowChain { - family: FieldDataType::Sketch(..), - .. - } - ) - ) - }) - .filter(|node| seen.insert(Rc::as_ptr(node))) - .collect() -} - -/// The theoretical guarantee of a query's output, as composed: the root -/// node's own, or a scalar root's composed over the operators it reads -/// (`scalar_guarantee`). -pub fn root_guarantee(root: &QueryRoot) -> Option { - match root { - QueryRoot::Operator(node) => node.guarantee.clone(), - QueryRoot::Scalar(expr) => scalar_guarantee(expr, &|_| None), - } -} - -/// Bind known statistics in existing contracts, then recover compositions -/// that previously required constant child bounds. Physical state contracts, -/// schemas and sharing are preserved. This does not -/// infer facts: `bind` must leave unsupported statistics unknown. -pub fn bind_guarantees( - root: &QueryRoot, - bind: &dyn Fn(&AccuracyGuarantee) -> AccuracyGuarantee, -) -> Option { - fn walk( - node: &Rc, - bind: &dyn Fn(&AccuracyGuarantee) -> AccuracyGuarantee, - cache: &mut NodeToRewrittenNodeCache, - ) -> Rc { - if let Some(done) = cache.get(&Rc::as_ptr(node)) { - return done.clone(); - } - let operator = node.operator.map_children(|child| walk(child, bind, cache)); - let mut rebuilt = OperatorNode::with_schema(operator, node.schema.clone()); - rebuilt.guarantee = node - .guarantee - .as_ref() - .map(bind) - .or_else(|| composed_guarantee(&rebuilt).as_ref().map(bind)); - let rebuilt = Rc::new(rebuilt); - cache.insert(Rc::as_ptr(node), rebuilt.clone()); - rebuilt - } - let mut cache = HashMap::new(); - let bound = match root { - QueryRoot::Operator(node) => QueryRoot::Operator(walk(node, bind, &mut cache)), - QueryRoot::Scalar(expr) => { - QueryRoot::Scalar(expr.map_operator_refs(&mut |node| walk(node, bind, &mut cache))) - } - }; - root_guarantee(&bound) -} - -/// The one source a summary state counts the distinct items of its own -/// rows of, as `source_distinct_guarantee` reads it: the source whose -/// distinct bound gives its failure probability. `None` for a state over -/// computed rows or several sources. -pub fn distinct_bound_source(state: &OperatorNode) -> Option { - let Some(ASAPOp::SummaryAgg { child, .. } | ASAPOp::SummaryTumblingWindowChain { child, .. }) = - state.asap() - else { - return None; - }; - source_scan(child)?; - source(child) -} - -/// `roots` with every node's guarantee composed again -/// ([`composed_guarantee`]), children first; a sub-DAG shared between roots -/// stays shared. Pass 2 calls it after re-sizing a shared summary, so each -/// consumer's guarantee is the shared state's. -pub fn recompose_guarantees(roots: &[QueryRoot]) -> Result, SchemaDerivationError> { - let mut cache = HashMap::new(); - roots - .iter() - .map(|root| { - Ok(match root { - QueryRoot::Operator(node) => { - QueryRoot::Operator(with_guarantees(node, &mut cache)?) - } - QueryRoot::Scalar(expr) => { - for node in expr.operator_refs() { - with_guarantees(node, &mut cache)?; - } - QueryRoot::Scalar( - expr.map_operator_refs(&mut |node| cache[&Rc::as_ptr(node)].clone()), - ) - } - }) - }) - .collect() -} - -/// `node` and everything below it, each node with its [`composed_guarantee`]. -pub fn with_guarantees( - node: &Rc, - cache: &mut NodeToRewrittenNodeCache, -) -> Result, SchemaDerivationError> { - if let Some(done) = cache.get(&Rc::as_ptr(node)) { - return Ok(done.clone()); - } - for child in node.children() { - with_guarantees(child, cache)?; - } - let mut rebuilt = node.with_new_children(|child| cache[&Rc::as_ptr(child)].clone())?; - rebuilt.guarantee = composed_guarantee(&rebuilt); - let rebuilt = Rc::new(rebuilt); - cache.insert(Rc::as_ptr(node), rebuilt.clone()); - Ok(rebuilt) -} - -/// An extremum over at most one row per group is an identity on its value: -/// an aggregate whose measures are all `MIN`/`MAX` over a (possibly -/// filtered) grouped estimate, where every column of a unique key of the -/// estimate is a group key or pinned to one value by the filter -/// (Pass 2's finer-aggregate same-level read). Unlike a general -/// aggregate over rank estimates, this does not change the population to -/// which the input's error bound refers. -fn selected_group_guarantee(node: &OperatorNode) -> Option { - let NonASAPOp::Aggregate { - reduction: Reduction::Reduce(by), - measures, - filters, - having: None, - child, - .. - } = node.non_asap()? - else { - return None; - }; - if by.is_without() - || !filters.iter().all(Option::is_none) - || measures.is_empty() - || !measures.iter().all(|measure| { - matches!( - measure, - AggIntent::Min { col: Some(_) } | AggIntent::Max { col: Some(_) } - ) - }) - { - return None; - } - let (pred, grouped) = match child.non_asap()? { - NonASAPOp::Filter { pred, child } => (Some(&pred.0), child), - _ => (None, child), - }; - let pinned = pred.map_or_else(Vec::new, pinned_columns); - // The filter may read only key columns: a predicate on an estimate - // would decide by an approximate value which groups are kept. - let reads = pred.map_or_else(Vec::new, ScalarExpr::columns_referenced); - if !grouped.schema.unique_keys.iter().any(|key| { - key.iter() - .all(|column| by.keys().contains(column) || pinned.contains(column)) - && reads.iter().all(|column| key.contains(column)) - }) { - return None; - } - let mut source = grouped; - while let Some(NonASAPOp::Project { cols, child, .. }) = source.non_asap() { - if !cols - .iter() - .enumerate() - .all(|(index, item)| matches!(item.expr, ScalarExpr::Column(column) if column == index)) - { - return None; - } - source = child; - } - let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = source.asap() else { - return None; - }; - let Some(ASAPOp::SummaryAgg { - reduction: Reduction::Reduce(keys), - child: raw, - .. - }) = summary_input.asap() - else { - return None; - }; - if keys.is_without() - || !raw - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact) - { - return None; - } - grouped.guarantee.clone() -} - -/// The theoretical guarantee of a composed node, given its children's, -/// composed with the built-in analytical rules (`propagate`); -/// Stage 1 reads no data statistics or deployment evidence. Exact over exact -/// inputs; a summary estimate has its sized family's bound when -/// `estimate_has_bound`, composed with its input's when that is -/// approximate; an exact aggregation or accumulator over approximate values -/// uses its function's rule (`approximate_input_composition`); arithmetic composes as -/// `arithmetic_guarantee`. A rule that needs a statistic leaves it -/// symbolic (`BoundExpr::Unknown`), and plan selection (Stage 3) binds it. -/// `None` where no rule applies: a comparison, a value `HAVING` or any other -/// predicate over an approximate value, a rank error through any operator (a -/// rank interval counts as none in the MVP). A projection of columns only -/// keeps its child's guarantee. A summary state carries none itself. -/// -/// A guarantee holds per output point (per series or group), with its -/// failure probability per point ([`AccuracyScope::PerValue`]). Plan -/// selection states it over a whole result of `G` points as `G·δ`. -/// -/// [`AccuracyScope::PerValue`]: asap_types::ir::properties::AccuracyScope::PerValue -pub fn composed_guarantee(node: &OperatorNode) -> Option { - if let Some(guarantee) = selected_group_guarantee(node) { - return Some(guarantee); - } - if let Some(guarantee) = rolled_up_guarantee(node) { - return guarantee; - } - if let Some(guarantee) = merged_tumbling_windows_guarantee(node) { - return guarantee; - } - let exact = |node: &OperatorNode| { - node.guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact) - }; - // A summary state's input rows and any subquery in its filter. - let exact_inputs = |state: &OperatorNode| state.children().into_iter().all(|c| exact(c)); - // Over approximate input rows, a summary's filter must not decide which - // rows count by an estimate: it may read no input column (guarantees are - // per node, so any column may carry the estimate; column-level tracking - // is #689), and its subqueries must be exact. - let exact_filter = |state: &OperatorNode| { - let Some(ASAPOp::SummaryAgg { filter, .. }) = state.asap() else { - return false; - }; - filter - .as_ref() - .is_none_or(|filter| !reads_columns(&filter.0)) - && state.children().into_iter().skip(1).all(|c| exact(c)) - }; - match &node.operator { - Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) => match child.asap() { - Some(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(..), - .. - }) if exact_inputs(child) => Some(AccuracyGuarantee::exact( - "exact accumulator over exact input", - )), - Some(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(kind, _), - child: input, - reduction, - .. - }) if exact_filter(child) && !groups_by_estimate(reduction, input) => { - let stats = fold_stats(reduction, input)?; - let input = operand_guarantee(input)?; - let op = match kind { - // Counts rows, not values. - ExactKind::Count => return Some(AccuracyGuarantee::exact("count of rows")), - ExactKind::Sum => CompositionOperator::ExactSum, - ExactKind::Min | ExactKind::Max => CompositionOperator::ExactExtremum, - ExactKind::Rate => CompositionOperator::CounterRate, - ExactKind::IRate => CompositionOperator::InstantCounterRate, - ExactKind::Increase => CompositionOperator::CounterIncrease, - }; - propagate(&op, &[input], None, &stats).ok() - } - _ => None, - }, - Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query, - }) => match summary_input.asap() { - Some(ASAPOp::SummaryAgg { - family, - input: update, - child: input, - reduction, - .. - }) if estimate_has_bound(family, update, query) - || estimate_bound_over_raw_data_samples(family, update, query).is_some() => - { - let local = match estimate_bound_over_raw_data_samples(family, update, query) { - Some(gated) => gated, - None => source_distinct_guarantee(family, query, input) - .or_else(|| local_guarantee(family, query))?, - }; - let local = interpolation_guarantee(family, update, query, input, local)?; - if exact_inputs(summary_input) { - Some(local) - } else if exact_filter(summary_input) && !groups_by_estimate(reduction, input) { - compose( - &CompositionOperator::ApproximateAggregate, - vec![input.guarantee.clone()?], - Some(&local), - ) - } else { - None - } - } - _ => None, - }, - Operator::ASAP(_) => None, - // Renaming or reordering columns keeps the child's guarantee. - Operator::NonASAP(NonASAPOp::Project { cols, child, .. }) - if cols - .iter() - .all(|item| matches!(item.expr, ScalarExpr::Column(_))) => - { - child.guarantee.clone() - } - // Each step re-evaluates the child: every sample keeps its guarantee. - Operator::NonASAP(NonASAPOp::PromqlSubquery { child, .. }) => child.guarantee.clone(), - Operator::NonASAP(_) if node.children().into_iter().all(|child| exact(child)) => { - Some(AccuracyGuarantee::exact("exact over exact inputs")) - } - // Over a join, the child's sample value may be an exact input's - // column, so each column's own guarantee is read instead. - Operator::NonASAP(NonASAPOp::Project { cols, child, .. }) if derives_from_join(child) => { - relational_project_guarantee(cols, child) - } - Operator::NonASAP(NonASAPOp::Project { cols, child, .. }) => project_guarantee(cols, child), - Operator::NonASAP(NonASAPOp::Join { .. }) => join_guarantee(node), - // A measure filter or `HAVING` over approximate values changes - // which rows or groups count: no rule. - Operator::NonASAP(NonASAPOp::Aggregate { - measures, - filters, - having: None, - reduction, - child, - .. - }) if filters.iter().all(Option::is_none) && !groups_by_estimate(reduction, child) => { - match measures.as_slice() { - [intent] => propagate( - &approximate_input_composition(intent)?, - &[operand_guarantee(child)?], - None, - &fold_stats(reduction, child)?, - ) - .ok(), - _ => None, - } - } - Operator::NonASAP(NonASAPOp::BinaryOp { - operator: - BinaryOperator { - kind: BinaryOpKind::Arithmetic(op), - .. - }, - lhs, - rhs, - .. - }) => arithmetic_guarantee( - op.clone(), - (operand_guarantee(lhs), None), - (operand_guarantee(rhs), None), - Rc::ptr_eq(lhs, rhs), - ), - Operator::NonASAP(_) => None, - } -} - -/// The guarantee of an estimate or finalized value read from a rollup (a -/// regrouping `SummaryMerge` of one input): the same read of the input -/// states, when the family's merged error for that read is exact or the -/// same as one build's over the union (the family-capability table). The -/// groups of one build partition its rows, so a rolled-up state is a merge -/// of disjoint parts, and a family whose bound holds for one build over -/// the union keeps it: KMV, HLL and DDSketch merge into the state a direct -/// coarse build would have, KLL into a state with the same rank bound, and -/// linear sketches into the direct build's counters, whose error is -/// relative to the coarse group's total. Each guarantee holds per output -/// point, so the number of groups does not change it. None when the merged -/// readout is approximate (heap top-k, entropy) or the family has no merge. -/// `None` for any node that does not read a rollup. -fn rolled_up_guarantee(node: &OperatorNode) -> Option> { - let state = match node.asap()? { - ASAPOp::SummaryEstimate { summary_input, .. } => summary_input, - ASAPOp::FinalizeExactAccumulator { child } => child, - _ => return None, - }; - let Some(ASAPOp::SummaryMerge { - children, - group_by: Some(_), - .. - }) = state.asap() - else { - return None; - }; - let [input] = children.as_slice() else { - return Some(None); - }; - // Kept whole, a selected state keeps the guarantee it was built with. - let input = - asap_types::ir::operator::asap::selected_from(input).unwrap_or_else(|| Rc::clone(input)); - let family = state.asap()?.produced_state()?; - let merged = match node.asap()? { - ASAPOp::SummaryEstimate { query, .. } => FamilyCapability::of(family).merged_error(query), - // An exact accumulator merged across groups is exact. - _ => FamilyCapability::merges_groups(family).then_some(MergedError::Exact), - }; - if !matches!( - merged, - Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) - ) { - return Some(None); - } - let consumer = OperatorNode::with_schema( - node.operator.map_children(|child| { - if Rc::ptr_eq(child, state) { - Rc::clone(&input) - } else { - Rc::clone(child) - } - }), - node.schema.clone(), - ); - Some(composed_guarantee(&consumer)) -} - -/// The guarantee of an estimate or finalized value read from merged -/// tumbling windows ([`merged_chain`]): the guarantee of the same read of one -/// build over the merged tumbling windows' time range (the whole chain's, or -/// its newest tumbling windows') when the -/// family's merged error for that read is exact or the same as that build's -/// (the family-capability table); none when it is approximate (evidence -/// only, for plan selection to judge) or the family has no merge. `None` for -/// any node that does not read merged tumbling windows. -fn merged_tumbling_windows_guarantee(node: &OperatorNode) -> Option> { - let merged = match node.asap()? { - ASAPOp::SummaryEstimate { - summary_input, - query, - } => { - let chain = merged_chain(summary_input)?; - let family = chain.asap()?.produced_state()?; - FamilyCapability::of(family).merged_error(query) - } - ASAPOp::FinalizeExactAccumulator { child } => { - let chain = merged_chain(child)?; - let family = chain.asap()?.produced_state()?; - // An exact accumulator's merged state is exact. - let merged = FamilyCapability::of(family).merge_inputs; - merged.map(|_| MergedError::Exact) - } - _ => return None, - }; - if !matches!( - merged, - Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) - ) { - return Some(None); - } - todo!( - "#1207 PR 4: the guarantee of a read of merged tumbling windows, as of one build over their window, over memo groups" - ) -} - -/// The guarantee of a distinct count of one instance per group over a -/// source's own rows, whose failure probability is left symbolic in the -/// source's distinct bound, which Stage 1 does not read: -/// - classic HLL's ([`hll::classic_guarantee`]); -/// - UnivMon's, sized for a distinct count ([`univmon::distinct_guarantee`]). -/// -/// `None` for any other summary, or one over computed rows, whose distinct -/// items the source's bound does not bound. -pub(crate) fn source_distinct_guarantee( - family: &FieldDataType, - query: &SketchStatistic, - input: &Rc, -) -> Option { - let FieldDataType::Sketch(algorithm, GroupingStrategy::PerSubpopulationInstance) = family - else { - return None; - }; - if *query != SketchStatistic::Cardinality { - return None; - } - source_scan(input)?; - match algorithm { - SketchAlgorithm::Hll | SketchAlgorithm::UnivMon => { - todo!( - "#1207 PR 4: the distinct-count guarantee of θ from LogicalProperties::parameters" - ) - } - _ => None, - } -} - -/// The rows one group of an exact fold over approximate `input` counts. -/// Across PromQL series (`Reduce`) they are at most the input series, an -/// input-row-count statistic left symbolic for plan selection; across SQL -/// groups they are a group-row-count statistic nothing binds; over time -/// (`PerEntity`) they are one series' samples in the window: the steps of a -/// subquery with an explicit resolution, `⌈range / resolution⌉`. `None` -/// when that count is not in the query. -pub(crate) fn fold_stats( - reduction: &Reduction, - input: &Rc, -) -> Option { - let sign_statistic = - estimates_raw_data_samples_of(input).map(|metric| raw_data_samples_sign_statistic(&metric)); - let stats = match reduction { - // The series count of the sources read bounds a PromQL fold's rows - // (series), not the rows of a SQL group. - Reduction::Reduce(_) if reads_table(input) => Some(PropagationStats { - row_count_statistic: Some(GROUP_ROW_COUNT), - ..PropagationStats::default() - }), - Reduction::Reduce(_) => Some(PropagationStats::default()), - Reduction::PerEntity => match input.non_asap() { - Some(NonASAPOp::PromqlSubquery { - range, - resolution: Some(resolution), - .. - }) if !resolution.is_zero() => Some(PropagationStats { - input_row_count: Some(range.as_nanos().div_ceil(resolution.as_nanos()) as u64), - ..PropagationStats::default() - }), - _ => None, - }, - }?; - Some(PropagationStats { - sign_statistic, - ..stats - }) -} - -/// The metric whose raw data samples, or plain sums of them, a composed node's -/// values are or estimate, or `None`: [`raw_data_samples_of_metric`]'s strict rule -/// over a composed candidate. An exact sum accumulator, a projection of -/// columns and a -/// heap estimate whose update weights carry the -/// [`WeightDomain::RawDataSamplesOfMetric`] requirement keep it, and so does a -/// quantile estimate of those values, built over the raw window or merged -/// from its tumbling windows: it is one of them, or between two of them, so it -/// shares their sign. Anything else (rate, arithmetic, other -/// functions) ends it. Only the sign of the values is read from it. -pub(crate) fn estimates_raw_data_samples_of(node: &OperatorNode) -> Option { - match &node.operator { - Operator::NonASAP(NonASAPOp::TimeRange { child, .. }) => { - estimates_raw_data_samples_of(child) - } - Operator::NonASAP(NonASAPOp::TimeShift { child, shift }) if shift.at.is_none() => { - estimates_raw_data_samples_of(child) - } - Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) => match child.asap()? { - ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Sum, _), - child, - filter: None, - .. - } => estimates_raw_data_samples_of(child), - _ => None, - }, - // Only a φ in [0, 1] picks one of the values; outside it the answer - // is -Inf or +Inf, whatever their sign. Merged tumbling windows of one - // definition ([`merged_chain`]) are read through their `SummaryTumblingWindowChain`: - // a quantile of the merged state is still one of the tumbling windows' - // samples, or between two of them. - Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input, - query: SketchStatistic::Quantile { q }, - }) if (0.0..=1.0).contains(q) => match merged_chain(summary_input) - .unwrap_or_else(|| Rc::clone(summary_input)) - .asap()? - { - ASAPOp::SummaryAgg { - input: - SummaryUpdate { - item: None, - weight: SummaryInputExpr::Column(ColumnRef::SampleValue), - .. - }, - filter: None, - child, - .. - } - | ASAPOp::SummaryTumblingWindowChain { - input: - SummaryUpdate { - item: None, - weight: SummaryInputExpr::Column(ColumnRef::SampleValue), - .. - }, - filter: None, - child, - .. - } => estimates_raw_data_samples_of(child), - _ => None, - }, - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - match summary_input.asap()? { - ASAPOp::SummaryAgg { - input: - SummaryUpdate { - weight_domain: WeightDomain::RawDataSamplesOfMetric { metric }, - .. - }, - filter: None, - .. - } => Some(metric.clone()), - _ => None, - } - } - // Renaming, reordering or dropping columns keeps every value. - Operator::NonASAP(NonASAPOp::Project { cols, child, .. }) - if cols - .iter() - .all(|item| matches!(item.expr, ScalarExpr::Column(_))) => - { - estimates_raw_data_samples_of(child) - } - _ => raw_data_samples_of_metric(node), - } -} - -/// Whether `node` reads a SQL table. -fn reads_table(node: &Rc) -> bool { - OperatorNode::reachable(node).iter().any(|node| { - matches!( - node.non_asap(), - Some(NonASAPOp::Scan { - source: Source::Table { .. }, - .. - }) - ) - }) -} - -/// Whether a grouping by `reduction` over an approximate `input` may decide -/// a row's group by an estimate: a grouping column other than a label (a -/// string) or the time index. Guarantees are per node, so any such column -/// may carry the estimate (column-level tracking is #689); a PromQL `by` -/// or `without` groups by labels only. `false` over exact input. -fn groups_by_estimate(reduction: &Reduction, input: &OperatorNode) -> bool { - let Some(by) = reduction.group_keys() else { - return false; - }; - if input - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact) - { - return false; - } - let schema = &input.schema; - let columns: Vec = if by.is_without() { - let value = resolve_column_ref(&ColumnRef::SampleValue, schema).ok(); - (0..schema.fields.len()) - .filter(|i| !by.keys().contains(i) && Some(*i) != value) - .collect() - } else { - by.keys().to_vec() - }; - columns.into_iter().any(|i| { - Some(i) != schema.time_index - && !matches!( - schema.fields.get(i).map(|f| &f.dtype), - Some(FieldDataType::Plain(DataType::Utf8)) - ) - }) -} - -/// Whether `expr` reads any input column. -fn reads_columns(expr: &ScalarExpr) -> bool { - let mut reads = false; - expr.map_columns(&mut |column| { - reads = true; - column - }); - reads -} - -/// The built-in rule for `op`, with every statistic unknown (Stage 1 reads -/// none); `None` when no rule applies. -fn compose( - op: &CompositionOperator, - inputs: Vec, - local: Option<&AccuracyGuarantee>, -) -> Option { - propagate(op, &inputs, local, &PropagationStats::default()).ok() -} - -/// A projection that computes over its child's sample value: the guarantee -/// of the one item that reads it ([`project_column_guarantee`]). `None` when -/// no single item reads the value. -fn project_guarantee(cols: &[ProjectItem], child: &Rc) -> Option { - let value = resolve_column_ref(&ColumnRef::SampleValue, &child.schema).ok()?; - let reads_value = |expr: &ScalarExpr| { - let mut reads = false; - expr.map_columns(&mut |column| { - reads |= column == value; - column - }); - reads - }; - let [item] = <[_; 1]>::try_from( - cols.iter() - .filter(|item| reads_value(&item.expr)) - .collect::>(), - ) - .ok()?; - scalar_guarantee(&item.expr, &|column| { - project_column_guarantee(child, column) - }) -} - -/// Whether `node` is a join, or reads one only through operators that keep -/// its columns (projections, filters, sorts, limits, deduplication). The -/// columns of such a node come from different inputs, so its sample value -/// says nothing about where the error lies. -fn derives_from_join(node: &OperatorNode) -> bool { - match node.non_asap() { - Some(NonASAPOp::Join { .. }) => true, - Some( - NonASAPOp::Project { child, .. } - | NonASAPOp::Filter { child, .. } - | NonASAPOp::Sort { child, .. } - | NonASAPOp::Limit { child, .. } - | NonASAPOp::Dedup { child, .. }, - ) => derives_from_join(child), - _ => false, - } -} - -/// The guarantee of each output column of `node`, where it is known: every -/// column of an exact node is exact; a join beside exact inputs -/// ([`join_guarantee`]) gives the exact inputs' columns exact and the -/// approximate input's columns its guarantee; a projection of columns maps -/// its child's. `None` anywhere else, a computed projection included. -/// Guarantees are per node, so every column of an approximate input counts -/// as approximate; column-level tracking is #689. -/// -/// This assumes every approximate node of Stage 1 has an exact row set: -/// its guarantee bounds each row's values, never which rows exist (a -/// top-k estimate, whose rows are approximate, has no bound; -/// `estimate_has_bound`). A guarantee on row membership would need these -/// rules revisited. -fn column_guarantees(node: &OperatorNode) -> Option> { - let width = node.schema.fields.len(); - if let Some(guarantee) = node.guarantee.as_ref().filter(|g| g.is_exact()) { - return Some(vec![guarantee.clone(); width]); - } - match &node.operator { - Operator::NonASAP(NonASAPOp::Join { - kind: - JoinKind::Inner | JoinKind::Left | JoinKind::Right | JoinKind::Full | JoinKind::Cross, - left, - right, - .. - }) => { - let input = join_input_guarantees(left, right)?; - (input.len() == width).then_some(input) - } - Operator::NonASAP(NonASAPOp::Project { cols, child, .. }) => { - let child = column_guarantees(child)?; - cols.iter() - .map(|item| match item.expr { - ScalarExpr::Column(id) => child.get(id).cloned(), - _ => None, - }) - .collect() - } - _ => None, - } -} - -/// The guarantees of a join's input columns (`left ++ right`) when at most -/// one input is approximate and the others are exact: the exact input's -/// columns exact, the approximate input's columns its guarantee. -fn join_input_guarantees( - left: &OperatorNode, - right: &OperatorNode, -) -> Option> { - let exact = |node: &OperatorNode| node.guarantee.as_ref().is_some_and(|g| g.is_exact()); - if !exact(left) && !exact(right) { - return None; - } - let side = |node: &OperatorNode| { - let guarantee = node.guarantee.clone()?; - Some(vec![guarantee; node.schema.fields.len()]) - }; - let mut columns = side(left)?; - columns.extend(side(right)?); - Some(columns) -} - -/// A join whose inputs are exact but one keeps that input's guarantee when -/// its predicate reads only exact columns (a cross join, or a key equality -/// on the exact inputs' columns): which rows match, and how often, then -/// does not depend on an estimate, and each output row carries one row of -/// the approximate input unchanged. Outer joins pad on the same exact -/// decision. A semi- or anti-join, a key on an approximate input's column -/// (per-node guarantees count all its columns approximate; #689), or two -/// approximate inputs have none. An exact join is composed before this. -/// Like [`column_guarantees`], this assumes the approximate input's row set -/// is exact: only its values carry error. -fn join_guarantee(node: &OperatorNode) -> Option { - let Operator::NonASAP(NonASAPOp::Join { - kind: JoinKind::Inner | JoinKind::Left | JoinKind::Right | JoinKind::Full | JoinKind::Cross, - pred, - left, - right, - }) = &node.operator - else { - return None; - }; - let columns = join_input_guarantees(left, right)?; - let pred_exact = - scalar_guarantee(&pred.0, &|id| columns.get(id).cloned()).is_some_and(|g| g.is_exact()); - if !pred_exact { - return None; - } - [left, right] - .into_iter() - .find_map(|input| input.guarantee.clone().filter(|g| !g.is_exact())) -} - -/// The guarantee of a projection over relational columns -/// ([`column_guarantees`]): each output column's as [`scalar_guarantee`] -/// (a `CASE` as [`case_guarantee`]), and the node's the weakest of them. -/// `None` when the child's column guarantees are unknown or any output -/// column has none. -fn relational_project_guarantee( - cols: &[ProjectItem], - child: &Rc, -) -> Option { - let columns = column_guarantees(child)?; - let column = |id: usize| columns.get(id).cloned(); - weakest( - cols.iter() - .map(|item| scalar_guarantee(&item.expr, &column)) - .collect::>>()?, - ) -} - -/// The weakest of `guarantees`: exact when all are, the approximate one -/// when there is one (or all approximate ones are equal), and otherwise -/// as an extremum of them ([`lipschitz_guarantee`]). -fn weakest(guarantees: Vec) -> Option { - let mut approximate: Vec = Vec::new(); - for guarantee in guarantees.into_iter().filter(|g| !g.is_exact()) { - if !approximate.contains(&guarantee) { - approximate.push(guarantee); - } - } - match approximate.len() { - 0 => Some(AccuracyGuarantee::exact("exact over exact operands")), - 1 => approximate.pop(), - _ => lipschitz_guarantee(approximate), - } -} - -/// A `CASE` whose operand and conditions are exact picks its branch by an -/// exact decision, so it keeps the weakest branch's guarantee (a literal or -/// NULL branch, or a missing `ELSE`, is exact). A condition over an -/// approximate value has none. -fn case_guarantee( - operand: Option<&ScalarExpr>, - branches: &[(ScalarExpr, ScalarExpr)], - else_expr: Option<&ScalarExpr>, - column: &dyn Fn(usize) -> Option, -) -> Option { - let exact = |expr: &ScalarExpr| scalar_guarantee(expr, column).is_some_and(|g| g.is_exact()); - if !operand.is_none_or(exact) || !branches.iter().all(|(when, _)| exact(when)) { - return None; - } - weakest( - branches - .iter() - .map(|(_, then)| then) - .chain(else_expr) - .map(|expr| scalar_guarantee(expr, column)) - .collect::>>()?, - ) -} - -/// The guarantee of `column` of a projection's `child`: over a join, the -/// column's own (`column_guarantees`); otherwise the child's sample -/// value has the child's as an operand ([`operand_guarantee`]) and other -/// columns (labels, timestamps) are exact. `None` when the child has no -/// sample value or the column's guarantee is unknown. -pub fn project_column_guarantee( - child: &Rc, - column: usize, -) -> Option { - // Over a join, each column's own guarantee ([`column_guarantees`]). - if derives_from_join(child) { - return column_guarantees(child)?.get(column).cloned(); - } - let value = resolve_column_ref(&ColumnRef::SampleValue, &child.schema).ok()?; - if column == value { - operand_guarantee(child) - } else { - Some(AccuracyGuarantee::exact( - "column other than the sample value", - )) - } -} - -/// The guarantee of a scalar expression, given each column's: `scalar(v)` -/// and a SQL scalar subquery have their relation's, a literal or the -/// evaluation time is exact, arithmetic composes as -/// [`arithmetic_guarantee`], and `clamp`/`clamp_min`/`clamp_max`/`abs` as -/// [`lipschitz_guarantee`]. Any other expression (a comparison, a rounding, -/// a non-linear function) is exact over exact operands and has none -/// otherwise. -pub(crate) fn scalar_guarantee( - expr: &ScalarExpr, - column: &dyn Fn(usize) -> Option, -) -> Option { - let literal = |expr: &ScalarExpr| match expr { - ScalarExpr::Literal(ScalarValue::Float64(value)) => Some(*value), - ScalarExpr::Literal(ScalarValue::Int64(value)) => Some(*value as f64), - _ => None, - }; - let operand = |expr: &ScalarExpr| (operand_of(expr, column), literal(expr)); - match expr { - ScalarExpr::Column(id) => column(*id), - ScalarExpr::PromqlScalarFromVector(node) | ScalarExpr::ScalarSubquery(node) => { - node.guarantee.clone() - } - ScalarExpr::Literal(_) | ScalarExpr::CurrentTimestamp | ScalarExpr::EvalTimestamp => { - Some(AccuracyGuarantee::exact("constant")) - } - ScalarExpr::Negative { expr, .. } => { - lipschitz_guarantee(vec![scalar_guarantee(expr, column)?]) - } - ScalarExpr::Case { - operand, - branches, - else_expr, - } => case_guarantee(operand.as_deref(), branches, else_expr.as_deref(), column), - ScalarExpr::Arithmetic { - op, left, right, .. - } => arithmetic_guarantee( - op.clone(), - operand(left), - operand(right), - same_value(left, right), - ), - ScalarExpr::FunctionCall { name, args } if is_monotone_lipschitz(name) => { - lipschitz_guarantee( - args.iter() - .map(|arg| operand_of(arg, column)) - .collect::>()?, - ) - } - _ => { - let mut columns = Vec::new(); - expr.map_columns(&mut |id| { - columns.push(id); - id - }); - let exact = |g: Option| g.is_some_and(|g| g.is_exact()); - (columns.into_iter().all(|id| exact(column(id))) - && expr - .operator_refs() - .into_iter() - .all(|node| exact(node.guarantee.clone()))) - .then(|| AccuracyGuarantee::exact("exact over exact operands")) - } - } -} - -/// The guarantee of a scalar operand as an input to arithmetic or a -/// function: a planned operator's as [`operand_guarantee`], anything else -/// as `scalar_guarantee`. -pub fn operand_of( - expr: &ScalarExpr, - column: &dyn Fn(usize) -> Option, -) -> Option { - match expr { - ScalarExpr::PromqlScalarFromVector(node) | ScalarExpr::ScalarSubquery(node) => { - operand_guarantee(node) - } - _ => scalar_guarantee(expr, column), - } -} - -/// `node`'s guarantee as an input to a computation over its values: a -/// Count-Min or Count Sketch bound restated as an absolute error over the -/// stream norm its summary's update weight allows (`stream_norm`, -/// `additive_as_absolute`). -pub fn operand_guarantee(node: &Rc) -> Option { - let guarantee = node.guarantee.clone()?; - Some(additive_as_absolute(guarantee, stream_norm(node).as_ref())) -} - -/// The stream norm that bounds the one Count-Min or Count Sketch summary -/// below `node`, by its update weight: the source's own norm when the -/// weight is the source's raw value column (the summary's input is the -/// source's scan, at most windowed or filtered); `|c|` times the source's -/// row count for a constant weight `c`. `None` for any other weight (a -/// computed value such as `m * 1000` or `rate(…)`), several summaries or -/// sources, or none. -pub(crate) fn stream_norm(node: &Rc) -> Option { - let summaries: Vec<_> = OperatorNode::reachable(node) - .into_iter() - .filter(|node| { - matches!( - node.asap(), - Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(algorithm, _), - .. - }) if matches!( - algorithm, - SketchAlgorithm::Cms - | SketchAlgorithm::CmsWithHeap - | SketchAlgorithm::CountSketch - | SketchAlgorithm::CountSketchWithHeap - ) - ) - }) - .collect(); - let [summary] = summaries.as_slice() else { - return None; - }; - let Some(ASAPOp::SummaryAgg { - input: update, - child, - .. - }) = summary.asap() - else { - return None; - }; - let source = source(child)?; - match &update.weight { - SummaryInputExpr::Constant(weight) => Some(StreamNorm::ConstantWeight { - weight: *weight, - source, - }), - SummaryInputExpr::Column(column) if raw_value_column(child, column) => { - Some(StreamNorm::Source(source)) - } - _ => None, - } -} - -/// Whether `column` of `node` is its source's raw data sample value: `node` is -/// the source's scan, at most behind filters and windows that keep its -/// schema, and `column` resolves to the scan's sample value. -fn raw_value_column(node: &Rc, column: &ColumnRef) -> bool { - let Some(schema) = source_scan(node) else { - return false; - }; - let resolved = resolve_column_ref(column, schema).ok(); - resolved.is_some() && resolved == resolve_column_ref(&ColumnRef::SampleValue, schema).ok() -} - -/// The scan's schema when `node` is a source's scan, at most behind filters -/// and windows that keep its schema: its rows are the source's own. -fn source_scan(node: &Rc) -> Option<&Schema> { - let mut current = node; - loop { - if current.schema != node.schema { - return None; - } - match current.non_asap() { - Some(NonASAPOp::Filter { child, .. } | NonASAPOp::TimeRange { child, .. }) => { - current = child - } - Some(NonASAPOp::Scan { schema, .. }) => return Some(schema), - _ => return None, - } - } -} - -/// The one source (PromQL metric or SQL table) `node` reads; `None` when it -/// reads several or none. -fn source(node: &Rc) -> Option { - let sources: HashSet = OperatorNode::reachable(node) - .iter() - .filter_map(|node| match node.non_asap() { - Some(NonASAPOp::Scan { source, .. }) => Some(match source { - Source::TimeSeries { metric } => metric.clone(), - Source::Table { table_ref } => table_ref.clone(), - }), - _ => None, - }) - .collect(); - let mut sources = sources.into_iter(); - match (sources.next(), sources.next()) { - (Some(source), None) => Some(source), - _ => None, - } -} - -/// Whether two scalar operands are one value: the same column, or the same -/// planned operator read twice. -fn same_value(left: &ScalarExpr, right: &ScalarExpr) -> bool { - match (left, right) { - (ScalarExpr::Column(a), ScalarExpr::Column(b)) => a == b, - ( - ScalarExpr::PromqlScalarFromVector(a) | ScalarExpr::ScalarSubquery(a), - ScalarExpr::PromqlScalarFromVector(b) | ScalarExpr::ScalarSubquery(b), - ) => Rc::ptr_eq(a, b), - _ => false, - } -} - -/// Functions that are 1-Lipschitz and, for the relative-error rule, -/// monotone in each argument with `|f(x)|` scaling with `|x|`: `clamp` -/// (`min(max(x, lo), hi)`), `clamp_min`, `clamp_max` and `abs`. -fn is_monotone_lipschitz(name: &str) -> bool { - matches!( - name, - "promql_clamp" | "promql_clamp_min" | "promql_clamp_max" | "promql_abs" | "abs" - ) -} - -/// The guarantee of a 1-Lipschitz function of `args` (negation, `abs`, -/// `clamp`): its value is within the largest of its arguments' absolute -/// errors, by the union bound over them; likewise for relative errors, as -/// for an exact `max`/`min` (`ExactExtremum`, one value per argument). -/// Count-Min and Count Sketch bounds count as absolute errors. `None` for a -/// rank error or a mix of absolute and relative ones. -fn lipschitz_guarantee(args: Vec) -> Option { - let approximate: Vec<_> = args - .into_iter() - .filter(|g| !g.is_exact()) - .map(|g| additive_as_absolute(g, None)) - .collect(); - if approximate.is_empty() { - return Some(AccuracyGuarantee::exact( - "Lipschitz function of exact operands", - )); - } - propagate( - &CompositionOperator::ExactExtremum, - &approximate, - None, - &PropagationStats { - input_row_count: Some(1), - ..PropagationStats::default() - }, - ) - .ok() -} - -/// An arithmetic operand: its guarantee, and its value when it is a literal. -type Operand = (Option, Option); - -/// The guarantee of `left op right` (#669, MVP). Count-Min and Count Sketch -/// bounds count as absolute errors (normalized bound times stream norm). -/// - `x ± y`: absolute errors add (union bound on the failure -/// probabilities); `x ± c` (`c` exact) keeps an absolute error. -/// - `c · x`, `x / c` (`c` a nonzero literal): an absolute error scales by -/// `|c|` (or `1/|c|`); a relative error is kept, as it is by `x · y` or -/// `x / y` with `y` exact. -/// - `x · y` of two relative errors: relative `a + b + a·b`. -/// - `x / y` with `y` a relative error and `x` a relative error or exact: -/// relative `(a + b)/(1 − b)` (`a = 0` for an exact `x`), none when -/// `b ≥ 1`. -/// - `x · y` of two other estimates, `x / y` with any other approximate -/// `y`: a bound that needs enforced operand domains, left symbolic -/// ([`operand_domain_statistic`]) for plan selection to bind. -/// - Everything else (a relative error shifted by a constant or added to -/// another, a rank error, `%`, `^`, `atan2`) has none. -/// -/// `same_operand`: both sides are one estimate (the same planned operator), -/// which fails or holds as one event, so its failure probability counts -/// once. Two estimates count once each, even over one shared summary: the -/// built-in guarantees hold per estimate, not simultaneously. -fn arithmetic_guarantee( - op: ArithmeticOpKind, - left: Operand, - right: Operand, - same_operand: bool, -) -> Option { - let (l, r) = ( - additive_as_absolute(left.0?, None), - additive_as_absolute(right.0?, None), - ); - let once = same_operand.then(|| l.spec.failure_probability.clone()); - let statistic = operand_domain_statistic(&op, &l, &r); - let composed = match (op, l.is_exact(), r.is_exact()) { - (_, true, true) => return Some(AccuracyGuarantee::exact("arithmetic over exact operands")), - (ArithmeticOpKind::Add | ArithmeticOpKind::Sub, true, false) => shifted(r), - (ArithmeticOpKind::Add | ArithmeticOpKind::Sub, false, true) => shifted(l), - (ArithmeticOpKind::Add | ArithmeticOpKind::Sub, false, false) => compose( - &CompositionOperator::Lipschitz { constant: 1.0 }, - vec![l], - Some(&r), - ), - (ArithmeticOpKind::Mul, true, false) => scaled(r, left.1.map(f64::abs)), - (ArithmeticOpKind::Mul, false, true) => scaled(l, right.1.map(f64::abs)), - (ArithmeticOpKind::Mul, false, false) if statistic.is_none() => relative_product(l, r), - (ArithmeticOpKind::Div, false, true) => match right.1 { - Some(0.0) => None, - c => scaled(l, c.map(|c| 1.0 / c.abs())), - }, - (ArithmeticOpKind::Div, _, false) if statistic.is_none() => propagate( - &CompositionOperator::ExactDivision, - &[l, r], - None, - &PropagationStats::default(), - ) - .ok(), - (ArithmeticOpKind::Mul | ArithmeticOpKind::Div, _, _) => symbolic(l, r, statistic?), - _ => None, - }?; - let mut composed = composed; - if let Some(failure_probability) = once { - composed.spec.failure_probability = failure_probability; - } - Some(composed) -} - -/// The statistic a product or quotient of `l` and `r` leaves symbolic on -/// its operands' domains, when it is one whose bound needs them: a product -/// of two estimates, other than of two relative errors -/// ([`PRODUCT_OPERAND_DOMAINS`]); a quotient with an approximate -/// denominator, other than of relative or exact over relative -/// (`QUOTIENT_OPERAND_DOMAINS`). `None` for any other operation (a -/// product or quotient of relative errors has a closed form), or when an -/// operand's error has no value form (a rank error). Plan selection -/// (Stage 3) finds the operations to bind with this same rule. -pub fn operand_domain_statistic( - op: &ArithmeticOpKind, - l: &AccuracyGuarantee, - r: &AccuracyGuarantee, -) -> Option<&'static str> { - let valued = |g: &AccuracyGuarantee| { - g.is_exact() - || matches!( - g.spec.accuracy_metric, - AccuracyMetric::ABSOLUTE | AccuracyMetric::Relative - ) - }; - let relative = - |g: &AccuracyGuarantee| g.is_exact() || g.spec.accuracy_metric == AccuracyMetric::Relative; - if !(valued(l) && valued(r)) { - return None; - } - match op { - ArithmeticOpKind::Mul - if !l.is_exact() - && !r.is_exact() - && !(l.spec.accuracy_metric == AccuracyMetric::Relative - && r.spec.accuracy_metric == AccuracyMetric::Relative) => - { - Some(PRODUCT_OPERAND_DOMAINS) - } - ArithmeticOpKind::Div if !r.is_exact() && !(relative(l) && relative(r)) => { - Some(QUOTIENT_OPERAND_DOMAINS) - } - _ => None, - } -} - -/// A product of two relative-error estimates: within relative -/// `(1 + a)(1 + b) − 1 = a + b + a·b`, whatever the signs. `None` for any -/// other pair. -fn relative_product(l: AccuracyGuarantee, r: AccuracyGuarantee) -> Option { - if l.spec.accuracy_metric != AccuracyMetric::Relative - || r.spec.accuracy_metric != AccuracyMetric::Relative - { - return None; - } - let bound = BoundExpr::Sum { - terms: vec![ - l.spec.error.clone(), - r.spec.error.clone(), - BoundExpr::Product { - factors: vec![l.spec.error.clone(), r.spec.error.clone()], - }, - ], - }; - Some(binary_guarantee( - AccuracyMetric::Relative, - bound, - l, - r, - None, - CompositionOperator::ExactProduct, - "relative_product_union_bound", - )) -} - -/// `x` times an exact factor (`Some(|c|)` when it is a literal): a -/// relative error is kept; an absolute error scales by `|c|`. -fn scaled(input: AccuracyGuarantee, factor: Option) -> Option { - match (input.spec.accuracy_metric, factor) { - (AccuracyMetric::Relative, _) => Some(input), - (_, Some(constant)) => compose( - &CompositionOperator::Lipschitz { constant }, - vec![input], - None, - ), - _ => None, - } -} - -/// `x` plus an exact value: an absolute error is kept. -fn shifted(input: AccuracyGuarantee) -> Option { - compose( - &CompositionOperator::Lipschitz { constant: 1.0 }, - vec![input], - None, - ) -} - -/// A product or quotient whose bound needs its operands' domains, which -/// Stage 1 does not read: the bound is the symbolic `statistic` -/// ([`operand_domain_statistic`]), absolute, with the union bound on the -/// failure probability. -fn symbolic( - l: AccuracyGuarantee, - r: AccuracyGuarantee, - statistic: &'static str, -) -> Option { - let (operator, rule) = match statistic { - QUOTIENT_OPERAND_DOMAINS => ( - CompositionOperator::ExactDivision, - "absolute_division_needs_operand_domains", - ), - _ => ( - CompositionOperator::ExactProduct, - "absolute_product_needs_operand_domains", - ), - }; - Some(binary_guarantee( - AccuracyMetric::ABSOLUTE, - BoundExpr::Unknown { - statistic: statistic.into(), - }, - l, - r, - Some(statistic), - operator, - rule, - )) -} - -/// A guarantee over two operands with the union bound on their failure -/// probabilities, and its provenance. -fn binary_guarantee( - metric: AccuracyMetric, - bound: BoundExpr, - l: AccuracyGuarantee, - r: AccuracyGuarantee, - unavailable: Option<&str>, - operator: CompositionOperator, - rule: &str, -) -> AccuracyGuarantee { - let failure_probability = ProbabilityExpr::UnionBound { - terms: vec![ - l.spec.failure_probability.clone(), - r.spec.failure_probability.clone(), - ], - }; - let provenance = [l, r] - .into_iter() - .enumerate() - .map(|(input_index, g)| GuaranteeSource::ChildGuarantee { - input_index, - guarantee: Box::new(g), - }) - .chain( - unavailable.map(|statistic| GuaranteeSource::UnavailableStatistic { - statistic: statistic.into(), - }), - ) - .chain([GuaranteeSource::CompositionStep { - operator, - rule: rule.into(), - }]) - .collect(); - AccuracyGuarantee::new(metric, bound, failure_probability, provenance) -} - -/// Whether the default accuracy model's bound for `family` answers `query` -/// over `update`. A heap sketch's bound is per item frequency, not Top-K -/// membership; a Count-Min bound assumes non-negative weights. -pub(crate) fn estimate_has_bound( - family: &FieldDataType, - update: &SummaryUpdate, - query: &SketchStatistic, -) -> bool { - let count_min = matches!( - family, - FieldDataType::Sketch(algorithm, _) - if matches!(algorithm, SketchAlgorithm::Cms | SketchAlgorithm::CmsWithHeap) - ); - !matches!(query, SketchStatistic::TopK { .. }) - && !(count_min && !matches!(update.weight_domain, WeightDomain::NonNegative { .. })) -} - -/// DDS quantiles use linear interpolation of neighbouring representatives. -/// Relative error survives that convex combination only without cancellation. -/// Endpoints select an extremum; interior quantiles need a sign proof. Keep a -/// raw metric's requirement symbolic so Stage 3 can bind declared source facts. -fn interpolation_guarantee( - family: &FieldDataType, - update: &SummaryUpdate, - query: &SketchStatistic, - input: &OperatorNode, - mut guarantee: AccuracyGuarantee, -) -> Option { - let interpolates = matches!(family, FieldDataType::Sketch(SketchAlgorithm::DDSketch, _)) - && matches!(query, SketchStatistic::Quantile { q } if *q > 0.0 && *q < 1.0); - if !interpolates || matches!(update.weight_domain, WeightDomain::NonNegative { .. }) { - return Some(guarantee); - } - let metric = match &update.weight_domain { - WeightDomain::RawDataSamplesOfMetric { metric } => metric.clone(), - _ if matches!( - update.weight, - SummaryInputExpr::Column(ColumnRef::SampleValue) - ) => - { - estimates_raw_data_samples_of(input)? - } - _ => return None, - }; - guarantee.spec.error = asap_types::ir::properties::BoundExpr::Product { - factors: vec![ - guarantee.spec.error, - asap_types::ir::properties::BoundExpr::Unknown { - statistic: raw_data_samples_sign_statistic(&metric), - }, - ], - }; - Some(guarantee) -} - -/// A Count-Min estimate whose weights carry the open requirement that they -/// are raw data samples of one metric ([`WeightDomain::RawDataSamplesOfMetric`]): its local -/// guarantee with the bound gated on that metric's sign -/// ([`raw_data_samples_sign_statistic`]), which plan selection binds to 1 when the -/// metric is declared a counter. `None` for any other estimate. -pub(crate) fn estimate_bound_over_raw_data_samples( - family: &FieldDataType, - update: &SummaryUpdate, - query: &SketchStatistic, -) -> Option { - let WeightDomain::RawDataSamplesOfMetric { metric } = &update.weight_domain else { - return None; - }; - let proven = SummaryUpdate { - weight_domain: WeightDomain::NonNegative { - proof: NonNegativeWeightProof::CounterRawDataSamples, - }, - ..update.clone() - }; - if !estimate_has_bound(family, &proven, query) { - return None; - } - let mut local = local_guarantee(family, query)?; - local.spec.error = asap_types::ir::properties::BoundExpr::Product { - factors: vec![ - local.spec.error, - asap_types::ir::properties::BoundExpr::Unknown { - statistic: raw_data_samples_sign_statistic(metric), - }, - ], - }; - Some(local) -} - -pub fn split_and(expr: &ScalarExpr, out: &mut Vec) { - match expr { - ScalarExpr::BoolAnd(items) => items.iter().for_each(|item| split_and(item, out)), - other => out.push(other.clone()), - } -} - -/// The columns `pred` pins to one literal value each: its conjuncts -/// `c = v` and `c IN (v)`. -fn pinned_columns(pred: &ScalarExpr) -> Vec { - let mut conjuncts = Vec::new(); - split_and(pred, &mut conjuncts); - conjuncts.iter().filter_map(pinned_column).collect() -} - -/// The column `conjunct` pins to one literal value (`c = v`, `c IN (v)`). -pub fn pinned_column(conjunct: &ScalarExpr) -> Option { - match conjunct { - ScalarExpr::Compare { - left, - op: CompareOpKind::Eq, - right, - .. - } => match (&**left, &**right) { - (ScalarExpr::Column(column), ScalarExpr::Literal(_)) - | (ScalarExpr::Literal(_), ScalarExpr::Column(column)) => Some(*column), - _ => None, - }, - ScalarExpr::InList { - expr, - list, - negated: false, - } => match (&**expr, list.as_slice()) { - (ScalarExpr::Column(column), [ScalarExpr::Literal(_)]) => Some(*column), - _ => None, - }, - _ => 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 6a1a4d88e..8c79269ae 100644 --- a/crates/accuracy/src/test_support.rs +++ b/crates/accuracy/src/test_support.rs @@ -2,54 +2,9 @@ //! `test-support` feature so the normal public API stays as narrow as its //! production users need. Each forwards to the crate-private function or //! constant of the same name. -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}; -use asap_types::ir::{OperatorNode, ScalarExpr}; - -use crate::composition::StreamNorm; -pub use crate::evidence::PropagationStats; -use crate::propagation; - -/// [`propagation::composed_guarantee`]. -pub fn composed_guarantee(node: &OperatorNode) -> Option { - propagation::composed_guarantee(node) -} - -/// `propagation::scalar_guarantee`. -pub fn scalar_guarantee( - expr: &ScalarExpr, - column: &dyn Fn(usize) -> Option, -) -> Option { - propagation::scalar_guarantee(expr, column) -} - -/// `propagation::stream_norm`. -pub fn stream_norm(node: &Rc) -> Option { - propagation::stream_norm(node) -} - -/// `propagation::fold_stats`. -pub fn fold_stats(reduction: &Reduction, input: &Rc) -> Option { - propagation::fold_stats(reduction, input) -} - -/// `propagation::estimates_raw_data_samples_of`. -pub fn estimates_raw_data_samples_of(node: &OperatorNode) -> Option { - propagation::estimates_raw_data_samples_of(node) -} - -/// `propagation::source_distinct_guarantee`. -pub fn source_distinct_guarantee( - family: &FieldDataType, - query: &SketchStatistic, - input: &Rc, -) -> Option { - propagation::source_distinct_guarantee(family, query, input) -} /// `crate::satisfies`. pub fn satisfies(guarantee: &AccuracyGuarantee, target: &AccuracyTarget) -> bool { @@ -68,24 +23,3 @@ pub fn kll_rank_error_99(k: u32) -> f64 { /// `crate::sizing::DEFAULT_DELTA`. pub const DEFAULT_DELTA: f64 = crate::sizing::DEFAULT_DELTA; - -/// `crate::composition::QUOTIENT_OPERAND_DOMAINS`. -pub const QUOTIENT_OPERAND_DOMAINS: &str = crate::composition::QUOTIENT_OPERAND_DOMAINS; - -/// `crate::composition::STREAM_ROW_COUNT`. -pub const STREAM_ROW_COUNT: &str = crate::composition::STREAM_ROW_COUNT; - -/// `crate::composition::source_norm_statistic`. -pub fn source_norm_statistic(norm: &str, source: &str) -> String { - crate::composition::source_norm_statistic(norm, source) -} - -/// `crate::composition::raw_data_samples_sign_statistic`. -pub fn raw_data_samples_sign_statistic(metric: &str) -> String { - crate::composition::raw_data_samples_sign_statistic(metric) -} - -/// `stats.sign_statistic` (see [`PropagationStats`]). -pub fn sign_statistic(stats: &PropagationStats) -> Option<&str> { - stats.sign_statistic.as_deref() -} diff --git a/crates/integration-tests/tests/promql_numeric_regressions.rs b/crates/integration-tests/tests/promql_numeric_regressions.rs index 5626606a4..fc11e67f5 100644 --- a/crates/integration-tests/tests/promql_numeric_regressions.rs +++ b/crates/integration-tests/tests/promql_numeric_regressions.rs @@ -128,21 +128,6 @@ fn checked_ratio_must_not_certify_cross_zero_interpolation() { "direct quantile ratio should remain an available candidate" ); assert!(node.guarantee.is_none(), "{:?}", node.guarantee); - use asap_accuracy::propagation::bind_guarantees; - use asap_accuracy::test_support::raw_data_samples_sign_statistic; - let root = asap_types::ir::QueryRoot::Operator(node.clone()); - assert!(bind_guarantees(&root, &Clone::clone).is_none()); - let proven = bind_guarantees(&root, &|guarantee| { - guarantee.bind(&|statistic| { - (statistic == raw_data_samples_sign_statistic("data")).then_some(1.0) - }) - }) - .expect("a declared nonnegative source recovers the ratio contract"); - assert!(proven.spec.error.evaluate().unwrap() <= 0.01, "{proven:?}"); - assert!( - node.guarantee.is_none(), - "conditional sizing must not attach a hypothetical sign proof" - ); physical_asap_dag(&node); // Keep the actual signed-sketch counterexample: division guards alone pass // even though the quantile interpolation does not preserve relative error. @@ -176,41 +161,6 @@ fn checked_ratio_must_not_certify_cross_zero_interpolation() { ); } -/// Interpolated DDS quantiles require a source-domain proof; extrema do not -/// interpolate, and a computed value must not inherit its source's sign. -#[test] -fn ddsketch_interpolation_requires_a_nonnegative_domain() { - use asap_accuracy::propagation::bind_guarantees; - use asap_accuracy::test_support::raw_data_samples_sign_statistic; - use asap_types::ir::{schema::SketchAlgorithm, QueryRoot}; - for (query, endpoint, raw_samples) in [ - ("quantile_over_time(0, data[5m])", true, true), - ("quantile_over_time(1, data[5m])", true, true), - ("quantile_over_time(0.5, data[5m])", false, true), - ("quantile(0.5, data - 100)", false, false), - ] { - let node = explicit_summary_candidate( - lower_promql(query, AccuracyTarget::max_error(0.01)).unwrap(), - SketchAlgorithm::DDSketch, - ); - let bound = node - .guarantee - .as_ref() - .and_then(|g| g.spec.error.evaluate()); - assert_eq!(bound.is_some(), endpoint, "{query}"); - let proven = bind_guarantees(&QueryRoot::Operator(node), &|guarantee| { - guarantee.bind(&|statistic| { - (statistic == raw_data_samples_sign_statistic("data")).then_some(1.0) - }) - }); - assert_eq!( - proven.and_then(|g| g.spec.error.evaluate()).is_some(), - raw_samples, - "{query}" - ); - } -} - /// Rewriting the inner average (its own target, as sum and count /// accumulators) must not invalidate an otherwise usable outer sketch. #[test] diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules/summary_inputs.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/summary_inputs.rs deleted file mode 100644 index 2bea6eaf2..000000000 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules/summary_inputs.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Expose private value projections and filters below summary builds as canonical inputs. -//! This preserves their full expressions and schema, including opaque series -//! identity, rather than assuming that a projection is an identity operation. - -use crate::memo::rules::Pass2RuleError; -use asap_types::stages::Memo; - -/// Rule 3, summary inputs: a value projection or filter below a summary build as its own input group. -pub(crate) fn factor_summary_inputs(memo: &mut Memo) -> Result<(), Pass2RuleError> { - let _ = memo; - todo!("#1207 PR 4: rule 3 over groups") -} From ec8284d6656016583f7d68f78512e2b52836d670 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:27:05 +0000 Subject: [PATCH 07/15] feat(types): error contracts per operator; Memo::set_input_requirements (#1207 PR 4) LogicalRequirements::error_propagation(consumer, metric) gives g_c's linear bound (beta and validity cap per input) for errors in one metric, or None when the consumer has no contract for it (the metric rule). The ratio's denominator validity cap is RATIO_DENOMINATOR_VALIDITY_CAP. Memo::set_input_requirements lets Pass 2 tighten each input's parameter floor after the rules. --- crates/types/src/stages/candidates.rs | 283 +++++++++++++++++++++++++- crates/types/src/stages/mod.rs | 1 + 2 files changed, 277 insertions(+), 7 deletions(-) diff --git a/crates/types/src/stages/candidates.rs b/crates/types/src/stages/candidates.rs index 90166186e..ed3b9626e 100644 --- a/crates/types/src/stages/candidates.rs +++ b/crates/types/src/stages/candidates.rs @@ -7,15 +7,20 @@ use std::collections::{HashMap, HashSet}; use super::hash_consing::{expression_hash, same_expression, same_value}; use super::{GroupProperties, MemoGroup, MemoGroupAsInput, MemoGroupId}; +use crate::ir::operator::{AggIntent, BinaryOpKind}; use crate::ir::properties::summary_coverage::SummaryCoverage; +use crate::ir::properties::AccuracyMetric; +use crate::ir::scalar::expr_ir::ArithmeticOpKind; +use crate::ir::schema::ExactKind; use crate::ir::schema::{ FamilyCapability, FieldDataType, GroupingStrategy, HydraKind, HydraParams, SketchAlgorithm, SketchParams, }; -use crate::ir::ASAPOp; +use crate::ir::BinaryOperator; use crate::ir::Operator; use crate::ir::QueryRoot; use crate::ir::SchemaDerivationError; +use crate::ir::{ASAPOp, NonASAPOp, ScalarExpr}; /// One candidate: position `index_in_group` in group `group`'s candidates /// ([`MemoGroup::candidates`]). @@ -196,6 +201,27 @@ impl Memo { } } + /// Replaces candidate `id`'s input requirements $r^{\log}_{c,i}$, one + /// per input group: Pass 2 tightens each parameter floor by the input's + /// validity and pruning caps once the rules are done. + /// + /// # Panics + /// + /// When `input_requirements` does not have one entry per input group. + pub fn set_input_requirements( + &mut self, + id: CandidateForMemoGroupId, + input_requirements: Vec, + ) { + let candidate = &mut self.groups[id.group.0 as usize].candidates[id.index_in_group]; + assert_eq!( + input_requirements.len(), + candidate.expression.operator.children().len(), + "one requirement per input group" + ); + candidate.input_requirements = input_requirements; + } + /// The candidate `id`. pub fn candidate(&self, id: CandidateForMemoGroupId) -> &CandidateForMemoGroup { &self.groups[id.group.0 as usize].candidates[id.index_in_group] @@ -769,15 +795,141 @@ impl LogicalRequirements { self.parameter_floor_for_required_accuracy.as_ref() } - /// $g_c$'s linear bound: $\beta_{c,i}$ and $b_{c,i}$ per input, read - /// from the operator and its arguments. An operator without an error - /// contract is reported as unsupported; no factor is guessed. - pub fn error_propagation(consumer: &Operator) -> ErrorPropagation { - let _ = consumer; - todo!("#1207 PR 4: the error contract of each operator") + /// $g_c$'s linear bound for errors in `metric`, each input's and the + /// output's: $\beta_{c,i}$ and $b_{c,i}$ per input, read from the + /// operator and its arguments. `None` when the consumer has no error + /// contract for `metric` (concepts doc, section 4, metric rule): an input + /// whose error is in `metric` then gives no edge, and no factor is + /// guessed. Every contract here keeps the metric (its output error is + /// in the inputs' metric): + /// - a value-preserving operator (a `Project` of columns, `Sort`, + /// `TimeRange`, an `offset` `TimeShift`, `Concat`) and a summary read + /// or merge (`SummaryEstimate`, `FinalizeExactAccumulator`, + /// `SummaryMerge`, whose input is a state): $\beta = 1$ in any metric; + /// - an aggregate of `min`, `max` or `avg` measures and an exact + /// `min`/`max` accumulator: $\beta = 1$ (absolute; relative for `min` + /// and `max`); `count` reads no value: $\beta = 0$ in any metric; + /// - `+` and `-`: $\beta = 1$ per operand, absolute error; + /// - `/`: relative error, $\beta = 1 / (1 - b)$ per operand with the + /// denominator capped at $b$ = [`RATIO_DENOMINATOR_VALIDITY_CAP`]; `*`: + /// relative error, $(1 + b, 1)$ with the right operand capped at $b$ + /// ($(1+x)(1+y) - 1 \le (1+b)\,x + y$ for $y \le b$); + /// - an operator without inputs: no factors. + /// + /// Every other operator (a filter, a ranking, a `sum`, a comparison, a + /// function, a summary build over approximate rows) has none. + pub fn error_propagation( + consumer: &Operator, + metric: AccuracyMetric, + ) -> Option { + let inputs = consumer.children().len(); + let uniform = |factor: f64| ErrorPropagation { + factor_per_input: vec![factor; inputs], + validity_cap_per_input: vec![None; inputs], + }; + let extremum_metric = matches!( + metric, + AccuracyMetric::Absolute { .. } | AccuracyMetric::Relative + ); + match consumer { + Operator::NonASAP(op) => match op { + NonASAPOp::Scan { .. } | NonASAPOp::Values { .. } => Some(uniform(1.0)), + NonASAPOp::Project { cols, .. } + if cols + .iter() + .all(|item| matches!(item.expr, ScalarExpr::Column(_))) => + { + Some(uniform(1.0)) + } + NonASAPOp::Sort { .. } | NonASAPOp::TimeRange { .. } | NonASAPOp::Concat { .. } => { + Some(uniform(1.0)) + } + NonASAPOp::TimeShift { shift, .. } if shift.at.is_none() => Some(uniform(1.0)), + NonASAPOp::Aggregate { + measures, + filters, + having: None, + .. + } if filters.iter().all(Option::is_none) && !measures.is_empty() => { + let mut factor: f64 = 0.0; + for measure in measures { + match measure { + AggIntent::Count { .. } => {} + AggIntent::Min { .. } | AggIntent::Max { .. } if extremum_metric => { + factor = factor.max(1.0) + } + AggIntent::Avg { .. } + if matches!(metric, AccuracyMetric::Absolute { .. }) => + { + factor = factor.max(1.0) + } + _ => return None, + } + } + Some(uniform(factor)) + } + NonASAPOp::BinaryOp { + operator: + BinaryOperator { + kind: BinaryOpKind::Arithmetic(kind), + .. + }, + return_bool: false, + .. + } => { + let b = RATIO_DENOMINATOR_VALIDITY_CAP; + match (kind, metric) { + ( + ArithmeticOpKind::Add | ArithmeticOpKind::Sub, + AccuracyMetric::Absolute { .. }, + ) => Some(uniform(1.0)), + (ArithmeticOpKind::Div, AccuracyMetric::Relative) => { + Some(ErrorPropagation { + factor_per_input: vec![1.0 / (1.0 - b); 2], + validity_cap_per_input: vec![None, Some(b)], + }) + } + (ArithmeticOpKind::Mul, AccuracyMetric::Relative) => { + Some(ErrorPropagation { + factor_per_input: vec![1.0 + b, 1.0], + validity_cap_per_input: vec![None, Some(b)], + }) + } + _ => None, + } + } + _ => None, + }, + Operator::ASAP(op) => match op { + ASAPOp::SummaryEstimate { .. } + | ASAPOp::FinalizeExactAccumulator { .. } + | ASAPOp::SummaryMerge { .. } => Some(uniform(1.0)), + ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(kind, _), + filter: None, + .. + } + | ASAPOp::SummaryTumblingWindowChain { + family: FieldDataType::ExactAggregate(kind, _), + filter: None, + .. + } => match kind { + ExactKind::Count => Some(uniform(0.0)), + ExactKind::Min | ExactKind::Max if extremum_metric => Some(uniform(1.0)), + _ => None, + }, + _ => None, + }, + } } } +/// $b$ of a ratio's denominator (and a product's right operand): the +/// relative error under which [`LogicalRequirements::error_propagation`]'s +/// linear bound holds, $\varepsilon_{X/Y} \le (\varepsilon_X + +/// \varepsilon_Y) / (1 - b)$ (concepts doc, section 4). +pub const RATIO_DENOMINATOR_VALIDITY_CAP: f64 = 0.1; + /// The local error propagation of one consumer: its output error is at /// most $\sum_i \beta_{c,i}\varepsilon_{c,i}$ while each input's error stays /// within its validity cap (concepts doc, section 4; approved 2026-11-05). @@ -1613,4 +1765,121 @@ mod memo_tests { assert_eq!(memo.groups().len(), 1); assert!(memo.groups()[g1.0 as usize].parent_groups().is_empty()); } + + /// `lhs ⊕ rhs` over groups 0 and 1. + fn arithmetic(kind: ArithmeticOpKind) -> Operator { + Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind: BinaryOpKind::Arithmetic(kind), + vector_match: None, + }, + return_bool: false, + lhs: MemoGroupId(0), + rhs: MemoGroupId(1), + }) + } + + /// The error contracts: a ratio's relative error passes through + /// `1 / (1 - b)` with its denominator capped at `b`, and has no + /// absolute contract; a sum of two operands adds absolute errors; a + /// projection of columns, a readout and a merge keep every metric; a + /// count reads no value; a filter and a top-k have none. + #[test] + fn error_propagation_follows_each_operators_error_contract() { + let b = RATIO_DENOMINATOR_VALIDITY_CAP; + let ratio = arithmetic(ArithmeticOpKind::Div); + assert_eq!( + LogicalRequirements::error_propagation(&ratio, AccuracyMetric::Relative), + Some(ErrorPropagation { + factor_per_input: vec![1.0 / (1.0 - b); 2], + validity_cap_per_input: vec![None, Some(b)], + }) + ); + assert_eq!( + LogicalRequirements::error_propagation(&ratio, AccuracyMetric::ABSOLUTE), + None + ); + assert_eq!( + LogicalRequirements::error_propagation(&ratio, AccuracyMetric::Rank), + None + ); + assert_eq!( + LogicalRequirements::error_propagation( + &arithmetic(ArithmeticOpKind::Add), + AccuracyMetric::ABSOLUTE + ), + Some(ErrorPropagation { + factor_per_input: vec![1.0, 1.0], + validity_cap_per_input: vec![None, None], + }) + ); + let keep_all = [ + Operator::NonASAP(NonASAPOp::Project { + cols: vec![crate::ir::ProjectItem { + alias: None, + expr: ScalarExpr::Column(0), + }], + child: MemoGroupId(0), + }), + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: MemoGroupId(0), + query: crate::ir::schema::SketchStatistic::Quantile { q: 0.5 }, + }), + Operator::ASAP(ASAPOp::SummaryMerge { + children: vec![MemoGroupId(0)], + newest_tumbling_windows: None, + group_by: None, + }), + ]; + for operator in &keep_all { + for metric in [ + AccuracyMetric::ABSOLUTE, + AccuracyMetric::Relative, + AccuracyMetric::Rank, + ] { + assert_eq!( + LogicalRequirements::error_propagation(operator, metric) + .map(|propagation| propagation.factor_per_input), + Some(vec![1.0]), + "{operator:?} {metric:?}" + ); + } + } + let aggregate = |measure: AggIntent| { + Operator::NonASAP(NonASAPOp::Aggregate { + child: MemoGroupId(0), + reduction: Reduction::by(vec![]), + measures: vec![measure], + output_names: vec![String::new()], + filters: vec![None], + having: None, + }) + }; + let count = aggregate(AggIntent::Count { + accuracy: crate::ir::properties::AccuracyTarget::exact(), + }); + assert_eq!( + LogicalRequirements::error_propagation(&count, AccuracyMetric::Rank) + .map(|propagation| propagation.factor_per_input), + Some(vec![0.0]) + ); + let topk = aggregate(AggIntent::TopK { + k: 3, + accuracy: crate::ir::properties::AccuracyTarget::exact(), + }); + assert_eq!( + LogicalRequirements::error_propagation(&topk, AccuracyMetric::ABSOLUTE), + None + ); + let filter = Operator::NonASAP(NonASAPOp::Filter { + pred: crate::ir::Predicate(ScalarExpr::Column(0)), + child: MemoGroupId(0), + }); + assert_eq!( + LogicalRequirements::error_propagation(&filter, AccuracyMetric::ABSOLUTE), + None + ); + } } diff --git a/crates/types/src/stages/mod.rs b/crates/types/src/stages/mod.rs index 873c276a6..790d68955 100644 --- a/crates/types/src/stages/mod.rs +++ b/crates/types/src/stages/mod.rs @@ -20,6 +20,7 @@ pub use candidates::{ parameter_list, producer_satisfies_logical_requirements, CandidateError, CandidateExpression, CandidateForMemoGroup, CandidateForMemoGroupId, ErrorPropagation, LogicalProperties, LogicalPropertiesError, LogicalRequirements, Memo, OriginOfCandidateForMemoGroup, WindowLayout, + RATIO_DENOMINATOR_VALIDITY_CAP, }; pub use logical_memo_candidates::{LogicalASAPMemoCandidates, LogicalEdge}; pub(crate) use memo::MemoGroupAsInput; From f367061a0475660f95f56a082e2eafe8695a9899 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:27:05 +0000 Subject: [PATCH 08/15] feat(stage1-pass2): reuse rules over groups, logical edges, metric rule, caps (#1207 PR 4) Rules over memo groups: 1 tumbling layouts as candidates of the state group (gcd closure of the evaluation grids, dividing the window); 2 scan widening (widened scan and label-passing images, Project candidates in the original groups); 4 top-k reuse (TopK over the largest top-k group); 5 finer aggregate; 6 rollup (SummaryMerge with group_by over the finer state). Rule 3 is Pass 1's (every input is a group) and rule 7 is rule 6 over windowed builds: both deleted. After the rules: error caps per group (query targets passed down through each consumer's error contract, min with validity caps), parameter floors from the caps for every readout or merge of a sketch state, each candidate's possible output errors, and the logical edges with alpha^log (floor and metric rule). --- Cargo.lock | 1 + crates/devtools/src/planning_stages.rs | 2 +- .../stage1-pass2-reuse-candidates/Cargo.toml | 7 +- .../stage1-pass2-reuse-candidates/src/lib.rs | 7 + .../src/logical_edges.rs | 579 +++++++++++++ .../src/logical_memo_candidates.rs | 31 +- .../src/memo/rules.rs | 61 +- .../src/memo/rules/finer_aggregate.rs | 322 ++++++- .../src/memo/rules/rollup.rs | 202 ++++- .../src/memo/rules/scan_labels.rs | 242 +++++- .../src/memo/rules/topk.rs | 91 +- .../src/memo/window_candidates.rs | 129 ++- .../src/parameter_floors.rs | 176 ++++ .../src/tests.rs | 807 ++++++++++++++++++ 14 files changed, 2592 insertions(+), 65 deletions(-) create mode 100644 crates/stage1-pass2-reuse-candidates/src/logical_edges.rs create mode 100644 crates/stage1-pass2-reuse-candidates/src/parameter_floors.rs create mode 100644 crates/stage1-pass2-reuse-candidates/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index ae3f95d4b..afe947361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,6 +457,7 @@ dependencies = [ name = "asap-stage1-pass2-reuse-candidates" version = "0.1.0" dependencies = [ + "asap-accuracy", "asap-types", "thiserror 2.0.18", ] diff --git a/crates/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs index 83a9aa62c..e5da65a99 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -323,7 +323,7 @@ pub fn run_stages_until( } }; stages.pass2_local_window_composition = Some(composed.clone()); - let logical = match pass2_after_local_window_composition(composed) { + let logical = match pass2_after_local_window_composition(composed, entries) { Ok(logical) => logical, Err(error) => { run.selection = Err(SelectionError::LogicalASAPCandidates(error.to_string())); diff --git a/crates/stage1-pass2-reuse-candidates/Cargo.toml b/crates/stage1-pass2-reuse-candidates/Cargo.toml index 6320ee25e..93667905d 100644 --- a/crates/stage1-pass2-reuse-candidates/Cargo.toml +++ b/crates/stage1-pass2-reuse-candidates/Cargo.toml @@ -5,8 +5,13 @@ edition = "2021" # #509 Stage 1, Pass 2: the reuse rules over memo groups and the logical # edges (`asap_types::stages::LogicalASAPMemoCandidates`) Stage 2 expands. -# Reads Pass 1's memo through `asap_types::stages`; depends on no other +# Reads Pass 1's memo through `asap_types::stages` and the shared accuracy +# model (`asap-accuracy`, for each listed θ's error); depends on no other # stage crate. [dependencies] asap-types = { path = "../types" } +asap-accuracy = { path = "../accuracy" } thiserror = "2" + +[dev-dependencies] +asap-types = { path = "../types", features = ["test-support"] } diff --git a/crates/stage1-pass2-reuse-candidates/src/lib.rs b/crates/stage1-pass2-reuse-candidates/src/lib.rs index 970b77f40..a35f90154 100644 --- a/crates/stage1-pass2-reuse-candidates/src/lib.rs +++ b/crates/stage1-pass2-reuse-candidates/src/lib.rs @@ -9,13 +9,20 @@ //! depends on no other stage crate, and the planner facade calls it after //! Pass 1. +/// The error caps, parameter floors and logical edges after the rules. +mod logical_edges; /// Stage 1, Pass 2's entry, producing [`asap_types::stages::LogicalASAPMemoCandidates`]. mod logical_memo_candidates; /// The rules over groups. mod memo; +/// Parameter floors from error caps. +mod parameter_floors; pub use logical_memo_candidates::{ generate_logical_asap_memo_candidates, pass2_after_local_window_composition, pass2_local_window_composition, }; pub use memo::rules::Pass2RuleError; +/// Pass 2's tests over memos built through the `Memo` methods. +#[cfg(test)] +mod tests; diff --git a/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs b/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs new file mode 100644 index 000000000..a36b92e37 --- /dev/null +++ b/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs @@ -0,0 +1,579 @@ +//! After the rules: the error caps of every input, the parameter floors +//! they set, and the logical edges $(u, c, i)$ with $\alpha^{\log}$ +//! (concepts doc, sections 3 and 4; #1201). +//! +//! Three passes over the memo's groups in dependency order: +//! 1. **Caps, top down.** Query $q$'s root group may be off by its target +//! $\varepsilon_q$. A consumer $c$ passes a cap to input $i$ through its +//! error contract (`LogicalRequirements::error_propagation`): +//! $\min(b_{c,i},\ \text{cap}/\beta_{c,i})$. A group keeps, per query +//! (and per statistic read, for a summary state), the largest cap any +//! consumer passes it: the bottleneck over paths, a lower bound on +//! $\lambda_{q,c,i}$ along any path, so the cap removes only what no +//! plan can use. +//! 2. **Floors.** A consumer reading a sketch state (a readout, or a merge +//! of states) gets, per input, the floor of its cap for the statistic +//! read ([`floor_for_cap`]), tightened with the floor it already has +//! (a top-k's heap size). +//! 3. **Output errors, bottom up, and edges.** Each candidate's possible +//! output errors ([`OutputError`]): exact, a bound in one metric, or no +//! guarantee. The metric rule: an input whose error is in a metric the +//! consumer's contract does not carry gives no edge ($\alpha^{\log} = +//! 0$); an exact producer always may. $\alpha^{\log}$ also needs the +//! producer's $\boldsymbol\theta$ to meet the floor. + +use crate::memo::rules::{expression, Pass2RuleError}; +use crate::parameter_floors::{floor_for_cap, strongest, weakest}; +use asap_accuracy::estimators::local_guarantee; +use asap_types::ir::properties::AccuracyMetric; +use asap_types::ir::schema::{FamilyCapability, FieldDataType, MergedError, SketchStatistic}; +use asap_types::ir::{ASAPOp, Operator}; +use asap_types::stages::{ + producer_satisfies_logical_requirements, CandidateForMemoGroupId, LogicalEdge, + LogicalRequirements, Memo, MemoGroupId, +}; +use asap_types::workload::QueryWorkloadEntry; + +/// The metrics a contract may be read in when a query's target names none. +const METRICS: [AccuracyMetric; 3] = [ + AccuracyMetric::ABSOLUTE, + AccuracyMetric::Relative, + AccuracyMetric::Rank, +]; + +/// What error a candidate's output may carry, by the producers its inputs +/// may read (the union over them). +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum OutputError { + /// No approximation error. + Exact, + /// An error bound in this metric. + Bound(AccuracyMetric), + /// An approximation without a guarantee the planner can state (a heap + /// readout of merged windows; a family without an error model): only a + /// value-preserving consumer carries it on, for Stage 3's accuracy + /// model to judge. + WithoutGuarantee, +} + +/// One cap of a group: query `query` may use this group's output with an +/// error of at most `cap` (infinite: any), read as `statistic` when the +/// group is a summary state. +#[derive(Debug, Clone)] +struct ErrorCap { + query: usize, + statistic: Option, + cap: f64, +} + +/// Sets every candidate's parameter floors from its inputs' error caps and +/// returns the logical edges, grouped by consumer and input, producers in +/// group order. `entries[q]` is query `q`'s workload entry (its accuracy +/// target). +pub(crate) fn logical_edges_with_capped_floors( + memo: &mut Memo, + entries: &[QueryWorkloadEntry], +) -> Result, Pass2RuleError> { + let order = memo + .topological_order() + .ok_or(Pass2RuleError::CyclicDependencies)?; + let caps = error_caps(memo, entries, &order); + set_floors(memo, entries, &caps); + let errors = output_errors(memo, &order); + let mut edges = Vec::new(); + for (g, group) in memo.groups().iter().enumerate() { + for (index_in_group, consumer) in group.candidates.iter().enumerate() { + let consumer_id = CandidateForMemoGroupId { + group: MemoGroupId(g as u32), + index_in_group, + }; + for (input, &child) in consumer + .expression + .operator + .children() + .into_iter() + .enumerate() + { + for (u, producer) in memo.groups()[child.0 as usize] + .candidates + .iter() + .enumerate() + { + let satisfies = producer_satisfies_logical_requirements( + &producer.logical_properties, + &consumer.input_requirements[input], + ) && (reads_state(memo, child) + || errors[child.0 as usize][u] + .iter() + .any(|error| carries(&consumer.expression.operator, *error))); + edges.push(LogicalEdge { + producer_candidate_id: CandidateForMemoGroupId { + group: child, + index_in_group: u, + }, + consumer_candidate_id: consumer_id, + input_index_of_consumer: input, + satisfies_logical_requirement: satisfies, + }); + } + } + } + } + Ok(edges) +} + +/// Whether `group`'s rows are summary states: its consumers read a state, +/// whose error the readout above states. +fn reads_state(memo: &Memo, group: MemoGroupId) -> bool { + matches!( + expression(memo, group), + Operator::ASAP( + ASAPOp::SummaryAgg { .. } + | ASAPOp::SummaryTumblingWindowChain { .. } + | ASAPOp::SummaryMerge { .. } + ) + ) +} + +/// The metric rule: whether `consumer`'s error contract carries an input +/// error `error`. Exact always; a bound in a metric its contract has; +/// no guarantee only through a value-preserving consumer (one whose +/// contract carries every metric with factor 1). +fn carries(consumer: &Operator, error: OutputError) -> bool { + match error { + OutputError::Exact => true, + OutputError::Bound(metric) => { + LogicalRequirements::error_propagation(consumer, metric).is_some() + } + OutputError::WithoutGuarantee => value_preserving(consumer), + } +} + +/// Whether `consumer` carries every metric unchanged (factor 1 on every +/// input): a projection of columns, a time range, a readout or a merge. +fn value_preserving(consumer: &Operator) -> bool { + METRICS.iter().all(|&metric| { + LogicalRequirements::error_propagation(consumer, metric).is_some_and(|propagation| { + propagation + .factor_per_input + .iter() + .all(|&factor| factor == 1.0) + }) + }) +} + +/// Each group's caps ([`ErrorCap`]), by group id: the roots' targets, +/// then every consumer's caps passed down through its contract. +fn error_caps( + memo: &Memo, + entries: &[QueryWorkloadEntry], + order: &[MemoGroupId], +) -> Vec> { + let mut caps: Vec> = vec![Vec::new(); memo.groups().len()]; + for (query, root) in memo.query_roots.iter().enumerate() { + let Some(entry) = entries.get(query) else { + continue; + }; + for &group in root.operator_refs() { + add_cap( + &mut caps[group.0 as usize], + ErrorCap { + query, + statistic: None, + cap: entry.requirements.accuracy.max_error, + }, + ); + } + } + for &group in order.iter().rev() { + let group_caps = caps[group.0 as usize].clone(); + if group_caps.is_empty() { + continue; + } + for consumer in &memo.groups()[group.0 as usize].candidates { + let operator = &consumer.expression.operator; + for (input, &child) in operator.children().into_iter().enumerate() { + for cap in &group_caps { + let target_metric = entries + .get(cap.query) + .and_then(|entry| entry.requirements.accuracy.accuracy_metric); + if let Some(passed) = + passed_cap(memo, operator, input, child, cap, target_metric) + { + add_cap(&mut caps[child.0 as usize], passed); + } + } + } + } + } + caps +} + +/// The cap `consumer` passes to its input `input` (group `child`) from its +/// own group's `cap`; `None` when it passes none (no contract, or a merge +/// whose merged state loses the statistic's guarantee). +fn passed_cap( + memo: &Memo, + consumer: &Operator, + input: usize, + child: MemoGroupId, + cap: &ErrorCap, + target_metric: Option, +) -> Option { + let statistic = match (consumer, &cap.statistic) { + // A readout reads its statistic from the state. + (Operator::ASAP(ASAPOp::SummaryEstimate { query, .. }), None) => Some(query.clone()), + // A merge passes the statistic read on, when the merged state keeps + // its guarantee. + (Operator::ASAP(ASAPOp::SummaryMerge { .. }), Some(statistic)) => { + if !keeps_guarantee(memo, child, statistic) { + return None; + } + Some(statistic.clone()) + } + (Operator::ASAP(ASAPOp::SummaryMerge { .. }), None) => return None, + // A summary build (or finalize) reads rows: their values. + _ => None, + }; + let metrics: &[AccuracyMetric] = match &target_metric { + Some(metric) => std::slice::from_ref(metric), + None => &METRICS, + }; + let passed = metrics + .iter() + .filter_map(|&metric| { + let propagation = LogicalRequirements::error_propagation(consumer, metric)?; + let factor = propagation.factor_per_input[input]; + let validity = propagation.validity_cap_per_input[input].unwrap_or(f64::INFINITY); + Some(if factor > 0.0 { + validity.min(cap.cap / factor) + } else { + validity + }) + }) + .reduce(f64::max)?; + Some(ErrorCap { + query: cap.query, + statistic, + cap: passed, + }) +} + +/// Whether a merged state of `state`'s family keeps the guarantee of +/// `statistic` (exact, or as if built over the union). +fn keeps_guarantee(memo: &Memo, state: MemoGroupId, statistic: &SketchStatistic) -> bool { + family(expression(memo, state)).is_some_and(|family| { + matches!( + FamilyCapability::of(family).merged_error(statistic), + Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) + ) + }) +} + +/// The family a state operator builds or merges; `None` for a merge (its +/// inputs' family) and every other operator. +fn family(operator: &Operator) -> Option<&FieldDataType> { + match operator { + Operator::ASAP( + ASAPOp::SummaryAgg { family, .. } | ASAPOp::SummaryTumblingWindowChain { family, .. }, + ) => Some(family), + _ => None, + } +} + +/// Adds `cap` to `caps`, keeping the larger cap of one query and +/// statistic. +fn add_cap(caps: &mut Vec, cap: ErrorCap) { + match caps + .iter_mut() + .find(|known| known.query == cap.query && known.statistic == cap.statistic) + { + Some(known) => known.cap = known.cap.max(cap.cap), + None => caps.push(cap), + } +} + +/// Tightens the floor of every input that reads a sketch state, through a +/// readout or a merge: the weakest floor over the statistics read of the +/// largest applicable cap over the queries, with the floor the input +/// already has. A query's cap applies only when its target names no +/// metric or the statistic's metric. +fn set_floors(memo: &mut Memo, entries: &[QueryWorkloadEntry], caps: &[Vec]) { + let mut updates = Vec::new(); + for (g, group) in memo.groups().iter().enumerate() { + for (index_in_group, consumer) in group.candidates.iter().enumerate() { + let operator = &consumer.expression.operator; + let reads: Vec<(SketchStatistic, f64, usize)> = match operator { + Operator::ASAP(ASAPOp::SummaryEstimate { query, .. }) => caps[g] + .iter() + .filter(|cap| cap.statistic.is_none()) + .map(|cap| (query.clone(), cap.cap, cap.query)) + .collect(), + Operator::ASAP(ASAPOp::SummaryMerge { .. }) => caps[g] + .iter() + .filter_map(|cap| Some((cap.statistic.clone()?, cap.cap, cap.query))) + .collect(), + _ => continue, + }; + let mut requirements = consumer.input_requirements.clone(); + let mut changed = false; + for (input, &child) in operator.children().into_iter().enumerate() { + let Some(Operator::ASAP( + ASAPOp::SummaryAgg { + family: family @ FieldDataType::Sketch(algorithm, _), + .. + } + | ASAPOp::SummaryTumblingWindowChain { + family: family @ FieldDataType::Sketch(algorithm, _), + .. + }, + )) = Some(expression(memo, child)) + else { + continue; + }; + let Some(floor) = floor_of_reads(family, *algorithm, &reads, entries) else { + continue; + }; + let tightened = match requirements[input].parameter_floor_for_required_accuracy() { + Some(existing) => strongest(existing, &floor).unwrap_or(existing.clone()), + None => floor, + }; + requirements[input] = LogicalRequirements::new(Some(tightened)); + changed = true; + } + if changed { + updates.push(( + CandidateForMemoGroupId { + group: MemoGroupId(g as u32), + index_in_group, + }, + requirements, + )); + } + } + } + for (id, requirements) in updates { + memo.set_input_requirements(id, requirements); + } +} + +/// The floor of reading a state of `family` as `reads` (statistic, cap, +/// query): per statistic, the largest cap over the queries whose target +/// metric is the statistic's (or unnamed), infinite when another query's +/// is not; then the weakest of the statistics' floors. `None` when some +/// statistic needs none. +fn floor_of_reads( + family: &FieldDataType, + algorithm: asap_types::ir::schema::SketchAlgorithm, + reads: &[(SketchStatistic, f64, usize)], + entries: &[QueryWorkloadEntry], +) -> Option { + let mut statistics: Vec<(&SketchStatistic, f64)> = Vec::new(); + for (statistic, cap, query) in reads { + let metric = parameter_list_metric(family, algorithm, statistic); + let target = entries + .get(*query) + .and_then(|entry| entry.requirements.accuracy.accuracy_metric); + let applicable = match (target, metric) { + (None, _) => *cap, + (Some(target), Some(metric)) if target == metric => *cap, + _ => f64::INFINITY, + }; + match statistics.iter_mut().find(|(known, _)| *known == statistic) { + Some((_, known)) => *known = known.max(applicable), + None => statistics.push((statistic, applicable)), + } + } + statistics + .into_iter() + .map(|(statistic, cap)| floor_for_cap(family, algorithm, statistic, cap)) + .reduce(|left, right| weakest(left.as_ref()?, right.as_ref()?)) + .flatten() +} + +/// The metric of reading `statistic` from a state of `family`, by its +/// first listed configuration. +fn parameter_list_metric( + family: &FieldDataType, + algorithm: asap_types::ir::schema::SketchAlgorithm, + statistic: &SketchStatistic, +) -> Option { + let parameters = asap_types::stages::parameter_list(algorithm).first()?; + Some( + local_guarantee(family, Some(parameters), statistic)? + .spec + .accuracy_metric, + ) +} + +/// Each candidate's possible output errors, by group id and index in the +/// group, children first. +fn output_errors(memo: &Memo, order: &[MemoGroupId]) -> Vec>> { + let mut errors: Vec>> = vec![Vec::new(); memo.groups().len()]; + for &group in order { + let mut group_errors = Vec::new(); + for candidate in &memo.groups()[group.0 as usize].candidates { + group_errors.push(candidate_errors( + memo, + &errors, + &candidate.expression.operator, + )); + } + errors[group.0 as usize] = group_errors; + } + errors +} + +/// The output errors of one candidate `operator` whose input groups' +/// candidates have `errors` already. A summary state's are its rows' +/// errors (through its accumulator's contract); a readout's are the +/// guarantee of each producer state it may read ([`read_errors`]). +fn candidate_errors( + memo: &Memo, + errors: &[Vec>], + operator: &Operator, +) -> Vec { + let children: Vec = operator.children().into_iter().copied().collect(); + match operator { + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input, + query, + }) => read_errors(memo, errors, *summary_input, query), + Operator::ASAP(ASAPOp::SummaryMerge { .. } | ASAPOp::FinalizeExactAccumulator { .. }) => { + let mut out = Vec::new(); + for &child in &children { + for error in errors[child.0 as usize].iter().flatten() { + push(&mut out, *error); + } + } + out + } + _ => { + // Per input, the errors the contract admits, as they leave it. + let mut out = Vec::new(); + let mut all_exact = true; + for (input, &child) in children.iter().enumerate() { + let mut admitted = Vec::new(); + for error in errors[child.0 as usize].iter().flatten() { + if carries(operator, *error) { + push(&mut admitted, *error); + } + } + if admitted.is_empty() { + return Vec::new(); + } + all_exact &= admitted.contains(&OutputError::Exact); + for error in admitted { + let leaves = match error { + OutputError::Bound(metric) => { + match LogicalRequirements::error_propagation(operator, metric) { + Some(propagation) if propagation.factor_per_input[input] == 0.0 => { + OutputError::Exact + } + _ => error, + } + } + other => other, + }; + if leaves != OutputError::Exact { + push(&mut out, leaves); + } + } + } + if all_exact || children.is_empty() { + push(&mut out, OutputError::Exact); + } + out + } + } +} + +/// The errors of reading `statistic` from state group `state`, over each of +/// its producers: a sketch build's guarantee of its $\boldsymbol\theta$ +/// ([`local_guarantee`]); a windowed build's the same when its family's +/// merged state keeps it ([`merged_tumbling_windows_guarantee`]); a +/// merge's those of the states it merges, when the merged state keeps the +/// guarantee; an exact accumulator's its rows' errors. A build over rows +/// none of whose producers it may read gives none. +fn read_errors( + memo: &Memo, + errors: &[Vec>], + state: MemoGroupId, + statistic: &SketchStatistic, +) -> Vec { + let mut out = Vec::new(); + for (index, producer) in memo.groups()[state.0 as usize] + .candidates + .iter() + .enumerate() + { + let rows = &errors[state.0 as usize][index]; + if rows.is_empty() { + continue; + } + match &producer.expression.operator { + Operator::ASAP(ASAPOp::SummaryMerge { children, .. }) => { + for &child in children { + if keeps_guarantee(memo, child, statistic) { + for error in read_errors(memo, errors, child, statistic) { + push(&mut out, error); + } + } else { + push(&mut out, OutputError::WithoutGuarantee); + } + } + } + operator => match family(operator) { + Some(family @ FieldDataType::Sketch(..)) => push( + &mut out, + merged_tumbling_windows_guarantee( + family, + producer.logical_properties.parameters(), + producer.logical_properties.window_layout().is_some(), + statistic, + ), + ), + _ => { + for error in rows { + push(&mut out, *error); + } + } + }, + } + } + out +} + +/// The error of reading `statistic` from a sketch build of `family` with +/// $\boldsymbol\theta$ `parameters`, built over the whole window or, when +/// `windowed`, merged from its tumbling windows: the guarantee of one +/// build over the window when the family's merged error for the read is +/// exact or as if built over the union (the family-capability table), no +/// guarantee otherwise. +fn merged_tumbling_windows_guarantee( + family: &FieldDataType, + parameters: Option<&asap_types::ir::schema::SketchParams>, + windowed: bool, + statistic: &SketchStatistic, +) -> OutputError { + if windowed + && !matches!( + FamilyCapability::of(family).merged_error(statistic), + Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) + ) + { + return OutputError::WithoutGuarantee; + } + match local_guarantee(family, parameters, statistic) { + Some(guarantee) if guarantee.is_exact() => OutputError::Exact, + Some(guarantee) => OutputError::Bound(guarantee.spec.accuracy_metric), + None => OutputError::WithoutGuarantee, + } +} + +/// Adds `error` to `errors` once. +fn push(errors: &mut Vec, error: OutputError) { + if !errors.contains(&error) { + errors.push(error); + } +} diff --git a/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs b/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs index 8088177ca..a5e3d2f39 100644 --- a/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs +++ b/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs @@ -2,21 +2,20 @@ //! rules over groups, and the logical edges //! ([`asap_types::stages::LogicalASAPMemoCandidates`]), which Stage 2 expands. -use crate::memo::rules::{ - finer_aggregate, rollup, scan_labels, summary_inputs, topk, Pass2RuleError, -}; +use crate::logical_edges::logical_edges_with_capped_floors; +use crate::memo::rules::{finer_aggregate, rollup, scan_labels, topk, Pass2RuleError}; use crate::memo::window_candidates::add_local_window_composition; use asap_types::stages::{LogicalASAPMemoCandidates, Memo}; use asap_types::workload::QueryWorkloadEntry; -/// Stage 1, Pass 2: rule 1 ([`pass2_local_window_composition`]), then rules -/// 2–7 and the logical edges ([`pass2_after_local_window_composition`]). +/// Stage 1, Pass 2: rule 1 ([`pass2_local_window_composition`]), then the +/// other rules and the logical edges ([`pass2_after_local_window_composition`]). /// `entries[i]` is query `i`'s workload entry. pub fn generate_logical_asap_memo_candidates( pass1: Memo, entries: &[QueryWorkloadEntry], ) -> Result { - pass2_after_local_window_composition(pass2_local_window_composition(pass1, entries)?) + pass2_after_local_window_composition(pass2_local_window_composition(pass1, entries)?, entries) } /// Pass 2's rule 1 alone: Pass 1's memo with the tumbling-window layouts @@ -31,23 +30,27 @@ pub fn pass2_local_window_composition( Ok(memo) } -/// The rest of Pass 2 on [`pass2_local_window_composition`]'s memo: rules -/// 2–7, then the logical edges with their pruning caps. +/// The rest of Pass 2 on [`pass2_local_window_composition`]'s memo: the +/// other rules, then each input's floor tightened by its validity and +/// pruning caps, and the logical edges. `entries[i]` is query `i`'s +/// workload entry (its accuracy target). +/// +/// Pass 1 already gives every input of a summary build its own group, so +/// the old rule 3 (summary inputs as groups) has nothing left to do; a +/// coarse state's rollup (rule 6) reads the finer state group, windowed +/// builds included, so the old rule 7 (window rollup) is that candidate. pub fn pass2_after_local_window_composition( mut memo: Memo, + entries: &[QueryWorkloadEntry], ) -> Result { // 2. Scan widening. scan_labels::add_scan_label_candidates(&mut memo)?; - // 3. Summary inputs. - summary_inputs::factor_summary_inputs(&mut memo)?; // 4. Top-k reuse. topk::add_topk_candidates(&mut memo)?; // 5. Finer aggregate. finer_aggregate::add_finer_aggregate_candidates(&mut memo)?; // 6. Rollup. rollup::add_rollup_candidates(&mut memo)?; - // 7. Window rollup. - rollup::add_window_rollup_candidates(&mut memo)?; - let _ = memo; - todo!("#1207 PR 4: the logical edges, each input's floor tightened by its pruning cap") + let edges = logical_edges_with_capped_floors(&mut memo, entries)?; + Ok(LogicalASAPMemoCandidates { memo, edges }) } diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs index c9b38ed85..cfee86415 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs @@ -1,19 +1,68 @@ //! Pass 2's rules over groups (concepts doc, section 3): each reads the //! memo and adds groups (`Memo::find_or_add_group`) or candidates -//! (`Memo::add_candidate`); none copies a consumer. +//! (`Memo::add_candidate`); none copies a consumer, so every consumer of a +//! group sees what a rule adds to it. pub(crate) mod finer_aggregate; pub(crate) mod rollup; pub(crate) mod scan_labels; -pub(crate) mod summary_inputs; pub(crate) mod topk; +use asap_types::ir::schema::SketchParams; +use asap_types::ir::Operator; +use asap_types::stages::{ + CandidateExpression, CandidateForMemoGroup, CandidateForMemoGroupId, LogicalProperties, + LogicalPropertiesError, LogicalRequirements, Memo, MemoGroupId, OriginOfCandidateForMemoGroup, + WindowLayout, +}; + /// Why a Pass 2 rule failed on a memo. #[derive(Debug, thiserror::Error)] pub enum Pass2RuleError { - #[error("memo contains an out-of-range group reference")] - InvalidGroupReference, - #[error("memo group capacity exceeds the group identifier range")] - TooManyGroups, + /// A group, or one of its candidates, reads itself through other groups. #[error("memo dependencies contain a cycle")] CyclicDependencies, + /// A rule gave a candidate logical properties its expression cannot + /// have: a bug in the rule. + #[error("a rule built invalid logical properties: {0}")] + LogicalProperties(#[from] LogicalPropertiesError), +} + +/// Adds the candidate `operator` with $\boldsymbol\theta$ `parameters` and +/// $\omega$ `window_layout` to `group`, reading each input group without a +/// parameter floor unless `input_requirements` gives one per input (Pass 2 +/// sets every floor after the rules). `None` when the memo rejects it: its +/// expression breaks the node rules or its output is not the group's, which +/// is how a rule's shape check ends (the candidate does not compute the +/// group's result). +pub(crate) fn add_candidate( + memo: &mut Memo, + group: MemoGroupId, + operator: Operator, + parameters: Option, + window_layout: Option, + input_requirements: Option>, + origin: OriginOfCandidateForMemoGroup, +) -> Result, Pass2RuleError> { + let expression = CandidateExpression { operator }; + let logical_properties = LogicalProperties::new(&expression, parameters, window_layout)?; + let input_requirements = input_requirements.unwrap_or_else(|| { + vec![LogicalRequirements::new(None); expression.operator.children().len()] + }); + Ok(memo + .add_candidate( + group, + CandidateForMemoGroup { + expression, + logical_properties, + input_requirements, + fused_groups: Vec::new(), + origin, + }, + ) + .ok()) +} + +/// The group `group`'s expression. +pub(crate) fn expression(memo: &Memo, group: MemoGroupId) -> &Operator { + &memo.groups()[group.0 as usize].expression } diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs index fee9f0bfd..425b2d5d4 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs @@ -1,12 +1,318 @@ -//! Read a SQL aggregate from a finer aggregate's result -//! ([`match_finer`](crate::memo::rules::finer_aggregate::match_finer)): filter the finer -//! rows, then merge them into the query's groups with each measure's rollup. +//! Rule 5, finer aggregate: a SQL aggregate read from a finer aggregate's +//! result: filter the finer rows, then merge them into the query's groups +//! with each measure's rollup. -use crate::memo::rules::Pass2RuleError; -use asap_types::stages::Memo; +use crate::memo::rules::{add_candidate, expression, Pass2RuleError}; +use asap_types::ir::operator::{AggIntent, Reduction}; +use asap_types::ir::scalar::expr_ir::CompareOpKind; +use asap_types::ir::{NonASAPOp, Operator, OperatorResultKind, Predicate, ScalarExpr}; +use asap_types::stages::{Memo, MemoGroupId, OriginOfCandidateForMemoGroup}; -/// Rule 5, finer aggregate: a candidate of a coarse group that filters and rolls up a finer group. +/// Rule 5: for two SQL aggregate groups with identical measures over rows +/// of one scan under the same time ranges ([`match_finer`]), the coarser +/// one's candidate `Aggregate(by query keys)` over the finer group's +/// result, through a `Filter` group of the query's extra conjuncts when it +/// has any. Each measure reads the finer value with its rollup +/// ([`value_rollup`]); without a rollup, each query group is one finer row, +/// read with `min`. The memo rejects a candidate whose output is not the +/// group's (a sum of counts of another type). pub(crate) fn add_finer_aggregate_candidates(memo: &mut Memo) -> Result<(), Pass2RuleError> { - let _ = memo; - todo!("#1207 PR 4: rule 5 over groups") + let aggregates: Vec = (0..memo.groups().len()) + .map(|index| MemoGroupId(index as u32)) + .filter(|&group| aggregate(memo, group).is_some()) + .collect(); + for &owner in &aggregates { + for &finer in &aggregates { + if owner == finer { + continue; + } + let (Some(query), Some(fine)) = (aggregate(memo, owner), aggregate(memo, finer)) else { + continue; + }; + if format!("{:?}", query.measures) != format!("{:?}", fine.measures) { + continue; + } + let Some(matched) = match_finer(memo, query.child, &query.keys, fine.child, &fine.keys) + else { + continue; + }; + let global_count = query.keys.is_empty(); + let measures = query + .measures + .iter() + .enumerate() + .map(|(index, measure)| { + let col = Some(fine.keys.len() + index); + if !matched.needs_rollup + && !(global_count && matches!(measure, AggIntent::Count { .. })) + { + Some(AggIntent::Min { col }) + } else { + value_rollup(measure, col, !query.keys.is_empty()) + } + }) + .collect::>>(); + let Some(measures) = measures else { + continue; + }; + let input = match matched.compensating_filter { + Some(pred) => { + let filter = Operator::NonASAP(NonASAPOp::Filter { pred, child: finer }); + let Ok(group) = memo.find_or_add_group(filter.clone()) else { + continue; + }; + add_candidate( + memo, + group, + filter, + None, + None, + None, + OriginOfCandidateForMemoGroup::Rollup, + )?; + group + } + None => finer, + }; + let filters = vec![None; measures.len()]; + add_candidate( + memo, + owner, + Operator::NonASAP(NonASAPOp::Aggregate { + child: input, + reduction: Reduction::by(matched.query_keys), + measures, + output_names: query.output_names, + filters, + having: None, + }), + None, + None, + None, + OriginOfCandidateForMemoGroup::Rollup, + )?; + } + } + Ok(()) +} + +/// The parts of a SQL aggregate group rule 5 reads. +struct Aggregate { + child: MemoGroupId, + keys: Vec, + measures: Vec, + output_names: Vec, +} + +/// `group` as an aggregate rule 5 may read or answer: a SQL relation +/// grouped `by` keys (not `without`), without measure filters or `HAVING`, +/// with no top-k or `count_values` measure. +fn aggregate(memo: &Memo, group: MemoGroupId) -> Option { + if memo.groups()[group.0 as usize].group_properties.result_kind != OperatorResultKind::Relation + { + return None; + } + let Operator::NonASAP(NonASAPOp::Aggregate { + child, + reduction: Reduction::Reduce(by), + measures, + output_names, + filters, + having: None, + }) = expression(memo, group) + else { + return None; + }; + (!by.is_without() + && filters.iter().all(Option::is_none) + && !measures.iter().any(|measure| { + matches!( + measure, + AggIntent::TopK { .. } | AggIntent::CountValues { .. } + ) + })) + .then(|| Aggregate { + child: *child, + keys: by.keys().to_vec(), + measures: measures.clone(), + output_names: output_names.clone(), + }) +} + +/// The measure that merges finer values of `measure` at column `col` into +/// a coarser group: a sum's sum, a count's sum (only into keyed groups: a +/// global count of no rows is 0, a sum of none is null), a minimum's +/// minimum, a maximum's maximum. `None` for every other measure. +fn value_rollup(measure: &AggIntent, col: Option, keyed: bool) -> Option { + Some(match measure { + AggIntent::Sum { .. } => AggIntent::Sum { col }, + AggIntent::Count { .. } if keyed => AggIntent::Sum { col }, + AggIntent::Min { .. } => AggIntent::Min { col }, + AggIntent::Max { .. } => AggIntent::Max { col }, + _ => return None, + }) +} + +/// How a query aggregate is read from a finer one ([`match_finer`]). +struct FinerAggregateMatch { + /// The query's extra conjuncts over the finer aggregate's output + /// columns (its keys come first); `None` without extra conjuncts. + compensating_filter: Option>, + /// The query keys, as the finer aggregate's output columns. + query_keys: Vec, + /// Whether some finer key is neither a query key nor pinned to one + /// value by an extra conjunct, so finer rows merge. + needs_rollup: bool, +} + +/// How a query aggregate over `query_rows` grouped by `query_keys` is +/// computed from a finer aggregate over `finer_rows` grouped by +/// `finer_keys` (keys are columns of the rows); `None` when it cannot be. +/// Both rows must be the same scan under the same time ranges ([`Rows`]); +/// the finer conjuncts must be among the query's; every other query +/// conjunct must read only finer keys and no subquery; the query keys must +/// be finer keys. Two aggregates with the same groups are one computation, +/// not one read from the other: `None`. +fn match_finer( + memo: &Memo, + query_rows: MemoGroupId, + query_keys: &[usize], + finer_rows: MemoGroupId, + finer_keys: &[usize], +) -> Option { + let query = Rows::of(memo, query_rows)?; + let finer = Rows::of(memo, finer_rows)?; + if query.spine != finer.spine { + return None; + } + let mut extra = query.conjuncts; + for conjunct in finer.conjuncts { + let position = extra.iter().position(|other| *other == conjunct)?; + extra.remove(position); + } + let finer_key = |column: usize| finer_keys.iter().position(|&key| key == column); + let query_key_positions = query_keys + .iter() + .map(|&key| finer_key(key)) + .collect::>>()?; + if !extra.iter().all(|conjunct| { + conjunct.operator_refs().is_empty() + && conjunct + .columns_referenced() + .into_iter() + .all(|column| finer_key(column).is_some()) + }) { + return None; + } + let pinned: Vec = extra.iter().filter_map(pinned_column).collect(); + let needs_rollup = finer_keys + .iter() + .any(|key| !query_keys.contains(key) && !pinned.contains(key)); + if extra.is_empty() && !needs_rollup { + return None; + } + let compensating_filter = (!extra.is_empty()).then(|| { + let mut remapped: Vec> = extra + .iter() + .map(|conjunct| conjunct.map_columns(&mut |column| finer_key(column).unwrap_or(column))) + .collect(); + Predicate(if remapped.len() == 1 { + remapped.remove(0) + } else { + ScalarExpr::BoolAnd(remapped) + }) + }); + Some(FinerAggregateMatch { + compensating_filter, + query_keys: query_key_positions, + needs_rollup, + }) +} + +/// An aggregate's input rows: the scan and time ranges they come from, and +/// the conjuncts that select them. +struct Rows { + /// Each time range above the scan, outermost first, then the scan + /// without its predicates. + spine: Vec>, + /// Scan predicates and filter predicates, split at `AND`. + conjuncts: Vec>, +} + +impl Rows { + /// The rows of `group`, a chain of `TimeRange` and `Filter` groups over + /// a `Scan` group, none changing the scan's columns; `None` for any + /// other group. + fn of(memo: &Memo, group: MemoGroupId) -> Option { + let schema = |group: MemoGroupId| &memo.groups()[group.0 as usize].group_properties.schema; + let mut rows = Rows { + spine: Vec::new(), + conjuncts: Vec::new(), + }; + let mut group = group; + loop { + match expression(memo, group) { + Operator::NonASAP(NonASAPOp::TimeRange { child, .. }) + if schema(*child) == schema(group) => + { + rows.spine + .push(expression(memo, group).map_children(|_| ())); + group = *child; + } + Operator::NonASAP(NonASAPOp::Filter { pred, child }) + if schema(*child) == schema(group) => + { + split_and(&pred.0, &mut rows.conjuncts); + group = *child; + } + Operator::NonASAP(NonASAPOp::Scan { + source, + schema, + predicates, + }) => { + for predicate in predicates { + split_and(&predicate.0, &mut rows.conjuncts); + } + rows.spine.push(Operator::NonASAP(NonASAPOp::Scan { + source: source.clone(), + schema: schema.clone(), + predicates: Vec::new(), + })); + return Some(rows); + } + _ => return None, + } + } + } +} + +/// `expr`'s conjuncts, nested `AND`s flattened, appended to `out`. +fn split_and(expr: &ScalarExpr, out: &mut Vec>) { + match expr { + ScalarExpr::BoolAnd(conjuncts) => { + for conjunct in conjuncts { + split_and(conjunct, out); + } + } + other => out.push(other.clone()), + } +} + +/// The column an equality conjunct pins to one literal value +/// (`column = literal`, either side); `None` for any other conjunct. +fn pinned_column(conjunct: &ScalarExpr) -> Option { + let ScalarExpr::Compare { + left, + op: CompareOpKind::Eq, + right, + .. + } = conjunct + else { + return None; + }; + match (left.as_ref(), right.as_ref()) { + (ScalarExpr::Column(column), ScalarExpr::Literal(_)) + | (ScalarExpr::Literal(_), ScalarExpr::Column(column)) => Some(*column), + _ => None, + } } diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs index 2f5b8a77f..847033925 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs @@ -1,18 +1,196 @@ -//! Producer-level exact and sketch rollups, using the existing capability rules. +//! Rule 6, rollup: a coarse grouped summary state read from a finer one's +//! states, merged into the coarse groups. -use crate::memo::rules::Pass2RuleError; -use asap_types::stages::Memo; +use crate::memo::rules::{add_candidate, expression, Pass2RuleError}; +use asap_types::ir::operator::operator_properties::Source; +use asap_types::ir::operator::{AggIntent, Reduction}; +use asap_types::ir::schema::{FamilyCapability, GroupingStrategy}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator}; +use asap_types::stages::{Memo, MemoGroupId, OriginOfCandidateForMemoGroup}; -/// Rule 6, rollup: a candidate of a coarse aggregate or summary group reading -/// a finer group whose state rolls up (exact or sketch merge). +/// Rule 6: to each grouped summary state group (a `SummaryAgg` by keys, +/// exact accumulator or sketch, whose family merges across groups, +/// `FamilyCapability::merges_groups`), for each finer state group of the +/// same family, update and filter over the same rows ([`same_rows`]) whose +/// keys strictly include its keys (by name), the candidate +/// `SummaryMerge(group_by: its keys as the finer state's columns)` over the +/// finer group. The groups of one state partition its rows, so the merged +/// state is the coarse build's: a sum, count, minimum or maximum stays +/// exact; a sketch keeps its family's merged guarantee, which the readout's +/// edge checks (`FamilyCapability::merged_error`). The finer group's +/// windowed builds (rule 1) serve the coarse state through it, so a coarse +/// and a finer query share one tumbling-window chain without a rule of +/// their own. pub(crate) fn add_rollup_candidates(memo: &mut Memo) -> Result<(), Pass2RuleError> { - let _ = memo; - todo!("#1207 PR 4: rule 6 over groups") + let states: Vec = (0..memo.groups().len()) + .map(|index| MemoGroupId(index as u32)) + .filter(|&group| grouped_state(memo, group).is_some()) + .collect(); + for &coarse in &states { + for &fine in &states { + if coarse == fine { + continue; + } + let Some(group_by) = rollup_keys(memo, coarse, fine) else { + continue; + }; + add_candidate( + memo, + coarse, + Operator::ASAP(ASAPOp::SummaryMerge { + children: vec![fine], + newest_tumbling_windows: None, + group_by: Some(group_by), + }), + None, + None, + None, + OriginOfCandidateForMemoGroup::Rollup, + )?; + } + } + Ok(()) } -/// Rule 7, window rollup: a candidate of a longer-range group merging a -/// shorter-range group's windows. -pub(crate) fn add_window_rollup_candidates(memo: &mut Memo) -> Result<(), Pass2RuleError> { - let _ = memo; - todo!("#1207 PR 4: rule 7 over groups") +/// The names of a grouped state's keys, when `group` is a `SummaryAgg` by +/// keys (not `without`), one instance per group, whose family merges +/// across groups. +fn grouped_state(memo: &Memo, group: MemoGroupId) -> Option> { + let Operator::ASAP(ASAPOp::SummaryAgg { + child, + family, + reduction: Reduction::Reduce(by), + grouping: GroupingStrategy::PerSubpopulationInstance, + .. + }) = expression(memo, group) + else { + return None; + }; + if by.is_without() || !FamilyCapability::merges_groups(family) { + return None; + } + let fields = &memo.groups()[child.0 as usize] + .group_properties + .schema + .fields; + by.keys() + .iter() + .map(|&key| fields.get(key).map(|field| field.name.clone())) + .collect() +} + +/// The finer state's output columns that hold `coarse`'s keys, in +/// `coarse`'s key order, when `fine` is a finer build of the same state: +/// the same family, update, grouping and filter over the same rows, with +/// keys that strictly include `coarse`'s (by name). `None` otherwise. +fn rollup_keys(memo: &Memo, coarse: MemoGroupId, fine: MemoGroupId) -> Option> { + let ( + Operator::ASAP(ASAPOp::SummaryAgg { + child, + family, + input, + grouping, + filter, + .. + }), + Operator::ASAP(ASAPOp::SummaryAgg { + child: fine_child, + family: fine_family, + input: fine_input, + grouping: fine_grouping, + filter: fine_filter, + .. + }), + ) = (expression(memo, coarse), expression(memo, fine)) + else { + return None; + }; + let (coarse_keys, fine_keys) = (grouped_state(memo, coarse)?, grouped_state(memo, fine)?); + if family != fine_family + || input != fine_input + || grouping != fine_grouping + || filter != fine_filter + || fine_keys.len() <= coarse_keys.len() + || !coarse_keys.iter().all(|key| fine_keys.contains(key)) + || !same_rows(memo, *child, *fine_child) + { + return None; + } + let state = &memo.groups()[fine.0 as usize] + .group_properties + .schema + .fields; + coarse_keys + .iter() + .map(|key| state.iter().position(|field| &field.name == key)) + .collect() +} + +/// Whether two groups hold the same rows up to the labels they list: one +/// group; two scans of one metric without predicates; equal time ranges or +/// time shifts over the same rows; or equal per-series reductions of one +/// `avg`, `sum`, `min` or `max` of the sample value over the same rows. +fn same_rows(memo: &Memo, left: MemoGroupId, right: MemoGroupId) -> bool { + if left == right { + return true; + } + match (expression(memo, left), expression(memo, right)) { + ( + Operator::NonASAP(NonASAPOp::Scan { + source: source @ Source::TimeSeries { .. }, + predicates, + .. + }), + Operator::NonASAP(NonASAPOp::Scan { + source: other_source, + predicates: other_predicates, + .. + }), + ) => source == other_source && predicates.is_empty() && other_predicates.is_empty(), + ( + Operator::NonASAP(NonASAPOp::TimeRange { range, kind, child }), + Operator::NonASAP(NonASAPOp::TimeRange { + range: other_range, + kind: other_kind, + child: other_child, + }), + ) => range == other_range && kind == other_kind && same_rows(memo, *child, *other_child), + ( + Operator::NonASAP(NonASAPOp::TimeShift { shift, child }), + Operator::NonASAP(NonASAPOp::TimeShift { + shift: other_shift, + child: other_child, + }), + ) => shift == other_shift && same_rows(memo, *child, *other_child), + ( + Operator::NonASAP(NonASAPOp::Aggregate { + child, + reduction: Reduction::PerEntity, + measures, + filters, + having: None, + .. + }), + Operator::NonASAP(NonASAPOp::Aggregate { + child: other_child, + reduction: Reduction::PerEntity, + measures: other_measures, + filters: other_filters, + having: None, + .. + }), + ) => { + matches!( + measures.as_slice(), + [AggIntent::Avg { col: None } + | AggIntent::Sum { col: None } + | AggIntent::Min { col: None } + | AggIntent::Max { col: None }] + ) && measures == other_measures + && filters.iter().all(Option::is_none) + && other_filters.iter().all(Option::is_none) + && same_rows(memo, *child, *other_child) + } + _ => false, + } } diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs index 345e7b5ec..aacee340f 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs @@ -1,11 +1,239 @@ -//! Scan-label widening for explicit memo state sharing. Reuses the existing -//! Pass 2 widening rule; only its source-chain boundaries are interned here. +//! Rule 2, scan widening: scans of one metric that list different labels +//! read the same rows, so a scan of the union of their labels serves each. -use crate::memo::rules::Pass2RuleError; -use asap_types::stages::Memo; +use std::collections::BTreeMap; -/// Rule 2, scan widening: a group for the widened scan, and in each original scan group a candidate `Project(widened)`. +use crate::memo::rules::{add_candidate, expression, Pass2RuleError}; +use asap_types::ir::operator::operator_properties::Source; +use asap_types::ir::schema::{Schema, PROMQL_SERIES_IDENTITY}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, ProjectItem, ScalarExpr}; +use asap_types::stages::{Memo, MemoGroupId, OriginOfCandidateForMemoGroup}; + +/// Rule 2: for each metric whose scans without predicates list different +/// labels ([`union_schemas`]), the widened scan's group (the union of their +/// labels), and above it the widened image of every group that passes the +/// labels on ([`passes_labels_on`]): the same operator over the widened +/// input, with the original group's own-expression candidates +/// ($\boldsymbol\theta$ and $\omega$ included). Each original group whose +/// rows are not a summary state gets the candidate `Project(widened image)` +/// that lists its own columns, by name, in its order; the memo rejects it +/// when the projection does not compute the group's result (an open schema +/// loses no column it does not list). Two queries whose builds differ only +/// in the labels they list then read one widened build. pub(crate) fn add_scan_label_candidates(memo: &mut Memo) -> Result<(), Pass2RuleError> { - let _ = memo; - todo!("#1207 PR 4: rule 2 over groups") + let unions = union_schemas(memo); + let original_count = memo.groups().len(); + // Each widened group, by the original group it stands for. + let mut widened: BTreeMap = BTreeMap::new(); + for index in 0..original_count { + let group = MemoGroupId(index as u32); + let Some((source, schema)) = unfiltered_series_scan(memo, group) else { + continue; + }; + let Some(Some(union)) = unions.get(&source) else { + continue; + }; + if *union == schema { + continue; + } + let mut union = union.clone(); + union.time_index = union.fields.iter().position(|field| field.name == "ts"); + let scan = Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { metric: source }, + predicates: Vec::new(), + schema: union, + }); + let Ok(wide) = memo.find_or_add_group(scan.clone()) else { + continue; + }; + add_candidate( + memo, + wide, + scan, + None, + None, + None, + OriginOfCandidateForMemoGroup::WidenedScans, + )?; + widened.insert(group, wide); + } + // Widen every group that passes labels on, inputs first (a group's + // inputs have lower ids than the group in Stage 0's and Pass 1's memo). + for index in 0..original_count { + let group = MemoGroupId(index as u32); + let own = expression(memo, group).clone(); + if widened.contains_key(&group) + || !passes_labels_on(&own) + || !own + .children() + .into_iter() + .all(|child| widened.contains_key(child)) + { + continue; + } + let image = own.map_children(|child| widened[child]); + let Ok(wide) = memo.find_or_add_group(image.clone()) else { + continue; + }; + let candidates: Vec<_> = memo.groups()[index] + .candidates + .iter() + .filter(|candidate| candidate.expression.operator == own) + .map(|candidate| { + ( + candidate.logical_properties.parameters().cloned(), + candidate.logical_properties.window_layout().copied(), + ) + }) + .collect(); + for (parameters, window_layout) in candidates { + add_candidate( + memo, + wide, + image.clone(), + parameters, + window_layout, + None, + OriginOfCandidateForMemoGroup::WidenedScans, + )?; + } + widened.insert(group, wide); + } + for (&group, &wide) in &widened { + if produces_state(expression(memo, group)) { + continue; + } + let Some(project) = narrowed(memo, wide, group) else { + continue; + }; + add_candidate( + memo, + group, + project, + None, + None, + None, + OriginOfCandidateForMemoGroup::WidenedScans, + )?; + } + Ok(()) +} + +/// The metric and schema of a time-series `Scan` group without predicates. +fn unfiltered_series_scan(memo: &Memo, group: MemoGroupId) -> Option<(String, Schema)> { + match expression(memo, group) { + Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { metric }, + predicates, + schema, + }) if predicates.is_empty() => Some((metric.clone(), schema.clone())), + _ => None, + } +} + +/// Each metric's union schema over its scans without predicates, in group +/// order: the first scan's schema, each other label inserted before the +/// series identity column (or appended), unique keys renumbered. `None` +/// for a metric two of whose scans list one name with different fields, or +/// that one scan lists completely (`closed`) and another does not. +fn union_schemas(memo: &Memo) -> BTreeMap> { + let mut unions: BTreeMap> = BTreeMap::new(); + for index in 0..memo.groups().len() { + let Some((metric, schema)) = unfiltered_series_scan(memo, MemoGroupId(index as u32)) else { + continue; + }; + let Some(entry) = unions.get_mut(&metric) else { + unions.insert(metric, Some(schema)); + continue; + }; + let Some(union) = entry.as_mut() else { + continue; + }; + if union.closed != schema.closed { + *entry = None; + continue; + } + for field in &schema.fields { + match union.fields.iter().find(|known| known.name == field.name) { + Some(known) if known == field => {} + Some(_) => { + *entry = None; + break; + } + None => { + let at = union + .fields + .iter() + .position(|known| known.name == PROMQL_SERIES_IDENTITY) + .unwrap_or(union.fields.len()); + union.fields.insert(at, field.clone()); + for column in union.unique_keys.iter_mut().flatten() { + if *column >= at { + *column += 1; + } + } + } + } + } + } + unions +} + +/// Whether `operator` passes every label of its input on unchanged, so it +/// may run over the widened scan: a time range, a time shift, a per-series +/// summary build without a filter, a merge, a readout or a finalize. +fn passes_labels_on(operator: &Operator) -> bool { + match operator { + Operator::NonASAP(op) => matches!( + op, + NonASAPOp::TimeRange { .. } | NonASAPOp::TimeShift { .. } + ), + Operator::ASAP(op) => match op { + ASAPOp::SummaryAgg { + reduction, filter, .. + } + | ASAPOp::SummaryTumblingWindowChain { + reduction, filter, .. + } => reduction.group_keys().is_none() && filter.is_none(), + ASAPOp::SummaryMerge { .. } + | ASAPOp::SummaryEstimate { .. } + | ASAPOp::FinalizeExactAccumulator { .. } => true, + _ => false, + }, + } +} + +/// Whether `operator`'s rows are summary states, which a `Project` cannot +/// narrow. +fn produces_state(operator: &Operator) -> bool { + matches!( + operator, + Operator::ASAP( + ASAPOp::SummaryAgg { .. } + | ASAPOp::SummaryTumblingWindowChain { .. } + | ASAPOp::SummaryMerge { .. } + ) + ) +} + +/// `Project(wide)` listing `group`'s columns, by name, in its order; `None` +/// when `wide` lacks one of them. +fn narrowed(memo: &Memo, wide: MemoGroupId, group: MemoGroupId) -> Option> { + let from = &memo.groups()[wide.0 as usize].group_properties.schema; + let to = &memo.groups()[group.0 as usize].group_properties.schema; + let cols = to + .fields + .iter() + .map(|field| { + let column = from + .fields + .iter() + .position(|known| known.name == field.name)?; + Some(ProjectItem { + alias: Some(field.name.clone()), + expr: ScalarExpr::Column(column), + }) + }) + .collect::>>()?; + Some(Operator::NonASAP(NonASAPOp::Project { cols, child: wide })) } diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules/topk.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/topk.rs index 0a82547ba..bccfb8488 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules/topk.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/topk.rs @@ -1,11 +1,88 @@ -//! Exact ranking reuse with explicit exact-input groups. No global selection -//! flags are required: only exact implementations can supply these inputs. +//! Rule 4, top-k reuse: an exact top-k reads a larger top-k of the same +//! input and partition, whose ranked rows hold its own. -use crate::memo::rules::Pass2RuleError; -use asap_types::stages::Memo; +use crate::memo::rules::{add_candidate, expression, Pass2RuleError}; +use asap_types::ir::operator::{AggIntent, Reduction}; +use asap_types::ir::{NonASAPOp, Operator}; +use asap_types::stages::{Memo, MemoGroupId, OriginOfCandidateForMemoGroup}; -/// Rule 4, top-k reuse: a candidate of each top-k group reading a larger-`k` group, with an exact-input requirement. +/// Rule 4: to each top-k group (`Aggregate` of one `TopK` measure, +/// partitioned `by` keys, without filters or `HAVING`) another top-k group +/// over the same input group and partition with the largest `k` serves, the +/// candidate `TopK(k)` over that group's ranked rows, partitioned by its +/// leading key columns. The `k` highest of the `k_max` highest rows are the +/// `k` highest. Only exact ranked rows give the reuse its answer: the +/// consumer has no error contract (`LogicalRequirements::error_propagation`), +/// so an approximate producer (a heap's readout) gives it no edge. pub(crate) fn add_topk_candidates(memo: &mut Memo) -> Result<(), Pass2RuleError> { - let _ = memo; - todo!("#1207 PR 4: rule 4 over groups") + let topks: Vec<(MemoGroupId, MemoGroupId, Reduction, usize)> = (0..memo.groups().len()) + .filter_map(|index| { + let group = MemoGroupId(index as u32); + let (child, reduction, k) = topk(expression(memo, group))?; + Some((group, child, reduction, k)) + }) + .collect(); + for (group, child, reduction, k) in &topks { + let Some((largest, k_max)) = topks + .iter() + .filter(|(_, other_child, other_reduction, _)| { + other_child == child && other_reduction == reduction + }) + .map(|(other, _, _, other_k)| (*other, *other_k)) + .max_by_key(|&(other, other_k)| (other_k, std::cmp::Reverse(other))) + else { + continue; + }; + if k_max <= *k { + continue; + } + let Operator::NonASAP(NonASAPOp::Aggregate { + measures, + output_names, + filters, + .. + }) = expression(memo, *group).clone() + else { + continue; + }; + let keys = reduction.group_keys().map_or(0, |by| by.keys().len()); + add_candidate( + memo, + *group, + Operator::NonASAP(NonASAPOp::Aggregate { + child: largest, + reduction: Reduction::by((0..keys).collect()), + measures, + output_names, + filters, + having: None, + }), + None, + None, + None, + OriginOfCandidateForMemoGroup::TopKReuse, + )?; + } + Ok(()) +} + +/// The input group, partition and `k` of a top-k group: one `TopK` measure, +/// `by` keys (not `without`), no measure filter, no `HAVING`. +fn topk(operator: &Operator) -> Option<(MemoGroupId, Reduction, usize)> { + let Operator::NonASAP(NonASAPOp::Aggregate { + child, + reduction: reduction @ Reduction::Reduce(by), + measures, + filters, + having: None, + .. + }) = operator + else { + return None; + }; + let [AggIntent::TopK { k, .. }] = measures.as_slice() else { + return None; + }; + (!by.is_without() && filters.iter().all(Option::is_none)) + .then(|| (*child, reduction.clone(), *k)) } diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/window_candidates.rs b/crates/stage1-pass2-reuse-candidates/src/memo/window_candidates.rs index 4ecb7e5d3..2fd3df860 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/window_candidates.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/window_candidates.rs @@ -1,18 +1,129 @@ //! Rule 1, local window composition: tumbling-window layouts $\omega_v$ as //! candidates of a summary state group (#1160). -use crate::memo::rules::Pass2RuleError; -use asap_types::stages::Memo; -use asap_types::workload::QueryWorkloadEntry; +use std::collections::BTreeSet; -/// Rule 1: for each summary state group, a candidate per tumbling width $w$ -/// from the gcd closure of the ranges and steps of the queries that reach it, -/// offered only when $w$ divides the group's query range. `entries[i]` is -/// query `i`'s workload entry. +use crate::memo::rules::{add_candidate, expression, Pass2RuleError}; +use asap_types::ir::schema::FamilyCapability; +use asap_types::ir::TimeRangeKind; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorResultKind}; +use asap_types::stages::{Memo, MemoGroupId, OriginOfCandidateForMemoGroup, WindowLayout}; +use asap_types::workload::{greatest_common_divisor, QueryWorkloadEntry}; + +/// Rule 1: to each summary state group whose build can be split into +/// tumbling windows ([`splittable_window_ms`], window $W$), for each of its +/// builds (a candidate whose expression is the group's, with its +/// $\boldsymbol\theta$, not fused, without a layout) and each width $w$ of +/// [`tumbling_window_widths`], the same build with $\omega$ = tumbling +/// windows of width $w$. Every consumer of the state group sees it: no +/// consumer is copied. Stage 3 checks that each consumer's context can read +/// the grid (`PhysicalCandidate::delivers_on`). `entries[i]` is query `i`'s +/// workload entry. pub(crate) fn add_local_window_composition( memo: &mut Memo, entries: &[QueryWorkloadEntry], ) -> Result<(), Pass2RuleError> { - let _ = (memo, entries); - todo!("#1207 PR 4: rule 1, tumbling layouts as candidates of the state group") + for index in 0..memo.groups().len() { + let group = MemoGroupId(index as u32); + let Some(window_ms) = splittable_window_ms(memo, group) else { + continue; + }; + let widths = tumbling_window_widths( + window_ms, + memo.groups()[index] + .queries + .iter() + .filter_map(|&query| entries.get(query)?.evaluation_grid_ms()), + ); + let own = expression(memo, group).clone(); + let builds: Vec<_> = memo.groups()[index] + .candidates + .iter() + .filter(|candidate| { + candidate.expression.operator == own + && candidate.fused_groups.is_empty() + && candidate.logical_properties.window_layout().is_none() + }) + .map(|candidate| { + ( + candidate.logical_properties.parameters().cloned(), + candidate.input_requirements.clone(), + ) + }) + .collect(); + for (parameters, input_requirements) in builds { + for &width_ms in &widths { + add_candidate( + memo, + group, + own.clone(), + parameters.clone(), + Some(WindowLayout::Tumbling { width_ms }), + Some(input_requirements.clone()), + OriginOfCandidateForMemoGroup::TumblingWindows, + )?; + } + } + } + Ok(()) +} + +/// The window $W$, in milliseconds, of a state group whose build can be +/// split into tumbling windows: a `SummaryAgg` whose family merges (the +/// family-capability table), over a range `TimeRange(W)` (`W > 0`) over an +/// instant vector or a SQL table's rows, read at the query's time or a +/// fixed offset from it: no `@` anchor on a time shift below the range. +fn splittable_window_ms(memo: &Memo, group: MemoGroupId) -> Option { + let Operator::ASAP(ASAPOp::SummaryAgg { child, family, .. }) = expression(memo, group) else { + return None; + }; + FamilyCapability::of(family).merge_inputs?; + let Operator::NonASAP(NonASAPOp::TimeRange { + range, + kind: TimeRangeKind::Range, + child: input, + }) = expression(memo, *child) + else { + return None; + }; + if !matches!( + memo.groups()[input.0 as usize].group_properties.result_kind, + OperatorResultKind::InstantVector | OperatorResultKind::Relation + ) { + return None; + } + let mut below = *input; + while let Operator::NonASAP(NonASAPOp::TimeShift { shift, child }) = expression(memo, below) { + if shift.at.is_some() { + return None; + } + below = *child; + } + u64::try_from(range.as_millis()).ok().filter(|&ms| ms > 0) +} + +/// The tumbling-window widths offered for a window of `window_ms`: the gcd +/// closure of the evaluation grids `grids_ms` (each query's interval, range +/// step and phase, `QueryWorkloadEntry::evaluation_grid_ms`), each width +/// then reduced to divide the window: $\gcd(W, c)$ for every $c$ in the +/// closure. Ascending; empty when no query has a grid. +pub(crate) fn tumbling_window_widths( + window_ms: u64, + grids_ms: impl IntoIterator, +) -> Vec { + let mut closure: BTreeSet = BTreeSet::new(); + for grid in grids_ms.into_iter().filter(|&grid| grid > 0) { + let gcds: Vec = closure + .iter() + .map(|&known| greatest_common_divisor(known, grid)) + .collect(); + closure.insert(grid); + closure.extend(gcds); + } + closure + .into_iter() + .map(|grid| greatest_common_divisor(window_ms, grid)) + .collect::>() + .into_iter() + .collect() } diff --git a/crates/stage1-pass2-reuse-candidates/src/parameter_floors.rs b/crates/stage1-pass2-reuse-candidates/src/parameter_floors.rs new file mode 100644 index 000000000..306061d71 --- /dev/null +++ b/crates/stage1-pass2-reuse-candidates/src/parameter_floors.rs @@ -0,0 +1,176 @@ +//! $\boldsymbol\theta^{\min}_{c,i}$ from an input's error cap +//! (concepts doc, sections 2 and 4): the weakest parameters whose +//! $\varepsilon$, for the statistic read, is within the cap. + +use asap_accuracy::estimators::local_guarantee; +use asap_types::ir::schema::{FieldDataType, SketchAlgorithm, SketchParams, SketchStatistic}; +use asap_types::stages::parameter_list; + +/// The parameter floor of reading `statistic` from a state of `family` +/// within error `cap`: the per-parameter minimum ([`weakest`]) of the +/// listed configurations ($\boldsymbol\theta$ in `parameter_list`) whose +/// $\varepsilon$ is at most `cap` (or not a constant, which only Stage 3 +/// can bind). A listed configuration that dominates it is admitted even if +/// its own $\varepsilon$ is larger, so the floor never excludes one that +/// meets the cap: pruning stays safe. `None` (no floor) when every listed +/// configuration meets the cap, none does (Stage 3 rejects the plans), or +/// `cap` is infinite. +pub(crate) fn floor_for_cap( + family: &FieldDataType, + algorithm: SketchAlgorithm, + statistic: &SketchStatistic, + cap: f64, +) -> Option { + if !cap.is_finite() { + return None; + } + let list = parameter_list(algorithm); + let meets: Vec<&SketchParams> = list + .iter() + .filter(|parameters| { + local_guarantee(family, Some(parameters), statistic) + .and_then(|guarantee| guarantee.spec.error.evaluate()) + .is_none_or(|error| error <= cap) + }) + .collect(); + if meets.is_empty() || meets.len() == list.len() { + return None; + } + meets + .into_iter() + .cloned() + .map(Some) + .reduce(|left, right| weakest(left.as_ref()?, right.as_ref()?)) + .flatten() +} + +/// The per-parameter minimum of two configurations of one family (the +/// larger DDSketch `alpha`): a floor both satisfy. `None` for two families +/// or a Hydra configuration, whose parameters no list gives. +pub(crate) fn weakest(left: &SketchParams, right: &SketchParams) -> Option { + combine(left, right, false) +} + +/// The per-parameter maximum of two floors of one family (the smaller +/// DDSketch `alpha`): what satisfies both. `None` for two families or a +/// Hydra configuration. +pub(crate) fn strongest(left: &SketchParams, right: &SketchParams) -> Option { + combine(left, right, true) +} + +/// [`weakest`] (`stronger` false) or [`strongest`] (true). Each parameter +/// that sets $\varepsilon$ or holds items is combined; the recorded error +/// and failure probability are the left one's (every listed configuration +/// of a family records the same failure probability). +fn combine(left: &SketchParams, right: &SketchParams, stronger: bool) -> Option { + use SketchParams as P; + let pick = |a: u32, b: u32| if stronger { a.max(b) } else { a.min(b) }; + Some(match (left, right) { + ( + P::Kll { + k, + failure_probability, + }, + P::Kll { k: other, .. }, + ) => P::Kll { + k: pick(*k, *other), + failure_probability: *failure_probability, + }, + (P::Kmv { k, relative_error }, P::Kmv { k: other, .. }) => P::Kmv { + k: pick(*k, *other), + relative_error: *relative_error, + }, + (P::DDSketch { alpha }, P::DDSketch { alpha: other }) => P::DDSketch { + alpha: if stronger { + alpha.min(*other) + } else { + alpha.max(*other) + }, + }, + ( + P::Hll { + precision, + relative_error, + }, + P::Hll { + precision: other, .. + }, + ) => P::Hll { + precision: if stronger { + (*precision).max(*other) + } else { + (*precision).min(*other) + }, + relative_error: *relative_error, + }, + (P::Cms { width, depth }, P::Cms { width: w, depth: d }) => P::Cms { + width: pick(*width, *w), + depth: pick(*depth, *d), + }, + (P::CountSketch { width, depth }, P::CountSketch { width: w, depth: d }) => { + P::CountSketch { + width: pick(*width, *w), + depth: pick(*depth, *d), + } + } + ( + P::CmsWithHeap { + width, + depth, + heap_size, + }, + P::CmsWithHeap { + width: w, + depth: d, + heap_size: h, + }, + ) => P::CmsWithHeap { + width: pick(*width, *w), + depth: pick(*depth, *d), + heap_size: pick(*heap_size, *h), + }, + ( + P::CountSketchWithHeap { + width, + depth, + heap_size, + }, + P::CountSketchWithHeap { + width: w, + depth: d, + heap_size: h, + }, + ) => P::CountSketchWithHeap { + width: pick(*width, *w), + depth: pick(*depth, *d), + heap_size: pick(*heap_size, *h), + }, + ( + P::UnivMon { + heap_size, + sketch_rows, + sketch_cols, + layers, + distinct_failure_probability, + }, + P::UnivMon { + heap_size: h, + sketch_rows: r, + sketch_cols: c, + layers: l, + .. + }, + ) => P::UnivMon { + heap_size: pick(*heap_size, *h), + sketch_rows: pick(*sketch_rows, *r), + sketch_cols: pick(*sketch_cols, *c), + layers: if stronger { + (*layers).max(*l) + } else { + (*layers).min(*l) + }, + distinct_failure_probability: *distinct_failure_probability, + }, + _ => return None, + }) +} diff --git a/crates/stage1-pass2-reuse-candidates/src/tests.rs b/crates/stage1-pass2-reuse-candidates/src/tests.rs new file mode 100644 index 000000000..d137a8700 --- /dev/null +++ b/crates/stage1-pass2-reuse-candidates/src/tests.rs @@ -0,0 +1,807 @@ +//! Pass 2's tests. Each builds its input memo through +//! `Memo::find_or_add_group`, `Memo::add_candidate` and `Memo::add_query`, +//! with the candidates Pass 1 would add, and reads what the rules and the +//! edge construction add; no other stage crate is called. +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::{AccuracyMetric, AccuracyTarget}; +use asap_types::ir::scalar::expr_ir::ArithmeticOpKind; +use asap_types::ir::scalar::{ColumnRef, CompareOpKind, ScalarValue}; +use asap_types::ir::schema::schema_support::with_promql_series_identity; +use asap_types::ir::schema::{ + DataType, ExactKind, Field, FieldDataType, GroupingStrategy, Schema, SketchAlgorithm, + SketchParams, SketchStatistic, SummaryInputExpr, SummaryUpdate, +}; +use asap_types::ir::{ + ASAPOp, BinaryOperator, ExprSemantics, NonASAPOp, Operator, OperatorNode, Predicate, QueryRoot, + ScalarExpr, TimeRangeKind, +}; +use asap_types::stages::{ + parameter_list, CandidateExpression, CandidateForMemoGroup, CandidateForMemoGroupId, + LogicalASAPMemoCandidates, LogicalProperties, LogicalRequirements, Memo, MemoGroupId, + OriginOfCandidateForMemoGroup, WindowLayout, +}; +use asap_types::workload::{ + Predictability, Query, QueryRecurrence, QueryRequirements, QueryWorkloadEntry, RepeatedDemand, + RepetitionInterval, +}; + +use crate::generate_logical_asap_memo_candidates; +use crate::memo::window_candidates::tumbling_window_widths; +use crate::parameter_floors::{strongest, weakest}; + +// ── Fixtures ────────────────────────────────────────────────────────────── + +/// Adds `operator` with `parameters` to `group`, as Pass 1 does. +fn candidate( + memo: &mut Memo, + group: MemoGroupId, + operator: Operator, + parameters: Option, +) -> CandidateForMemoGroupId { + let expression = CandidateExpression { operator }; + let logical_properties = LogicalProperties::new(&expression, parameters, None).unwrap(); + let input_requirements = + vec![LogicalRequirements::new(None); expression.operator.children().len()]; + memo.add_candidate( + group, + CandidateForMemoGroup { + expression, + logical_properties, + input_requirements, + fused_groups: Vec::new(), + origin: OriginOfCandidateForMemoGroup::Pass1, + }, + ) + .unwrap() +} + +/// The group of `operator`, with `operator` as its exact candidate. +fn group(memo: &mut Memo, operator: Operator) -> MemoGroupId { + let group = memo.find_or_add_group(operator.clone()).unwrap(); + candidate(memo, group, operator, None); + group +} + +/// `[ts, value, labels...]`, the series identity realized as the planner +/// realizes it before Stage 0. +fn series_schema(labels: &[&str]) -> Schema { + let mut fields = vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("value", DataType::Float64, false), + ]; + fields.extend( + labels + .iter() + .map(|n| Field::plain(*n, DataType::Utf8, true)), + ); + let open = OperatorNode::new_shared(scan_operator( + "m", + Schema::with_time_index(fields, 0, vec![]), + )) + .unwrap(); + with_promql_series_identity(&open).unwrap().schema.clone() +} + +fn scan_operator(metric: &str, schema: Schema) -> Operator { + Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { + metric: metric.into(), + }, + predicates: vec![], + schema, + }) +} + +/// A time-series scan of `metric` listing `labels`. +fn scan(memo: &mut Memo, metric: &str, labels: &[&str]) -> MemoGroupId { + let schema = series_schema(labels); + group(memo, scan_operator(metric, schema)) +} + +/// `child[seconds]`. +fn range(memo: &mut Memo, child: MemoGroupId, seconds: u64) -> MemoGroupId { + group( + memo, + Operator::NonASAP(NonASAPOp::TimeRange { + range: Duration::from_secs(seconds), + kind: TimeRangeKind::Range, + child, + }), + ) +} + +/// A summary build of `algorithm` over the sample values of `child`, +/// grouped by `reduction`. +fn build(child: MemoGroupId, family: FieldDataType, reduction: Reduction) -> Operator { + Operator::ASAP(ASAPOp::SummaryAgg { + child, + family, + input: SummaryUpdate { + item: None, + weight: SummaryInputExpr::Column(ColumnRef::SampleValue), + weight_domain: Default::default(), + }, + reduction, + grouping: GroupingStrategy::default(), + filter: None, + }) +} + +fn sketch(algorithm: SketchAlgorithm) -> FieldDataType { + FieldDataType::Sketch(algorithm, GroupingStrategy::default()) +} + +/// The state group of a sketch build, one candidate per listed θ. +fn sketch_state( + memo: &mut Memo, + child: MemoGroupId, + algorithm: SketchAlgorithm, + reduction: Reduction, +) -> MemoGroupId { + let operator = build(child, sketch(algorithm), reduction); + let state = memo.find_or_add_group(operator.clone()).unwrap(); + for parameters in parameter_list(algorithm) { + candidate(memo, state, operator.clone(), Some(parameters.clone())); + } + state +} + +/// A readout group of `statistic` over `state`. +fn estimate(memo: &mut Memo, state: MemoGroupId, statistic: SketchStatistic) -> MemoGroupId { + group( + memo, + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: state, + query: statistic, + }), + ) +} + +fn arithmetic( + memo: &mut Memo, + kind: ArithmeticOpKind, + lhs: MemoGroupId, + rhs: MemoGroupId, +) -> MemoGroupId { + group( + memo, + Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind: BinaryOpKind::Arithmetic(kind), + vector_match: None, + }, + return_bool: false, + lhs, + rhs, + }), + ) +} + +/// A query repeating every `interval_ms` at phase 0 with `accuracy`. +fn entry(interval_ms: u32, accuracy: AccuracyTarget) -> QueryWorkloadEntry { + QueryWorkloadEntry { + query: Query("q".into()), + requirements: QueryRequirements { + accuracy, + max_latency_ms: None, + }, + predictability: Predictability::Predictable { known_at: None }, + recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( + interval_ms, + ))), + range_evaluation: None, + } +} + +fn pass2(memo: Memo, entries: &[QueryWorkloadEntry]) -> LogicalASAPMemoCandidates { + generate_logical_asap_memo_candidates(memo, entries).unwrap() +} + +fn candidates_of(memo: &Memo, group: MemoGroupId) -> &[CandidateForMemoGroup] { + &memo.groups()[group.0 as usize].candidates +} + +/// The edges into input `input` of `consumer`: each producer and α^log. +fn edges_into( + logical: &LogicalASAPMemoCandidates, + consumer: CandidateForMemoGroupId, + input: usize, +) -> Vec<(CandidateForMemoGroupId, bool)> { + logical + .edges + .iter() + .filter(|edge| { + edge.consumer_candidate_id == consumer && edge.input_index_of_consumer == input + }) + .map(|edge| { + ( + edge.producer_candidate_id, + edge.satisfies_logical_requirement, + ) + }) + .collect() +} + +fn own_candidate(group: MemoGroupId) -> CandidateForMemoGroupId { + CandidateForMemoGroupId { + group, + index_in_group: 0, + } +} + +/// `quantile_over_time(q, m[seconds])` per series through a sketch state +/// of `algorithm`: the range group, the state group and the readout group. +fn quantile_over_time( + memo: &mut Memo, + algorithm: SketchAlgorithm, + q: f64, + seconds: u64, +) -> (MemoGroupId, MemoGroupId, MemoGroupId) { + let input = scan(memo, "m", &["job"]); + let window = range(memo, input, seconds); + let state = sketch_state(memo, window, algorithm, Reduction::PerEntity); + let readout = estimate(memo, state, SketchStatistic::Quantile { q }); + (window, state, readout) +} + +// ── Rule 1: tumbling layouts ────────────────────────────────────────────── + +/// A 5m window's widths are the gcds of the window with the evaluation +/// grids and their gcd closure: 120 s gives 60 s; 90 s gives 30 s; both +/// queries add their gcd 30 s once; no grid gives none. +#[test] +fn tumbling_window_widths_are_the_gcd_closure_dividing_the_window() { + assert_eq!(tumbling_window_widths(300_000, [120_000]), vec![60_000]); + assert_eq!(tumbling_window_widths(300_000, [90_000]), vec![30_000]); + assert_eq!(tumbling_window_widths(300_000, [300_000]), vec![300_000]); + assert_eq!( + tumbling_window_widths(300_000, [120_000, 90_000]), + vec![30_000, 60_000] + ); + assert!(tumbling_window_widths(300_000, []).is_empty()); +} + +/// Every build of a mergeable state over a range gets, per width, the same +/// build with a tumbling layout: one per listed θ. Every consumer of the +/// state group sees them; no consumer is copied. +#[test] +fn a_mergeable_state_gets_one_windowed_build_per_listed_parameters_and_width() { + let mut memo = Memo::default(); + let (_, state, readout) = quantile_over_time(&mut memo, SketchAlgorithm::Kll, 0.9, 300); + memo.add_query(QueryRoot::Operator(readout)); + let logical = pass2(memo, &[entry(60_000, AccuracyTarget::max_error(0.1))]); + let windowed: Vec<_> = candidates_of(&logical.memo, state) + .iter() + .filter(|c| c.logical_properties.window_layout().is_some()) + .collect(); + let listed = parameter_list(SketchAlgorithm::Kll).len(); + assert_eq!(windowed.len(), listed); + assert!(windowed.iter().all(|c| { + c.logical_properties.window_layout() == Some(&WindowLayout::Tumbling { width_ms: 60_000 }) + && c.origin == OriginOfCandidateForMemoGroup::TumblingWindows + })); + // The readout is one candidate, and reads every build of the state. + assert_eq!(candidates_of(&logical.memo, readout).len(), 1); + assert_eq!( + edges_into(&logical, own_candidate(readout), 0).len(), + 2 * listed + ); +} + +/// No layout for a family without a merge (an instant rate), nor for a +/// window read at an `@` anchor. +#[test] +fn no_tumbling_layout_without_a_merge_or_at_an_anchor() { + use asap_types::ir::operator::operator_properties::{AtModifier, TimeShift}; + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["job"]); + let window = range(&mut memo, input, 300); + let irate = group( + &mut memo, + build( + window, + FieldDataType::exact(ExactKind::IRate), + Reduction::PerEntity, + ), + ); + let anchored = group( + &mut memo, + Operator::NonASAP(NonASAPOp::TimeShift { + shift: TimeShift { + offset_ms: 0, + at: Some(AtModifier::Start), + }, + child: input, + }), + ); + let anchored_window = range(&mut memo, anchored, 300); + let kll = sketch_state( + &mut memo, + anchored_window, + SketchAlgorithm::Kll, + Reduction::PerEntity, + ); + let finalized = group( + &mut memo, + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: irate }), + ); + let readout = estimate(&mut memo, kll, SketchStatistic::Quantile { q: 0.5 }); + memo.add_query(QueryRoot::Operator(finalized)); + memo.add_query(QueryRoot::Operator(readout)); + let target = AccuracyTarget::max_error(0.1); + let logical = pass2( + memo, + &[entry(60_000, target.clone()), entry(60_000, target)], + ); + for state in [irate, kll] { + assert!(candidates_of(&logical.memo, state) + .iter() + .all(|c| c.logical_properties.window_layout().is_none())); + } +} + +// ── Rule 2: scan widening ───────────────────────────────────────────────── + +/// Scans of one metric listing different labels get the union scan (each +/// new label before the series identity), and each original scan, and +/// each group above it that passes labels on, reads it through a +/// projection of its own columns. +#[test] +fn scans_of_one_metric_with_other_labels_read_one_widened_scan() { + let mut memo = Memo::default(); + let by_job = scan(&mut memo, "m", &["job"]); + let by_host = scan(&mut memo, "m", &["host", "job"]); + let job_window = range(&mut memo, by_job, 60); + let host_window = range(&mut memo, by_host, 60); + memo.add_query(QueryRoot::Operator(job_window)); + memo.add_query(QueryRoot::Operator(host_window)); + let target = AccuracyTarget::exact(); + let logical = pass2( + memo, + &[entry(60_000, target.clone()), entry(60_000, target)], + ); + let memo = &logical.memo; + let reads_widened = |group: MemoGroupId| -> Option { + candidates_of(memo, group) + .iter() + .find_map(|c| match (&c.origin, &c.expression.operator) { + ( + OriginOfCandidateForMemoGroup::WidenedScans, + Operator::NonASAP(NonASAPOp::Project { child, .. }), + ) => Some(*child), + _ => None, + }) + }; + let wide_job = reads_widened(by_job).expect("the narrow scan reads the widened one"); + let wide_host = reads_widened(by_host).expect("both scans read it"); + assert_eq!(wide_job, wide_host); + let names: Vec<&str> = memo.groups()[wide_job.0 as usize] + .group_properties + .schema + .fields + .iter() + .map(|f| f.name.as_str()) + .collect(); + assert_eq!( + names, + ["ts", "value", "job", "host", "$promql_series_identity"] + ); + // The time ranges above them read one widened time range. + assert_eq!(reads_widened(job_window), reads_widened(host_window)); + assert!(reads_widened(job_window).is_some()); +} + +// ── Rule 4: top-k reuse ─────────────────────────────────────────────────── + +/// An exact top-3 reads the top-5 of the same input and partition; the +/// top-5 reads nothing new. Only the exact top-5 gives it an edge. +#[test] +fn a_smaller_topk_reads_the_largest_topk_of_the_same_input() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["job"]); + let topk = |memo: &mut Memo, k| { + group( + memo, + Operator::NonASAP(NonASAPOp::Aggregate { + child: input, + reduction: Reduction::by(vec![]), + measures: vec![AggIntent::TopK { + k, + accuracy: AccuracyTarget::exact(), + }], + output_names: vec!["topk".into()], + filters: vec![None], + having: None, + }), + ) + }; + let top3 = topk(&mut memo, 3); + let top5 = topk(&mut memo, 5); + memo.add_query(QueryRoot::Operator(top3)); + memo.add_query(QueryRoot::Operator(top5)); + let exact = AccuracyTarget::exact(); + let logical = pass2(memo, &[entry(60_000, exact.clone()), entry(60_000, exact)]); + let reuse: Vec<_> = candidates_of(&logical.memo, top3) + .iter() + .enumerate() + .filter(|(_, c)| c.origin == OriginOfCandidateForMemoGroup::TopKReuse) + .collect(); + assert_eq!(reuse.len(), 1); + assert_eq!(reuse[0].1.expression.operator.children(), vec![&top5]); + assert!(candidates_of(&logical.memo, top5) + .iter() + .all(|c| c.origin != OriginOfCandidateForMemoGroup::TopKReuse)); + let edges = edges_into( + &logical, + CandidateForMemoGroupId { + group: top3, + index_in_group: reuse[0].0, + }, + 0, + ); + assert_eq!(edges, vec![(own_candidate(top5), true)]); +} + +// ── Rule 5: finer aggregate ─────────────────────────────────────────────── + +fn table_scan(memo: &mut Memo) -> MemoGroupId { + group( + 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("dst", DataType::Utf8, false), + Field::plain("bytes", DataType::Float64, false), + ]), + }), + ) +} + +fn sql_sum(memo: &mut Memo, child: MemoGroupId, keys: Vec) -> MemoGroupId { + group( + memo, + Operator::NonASAP(NonASAPOp::Aggregate { + child, + reduction: Reduction::by(keys), + measures: vec![AggIntent::Sum { col: Some(2) }], + output_names: vec!["total".into()], + filters: vec![None], + having: None, + }), + ) +} + +/// `sum(bytes) by src` reads `sum(bytes) by (src, dst)`, summing its rows +/// into its groups; the finer one reads nothing from the coarser one. +#[test] +fn a_coarse_sql_sum_rolls_up_a_finer_sum() { + let mut memo = Memo::default(); + let rows = table_scan(&mut memo); + let coarse = sql_sum(&mut memo, rows, vec![0]); + let fine = sql_sum(&mut memo, rows, vec![0, 1]); + memo.add_query(QueryRoot::Operator(coarse)); + memo.add_query(QueryRoot::Operator(fine)); + let exact = AccuracyTarget::exact(); + let logical = pass2(memo, &[entry(60_000, exact.clone()), entry(60_000, exact)]); + let rollups: Vec<_> = candidates_of(&logical.memo, coarse) + .iter() + .filter(|c| c.origin == OriginOfCandidateForMemoGroup::Rollup) + .collect(); + assert_eq!(rollups.len(), 1); + let Operator::NonASAP(NonASAPOp::Aggregate { + child, + reduction, + measures, + .. + }) = &rollups[0].expression.operator + else { + panic!("{:?}", rollups[0].expression.operator); + }; + assert_eq!(*child, fine); + assert_eq!(*reduction, Reduction::by(vec![0])); + assert_eq!(*measures, vec![AggIntent::Sum { col: Some(2) }]); + assert!(candidates_of(&logical.memo, fine) + .iter() + .all(|c| c.origin != OriginOfCandidateForMemoGroup::Rollup)); +} + +/// `… WHERE dst = 'a' GROUP BY src` reads the finer groups through a +/// filter on the finer key; the key is pinned, so each query group is one +/// finer row, read with `min`. +#[test] +fn a_filtered_sql_sum_reads_the_finer_rows_through_a_compensating_filter() { + let mut memo = Memo::default(); + let rows = table_scan(&mut memo); + let fine = sql_sum(&mut memo, rows, vec![0, 1]); + let filtered = group( + &mut memo, + Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(1)), + op: CompareOpKind::Eq, + right: Box::new(ScalarExpr::Literal(ScalarValue::Utf8("a".into()))), + semantics: ExprSemantics::Sql, + }), + child: rows, + }), + ); + let coarse = sql_sum(&mut memo, filtered, vec![0]); + memo.add_query(QueryRoot::Operator(coarse)); + memo.add_query(QueryRoot::Operator(fine)); + let exact = AccuracyTarget::exact(); + let logical = pass2(memo, &[entry(60_000, exact.clone()), entry(60_000, exact)]); + let rollup = candidates_of(&logical.memo, coarse) + .iter() + .find(|c| c.origin == OriginOfCandidateForMemoGroup::Rollup) + .expect("the filtered sum reads the finer one"); + let Operator::NonASAP(NonASAPOp::Aggregate { + child, measures, .. + }) = &rollup.expression.operator + else { + panic!(); + }; + assert_eq!(*measures, vec![AggIntent::Min { col: Some(2) }]); + let Operator::NonASAP(NonASAPOp::Filter { child: below, .. }) = + &logical.memo.groups()[child.0 as usize].expression + else { + panic!("a compensating filter group"); + }; + assert_eq!(*below, fine); +} + +// ── Rule 6: rollup ──────────────────────────────────────────────────────── + +/// A KLL state grouped by `job` reads the one grouped by `(host, job)` over +/// the same rows, merged into its groups (`group_by` the finer state's +/// `job` column); the finer one reads nothing from it. +#[test] +fn a_coarse_sketch_state_merges_a_finer_state() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["host", "job"]); + let window = range(&mut memo, input, 60); + let coarse = sketch_state( + &mut memo, + window, + SketchAlgorithm::Kll, + Reduction::by(vec![3]), + ); + let fine = sketch_state( + &mut memo, + window, + SketchAlgorithm::Kll, + Reduction::by(vec![2, 3]), + ); + let coarse_readout = estimate(&mut memo, coarse, SketchStatistic::Quantile { q: 0.9 }); + let fine_readout = estimate(&mut memo, fine, SketchStatistic::Quantile { q: 0.9 }); + memo.add_query(QueryRoot::Operator(coarse_readout)); + memo.add_query(QueryRoot::Operator(fine_readout)); + let target = AccuracyTarget::max_error(0.1); + let logical = pass2( + memo, + &[entry(60_000, target.clone()), entry(60_000, target)], + ); + let merges: Vec<_> = candidates_of(&logical.memo, coarse) + .iter() + .filter(|c| c.origin == OriginOfCandidateForMemoGroup::Rollup) + .collect(); + assert_eq!(merges.len(), 1, "{merges:?}"); + let Operator::ASAP(ASAPOp::SummaryMerge { + children, + group_by, + newest_tumbling_windows: None, + }) = &merges[0].expression.operator + else { + panic!("{:?}", merges[0].expression.operator); + }; + assert_eq!(*children, vec![fine]); + assert_eq!(*group_by, Some(vec![1])); + assert!(candidates_of(&logical.memo, fine) + .iter() + .all(|c| c.origin != OriginOfCandidateForMemoGroup::Rollup)); +} + +// ── Edges, metric rule, caps ────────────────────────────────────────────── + +/// Every producer of an input's group is an edge into it, rejected ones +/// included: each consumer input lists every candidate of its group. +#[test] +fn every_candidate_of_an_input_group_is_an_edge() { + let mut memo = Memo::default(); + let (_, state, readout) = quantile_over_time(&mut memo, SketchAlgorithm::Kll, 0.9, 60); + memo.add_query(QueryRoot::Operator(readout)); + let logical = pass2(memo, &[]); + let edges = edges_into(&logical, own_candidate(readout), 0); + assert_eq!(edges.len(), candidates_of(&logical.memo, state).len()); + // Without a target no input is capped: every edge satisfies. + assert!(edges.iter().all(|&(_, satisfies)| satisfies)); +} + +/// A rank target 0.01 caps the KLL readout's input at 0.01: its floor is +/// the weakest listed k within it, the weaker builds' edges fail and the +/// stronger ones' hold. +#[test] +fn a_query_target_caps_the_readout_input_with_a_parameter_floor() { + let mut memo = Memo::default(); + let (_, state, readout) = quantile_over_time(&mut memo, SketchAlgorithm::Kll, 0.9, 60); + memo.add_query(QueryRoot::Operator(readout)); + let target = AccuracyTarget::max_error(0.01).accuracy_metric(AccuracyMetric::Rank); + let logical = pass2(memo, &[entry(60_000, target)]); + let floor = candidates_of(&logical.memo, readout)[0].input_requirements[0] + .parameter_floor_for_required_accuracy() + .cloned() + .expect("a floor"); + let SketchParams::Kll { k: floor_k, .. } = floor else { + panic!("{floor:?}"); + }; + for (producer, satisfies) in edges_into(&logical, own_candidate(readout), 0) { + let Some(SketchParams::Kll { k, .. }) = logical + .memo + .candidate(producer) + .logical_properties + .parameters() + else { + panic!(); + }; + assert_eq!(satisfies, *k >= floor_k, "k = {k}"); + } + assert!(edges_into(&logical, own_candidate(readout), 0) + .iter() + .any(|&(_, satisfies)| !satisfies)); + assert!(producer_count(&logical, state) > 0); +} + +fn producer_count(logical: &LogicalASAPMemoCandidates, group: MemoGroupId) -> usize { + candidates_of(&logical.memo, group).len() +} + +/// The metric rule: a relative-error ratio reads a DDSketch readout +/// (relative error) but not a KLL one (rank error), and always an exact +/// value. +#[test] +fn a_ratio_reads_no_rank_error() { + let mut memo = Memo::default(); + let (_, _, kll) = quantile_over_time(&mut memo, SketchAlgorithm::Kll, 0.99, 60); + let (_, _, ddsketch) = quantile_over_time(&mut memo, SketchAlgorithm::DDSketch, 0.5, 60); + let ratio = arithmetic(&mut memo, ArithmeticOpKind::Div, kll, ddsketch); + memo.add_query(QueryRoot::Operator(ratio)); + let target = AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Relative); + let logical = pass2(memo, &[entry(60_000, target)]); + assert_eq!( + edges_into(&logical, own_candidate(ratio), 0), + vec![(own_candidate(kll), false)] + ); + assert_eq!( + edges_into(&logical, own_candidate(ratio), 1), + vec![(own_candidate(ddsketch), true)] + ); +} + +/// A ratio passes its target down through `1 / (1 - b)` (and caps its +/// denominator at `b`): with a relative target 0.05 both readouts' inputs +/// need `alpha ≤ 0.045`, so the weakest listed alpha within it. +#[test] +fn a_ratio_divides_its_target_by_its_propagation_factor() { + let mut memo = Memo::default(); + let (_, _, numerator) = quantile_over_time(&mut memo, SketchAlgorithm::DDSketch, 0.99, 60); + let input = scan(&mut memo, "m", &["job"]); + let window = range(&mut memo, input, 120); + let state = sketch_state( + &mut memo, + window, + SketchAlgorithm::DDSketch, + Reduction::PerEntity, + ); + let denominator = estimate(&mut memo, state, SketchStatistic::Quantile { q: 0.5 }); + let ratio = arithmetic(&mut memo, ArithmeticOpKind::Div, numerator, denominator); + memo.add_query(QueryRoot::Operator(ratio)); + let target = AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Relative); + let logical = pass2(memo, &[entry(60_000, target)]); + for readout in [numerator, denominator] { + assert_eq!( + candidates_of(&logical.memo, readout)[0].input_requirements[0] + .parameter_floor_for_required_accuracy(), + Some(&SketchParams::DDSketch { alpha: 0.02 }), + ); + } +} + +/// A query whose target names another metric than the readout's sets no +/// floor: the cap would compare errors in two metrics. +#[test] +fn a_target_in_another_metric_sets_no_floor() { + let mut memo = Memo::default(); + let (_, _, readout) = quantile_over_time(&mut memo, SketchAlgorithm::Kll, 0.9, 60); + memo.add_query(QueryRoot::Operator(readout)); + let target = AccuracyTarget::max_error(0.001).accuracy_metric(AccuracyMetric::Relative); + let logical = pass2(memo, &[entry(60_000, target)]); + assert!( + candidates_of(&logical.memo, readout)[0].input_requirements[0] + .parameter_floor_for_required_accuracy() + .is_none() + ); +} + +/// Two queries reading one readout: the larger cap wins, since either may +/// use it. A query reading it through a sum, which carries no rank error, +/// gives it no cap. +#[test] +fn the_loosest_query_sets_a_shared_inputs_cap() { + let strict = AccuracyTarget::max_error(0.001).accuracy_metric(AccuracyMetric::Rank); + let loose = AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Rank); + let floor = |targets: &[&AccuracyTarget], through_sum: bool| { + let mut memo = Memo::default(); + let (_, _, readout) = quantile_over_time(&mut memo, SketchAlgorithm::Kll, 0.9, 60); + memo.add_query(QueryRoot::Operator(readout)); + if targets.len() > 1 { + let root = if through_sum { + arithmetic(&mut memo, ArithmeticOpKind::Add, readout, readout) + } else { + readout + }; + memo.add_query(QueryRoot::Operator(root)); + } + let entries: Vec<_> = targets + .iter() + .map(|t| entry(60_000, (*t).clone())) + .collect(); + let logical = pass2(memo, &entries); + candidates_of(&logical.memo, readout)[0].input_requirements[0] + .parameter_floor_for_required_accuracy() + .cloned() + }; + assert_ne!(floor(&[&strict], false), floor(&[&loose], false)); + assert_eq!(floor(&[&strict, &loose], false), floor(&[&loose], false)); + assert_eq!(floor(&[&strict, &loose], true), floor(&[&strict], false)); +} + +/// A top-k readout's heap floor (heap size ≥ k) stays when a cap tightens +/// the width: the floor satisfies both. +#[test] +fn floors_combine_per_parameter() { + let heap = SketchParams::CmsWithHeap { + width: 100, + depth: 3, + heap_size: 50, + }; + let wide = SketchParams::CmsWithHeap { + width: 400, + depth: 5, + heap_size: 10, + }; + assert_eq!( + strongest(&heap, &wide), + Some(SketchParams::CmsWithHeap { + width: 400, + depth: 5, + heap_size: 50 + }) + ); + assert_eq!( + weakest(&heap, &wide), + Some(SketchParams::CmsWithHeap { + width: 100, + depth: 3, + heap_size: 10 + }) + ); + assert_eq!( + strongest( + &SketchParams::DDSketch { alpha: 0.01 }, + &SketchParams::DDSketch { alpha: 0.05 } + ), + Some(SketchParams::DDSketch { alpha: 0.01 }) + ); + assert_eq!( + strongest(&heap, &SketchParams::DDSketch { alpha: 0.01 }), + None + ); +} From e9630ad35bf79ba15b4e40ec366d5f64e3ce2248 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:30:23 +0000 Subject: [PATCH 09/15] fix(stage1-pass2): merge floors from their consumers; scalar roots uncapped; tests (#1207 PR 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merge of sketch states takes the weakest floor of the consumers reading its group (the merged state has its inputs' θ), which also carries a top-k's heap size; caps no longer track statistics. A scalar root's operands get no cap (its expression is not read). Scan widening keeps each mirrored build's input requirements and skips fused builds. planner-layering.md: Pass 2's rules and edges. --- .../src/logical_edges.rs | 249 +++++++++--------- .../src/memo/rules/scan_labels.rs | 11 +- .../src/tests.rs | 182 +++++++++++++ .../design_docs/proposals/planner-layering.md | 28 +- 4 files changed, 325 insertions(+), 145 deletions(-) diff --git a/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs b/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs index a36b92e37..a55be627f 100644 --- a/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs +++ b/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs @@ -6,15 +6,15 @@ //! 1. **Caps, top down.** Query $q$'s root group may be off by its target //! $\varepsilon_q$. A consumer $c$ passes a cap to input $i$ through its //! error contract (`LogicalRequirements::error_propagation`): -//! $\min(b_{c,i},\ \text{cap}/\beta_{c,i})$. A group keeps, per query -//! (and per statistic read, for a summary state), the largest cap any -//! consumer passes it: the bottleneck over paths, a lower bound on -//! $\lambda_{q,c,i}$ along any path, so the cap removes only what no -//! plan can use. -//! 2. **Floors.** A consumer reading a sketch state (a readout, or a merge -//! of states) gets, per input, the floor of its cap for the statistic -//! read ([`floor_for_cap`]), tightened with the floor it already has -//! (a top-k's heap size). +//! $\min(b_{c,i},\ \text{cap}/\beta_{c,i})$. A group keeps, per query, +//! the largest cap any consumer passes it: the bottleneck over paths, +//! with $\lambda_{q,c,i}$ bounded below along any path, so the cap +//! removes only what no plan can use. +//! 2. **Floors, top down.** A readout of a sketch state gets the floor of +//! its group's cap for the statistic it reads ([`floor_for_cap`]), +//! tightened with the floor it already has (a top-k's heap size). A +//! merge of sketch states gets the weakest floor of the consumers +//! reading its group: the merged state has its inputs' $\boldsymbol\theta$. //! 3. **Output errors, bottom up, and edges.** Each candidate's possible //! output errors ([`OutputError`]): exact, a bound in one metric, or no //! guarantee. The metric rule: an input whose error is in a metric the @@ -26,8 +26,10 @@ use crate::memo::rules::{expression, Pass2RuleError}; use crate::parameter_floors::{floor_for_cap, strongest, weakest}; use asap_accuracy::estimators::local_guarantee; use asap_types::ir::properties::AccuracyMetric; -use asap_types::ir::schema::{FamilyCapability, FieldDataType, MergedError, SketchStatistic}; -use asap_types::ir::{ASAPOp, Operator}; +use asap_types::ir::schema::{ + FamilyCapability, FieldDataType, MergedError, SketchAlgorithm, SketchParams, SketchStatistic, +}; +use asap_types::ir::{ASAPOp, Operator, QueryRoot}; use asap_types::stages::{ producer_satisfies_logical_requirements, CandidateForMemoGroupId, LogicalEdge, LogicalRequirements, Memo, MemoGroupId, @@ -57,12 +59,10 @@ pub(crate) enum OutputError { } /// One cap of a group: query `query` may use this group's output with an -/// error of at most `cap` (infinite: any), read as `statistic` when the -/// group is a summary state. +/// error of at most `cap` (infinite: any). #[derive(Debug, Clone)] struct ErrorCap { query: usize, - statistic: Option, cap: f64, } @@ -78,7 +78,7 @@ pub(crate) fn logical_edges_with_capped_floors( .topological_order() .ok_or(Pass2RuleError::CyclicDependencies)?; let caps = error_caps(memo, entries, &order); - set_floors(memo, entries, &caps); + set_floors(memo, entries, &caps, &order); let errors = output_errors(memo, &order); let mut edges = Vec::new(); for (g, group) in memo.groups().iter().enumerate() { @@ -162,28 +162,26 @@ fn value_preserving(consumer: &Operator) -> bool { }) } -/// Each group's caps ([`ErrorCap`]), by group id: the roots' targets, -/// then every consumer's caps passed down through its contract. +/// Each group's caps ([`ErrorCap`]), by group id: each query root group's +/// target, then every consumer's caps passed down through its contract. fn error_caps( memo: &Memo, entries: &[QueryWorkloadEntry], order: &[MemoGroupId], ) -> Vec> { let mut caps: Vec> = vec![Vec::new(); memo.groups().len()]; + // A scalar root's expression is not read here: its operands get no cap. for (query, root) in memo.query_roots.iter().enumerate() { - let Some(entry) = entries.get(query) else { + let (Some(entry), QueryRoot::Operator(group)) = (entries.get(query), root) else { continue; }; - for &group in root.operator_refs() { - add_cap( - &mut caps[group.0 as usize], - ErrorCap { - query, - statistic: None, - cap: entry.requirements.accuracy.max_error, - }, - ); - } + add_cap( + &mut caps[group.0 as usize], + ErrorCap { + query, + cap: entry.requirements.accuracy.max_error, + }, + ); } for &group in order.iter().rev() { let group_caps = caps[group.0 as usize].clone(); @@ -197,9 +195,7 @@ fn error_caps( let target_metric = entries .get(cap.query) .and_then(|entry| entry.requirements.accuracy.accuracy_metric); - if let Some(passed) = - passed_cap(memo, operator, input, child, cap, target_metric) - { + if let Some(passed) = passed_cap(operator, input, cap, target_metric) { add_cap(&mut caps[child.0 as usize], passed); } } @@ -209,32 +205,16 @@ fn error_caps( caps } -/// The cap `consumer` passes to its input `input` (group `child`) from its -/// own group's `cap`; `None` when it passes none (no contract, or a merge -/// whose merged state loses the statistic's guarantee). +/// The cap `consumer` passes to its input `input` from its own group's +/// `cap`, through its contract in the query's target metric (in every +/// metric when the target names none, the largest); `None` when it has no +/// contract for it. fn passed_cap( - memo: &Memo, consumer: &Operator, input: usize, - child: MemoGroupId, cap: &ErrorCap, target_metric: Option, ) -> Option { - let statistic = match (consumer, &cap.statistic) { - // A readout reads its statistic from the state. - (Operator::ASAP(ASAPOp::SummaryEstimate { query, .. }), None) => Some(query.clone()), - // A merge passes the statistic read on, when the merged state keeps - // its guarantee. - (Operator::ASAP(ASAPOp::SummaryMerge { .. }), Some(statistic)) => { - if !keeps_guarantee(memo, child, statistic) { - return None; - } - Some(statistic.clone()) - } - (Operator::ASAP(ASAPOp::SummaryMerge { .. }), None) => return None, - // A summary build (or finalize) reads rows: their values. - _ => None, - }; let metrics: &[AccuracyMetric] = match &target_metric { Some(metric) => std::slice::from_ref(metric), None => &METRICS, @@ -254,7 +234,6 @@ fn passed_cap( .reduce(f64::max)?; Some(ErrorCap { query: cap.query, - statistic, cap: passed, }) } @@ -281,57 +260,49 @@ fn family(operator: &Operator) -> Option<&FieldDataType> { } } -/// Adds `cap` to `caps`, keeping the larger cap of one query and -/// statistic. +/// Adds `cap` to `caps`, keeping the larger cap of one query. fn add_cap(caps: &mut Vec, cap: ErrorCap) { - match caps - .iter_mut() - .find(|known| known.query == cap.query && known.statistic == cap.statistic) - { + match caps.iter_mut().find(|known| known.query == cap.query) { Some(known) => known.cap = known.cap.max(cap.cap), None => caps.push(cap), } } -/// Tightens the floor of every input that reads a sketch state, through a -/// readout or a merge: the weakest floor over the statistics read of the -/// largest applicable cap over the queries, with the floor the input -/// already has. A query's cap applies only when its target names no -/// metric or the statistic's metric. -fn set_floors(memo: &mut Memo, entries: &[QueryWorkloadEntry], caps: &[Vec]) { - let mut updates = Vec::new(); - for (g, group) in memo.groups().iter().enumerate() { - for (index_in_group, consumer) in group.candidates.iter().enumerate() { - let operator = &consumer.expression.operator; - let reads: Vec<(SketchStatistic, f64, usize)> = match operator { - Operator::ASAP(ASAPOp::SummaryEstimate { query, .. }) => caps[g] - .iter() - .filter(|cap| cap.statistic.is_none()) - .map(|cap| (query.clone(), cap.cap, cap.query)) - .collect(), - Operator::ASAP(ASAPOp::SummaryMerge { .. }) => caps[g] - .iter() - .filter_map(|cap| Some((cap.statistic.clone()?, cap.cap, cap.query))) - .collect(), - _ => continue, +/// Tightens the floor of every input that reads a sketch state, consumers +/// before producers (`order` reversed): a readout's, by the cap of its +/// group ([`readout_floor`]) with the floor it already has; a merge's, the +/// weakest floor of the consumers reading its group (any of them may read +/// the merged state, which has its inputs' $\boldsymbol\theta$). +fn set_floors( + memo: &mut Memo, + entries: &[QueryWorkloadEntry], + caps: &[Vec], + order: &[MemoGroupId], +) { + for &group in order.iter().rev() { + for index_in_group in 0..memo.groups()[group.0 as usize].candidates.len() { + let id = CandidateForMemoGroupId { + group, + index_in_group, }; + let consumer = memo.candidate(id); + let operator = &consumer.expression.operator; let mut requirements = consumer.input_requirements.clone(); let mut changed = false; for (input, &child) in operator.children().into_iter().enumerate() { - let Some(Operator::ASAP( - ASAPOp::SummaryAgg { - family: family @ FieldDataType::Sketch(algorithm, _), - .. - } - | ASAPOp::SummaryTumblingWindowChain { - family: family @ FieldDataType::Sketch(algorithm, _), - .. - }, - )) = Some(expression(memo, child)) - else { + let Some((family, algorithm)) = sketch_family(expression(memo, child)) else { continue; }; - let Some(floor) = floor_of_reads(family, *algorithm, &reads, entries) else { + let floor = match operator { + Operator::ASAP(ASAPOp::SummaryEstimate { query, .. }) => { + readout_floor(family, algorithm, query, &caps[group.0 as usize], entries) + } + Operator::ASAP(ASAPOp::SummaryMerge { .. }) => { + weakest_floor_of_consumers(memo, group) + } + _ => None, + }; + let Some(floor) = floor else { continue; }; let tightened = match requirements[input].parameter_floor_for_required_accuracy() { @@ -342,51 +313,75 @@ fn set_floors(memo: &mut Memo, entries: &[QueryWorkloadEntry], caps: &[Vec) -> Option<(&FieldDataType, SketchAlgorithm)> { + match family(operator)? { + family @ FieldDataType::Sketch(algorithm, _) => Some((family, *algorithm)), + _ => None, } } -/// The floor of reading a state of `family` as `reads` (statistic, cap, -/// query): per statistic, the largest cap over the queries whose target -/// metric is the statistic's (or unnamed), infinite when another query's -/// is not; then the weakest of the statistics' floors. `None` when some -/// statistic needs none. -fn floor_of_reads( +/// The floor of reading `statistic` from a state of `family` within the +/// largest of `caps` (its group's, per query) that applies: a query's cap +/// applies when its target names no metric or the statistic's; one that +/// does not leaves the input unbounded. `None` without caps. +fn readout_floor( family: &FieldDataType, - algorithm: asap_types::ir::schema::SketchAlgorithm, - reads: &[(SketchStatistic, f64, usize)], + algorithm: SketchAlgorithm, + statistic: &SketchStatistic, + caps: &[ErrorCap], entries: &[QueryWorkloadEntry], -) -> Option { - let mut statistics: Vec<(&SketchStatistic, f64)> = Vec::new(); - for (statistic, cap, query) in reads { - let metric = parameter_list_metric(family, algorithm, statistic); - let target = entries - .get(*query) - .and_then(|entry| entry.requirements.accuracy.accuracy_metric); - let applicable = match (target, metric) { - (None, _) => *cap, - (Some(target), Some(metric)) if target == metric => *cap, - _ => f64::INFINITY, - }; - match statistics.iter_mut().find(|(known, _)| *known == statistic) { - Some((_, known)) => *known = known.max(applicable), - None => statistics.push((statistic, applicable)), +) -> Option { + let metric = parameter_list_metric(family, algorithm, statistic); + let cap = caps + .iter() + .map(|cap| { + let target = entries + .get(cap.query) + .and_then(|entry| entry.requirements.accuracy.accuracy_metric); + match (target, metric) { + (None, _) => cap.cap, + (Some(target), Some(metric)) if target == metric => cap.cap, + _ => f64::INFINITY, + } + }) + .reduce(f64::max)?; + floor_for_cap(family, algorithm, statistic, cap) +} + +/// The weakest of the floors the consumers of `group` (the candidates of +/// its parent groups) set on their inputs reading it; `None` when one of +/// them sets none. +fn weakest_floor_of_consumers(memo: &Memo, group: MemoGroupId) -> Option { + let mut floors = Vec::new(); + for &parent in memo.groups()[group.0 as usize].parent_groups() { + for consumer in &memo.groups()[parent.0 as usize].candidates { + for (input, &child) in consumer + .expression + .operator + .children() + .into_iter() + .enumerate() + { + if child == group { + floors.push( + consumer.input_requirements[input] + .parameter_floor_for_required_accuracy()? + .clone(), + ); + } + } } } - statistics + floors .into_iter() - .map(|(statistic, cap)| floor_for_cap(family, algorithm, statistic, cap)) + .map(Some) .reduce(|left, right| weakest(left.as_ref()?, right.as_ref()?)) .flatten() } @@ -395,7 +390,7 @@ fn floor_of_reads( /// first listed configuration. fn parameter_list_metric( family: &FieldDataType, - algorithm: asap_types::ir::schema::SketchAlgorithm, + algorithm: SketchAlgorithm, statistic: &SketchStatistic, ) -> Option { let parameters = asap_types::stages::parameter_list(algorithm).first()?; @@ -550,7 +545,7 @@ fn read_errors( /// build over the window when the family's merged error for the read is /// exact or as if built over the union (the family-capability table), no /// guarantee otherwise. -fn merged_tumbling_windows_guarantee( +pub(crate) fn merged_tumbling_windows_guarantee( family: &FieldDataType, parameters: Option<&asap_types::ir::schema::SketchParams>, windowed: bool, diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs index aacee340f..5fc798a8c 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs @@ -14,7 +14,7 @@ use asap_types::stages::{Memo, MemoGroupId, OriginOfCandidateForMemoGroup}; /// labels), and above it the widened image of every group that passes the /// labels on ([`passes_labels_on`]): the same operator over the widened /// input, with the original group's own-expression candidates -/// ($\boldsymbol\theta$ and $\omega$ included). Each original group whose +/// ($\boldsymbol\theta$, $\omega$ and input requirements included). Each original group whose /// rows are not a summary state gets the candidate `Project(widened image)` /// that lists its own columns, by name, in its order; the memo rejects it /// when the projection does not compute the group's result (an open schema @@ -78,22 +78,25 @@ pub(crate) fn add_scan_label_candidates(memo: &mut Memo) -> Result<(), Pass2Rule let candidates: Vec<_> = memo.groups()[index] .candidates .iter() - .filter(|candidate| candidate.expression.operator == own) + .filter(|candidate| { + candidate.expression.operator == own && candidate.fused_groups.is_empty() + }) .map(|candidate| { ( candidate.logical_properties.parameters().cloned(), candidate.logical_properties.window_layout().copied(), + candidate.input_requirements.clone(), ) }) .collect(); - for (parameters, window_layout) in candidates { + for (parameters, window_layout, input_requirements) in candidates { add_candidate( memo, wide, image.clone(), parameters, window_layout, - None, + Some(input_requirements), OriginOfCandidateForMemoGroup::WidenedScans, )?; } diff --git a/crates/stage1-pass2-reuse-candidates/src/tests.rs b/crates/stage1-pass2-reuse-candidates/src/tests.rs index d137a8700..f80958122 100644 --- a/crates/stage1-pass2-reuse-candidates/src/tests.rs +++ b/crates/stage1-pass2-reuse-candidates/src/tests.rs @@ -29,6 +29,7 @@ use asap_types::workload::{ }; use crate::generate_logical_asap_memo_candidates; +use crate::logical_edges::{merged_tumbling_windows_guarantee, OutputError}; use crate::memo::window_candidates::tumbling_window_widths; use crate::parameter_floors::{strongest, weakest}; @@ -396,6 +397,61 @@ fn scans_of_one_metric_with_other_labels_read_one_widened_scan() { assert!(reads_widened(job_window).is_some()); } +/// Two queries whose quantile builds differ only in the labels their scans +/// list read one widened build: each readout gets a projection of the one +/// widened readout. +#[test] +fn builds_over_scans_listing_other_labels_share_one_widened_build() { + let mut memo = Memo::default(); + let mut readouts = Vec::new(); + for labels in [&["job"][..], &["host", "job"][..]] { + let input = scan(&mut memo, "m", labels); + let window = range(&mut memo, input, 60); + let state = sketch_state( + &mut memo, + window, + SketchAlgorithm::Kll, + Reduction::PerEntity, + ); + let readout = estimate(&mut memo, state, SketchStatistic::Quantile { q: 0.9 }); + memo.add_query(QueryRoot::Operator(readout)); + readouts.push(readout); + } + let target = AccuracyTarget::max_error(0.1); + let logical = pass2( + memo, + &[entry(60_000, target.clone()), entry(60_000, target)], + ); + let widened: Vec = readouts + .iter() + .map(|&readout| { + candidates_of(&logical.memo, readout) + .iter() + .find_map(|c| match &c.expression.operator { + Operator::NonASAP(NonASAPOp::Project { child, .. }) + if c.origin == OriginOfCandidateForMemoGroup::WidenedScans => + { + Some(*child) + } + _ => None, + }) + .expect("a projection of the widened readout") + }) + .collect(); + assert_eq!(widened[0], widened[1]); + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = + &logical.memo.groups()[widened[0].0 as usize].expression + else { + panic!("the widened readout"); + }; + // The widened state has the original builds' θ and layouts: every + // listed θ, without and with rule 1's 60 s tumbling windows. + assert_eq!( + candidates_of(&logical.memo, *summary_input).len(), + 2 * parameter_list(SketchAlgorithm::Kll).len() + ); +} + // ── Rule 4: top-k reuse ─────────────────────────────────────────────────── /// An exact top-3 reads the top-5 of the same input and partition; the @@ -609,6 +665,132 @@ fn a_coarse_sketch_state_merges_a_finer_state() { .all(|c| c.origin != OriginOfCandidateForMemoGroup::Rollup)); } +/// The finer state's windowed builds (rule 1) serve the coarse state +/// through its rollup: one tumbling-window chain for both queries, without +/// a window rollup rule. +#[test] +fn a_coarse_state_reads_the_finer_states_windowed_builds() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["host", "job"]); + let window = range(&mut memo, input, 300); + let coarse = sketch_state( + &mut memo, + window, + SketchAlgorithm::Kll, + Reduction::by(vec![3]), + ); + let fine = sketch_state( + &mut memo, + window, + SketchAlgorithm::Kll, + Reduction::by(vec![2, 3]), + ); + for state in [coarse, fine] { + let readout = estimate(&mut memo, state, SketchStatistic::Quantile { q: 0.9 }); + memo.add_query(QueryRoot::Operator(readout)); + } + let target = AccuracyTarget::max_error(0.1); + let logical = pass2( + memo, + &[entry(60_000, target.clone()), entry(60_000, target)], + ); + let merge = candidates_of(&logical.memo, coarse) + .iter() + .position(|c| c.origin == OriginOfCandidateForMemoGroup::Rollup) + .expect("the rollup"); + let windowed = edges_into( + &logical, + CandidateForMemoGroupId { + group: coarse, + index_in_group: merge, + }, + 0, + ) + .into_iter() + .filter(|&(producer, _)| { + logical + .memo + .candidate(producer) + .logical_properties + .window_layout() + .is_some() + }) + .count(); + assert_eq!(windowed, parameter_list(SketchAlgorithm::Kll).len()); +} + +/// A merge of finer states takes the floor of the readouts of the coarse +/// state: the merged state has the finer build's θ. +#[test] +fn a_rollup_merge_takes_its_readouts_floor() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["host", "job"]); + let window = range(&mut memo, input, 60); + let coarse = sketch_state( + &mut memo, + window, + SketchAlgorithm::Kll, + Reduction::by(vec![3]), + ); + let fine = sketch_state( + &mut memo, + window, + SketchAlgorithm::Kll, + Reduction::by(vec![2, 3]), + ); + let readout = estimate(&mut memo, coarse, SketchStatistic::Quantile { q: 0.9 }); + let fine_readout = estimate(&mut memo, fine, SketchStatistic::Quantile { q: 0.9 }); + memo.add_query(QueryRoot::Operator(readout)); + memo.add_query(QueryRoot::Operator(fine_readout)); + let target = AccuracyTarget::max_error(0.01).accuracy_metric(AccuracyMetric::Rank); + let logical = pass2( + memo, + &[entry(60_000, target.clone()), entry(60_000, target)], + ); + let readout_floor = candidates_of(&logical.memo, readout)[0].input_requirements[0] + .parameter_floor_for_required_accuracy() + .cloned(); + assert!(readout_floor.is_some()); + let merge = candidates_of(&logical.memo, coarse) + .iter() + .find(|c| c.origin == OriginOfCandidateForMemoGroup::Rollup) + .expect("the rollup"); + assert_eq!( + merge.input_requirements[0] + .parameter_floor_for_required_accuracy() + .cloned(), + readout_floor + ); +} + +/// Reading merged tumbling windows keeps one build's guarantee when the +/// family's merged readout does (a KLL quantile), and has none when it +/// does not (a heap's top-k). +#[test] +fn merged_windows_keep_a_guarantee_only_where_the_family_merges_it() { + let kll = sketch(SketchAlgorithm::Kll); + let quantile = SketchStatistic::Quantile { q: 0.9 }; + let k = parameter_list(SketchAlgorithm::Kll).first(); + assert_eq!( + merged_tumbling_windows_guarantee(&kll, k, true, &quantile), + OutputError::Bound(AccuracyMetric::Rank) + ); + assert_eq!( + merged_tumbling_windows_guarantee(&kll, k, false, &quantile), + OutputError::Bound(AccuracyMetric::Rank) + ); + let heap = sketch(SketchAlgorithm::CmsWithHeap); + assert_eq!( + merged_tumbling_windows_guarantee( + &heap, + parameter_list(SketchAlgorithm::CmsWithHeap).first(), + true, + &SketchStatistic::TopK { k: 3 } + ), + OutputError::WithoutGuarantee + ); +} + // ── Edges, metric rule, caps ────────────────────────────────────────────── /// Every producer of an input's group is an edge into it, rejected ones diff --git a/docs/design_docs/proposals/planner-layering.md b/docs/design_docs/proposals/planner-layering.md index 93cb4441d..8d672becb 100644 --- a/docs/design_docs/proposals/planner-layering.md +++ b/docs/design_docs/proposals/planner-layering.md @@ -311,19 +311,19 @@ rule's origin, and gives each reader a candidate that reads that group. | `OriginOfCandidateForMemoGroup` | Added by | Candidates | |---|---|---| | `Pass1` | `try_populate_pass1` (`pass1/memo_population.rs`) | Every Pass 1 choice of the group's measures: exact, exact accumulator, each summary family and size, and fused computations. Each summary choice comes at every `accuracy_budget_parts` from 1 to the most approximate inputs a query reaching the group has. A fused candidate implements the groups it absorbs only when no other reader needs them; otherwise they stay groups of their own. | -| `TumblingWindows` | Window composition: `add_local_window_composition` and `extend_local_window_composition` (`pass2/memo/window_candidates.rs`) | A window summary computed from tumbling windows, at each tumbling-window width the reader's evaluation grid allows. The tumbling-window chain stays private to the candidate. | -| `WidenedScans` | Scan widening: `add_scan_label_candidates` (`pass2/memo/rules/scan_labels.rs`) | A reader of a metric's scans widened to a shared label set, read through a `Project`. | -| `SummaryInput` | `factor_summary_inputs` (`pass2/memo/rules/summary_inputs.rs`) | Canonical value preprocessing feeding a summary build. | -| `TopKReuse` | Exact top-k reuse: `add_topk_candidates` (`pass2/memo/rules/topk.rs`) | One ranking (`Limit(k_max)` over `Sort`), and each top-k reading its first `k` rows from it. | -| `Rollup` | Rollup: `add_finer_aggregate_candidates` (`pass2/memo/rules/finer_aggregate.rs`), `add_rollup_candidates` and `add_window_rollup_candidates` (`pass2/memo/rules/rollup.rs`) | A coarse group computed from a finer group's values or states. | -| `SummaryResult` | `factor_summary_results` (`pass2/memo/rules/summary_results.rs`) | A summary readout factored out of a composite implementation, so that other readers can read it. | -| `SharedBuild` | Summary capability: `add_shared_summary_state_groups` (`pass2/memo/state_groups.rs`), Pass 2's last step | For each build made two or more times (by different candidates, or twice within one), one concrete summary-state group with one `SharedBuild` candidate; and for each candidate making such builds, one `SharedBuild` copy that reads the groups instead of building its own states. | - -The state-configuration rule (`pass2/memo/rules/shared_configuration_readers.rs`) runs just -before the shared builds are found. It rewrites readers to each shared size -and to the newest tumbling windows of each tumbling-window chain layout they may read, so that -equal builds appear, and a rewrite keeps the origin of the candidate it -rewrites. +| `TumblingWindows` | Rule 1, window composition: `add_local_window_composition` (`stage1-pass2-reuse-candidates/src/memo/window_candidates.rs`) | A build of a mergeable summary state over a range with a tumbling-window layout $\omega$, one per listed $\boldsymbol\theta$ and per width of the gcd closure of the evaluation grids of the queries reaching the state group (each width divides the window). Added to the state group, so every consumer of it sees it. | +| `WidenedScans` | Rule 2, scan widening: `add_scan_label_candidates` (`memo/rules/scan_labels.rs`) | The group of a metric's scan widened to the union of its scans' labels, the widened image of every group above that passes labels on, and in each original group a `Project` of its own columns over its widened image. | +| `TopKReuse` | Rule 4, exact top-k reuse: `add_topk_candidates` (`memo/rules/topk.rs`) | A top-k over the largest top-k group of the same input and partition. A top-k has no error contract, so only an exact producer gives it an edge. | +| `Rollup` | Rule 5, `add_finer_aggregate_candidates` (`memo/rules/finer_aggregate.rs`); rule 6, `add_rollup_candidates` (`memo/rules/rollup.rs`) | A coarse SQL aggregate over a finer aggregate's result (through a compensating `Filter`); a coarse summary state merged from a finer state (`SummaryMerge` with `group_by`), whose windowed builds then serve the coarse state too. | + +Pass 1 gives every input of a summary build its own group, so no rule +factors summary inputs, and a coarse state's rollup reads the finer state +group with its windowed builds, so no separate window rollup rule exists. +After the rules, Pass 2 passes each query's accuracy target down through +each consumer's error contract (`LogicalRequirements::error_propagation`), +capped by its validity caps, sets the parameter floor of every consumer +reading a sketch state from that cap, and lists the logical edges with +$\alpha^{\log}$: the floor and the metric rule (concepts doc, section 4). **Each stage over the memo.** Each stage keeps its decision, on groups instead of whole candidates: @@ -332,7 +332,7 @@ instead of whole candidates: |---|---| | 0 | Hash-conses the frontends' roots into groups (`DagOfLogicalMemoGroups`). | | 1, Pass 1 | Emits the candidates for each memo group: exact, exact accumulator, each summary family and size, fused computations. | -| 1, Pass 2 | Each rule adds candidates to groups. Identical expression is group identity (nothing to add). Summary capability: a reader reading a concrete summary-state group shared with other groups, one group per shared size. Window composition: tumbling windows, and the newest tumbling windows of a shared chain, one concrete group per chain layout (grid, longest window, size). Rollup and exact top-k reuse: a candidate that reads another group (a finer grouping, a ranking). Scan widening: a reader of a metric's scans widened to a shared label set. | +| 1, Pass 2 | Each rule adds candidates or groups; none copies a consumer. Identical expression is group identity (nothing to add). Window composition: tumbling-window layouts of a state group's builds. Rollup and exact top-k reuse: a candidate that reads another group (a finer grouping or state, a larger top-k). Scan widening: a projection of a metric's scans widened to a shared label set. Then the logical edges, with parameter floors from the error caps and the metric rule. | | 2 | Chooses each candidate's materialization, once for a group however many parents read it. A scan read through one window is built once; one read through different windows may also be read per reader. | | 3 | Chooses one candidate per needed group, and costs each candidate's own nodes, per node, given its readers; plan-level charges are added once per plan. A source's retained raw data is a concrete group `raw window(S, H)`, one per distinct lookback `H` of the source's query-time readers (a window plus any offset); its storage of `H` of raw data is charged once when it is chosen (planned). | From 231e6b306218dc9d42d370aeed04be23bb326ad2 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:36:43 +0000 Subject: [PATCH 10/15] docs: Pass 1 comments name Pass 2's logical edges; distinct-count guarantees wait for PR 8 (#1207 PR 4) --- crates/accuracy/src/estimators/hll.rs | 2 +- crates/accuracy/src/estimators/univmon.rs | 4 ++-- crates/stage1-pass1-per-computation-candidates/src/lib.rs | 4 ++-- .../src/logical_memo_candidates.rs | 2 +- .../src/per_computation_candidates.rs | 2 +- .../src/per_computation_candidates_tests.rs | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/accuracy/src/estimators/hll.rs b/crates/accuracy/src/estimators/hll.rs index 09349cb4e..6aa82ac57 100644 --- a/crates/accuracy/src/estimators/hll.rs +++ b/crates/accuracy/src/estimators/hll.rs @@ -81,7 +81,7 @@ pub fn parse_classic_failure_statistic(statistic: &str) -> Option<(u8, f64, &str /// distinct bound ([`classic_failure_statistic`]). The failure probability /// is per output row: each group's estimate fails with at most it. `None` /// for a precision outside the contract's. -#[allow(dead_code)] // Read again in #1207 PR 4, with θ from `LogicalProperties::parameters`. +#[allow(dead_code)] // Read again in #1207 PR 8: δ of a distinct count over a source's own rows, with θ. pub(crate) fn classic_guarantee( precision: u8, relative_error: Option, diff --git a/crates/accuracy/src/estimators/univmon.rs b/crates/accuracy/src/estimators/univmon.rs index 85d6c9d5b..6b34b8864 100644 --- a/crates/accuracy/src/estimators/univmon.rs +++ b/crates/accuracy/src/estimators/univmon.rs @@ -219,7 +219,7 @@ fn distinct_heap(eps: f64, delta: f64) -> u32 { /// The statistic a UnivMon distinct count over `source` leaves its /// top-layer failure probability as. -#[allow(dead_code)] // Read again in #1207 PR 4, with θ from `LogicalProperties::parameters`. +#[allow(dead_code)] // Read again in #1207 PR 8: δ of a distinct count over a source's own rows, with θ. pub(crate) fn incomplete_top_layer_statistic(heap_size: u32, layers: u8, source: &str) -> String { format!("{INCOMPLETE_TOP_LAYER}[k={heap_size},layers={layers}]({MAX_DISTINCT}[{source}])") } @@ -264,7 +264,7 @@ pub fn incomplete_top_layer_probability(heap_size: u32, layers: u8, max_distinct /// `δ_c` plus the chance of an incomplete top layer, left symbolic in the /// source's distinct bound. `None` for a shape that was not sized for a /// distinct count, a heap under 8, or a shape the library would not build. -#[allow(dead_code)] // Read again in #1207 PR 4, with θ from `LogicalProperties::parameters`. +#[allow(dead_code)] // Read again in #1207 PR 8: δ of a distinct count over a source's own rows, with θ. pub(crate) fn distinct_guarantee(params: &SketchParams, source: &str) -> Option { let SketchParams::UnivMon { heap_size, diff --git a/crates/stage1-pass1-per-computation-candidates/src/lib.rs b/crates/stage1-pass1-per-computation-candidates/src/lib.rs index 2c71a6875..5de954acf 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/lib.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/lib.rs @@ -16,8 +16,8 @@ //! 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 +//! validity caps and the pruning caps are decided per edge after Pass 2's rules +//! (Pass 2's logical edges); 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. 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 28b274470..e48200183 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 @@ -31,7 +31,7 @@ pub enum Pass1Error { /// 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). +/// Pass 2's rules (its logical edges). pub fn pass1_memo( mut memo: Memo, supported_summaries: Option<&[SupportedSummary]>, 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 b1588565e..0f8a13c56 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 @@ -38,7 +38,7 @@ //! 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). +//! decided per edge, after the last Pass 2 rule (Pass 2's logical edges). //! //! A measure filter or `HAVING` prunes nothing. The target records the //! measure filter (applied as `SummaryAgg.filter`) and one [`HavingEntry`] 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 106e8787f..b16a255c1 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 @@ -235,7 +235,7 @@ fn divide(memo: &mut Memo, lhs: MemoGroupId, rhs: MemoGroupId) -> MemoGroupId { /// 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, +/// (B's relative ratio cannot read KLL's rank error) drops edges in Pass 2, /// so Pass 1 offers both families to every quantile group. #[test] fn running_example_gives_one_build_per_theta_and_separate_readouts() { From 72a403782e3933a3927414a057bc2cdcdc784703 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:38:48 +0000 Subject: [PATCH 11/15] fix(stage1-pass2): review fixes: exact count over approximate rows, rollup filters over one child, states without an error model (#1207 PR 4) A count's factor 0 makes an approximate input exact before the all-exact check. Rule 6 compares two states' filters only over one child (filter columns are positional). A readout of a sample, wavelet or model state has no guarantee. Shared produces_state, merge_keeps_guarantee and weakest_of; measures compared with ==; RATIO_DENOMINATOR_VALIDITY_CAP -> RELATIVE_OPERAND_VALIDITY_CAP; rule 7 wording: a longer range from a shorter range's windows is not offered. --- .../src/logical_edges.rs | 82 ++++++++----------- .../src/logical_memo_candidates.rs | 7 +- .../src/memo/rules.rs | 23 +++++- .../src/memo/rules/finer_aggregate.rs | 2 +- .../src/memo/rules/rollup.rs | 6 +- .../src/memo/rules/scan_labels.rs | 15 +--- .../src/parameter_floors.rs | 9 +- .../src/tests.rs | 30 +++++++ crates/types/src/stages/candidates.rs | 8 +- crates/types/src/stages/mod.rs | 2 +- .../design_docs/proposals/planner-layering.md | 2 +- 11 files changed, 108 insertions(+), 78 deletions(-) diff --git a/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs b/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs index a55be627f..0367241c0 100644 --- a/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs +++ b/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs @@ -22,8 +22,8 @@ //! 0$); an exact producer always may. $\alpha^{\log}$ also needs the //! producer's $\boldsymbol\theta$ to meet the floor. -use crate::memo::rules::{expression, Pass2RuleError}; -use crate::parameter_floors::{floor_for_cap, strongest, weakest}; +use crate::memo::rules::{expression, produces_state, Pass2RuleError}; +use crate::parameter_floors::{floor_for_cap, strongest, weakest_of}; use asap_accuracy::estimators::local_guarantee; use asap_types::ir::properties::AccuracyMetric; use asap_types::ir::schema::{ @@ -102,7 +102,7 @@ pub(crate) fn logical_edges_with_capped_floors( let satisfies = producer_satisfies_logical_requirements( &producer.logical_properties, &consumer.input_requirements[input], - ) && (reads_state(memo, child) + ) && (produces_state(expression(memo, child)) || errors[child.0 as usize][u] .iter() .any(|error| carries(&consumer.expression.operator, *error))); @@ -122,19 +122,6 @@ pub(crate) fn logical_edges_with_capped_floors( Ok(edges) } -/// Whether `group`'s rows are summary states: its consumers read a state, -/// whose error the readout above states. -fn reads_state(memo: &Memo, group: MemoGroupId) -> bool { - matches!( - expression(memo, group), - Operator::ASAP( - ASAPOp::SummaryAgg { .. } - | ASAPOp::SummaryTumblingWindowChain { .. } - | ASAPOp::SummaryMerge { .. } - ) - ) -} - /// The metric rule: whether `consumer`'s error contract carries an input /// error `error`. Exact always; a bound in a metric its contract has; /// no guarantee only through a value-preserving consumer (one whose @@ -241,12 +228,17 @@ fn passed_cap( /// Whether a merged state of `state`'s family keeps the guarantee of /// `statistic` (exact, or as if built over the union). fn keeps_guarantee(memo: &Memo, state: MemoGroupId, statistic: &SketchStatistic) -> bool { - family(expression(memo, state)).is_some_and(|family| { - matches!( - FamilyCapability::of(family).merged_error(statistic), - Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) - ) - }) + family(expression(memo, state)).is_some_and(|family| merge_keeps_guarantee(family, statistic)) +} + +/// Whether a merged state of `family` keeps one build's guarantee of +/// `statistic`: its merged error is exact or as if built over the union +/// (the family-capability table). +fn merge_keeps_guarantee(family: &FieldDataType, statistic: &SketchStatistic) -> bool { + matches!( + FamilyCapability::of(family).merged_error(statistic), + Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) + ) } /// The family a state operator builds or merges; `None` for a merge (its @@ -379,11 +371,7 @@ fn weakest_floor_of_consumers(memo: &Memo, group: MemoGroupId) -> Option { - match LogicalRequirements::error_propagation(operator, metric) { - Some(propagation) if propagation.factor_per_input[input] == 0.0 => { - OutputError::Exact - } - _ => error, - } + // Each admitted error as it leaves the contract: a factor 0 + // (a count reads no value) makes it exact. + let leaving: Vec = admitted + .into_iter() + .map(|error| match error { + OutputError::Bound(metric) + if LogicalRequirements::error_propagation(operator, metric) + .is_some_and(|p| p.factor_per_input[input] == 0.0) => + { + OutputError::Exact } other => other, - }; - if leaves != OutputError::Exact { - push(&mut out, leaves); + }) + .collect(); + all_exact &= leaving.contains(&OutputError::Exact); + for error in leaving { + if error != OutputError::Exact { + push(&mut out, error); } } } @@ -528,11 +519,13 @@ fn read_errors( statistic, ), ), - _ => { + Some(FieldDataType::ExactAggregate(..)) => { for error in rows { push(&mut out, *error); } } + // A sample, wavelet or model state has no error model. + _ => push(&mut out, OutputError::WithoutGuarantee), }, } } @@ -551,12 +544,7 @@ pub(crate) fn merged_tumbling_windows_guarantee( windowed: bool, statistic: &SketchStatistic, ) -> OutputError { - if windowed - && !matches!( - FamilyCapability::of(family).merged_error(statistic), - Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) - ) - { + if windowed && !merge_keeps_guarantee(family, statistic) { return OutputError::WithoutGuarantee; } match local_guarantee(family, parameters, statistic) { diff --git a/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs b/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs index a5e3d2f39..a36c623cd 100644 --- a/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs +++ b/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs @@ -36,9 +36,12 @@ pub fn pass2_local_window_composition( /// workload entry (its accuracy target). /// /// Pass 1 already gives every input of a summary build its own group, so -/// the old rule 3 (summary inputs as groups) has nothing left to do; a +/// the old rule 3 (summary inputs as groups) has nothing left to do. A /// coarse state's rollup (rule 6) reads the finer state group, windowed -/// builds included, so the old rule 7 (window rollup) is that candidate. +/// builds included, which covers the old rule 7 (window rollup) over the +/// same range. A longer range read from a shorter range's tumbling windows +/// needs a window requirement on the input, which `LogicalRequirements` +/// does not have: not offered (#1221). pub fn pass2_after_local_window_composition( mut memo: Memo, entries: &[QueryWorkloadEntry], diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs index cfee86415..b2b00fb20 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs @@ -8,7 +8,7 @@ pub(crate) mod scan_labels; pub(crate) mod topk; use asap_types::ir::schema::SketchParams; -use asap_types::ir::Operator; +use asap_types::ir::{ASAPOp, Operator}; use asap_types::stages::{ CandidateExpression, CandidateForMemoGroup, CandidateForMemoGroupId, LogicalProperties, LogicalPropertiesError, LogicalRequirements, Memo, MemoGroupId, OriginOfCandidateForMemoGroup, @@ -31,9 +31,10 @@ pub enum Pass2RuleError { /// $\omega$ `window_layout` to `group`, reading each input group without a /// parameter floor unless `input_requirements` gives one per input (Pass 2 /// sets every floor after the rules). `None` when the memo rejects it: its -/// expression breaks the node rules or its output is not the group's, which -/// is how a rule's shape check ends (the candidate does not compute the -/// group's result). +/// expression breaks the node rules (a merge whose inputs' coverage does not +/// merge) or its output is not the group's. Rules 2, 4, 5 and 6 build a +/// candidate from a shape match and let the memo's checks decide whether it +/// computes the group's result, so a rejection ends the match, not Pass 2. pub(crate) fn add_candidate( memo: &mut Memo, group: MemoGroupId, @@ -66,3 +67,17 @@ pub(crate) fn add_candidate( pub(crate) fn expression(memo: &Memo, group: MemoGroupId) -> &Operator { &memo.groups()[group.0 as usize].expression } + +/// Whether `operator`'s rows are summary states (a build, a tumbling-window +/// chain or a merge): their consumers read a state, whose error the readout +/// above states, and a `Project` cannot narrow them. +pub(crate) fn produces_state(operator: &Operator) -> bool { + matches!( + operator, + Operator::ASAP( + ASAPOp::SummaryAgg { .. } + | ASAPOp::SummaryTumblingWindowChain { .. } + | ASAPOp::SummaryMerge { .. } + ) + ) +} diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs index 425b2d5d4..11ed9f66c 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs @@ -29,7 +29,7 @@ pub(crate) fn add_finer_aggregate_candidates(memo: &mut Memo) -> Result<(), Pass let (Some(query), Some(fine)) = (aggregate(memo, owner), aggregate(memo, finer)) else { continue; }; - if format!("{:?}", query.measures) != format!("{:?}", fine.measures) { + if query.measures != fine.measures { continue; } let Some(matched) = match_finer(memo, query.child, &query.keys, fine.child, &fine.keys) diff --git a/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs index 847033925..6d98e9cc9 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs @@ -19,8 +19,7 @@ use asap_types::stages::{Memo, MemoGroupId, OriginOfCandidateForMemoGroup}; /// exact; a sketch keeps its family's merged guarantee, which the readout's /// edge checks (`FamilyCapability::merged_error`). The finer group's /// windowed builds (rule 1) serve the coarse state through it, so a coarse -/// and a finer query share one tumbling-window chain without a rule of -/// their own. +/// and a finer query over the same range share one tumbling-window chain. pub(crate) fn add_rollup_candidates(memo: &mut Memo) -> Result<(), Pass2RuleError> { let states: Vec = (0..memo.groups().len()) .map(|index| MemoGroupId(index as u32)) @@ -110,6 +109,9 @@ fn rollup_keys(memo: &Memo, coarse: MemoGroupId, fine: MemoGroupId) -> Option) -> bool { } } -/// Whether `operator`'s rows are summary states, which a `Project` cannot -/// narrow. -fn produces_state(operator: &Operator) -> bool { - matches!( - operator, - Operator::ASAP( - ASAPOp::SummaryAgg { .. } - | ASAPOp::SummaryTumblingWindowChain { .. } - | ASAPOp::SummaryMerge { .. } - ) - ) -} - /// `Project(wide)` listing `group`'s columns, by name, in its order; `None` /// when `wide` lacks one of them. fn narrowed(memo: &Memo, wide: MemoGroupId, group: MemoGroupId) -> Option> { diff --git a/crates/stage1-pass2-reuse-candidates/src/parameter_floors.rs b/crates/stage1-pass2-reuse-candidates/src/parameter_floors.rs index 306061d71..0fa266033 100644 --- a/crates/stage1-pass2-reuse-candidates/src/parameter_floors.rs +++ b/crates/stage1-pass2-reuse-candidates/src/parameter_floors.rs @@ -36,9 +36,14 @@ pub(crate) fn floor_for_cap( if meets.is_empty() || meets.len() == list.len() { return None; } - meets + weakest_of(meets.into_iter().cloned()) +} + +/// The [`weakest`] of `floors`; `None` when there is none or two do not +/// combine. +pub(crate) fn weakest_of(floors: impl IntoIterator) -> Option { + floors .into_iter() - .cloned() .map(Some) .reduce(|left, right| weakest(left.as_ref()?, right.as_ref()?)) .flatten() diff --git a/crates/stage1-pass2-reuse-candidates/src/tests.rs b/crates/stage1-pass2-reuse-candidates/src/tests.rs index f80958122..d55fba2d6 100644 --- a/crates/stage1-pass2-reuse-candidates/src/tests.rs +++ b/crates/stage1-pass2-reuse-candidates/src/tests.rs @@ -867,6 +867,36 @@ fn a_ratio_reads_no_rank_error() { ); } +/// A count reads no value: over readouts only (approximate), its result +/// is exact, so a ratio may read it. +#[test] +fn a_count_of_approximate_values_is_exact() { + let mut memo = Memo::default(); + let (_, _, readout) = quantile_over_time(&mut memo, SketchAlgorithm::Kll, 0.9, 60); + let count = group( + &mut memo, + Operator::NonASAP(NonASAPOp::Aggregate { + child: readout, + reduction: Reduction::by(vec![]), + measures: vec![AggIntent::Count { + accuracy: AccuracyTarget::exact(), + }], + output_names: vec!["count".into()], + filters: vec![None], + having: None, + }), + ); + let (_, _, ddsketch) = quantile_over_time(&mut memo, SketchAlgorithm::DDSketch, 0.5, 60); + let ratio = arithmetic(&mut memo, ArithmeticOpKind::Div, ddsketch, count); + memo.add_query(QueryRoot::Operator(ratio)); + let target = AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Relative); + let logical = pass2(memo, &[entry(60_000, target)]); + assert_eq!( + edges_into(&logical, own_candidate(ratio), 1), + vec![(own_candidate(count), true)] + ); +} + /// A ratio passes its target down through `1 / (1 - b)` (and caps its /// denominator at `b`): with a relative target 0.05 both readouts' inputs /// need `alpha ≤ 0.045`, so the weakest listed alpha within it. diff --git a/crates/types/src/stages/candidates.rs b/crates/types/src/stages/candidates.rs index ed3b9626e..a8118738c 100644 --- a/crates/types/src/stages/candidates.rs +++ b/crates/types/src/stages/candidates.rs @@ -811,7 +811,7 @@ impl LogicalRequirements { /// and `max`); `count` reads no value: $\beta = 0$ in any metric; /// - `+` and `-`: $\beta = 1$ per operand, absolute error; /// - `/`: relative error, $\beta = 1 / (1 - b)$ per operand with the - /// denominator capped at $b$ = [`RATIO_DENOMINATOR_VALIDITY_CAP`]; `*`: + /// denominator capped at $b$ = [`RELATIVE_OPERAND_VALIDITY_CAP`]; `*`: /// relative error, $(1 + b, 1)$ with the right operand capped at $b$ /// ($(1+x)(1+y) - 1 \le (1+b)\,x + y$ for $y \le b$); /// - an operator without inputs: no factors. @@ -877,7 +877,7 @@ impl LogicalRequirements { return_bool: false, .. } => { - let b = RATIO_DENOMINATOR_VALIDITY_CAP; + let b = RELATIVE_OPERAND_VALIDITY_CAP; match (kind, metric) { ( ArithmeticOpKind::Add | ArithmeticOpKind::Sub, @@ -928,7 +928,7 @@ impl LogicalRequirements { /// relative error under which [`LogicalRequirements::error_propagation`]'s /// linear bound holds, $\varepsilon_{X/Y} \le (\varepsilon_X + /// \varepsilon_Y) / (1 - b)$ (concepts doc, section 4). -pub const RATIO_DENOMINATOR_VALIDITY_CAP: f64 = 0.1; +pub const RELATIVE_OPERAND_VALIDITY_CAP: f64 = 0.1; /// The local error propagation of one consumer: its output error is at /// most $\sum_i \beta_{c,i}\varepsilon_{c,i}$ while each input's error stays @@ -1788,7 +1788,7 @@ mod memo_tests { /// count reads no value; a filter and a top-k have none. #[test] fn error_propagation_follows_each_operators_error_contract() { - let b = RATIO_DENOMINATOR_VALIDITY_CAP; + let b = RELATIVE_OPERAND_VALIDITY_CAP; let ratio = arithmetic(ArithmeticOpKind::Div); assert_eq!( LogicalRequirements::error_propagation(&ratio, AccuracyMetric::Relative), diff --git a/crates/types/src/stages/mod.rs b/crates/types/src/stages/mod.rs index 790d68955..0bad84223 100644 --- a/crates/types/src/stages/mod.rs +++ b/crates/types/src/stages/mod.rs @@ -20,7 +20,7 @@ pub use candidates::{ parameter_list, producer_satisfies_logical_requirements, CandidateError, CandidateExpression, CandidateForMemoGroup, CandidateForMemoGroupId, ErrorPropagation, LogicalProperties, LogicalPropertiesError, LogicalRequirements, Memo, OriginOfCandidateForMemoGroup, WindowLayout, - RATIO_DENOMINATOR_VALIDITY_CAP, + RELATIVE_OPERAND_VALIDITY_CAP, }; pub use logical_memo_candidates::{LogicalASAPMemoCandidates, LogicalEdge}; pub(crate) use memo::MemoGroupAsInput; diff --git a/docs/design_docs/proposals/planner-layering.md b/docs/design_docs/proposals/planner-layering.md index 8d672becb..e4880779a 100644 --- a/docs/design_docs/proposals/planner-layering.md +++ b/docs/design_docs/proposals/planner-layering.md @@ -318,7 +318,7 @@ rule's origin, and gives each reader a candidate that reads that group. Pass 1 gives every input of a summary build its own group, so no rule factors summary inputs, and a coarse state's rollup reads the finer state -group with its windowed builds, so no separate window rollup rule exists. +group with its windowed builds, which covers a window rollup over the same range; a longer range read from a shorter range's tumbling windows is not offered (it needs a window requirement on the input, #1221). After the rules, Pass 2 passes each query's accuracy target down through each consumer's error contract (`LogicalRequirements::error_propagation`), capped by its validity caps, sets the parameter floor of every consumer From ef7b2a6d6415c4960e2b1c918dbf6d969739ab57 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:53:54 +0000 Subject: [PATCH 12/15] feat(types): index the admissible physical edges; Stage 2 prune reasons for levels 4-5 (#1207 PR 5) PhysicalASAPMemoCandidates::new builds the admissible-edge indexes into each (consumer, input) and out of each producer. PruneReason gains NoIngestionPipeline, QueryTimeResultsNotRetained and AcrossEvaluationsWithoutWindowLayout; TooFewQueryReaders (a consumer-count policy, deleted with constraint (J)) is deleted. --- .../src/stages/physical/memo_candidates.rs | 38 ++++++++++++------ crates/types/src/stages/pruning.rs | 39 ++++++++++++------- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/crates/types/src/stages/physical/memo_candidates.rs b/crates/types/src/stages/physical/memo_candidates.rs index 3eeff0d8e..047ed068c 100644 --- a/crates/types/src/stages/physical/memo_candidates.rs +++ b/crates/types/src/stages/physical/memo_candidates.rs @@ -143,15 +143,35 @@ pub struct PhysicalASAPMemoCandidates { impl PhysicalASAPMemoCandidates { /// Stage 2's output over `physical_candidates` and `edges` (grouped by - /// (consumer, input)), with the admissible-edge indexes built once. + /// (consumer, input)), with the admissible-edge indexes built once and + /// no pruned candidates yet (Stage 2 sets `pruned_candidates`). pub fn new( logical_candidates: LogicalASAPMemoCandidates, physical_candidates: Vec, query_roots: Vec>, edges: Vec, ) -> Self { - let _ = (logical_candidates, physical_candidates, query_roots, edges); - todo!("#1207 PR 5: index the admissible edges") + let mut admissible_edges_into_input: HashMap<_, Vec> = HashMap::new(); + let mut admissible_edges_out_of_candidate = vec![Vec::new(); physical_candidates.len()]; + for (id, edge) in edges.iter().enumerate() { + if edge.satisfies_logical_requirement && edge.satisfies_physical_requirement { + admissible_edges_into_input + .entry((edge.consumer_candidate_id, edge.input_index_of_consumer)) + .or_default() + .push(PhysicalEdgeId(id)); + admissible_edges_out_of_candidate[edge.producer_candidate_id.0] + .push(PhysicalEdgeId(id)); + } + } + Self { + logical_candidates, + physical_candidates, + query_roots, + pruned_candidates: Vec::new(), + edges, + admissible_edges_into_input, + admissible_edges_out_of_candidate, + } } /// $\mathcal E$: every edge, in (consumer, input) order. @@ -170,18 +190,14 @@ impl PhysicalASAPMemoCandidates { consumer: PhysicalCandidateId, input_index_of_consumer: usize, ) -> &[PhysicalEdgeId] { - let _ = ( - consumer, - input_index_of_consumer, - &self.admissible_edges_into_input, - ); - todo!("#1207 PR 5") + self.admissible_edges_into_input + .get(&(consumer, input_index_of_consumer)) + .map_or(&[], Vec::as_slice) } /// The admissible edges out of `producer`: every consumer that may share it. pub fn admissible_edges_out_of(&self, producer: PhysicalCandidateId) -> &[PhysicalEdgeId] { - let _ = (producer, &self.admissible_edges_out_of_candidate); - todo!("#1207 PR 5") + &self.admissible_edges_out_of_candidate[producer.0] } } diff --git a/crates/types/src/stages/pruning.rs b/crates/types/src/stages/pruning.rs index a2f8446f4..a56aad034 100644 --- a/crates/types/src/stages/pruning.rs +++ b/crates/types/src/stages/pruning.rs @@ -1,6 +1,6 @@ //! What a stage removed, and why: one shape for every stage (#1047 P6). //! Each stage removes only what its own knowledge rules out (option C): -//! Stage 2 what cannot be implemented, built or materialized. Stage 0 fails with an +//! Stage 2 what cannot be built, run or read by a query output. Stage 0 fails with an //! error, Pass 1 records nothing it leaves out (#1205 will show it), and //! Pass 2 only adds candidates. Stage 3 prunes before //! its selection MILP (dominated and uncostable candidates); its @@ -21,31 +21,40 @@ pub struct PrunedCandidate { } /// Why a stage removed a candidate: first Stage 2's (the physical candidate -/// cannot be built, used or materialized), then Stage 3's, before its selection -/// MILP. +/// cannot be built, run or read by a query output), then Stage 3's, before +/// its selection MILP. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PruneReason { - /// Stage 2: the executor cannot run its operator node, an exact - /// per-entity `Aggregate` (a per-series range reduction over raw data - /// samples) with more than one measure, a measure without an exact - /// per-entity kernel (e.g. `stddev_over_time`), a measure filter or a - /// `HAVING` (`agg_has_exact_per_entity_kernel`). Every timing and - /// materialization of the node is pruned. + /// Stage 2: the executor cannot run its operator, an exact per-entity + /// `Aggregate` (a per-series range reduction over raw data samples) + /// with more than one measure, a measure without an exact per-entity + /// kernel (e.g. `stddev_over_time`), a measure filter or a `HAVING` + /// (`agg_has_exact_per_entity_kernel`). Every timing and lifetime of + /// the candidate is pruned. PerEntityAggregateNotExecutable, + /// Stage 2, decision-tree level 4: it runs at ingestion time, and the + /// data workload has no ingestion pipeline (its data is at rest or its + /// arrival unknown). + NoIngestionPipeline, /// Stage 2: it scans raw data at query time, and the deployment does /// not retain raw data, so no window of it is there to scan. RawDataNotRetained, - /// Stage 2: one of its inputs has no producer candidate that can be + /// Stage 2, decision-tree level 5: it is a query-time result + /// materialized across evaluations, and the deployment keeps no + /// query-time results. + QueryTimeResultsNotRetained, + /// Stage 2, decision-tree level 5: it is materialized across + /// evaluations without a window layout. Only a tumbling-window grid + /// says when such a result can be read (the concepts doc's alignment + /// table, section 2), so it is not offered yet. + AcrossEvaluationsWithoutWindowLayout, + /// Stage 2: one of its inputs has no admissible producer that can be /// built: none is left, or every one depends, directly or through a /// cycle, on an input that cannot be built. InputHasNoBuildableProducer, /// Stage 2: no query output can read it, directly or through other - /// candidates, once each candidate's allowed consumers are applied. + /// candidates, over admissible edges. ReachesNoQueryOutput, - /// Stage 2: it is shared within one query evaluation - /// (`OneEvaluation`), which needs at least two reading queries, and fewer - /// queries can reach it. - TooFewQueryReaders, /// Stage 3: the candidate it names, in the same memo group with the /// same producers and consumers, is at least as good in every respect /// (guarantee, usage, latency, output, held state), so no optimal plan From 8f7926fb4097ede545528357b3a032cd33111675 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:54:00 +0000 Subject: [PATCH 13/15] feat(stage2): physical expansion (decision-tree levels 4-5), physical edges, pruned_candidates (#1207 PR 5) generate_physical_asap_memo_candidates expands each logical candidate into every valid (timing, lifetime), in memory only (#1217), pruning ingestion time without an ingestion pipeline, query-time raw scans without retained raw data, across-evaluations results without a window layout or (at query time) without retained query-time results, and per-entity aggregates the executor cannot run. Each logical edge becomes one physical edge per pair of alternatives with alpha^log copied and alpha^phys from producer_satisfies_physical_requirements. Alternatives with an input no buildable producer supplies, or that no query output reads, are pruned over the admissible edges. Stage 2 cannot fail: its error type, the facade's SelectionError::PhysicalASAPCandidates and the unused workload-entries argument are deleted. Tests build Pass 2's output through the Memo APIs. --- Cargo.lock | 1 - crates/devtools/src/planning_stages.rs | 11 +- crates/planner/src/run.rs | 8 +- .../stage2-physical-optimization/Cargo.toml | 5 +- .../src/catalog/configurations.rs | 97 +++ .../src/catalog/mod.rs | 190 +++++- .../src/catalog/pruning.rs | 92 +++ .../src/catalog/tests.rs | 578 ++++++++++++++++++ .../stage2-physical-optimization/src/lib.rs | 7 +- 9 files changed, 940 insertions(+), 49 deletions(-) create mode 100644 crates/stage2-physical-optimization/src/catalog/configurations.rs create mode 100644 crates/stage2-physical-optimization/src/catalog/pruning.rs create mode 100644 crates/stage2-physical-optimization/src/catalog/tests.rs diff --git a/Cargo.lock b/Cargo.lock index afe947361..130fc7200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,7 +467,6 @@ name = "asap-stage2-physical-optimization" version = "0.1.0" dependencies = [ "asap-types", - "thiserror 2.0.18", ] [[package]] diff --git a/crates/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs index e5da65a99..8a3600636 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -336,19 +336,12 @@ pub fn run_stages_until( return Ok(stages); } let logical = run.logical_asap_candidates.as_ref().expect("set above"); - let physical = match generate_physical_asap_memo_candidates( + let physical = generate_physical_asap_memo_candidates( logical, - entries, data, models.deployment_capabilities.query_time_results_retained, models.deployment_capabilities.raw_data_retained, - ) { - Ok(physical) => physical, - Err(error) => { - run.selection = Err(SelectionError::PhysicalASAPCandidates(error.to_string())); - return Ok(stages); - } - }; + ); if until == RunUntil::Stage2 { run.physical_asap_candidates = Some(physical); return Ok(stages); diff --git a/crates/planner/src/run.rs b/crates/planner/src/run.rs index 82fac5723..3be733dbc 100644 --- a/crates/planner/src/run.rs +++ b/crates/planner/src/run.rs @@ -51,9 +51,6 @@ pub enum SelectionError { /// Pass 1's memo candidates or Pass 2 failed. #[error("logical ASAP-aware optimization: {0}")] LogicalASAPCandidates(String), - /// Stage 2 failed. - #[error("physical ASAP-aware optimization: {0}")] - PhysicalASAPCandidates(String), /// Stage 3 selected no plan. #[error("plan selection: {0}")] Selection(ErrorSelectingPlan), @@ -175,15 +172,12 @@ pub fn run_planning( let started = Instant::now(); let physical = generate_physical_asap_memo_candidates( logical, - entries, data, models.deployment_capabilities.query_time_results_retained, models.deployment_capabilities.raw_data_retained, ); report.stage2_ms = elapsed_ms(started); - let physical = physical_asap_candidates.insert( - physical.map_err(|error| SelectionError::PhysicalASAPCandidates(error.to_string()))?, - ); + let physical = physical_asap_candidates.insert(physical); report.physical_candidates = physical.physical_candidates.len(); report.stage2_pruned_candidates = physical.pruned_candidates.len(); // Stage 3. diff --git a/crates/stage2-physical-optimization/Cargo.toml b/crates/stage2-physical-optimization/Cargo.toml index db1f94888..b641486e3 100644 --- a/crates/stage2-physical-optimization/Cargo.toml +++ b/crates/stage2-physical-optimization/Cargo.toml @@ -3,10 +3,9 @@ name = "asap-stage2-physical-optimization" version = "0.1.0" edition = "2021" -# #509 Stage 2, physical ASAP-aware optimization: materialization and -# physical operator implementation of each Stage 1 candidate, and the +# #509 Stage 2, physical ASAP-aware optimization: the physical properties +# (timing, lifetime) of each Stage 1 candidate, the physical edges, and the # PhysicalASAPMemoCandidates Stage 3 selects from (`catalog`). Depends only on # asap-types, never on Stage 1's crate, plan selection or the executor. [dependencies] asap-types = { path = "../types" } -thiserror = "2" diff --git a/crates/stage2-physical-optimization/src/catalog/configurations.rs b/crates/stage2-physical-optimization/src/catalog/configurations.rs new file mode 100644 index 000000000..2bec5e49c --- /dev/null +++ b/crates/stage2-physical-optimization/src/catalog/configurations.rs @@ -0,0 +1,97 @@ +//! Decision-tree levels 4 and 5 (concepts doc, section 2): each logical +//! candidate's physical properties $p^{\mathrm{phys}} = (\tau, \ell)$, the +//! product of the timing and lifetime values less the invalid combination +//! (`PhysicalProperties::new` rejects it) and the values a prune rule +//! removes. Each prune rule reads only the value it removes and the values +//! decided above it. + +use asap_types::ir::operator::{agg_has_exact_per_entity_kernel, Reduction}; +use asap_types::ir::{NonASAPOp, Operator}; +use asap_types::physical::{ExecutionTiming, Lifetime, PhysicalProperties, StorageMedium}; +use asap_types::stages::{CandidateForMemoGroup, PruneReason}; + +/// What the deployment and the data allow, read by the prune rules. +pub(super) struct Deployment { + /// The data keeps arriving (`DataArrival::Ingesting` or `Mixed`), so an + /// ingestion pipeline can run candidates at ingestion time. + pub(super) has_ingestion_pipeline: bool, + pub(super) query_time_results_retained: bool, + pub(super) raw_data_retained: bool, +} + +/// Level 4's values, $\tau$. +const TIMINGS: [ExecutionTiming; 2] = [ExecutionTiming::QueryTime, ExecutionTiming::IngestionTime]; + +/// Level 5's values, $\ell$. A materialized result is stored in memory +/// only: the executor does not store `Disk` results yet (#1217). +const LIFETIMES: [Lifetime; 3] = [ + Lifetime::None, + Lifetime::OneEvaluation(StorageMedium::Memory), + Lifetime::AcrossEvaluations(StorageMedium::Memory), +]; + +/// Every valid $(\tau, \ell)$ of `candidate`, in [`TIMINGS`] × +/// [`LIFETIMES`] order: `Ok` for an offered one, `Err` with the reason for +/// a pruned one. The invalid (IngestionTime, OneEvaluation) is not a +/// physical property, so it is neither. +pub(super) fn physical_properties_of( + candidate: &CandidateForMemoGroup, + deployment: &Deployment, +) -> Vec> { + let operator = &candidate.expression.operator; + let mut alternatives = Vec::new(); + for timing in TIMINGS { + for lifetime in LIFETIMES { + let Ok(properties) = PhysicalProperties::new(timing, lifetime) else { + continue; + }; + let prune = |reason| Err((properties, reason)); + alternatives.push(if !executor_can_run(operator) { + // Level 1, kept in Stage 2: Pass 1 and Pass 2 record no + // pruned candidates. + prune(PruneReason::PerEntityAggregateNotExecutable) + } else if timing == ExecutionTiming::IngestionTime && !deployment.has_ingestion_pipeline + { + prune(PruneReason::NoIngestionPipeline) + } else if timing == ExecutionTiming::QueryTime + && !deployment.raw_data_retained + && matches!(operator, Operator::NonASAP(NonASAPOp::Scan { .. })) + { + prune(PruneReason::RawDataNotRetained) + } else if !matches!(lifetime, Lifetime::AcrossEvaluations(_)) { + Ok(properties) + } else if candidate.logical_properties.window_layout().is_none() { + prune(PruneReason::AcrossEvaluationsWithoutWindowLayout) + } else if timing == ExecutionTiming::QueryTime + && !deployment.query_time_results_retained + { + prune(PruneReason::QueryTimeResultsNotRetained) + } else { + Ok(properties) + }); + } + } + alternatives +} + +/// Whether the executor can run `operator`: false only for an exact +/// per-entity `Aggregate` (a per-series range reduction over raw data +/// samples) that has more than one measure, a measure without an exact +/// per-entity kernel ([`agg_has_exact_per_entity_kernel`]), a measure +/// filter or a `HAVING`. +fn executor_can_run(operator: &Operator) -> bool { + match operator { + Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::PerEntity, + measures, + filters, + having, + .. + }) => { + matches!(measures.as_slice(), [measure] if agg_has_exact_per_entity_kernel(measure)) + && filters.iter().all(Option::is_none) + && having.is_none() + } + _ => true, + } +} diff --git a/crates/stage2-physical-optimization/src/catalog/mod.rs b/crates/stage2-physical-optimization/src/catalog/mod.rs index d278afef4..df7073ace 100644 --- a/crates/stage2-physical-optimization/src/catalog/mod.rs +++ b/crates/stage2-physical-optimization/src/catalog/mod.rs @@ -1,45 +1,187 @@ //! Stage 2's physical candidates: expand each logical candidate into one -//! physical candidate per valid $(\tau, \ell, \sigma)$, and each logical edge -//! into the physical edges between the two candidates' alternatives +//! physical candidate per valid $(\tau, \ell)$ (decision-tree levels 4 and +//! 5, [`configurations`]), and each logical edge into the physical edges +//! between the two candidates' alternatives //! ([`generate_physical_asap_memo_candidates`]); and realize one selected //! DAG as a physical plan (#1047). The output type, //! `PhysicalASAPMemoCandidates`, lives in `asap_types::stages`; Stage 3 //! selects from it. +mod configurations; +mod pruning; mod realize; +#[cfg(test)] +mod tests; pub use realize::Stage2AssignmentRealizer; -use asap_types::stages::{LogicalASAPMemoCandidates, PhysicalASAPMemoCandidates}; -use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; +use std::collections::HashMap; -/// Why [`generate_physical_asap_memo_candidates`] failed. -#[derive(Debug, thiserror::Error)] -pub enum ErrorGeneratingPhysicalASAPMemoCandidates { - /// Structural pruning found the candidates malformed. - #[error("{0}")] - Pruning(String), -} +use asap_types::stages::{ + producer_satisfies_physical_requirements, CandidateForMemoGroupId, LogicalASAPMemoCandidates, + LogicalEdge, MemoGroupId, PhysicalASAPMemoCandidates, PhysicalCandidate, PhysicalCandidateId, + PhysicalCandidateKey, PhysicalEdge, PrunedCandidate, +}; +use asap_types::workload::DataWorkload; + +use configurations::{physical_properties_of, Deployment}; /// Stage 2: expand every logical candidate of `logical` into its physical -/// alternatives (decision-tree levels 4–6: timing, lifetime, schedule) the -/// data and deployment allow (`query_time_results_retained`; no query-time -/// raw scans unless `raw_data_retained`), and every logical edge into its -/// physical edges with $\alpha^{\log}$ and $\alpha^{\mathrm{phys}}$. Every -/// alternative not generated is recorded in the output's -/// `pruned_candidates` with its `PruneReason`. Prices nothing. +/// alternatives (decision-tree levels 4–5: timing, then lifetime) that the +/// data (an ingestion pipeline when `data` keeps arriving) and the +/// deployment (`query_time_results_retained`, `raw_data_retained`) allow, +/// and every logical edge into its physical edges with $\alpha^{\log}$ and +/// $\alpha^{\mathrm{phys}}$. Every alternative not generated is recorded in +/// the output's `pruned_candidates` with its `PruneReason`: first the +/// alternatives a decision-tree prune rule removes, then those that cannot +/// be built or that no query output can read ([`pruning`]). Prices +/// nothing; alignment with each execution context is Stage 3's check +/// (`PhysicalCandidate::delivers_on`). pub fn generate_physical_asap_memo_candidates( logical: &LogicalASAPMemoCandidates, - entries: &[QueryWorkloadEntry], data: &DataWorkload, query_time_results_retained: bool, raw_data_retained: bool, -) -> Result { - let _ = ( - logical, - entries, - data, +) -> PhysicalASAPMemoCandidates { + let memo = &logical.memo; + let deployment = Deployment { + has_ingestion_pipeline: data.arrival.rows_per_second().is_some(), query_time_results_retained, raw_data_retained, + }; + let mut pruned_candidates = Vec::new(); + // Levels 4 and 5: the offered alternatives, before pruning, and the + // alternatives of each logical candidate. + let mut alternatives = Vec::new(); + let mut alternatives_of_logical_candidate: HashMap> = + HashMap::new(); + for (group_index, group) in memo.groups().iter().enumerate() { + for (index_in_group, candidate) in group.candidates.iter().enumerate() { + let logical_candidate_id = CandidateForMemoGroupId { + group: MemoGroupId(group_index as u32), + index_in_group, + }; + let ids = alternatives_of_logical_candidate + .entry(logical_candidate_id) + .or_default(); + for alternative in physical_properties_of(candidate, &deployment) { + match alternative { + Ok(physical_properties) => { + ids.push(alternatives.len()); + alternatives.push(PhysicalCandidate { + logical_candidate_id, + physical_properties, + }); + } + Err((physical_properties, reason)) => pruned_candidates.push(PrunedCandidate { + group: logical_candidate_id.group, + candidate: PhysicalCandidateKey { + logical_candidate_id, + physical_properties, + }, + reason, + }), + } + } + } + } + // Every physical edge between the alternatives, grouped by (consumer, + // input), over provisional IDs (indices into `alternatives`); and each + // (consumer, input)'s admissible producers, which pruning reads. + let logical_edges_into_input = logical_edges_into_input(&logical.edges); + let mut edges = Vec::new(); + let mut admissible_producers_per_input = Vec::with_capacity(alternatives.len()); + for (consumer_index, consumer) in alternatives.iter().enumerate() { + let input_count = memo + .candidate(consumer.logical_candidate_id) + .expression + .operator + .children() + .len(); + let mut admissible_producers = vec![Vec::new(); input_count]; + for (input_index_of_consumer, admissible) in admissible_producers.iter_mut().enumerate() { + let logical_edges = logical_edges_into_input + .get(&(consumer.logical_candidate_id, input_index_of_consumer)) + .map_or(&[][..], Vec::as_slice); + for logical_edge in logical_edges { + for &producer_index in + &alternatives_of_logical_candidate[&logical_edge.producer_candidate_id] + { + let edge = PhysicalEdge { + producer_candidate_id: PhysicalCandidateId(producer_index), + consumer_candidate_id: PhysicalCandidateId(consumer_index), + input_index_of_consumer, + satisfies_logical_requirement: logical_edge.satisfies_logical_requirement, + satisfies_physical_requirement: producer_satisfies_physical_requirements( + &alternatives[producer_index], + consumer, + ), + }; + if edge.satisfies_logical_requirement && edge.satisfies_physical_requirement { + admissible.push(producer_index); + } + edges.push(edge); + } + } + } + admissible_producers_per_input.push(admissible_producers); + } + // Candidates that cannot be built or that no query output reads. + let prune_reasons = + pruning::prune_reasons(&alternatives, &admissible_producers_per_input, memo); + let mut physical_candidate_id = vec![None; alternatives.len()]; + let mut physical_candidates = Vec::new(); + for ((alternative, reason), id) in alternatives + .iter() + .zip(prune_reasons) + .zip(&mut physical_candidate_id) + { + match reason { + Some(reason) => pruned_candidates.push(PrunedCandidate { + group: alternative.logical_candidate_id.group, + candidate: PhysicalCandidateKey { + logical_candidate_id: alternative.logical_candidate_id, + physical_properties: alternative.physical_properties, + }, + reason, + }), + None => { + *id = Some(PhysicalCandidateId(physical_candidates.len())); + physical_candidates.push(*alternative); + } + } + } + // $\mathcal E$ over $\mathcal C$: the edges between emitted candidates, + // renumbered. + let edges = edges + .into_iter() + .filter_map(|edge| { + Some(PhysicalEdge { + producer_candidate_id: physical_candidate_id[edge.producer_candidate_id.0]?, + consumer_candidate_id: physical_candidate_id[edge.consumer_candidate_id.0]?, + ..edge + }) + }) + .collect(); + let mut physical = PhysicalASAPMemoCandidates::new( + logical.clone(), + physical_candidates, + memo.query_roots.clone(), + edges, ); - todo!("#1207 PR 5: physical expansion and physical edges") + physical.pruned_candidates = pruned_candidates; + physical +} + +/// The logical edges into each (consumer, input), in `edges` order. +fn logical_edges_into_input( + edges: &[LogicalEdge], +) -> HashMap<(CandidateForMemoGroupId, usize), Vec<&LogicalEdge>> { + let mut into_input: HashMap<_, Vec<&LogicalEdge>> = HashMap::new(); + for edge in edges { + into_input + .entry((edge.consumer_candidate_id, edge.input_index_of_consumer)) + .or_default() + .push(edge); + } + into_input } diff --git a/crates/stage2-physical-optimization/src/catalog/pruning.rs b/crates/stage2-physical-optimization/src/catalog/pruning.rs new file mode 100644 index 000000000..cd7a92345 --- /dev/null +++ b/crates/stage2-physical-optimization/src/catalog/pruning.rs @@ -0,0 +1,92 @@ +//! Physical alternatives no plan can use, removed before Stage 3: one with +//! an input no buildable producer can supply, or one no query output reads. +//! Both follow from the admissible edges alone; no cost is involved. + +use asap_types::physical::{ExecutionTiming, Lifetime, PhysicalProperties}; +use asap_types::stages::{ + producer_satisfies_physical_requirements, root_groups, Memo, PhysicalCandidate, PruneReason, +}; + +/// Why each of `alternatives` cannot be used, `None` for a usable one. +/// `admissible_producers_per_input[c][i]` lists the alternatives (by +/// index) on admissible edges into input `i` of alternative `c`. +/// +/// - [`PruneReason::InputHasNoBuildableProducer`]: not buildable, where +/// buildable is the least fixed point of "every input has a buildable +/// admissible producer". A candidate without inputs is buildable; a cycle +/// without a buildable way out is not. +/// - [`PruneReason::ReachesNoQueryOutput`]: buildable, but not reached from +/// a query output over admissible edges into buildable producers. A query +/// reads its output at query time, so a query output is an alternative of +/// a query's root group that a query-time consumer may read. +pub(super) fn prune_reasons( + alternatives: &[PhysicalCandidate], + admissible_producers_per_input: &[Vec>], + memo: &Memo, +) -> Vec> { + let mut buildable = vec![false; alternatives.len()]; + loop { + let mut changed = false; + for (candidate, inputs) in admissible_producers_per_input.iter().enumerate() { + if !buildable[candidate] + && inputs + .iter() + .all(|producers| producers.iter().any(|&producer| buildable[producer])) + { + buildable[candidate] = true; + changed = true; + } + } + if !changed { + break; + } + } + let root_groups = root_groups(&memo.query_roots); + let mut reached = vec![false; alternatives.len()]; + let mut pending: Vec = alternatives + .iter() + .enumerate() + .filter(|(index, alternative)| { + buildable[*index] + && root_groups.contains(&alternative.logical_candidate_id.group) + && read_by_query_output(alternative) + }) + .map(|(index, _)| index) + .collect(); + while let Some(candidate) = pending.pop() { + if !reached[candidate] { + reached[candidate] = true; + pending.extend( + admissible_producers_per_input[candidate] + .iter() + .flatten() + .copied() + .filter(|&producer| buildable[producer]), + ); + } + } + buildable + .into_iter() + .zip(reached) + .map(|(buildable, reached)| { + if !buildable { + Some(PruneReason::InputHasNoBuildableProducer) + } else if !reached { + Some(PruneReason::ReachesNoQueryOutput) + } else { + None + } + }) + .collect() +} + +/// Whether a query may read `alternative` as its output: as a query-time +/// consumer that is not materialized would ($\alpha^{\mathrm{phys}}$). +fn read_by_query_output(alternative: &PhysicalCandidate) -> bool { + let query = PhysicalCandidate { + logical_candidate_id: alternative.logical_candidate_id, + physical_properties: PhysicalProperties::new(ExecutionTiming::QueryTime, Lifetime::None) + .expect("query time, not materialized, is valid"), + }; + producer_satisfies_physical_requirements(alternative, &query) +} diff --git a/crates/stage2-physical-optimization/src/catalog/tests.rs b/crates/stage2-physical-optimization/src/catalog/tests.rs new file mode 100644 index 000000000..049325995 --- /dev/null +++ b/crates/stage2-physical-optimization/src/catalog/tests.rs @@ -0,0 +1,578 @@ +//! Stage 2's tests. Each builds Pass 2's output, the memo and its logical +//! edges, through `Memo::find_or_add_group`, `Memo::add_candidate` and +//! `Memo::add_query`, and reads the physical candidates, edges and pruned +//! alternatives; no other stage crate is called. +use std::collections::BTreeSet; + +use asap_types::ir::operator::operator_properties::{Reduction, Source}; +use asap_types::ir::operator::AggIntent; +use asap_types::ir::scalar::ColumnRef; +use asap_types::ir::schema::{ + DataType, Field, FieldDataType, GroupingStrategy, Schema, SketchAlgorithm, SketchParams, + SketchStatistic, SummaryUpdate, +}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, QueryRoot}; +use asap_types::physical::{ExecutionTiming, Lifetime, PhysicalProperties, StorageMedium}; +use asap_types::stages::{ + parameter_list, CandidateExpression, CandidateForMemoGroup, CandidateForMemoGroupId, + ExecutionContext, LogicalASAPMemoCandidates, LogicalEdge, LogicalProperties, + LogicalRequirements, Memo, MemoGroupId, OriginOfCandidateForMemoGroup, + PhysicalASAPMemoCandidates, PhysicalCandidateId, PruneReason, WindowLayout, +}; +use asap_types::workload::{ + DataArrival, DataWorkload, QueryRecurrence, RepeatedDemand, RepetitionInterval, TimestampMs, +}; +use ExecutionTiming::{IngestionTime, QueryTime}; + +use super::generate_physical_asap_memo_candidates; + +// ── Fixtures ────────────────────────────────────────────────────────────── + +const MEMORY: StorageMedium = StorageMedium::Memory; +const QT_NONE: (ExecutionTiming, Lifetime) = (QueryTime, Lifetime::None); +const QT_ONE: (ExecutionTiming, Lifetime) = (QueryTime, Lifetime::OneEvaluation(MEMORY)); +const QT_ACROSS: (ExecutionTiming, Lifetime) = (QueryTime, Lifetime::AcrossEvaluations(MEMORY)); +const IT_NONE: (ExecutionTiming, Lifetime) = (IngestionTime, Lifetime::None); +const IT_ACROSS: (ExecutionTiming, Lifetime) = (IngestionTime, Lifetime::AcrossEvaluations(MEMORY)); + +/// Adds `operator` with `parameters` and `window_layout` to `group`, as +/// Pass 1 or Pass 2 does. +fn candidate( + memo: &mut Memo, + group: MemoGroupId, + operator: Operator, + parameters: Option, + window_layout: Option, +) -> CandidateForMemoGroupId { + let expression = CandidateExpression { operator }; + let logical_properties = + LogicalProperties::new(&expression, parameters, window_layout).unwrap(); + let input_requirements = + vec![LogicalRequirements::new(None); expression.operator.children().len()]; + memo.add_candidate( + group, + CandidateForMemoGroup { + expression, + logical_properties, + input_requirements, + fused_groups: Vec::new(), + origin: OriginOfCandidateForMemoGroup::Pass1, + }, + ) + .unwrap() +} + +/// The group of `operator`, with `operator` as its exact candidate. +fn group(memo: &mut Memo, operator: Operator) -> CandidateForMemoGroupId { + let group = memo.find_or_add_group(operator.clone()).unwrap(); + candidate(memo, group, operator, None, None) +} + +fn scan_operator() -> Operator { + Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { + metric: "latency".into(), + }, + predicates: vec![], + schema: Schema::with_time_index( + vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("value", DataType::Float64, false), + ], + 0, + vec![], + ), + }) +} + +/// The running example's query A over one scan: a scan group, a KLL state +/// group with an unwindowed candidate and one of 10 s tumbling windows, and +/// the p95 readout group, the query's root. Each edge's $\alpha^{\log}$ is +/// true except the readout's from the unwindowed state when +/// `unwindowed_state_satisfies_the_readout` is false. +struct Example { + logical: LogicalASAPMemoCandidates, + scan: CandidateForMemoGroupId, + unwindowed_state: CandidateForMemoGroupId, + windowed_state: CandidateForMemoGroupId, + readout: CandidateForMemoGroupId, +} + +fn example(unwindowed_state_satisfies_the_readout: bool) -> Example { + let mut memo = Memo::default(); + let scan = group(&mut memo, scan_operator()); + let build = Operator::ASAP(ASAPOp::SummaryAgg { + child: scan.group, + family: FieldDataType::Sketch(SketchAlgorithm::Kll, GroupingStrategy::default()), + input: SummaryUpdate::column(ColumnRef::SampleValue), + reduction: Reduction::PerEntity, + grouping: GroupingStrategy::default(), + filter: None, + }); + let states = memo.find_or_add_group(build.clone()).unwrap(); + let parameters = Some(parameter_list(SketchAlgorithm::Kll)[0].clone()); + let unwindowed_state = candidate(&mut memo, states, build.clone(), parameters.clone(), None); + let windowed_state = candidate( + &mut memo, + states, + build, + parameters, + Some(WindowLayout::Tumbling { width_ms: 10_000 }), + ); + let readout = group( + &mut memo, + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: states, + query: SketchStatistic::Quantile { q: 0.95 }, + }), + ); + memo.add_query(QueryRoot::Operator(readout.group)); + let edge = |producer, consumer, satisfies_logical_requirement| LogicalEdge { + producer_candidate_id: producer, + consumer_candidate_id: consumer, + input_index_of_consumer: 0, + satisfies_logical_requirement, + }; + let edges = vec![ + edge(scan, unwindowed_state, true), + edge(scan, windowed_state, true), + edge( + unwindowed_state, + readout, + unwindowed_state_satisfies_the_readout, + ), + edge(windowed_state, readout, true), + ]; + Example { + logical: LogicalASAPMemoCandidates { memo, edges }, + scan, + unwindowed_state, + windowed_state, + readout, + } +} + +fn ingesting() -> DataWorkload { + DataWorkload { + arrival: DataArrival::Ingesting { + rows_per_second: 1000.0, + }, + ..DataWorkload::default() + } +} + +/// The deployment of most tests: ingesting data, query-time results +/// retained, raw data retained. +fn stage2(logical: &LogicalASAPMemoCandidates) -> PhysicalASAPMemoCandidates { + generate_physical_asap_memo_candidates(logical, &ingesting(), true, true) +} + +fn properties((timing, lifetime): (ExecutionTiming, Lifetime)) -> PhysicalProperties { + PhysicalProperties::new(timing, lifetime).unwrap() +} + +/// The physical properties of `logical_candidate`'s emitted alternatives. +fn offered( + physical: &PhysicalASAPMemoCandidates, + logical_candidate: CandidateForMemoGroupId, +) -> Vec { + physical + .physical_candidates + .iter() + .filter(|candidate| candidate.logical_candidate_id == logical_candidate) + .map(|candidate| candidate.physical_properties) + .collect() +} + +/// The pruned alternatives of `logical_candidate`, with their reasons. +fn pruned( + physical: &PhysicalASAPMemoCandidates, + logical_candidate: CandidateForMemoGroupId, +) -> Vec<(PhysicalProperties, PruneReason)> { + physical + .pruned_candidates + .iter() + .filter(|pruned| pruned.candidate.logical_candidate_id == logical_candidate) + .inspect(|pruned| assert_eq!(pruned.group, logical_candidate.group)) + .map(|pruned| (pruned.candidate.physical_properties, pruned.reason)) + .collect() +} + +fn id_of( + physical: &PhysicalASAPMemoCandidates, + logical_candidate: CandidateForMemoGroupId, + state: (ExecutionTiming, Lifetime), +) -> PhysicalCandidateId { + let position = physical + .physical_candidates + .iter() + .position(|candidate| { + candidate.logical_candidate_id == logical_candidate + && candidate.physical_properties == properties(state) + }) + .unwrap_or_else(|| panic!("{logical_candidate:?} at {state:?} is not offered")); + PhysicalCandidateId(position) +} + +// ── Decision-tree levels 4 and 5 ─────────────────────────────────────────── + +/// Levels 4 and 5 over the running example when the deployment allows +/// everything: every logical candidate's valid (τ, ℓ) are tried, in timing +/// then lifetime order. Materialized across evaluations needs a window +/// layout; an ingestion-time result that is not materialized is read only +/// at ingestion time, so only the windowed state's ingestion-time build +/// reads the ingestion-time scan, and an unmaterialized ingestion-time state +/// or readout reaches no query output. +#[test] +fn every_logical_candidate_expands_into_its_valid_timings_and_lifetimes() { + let example = example(true); + let physical = stage2(&example.logical); + use PruneReason::{AcrossEvaluationsWithoutWindowLayout as NoLayout, ReachesNoQueryOutput}; + let all = |states: &[(ExecutionTiming, Lifetime)]| -> Vec { + states.iter().copied().map(properties).collect() + }; + let reasons = |states: &[((ExecutionTiming, Lifetime), PruneReason)]| { + states + .iter() + .map(|&(state, reason)| (properties(state), reason)) + .collect::>() + }; + // Unmaterialized and one-evaluation candidates without a window layout. + for unwindowed in [example.scan, example.unwindowed_state, example.readout] { + let expected_offered = if unwindowed == example.scan { + vec![QT_NONE, QT_ONE, IT_NONE] + } else { + vec![QT_NONE, QT_ONE] + }; + assert_eq!( + offered(&physical, unwindowed), + all(&expected_offered), + "{unwindowed:?}" + ); + } + assert_eq!( + pruned(&physical, example.scan), + reasons(&[(QT_ACROSS, NoLayout), (IT_ACROSS, NoLayout)]) + ); + for not_read_at_ingestion in [example.unwindowed_state, example.readout] { + let mut expected = reasons(&[(QT_ACROSS, NoLayout), (IT_ACROSS, NoLayout)]); + expected.push((properties(IT_NONE), ReachesNoQueryOutput)); + assert_eq!(pruned(&physical, not_read_at_ingestion), expected); + } + // The windowed state: every valid (τ, ℓ). + assert_eq!( + offered(&physical, example.windowed_state), + all(&[QT_NONE, QT_ONE, QT_ACROSS, IT_ACROSS]) + ); + assert_eq!( + pruned(&physical, example.windowed_state), + reasons(&[(IT_NONE, ReachesNoQueryOutput)]) + ); +} + +/// The invalid combination (IngestionTime, OneEvaluation) is rejected by +/// `PhysicalProperties::new`, so Stage 2 neither offers nor prunes it; and +/// no materialized result is stored on disk (#1217). +#[test] +fn the_invalid_combination_and_disk_are_never_alternatives() { + assert!(PhysicalProperties::new(IngestionTime, Lifetime::OneEvaluation(MEMORY)).is_err()); + let example = example(true); + let physical = stage2(&example.logical); + let every_state = physical + .physical_candidates + .iter() + .map(|candidate| candidate.physical_properties) + .chain( + physical + .pruned_candidates + .iter() + .map(|pruned| pruned.candidate.physical_properties), + ); + for state in every_state { + assert!( + !(state.timing() == IngestionTime + && matches!(state.lifetime(), Lifetime::OneEvaluation(_))), + "{state:?}" + ); + assert!( + !matches!( + state.lifetime(), + Lifetime::OneEvaluation(StorageMedium::Disk) + | Lifetime::AcrossEvaluations(StorageMedium::Disk) + ), + "{state:?}" + ); + } + // Each logical candidate's five valid memory states are either offered + // or pruned, once. + for logical_candidate in [ + example.scan, + example.unwindowed_state, + example.windowed_state, + example.readout, + ] { + let states: BTreeSet = offered(&physical, logical_candidate) + .into_iter() + .chain( + pruned(&physical, logical_candidate) + .into_iter() + .map(|(state, _)| state), + ) + .map(|state| format!("{state:?}")) + .collect(); + assert_eq!(states.len(), 5, "{logical_candidate:?}"); + } +} + +/// Level 4: without an ingestion pipeline (data at rest, or an unknown +/// arrival) every ingestion-time alternative is pruned; data that is +/// partly stored and partly arriving has one. +#[test] +fn ingestion_time_needs_an_ingestion_pipeline() { + let example = example(true); + for (arrival, has_pipeline) in [ + (DataArrival::AtRest, false), + (DataArrival::Unknown, false), + ( + DataArrival::Mixed { + rows_per_second: 10.0, + }, + true, + ), + ] { + let data = DataWorkload { + arrival, + ..DataWorkload::default() + }; + let physical = generate_physical_asap_memo_candidates(&example.logical, &data, true, true); + assert_eq!( + physical + .physical_candidates + .iter() + .any(|candidate| candidate.physical_properties.timing() == IngestionTime), + has_pipeline, + "{arrival:?}" + ); + let pruned_for_no_pipeline: Vec<_> = physical + .pruned_candidates + .iter() + .filter(|pruned| pruned.reason == PruneReason::NoIngestionPipeline) + .map(|pruned| pruned.candidate.physical_properties.timing()) + .collect(); + if has_pipeline { + assert!(pruned_for_no_pipeline.is_empty()); + } else { + // Both ingestion-time lifetimes of each of the four candidates. + assert_eq!(pruned_for_no_pipeline, vec![IngestionTime; 8]); + } + } +} + +/// Level 5: a query-time result is materialized across evaluations only +/// when the deployment keeps query-time results; the ingestion-time one +/// stays. +#[test] +fn query_time_across_evaluations_needs_retained_query_time_results() { + let example = example(true); + let physical = + generate_physical_asap_memo_candidates(&example.logical, &ingesting(), false, true); + assert_eq!( + offered(&physical, example.windowed_state), + vec![ + properties(QT_NONE), + properties(QT_ONE), + properties(IT_ACROSS) + ] + ); + assert!(pruned(&physical, example.windowed_state).contains(&( + properties(QT_ACROSS), + PruneReason::QueryTimeResultsNotRetained + ))); +} + +/// Without raw data retained, the scan runs only at ingestion time; the +/// query-time states then have no buildable producer, and the readout reads +/// the windowed state materialized at ingestion. +#[test] +fn without_raw_data_only_ingestion_time_builds_read_the_scan() { + let example = example(true); + let physical = + generate_physical_asap_memo_candidates(&example.logical, &ingesting(), true, false); + assert_eq!(offered(&physical, example.scan), vec![properties(IT_NONE)]); + for state in [QT_NONE, QT_ONE] { + assert!(pruned(&physical, example.scan) + .contains(&(properties(state), PruneReason::RawDataNotRetained))); + } + assert!(offered(&physical, example.unwindowed_state).is_empty()); + for state in [QT_NONE, QT_ONE, QT_ACROSS] { + assert!(pruned(&physical, example.windowed_state) + .contains(&(properties(state), PruneReason::InputHasNoBuildableProducer))); + } + assert_eq!( + offered(&physical, example.windowed_state), + vec![properties(IT_ACROSS)] + ); + assert_eq!( + offered(&physical, example.readout), + vec![properties(QT_NONE), properties(QT_ONE)] + ); +} + +/// An exact per-entity aggregate the executor has no kernel for +/// (`stddev_over_time`) is pruned at every timing and lifetime. +#[test] +fn a_per_entity_aggregate_without_a_kernel_is_pruned_whole() { + let mut memo = Memo::default(); + let scan = group(&mut memo, scan_operator()); + let aggregate = group( + &mut memo, + Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::PerEntity, + measures: vec![AggIntent::StdDev { + col: Some(1), + population: true, + }], + output_names: vec!["stddev".into()], + filters: vec![None], + having: None, + child: scan.group, + }), + ); + memo.add_query(QueryRoot::Operator(aggregate.group)); + let logical = LogicalASAPMemoCandidates { + memo, + edges: vec![LogicalEdge { + producer_candidate_id: scan, + consumer_candidate_id: aggregate, + input_index_of_consumer: 0, + satisfies_logical_requirement: true, + }], + }; + let physical = stage2(&logical); + assert!(offered(&physical, aggregate).is_empty()); + let reasons = pruned(&physical, aggregate); + assert_eq!(reasons.len(), 5); + assert!(reasons + .iter() + .all(|(_, reason)| *reason == PruneReason::PerEntityAggregateNotExecutable)); + // The scan now reaches no query output. + assert!(offered(&physical, scan).is_empty()); +} + +// ── Physical edges ───────────────────────────────────────────────────────── + +/// Each logical edge becomes one physical edge per pair of emitted +/// alternatives, grouped by (consumer, input), with $\alpha^{\log}$ copied +/// from the logical edge and $\alpha^{\mathrm{phys}}$ from timing and +/// lifetime; the admissible accessors return exactly the edges with both. +#[test] +fn physical_edges_carry_both_compatibilities() { + let example = example(true); + let physical = stage2(&example.logical); + let edges: Vec<_> = physical.edges().collect(); + // Scan (3 alternatives) → unwindowed state (2) and windowed state (4); + // states (2 + 4) → readout (2). + assert_eq!(edges.len(), 3 * 2 + 3 * 4 + (2 + 4) * 2); + let mut consumer_inputs: Vec<_> = edges + .iter() + .map(|(_, edge)| (edge.consumer_candidate_id, edge.input_index_of_consumer)) + .collect(); + let in_order = consumer_inputs.clone(); + consumer_inputs.sort(); + assert_eq!(in_order, consumer_inputs, "grouped by (consumer, input)"); + for (_, edge) in &edges { + let producer = physical.physical_candidates[edge.producer_candidate_id.0]; + let consumer = physical.physical_candidates[edge.consumer_candidate_id.0]; + assert!(edge.satisfies_logical_requirement); + assert_eq!( + edge.satisfies_physical_requirement, + asap_types::stages::producer_satisfies_physical_requirements(&producer, &consumer) + ); + } + // The ingestion-time scan feeds only the windowed state's ingestion + // build; the query-time one every query-time state. + let scan_at_ingestion = id_of(&physical, example.scan, IT_NONE); + let windowed_at_ingestion = id_of(&physical, example.windowed_state, IT_ACROSS); + let into_ingestion_build = physical.admissible_edges_into(windowed_at_ingestion, 0); + assert_eq!(into_ingestion_build.len(), 1); + assert_eq!( + edges[into_ingestion_build[0].0].1.producer_candidate_id, + scan_at_ingestion + ); + let windowed_query_time = id_of(&physical, example.windowed_state, QT_NONE); + assert!(edges.iter().any(|(_, edge)| { + edge.producer_candidate_id == scan_at_ingestion + && edge.consumer_candidate_id == windowed_query_time + && !edge.satisfies_physical_requirement + })); + assert_eq!(physical.admissible_edges_out_of(scan_at_ingestion).len(), 1); + // The readout reads every emitted state alternative: each state is + // query-time or materialized at ingestion. + let readout = id_of(&physical, example.readout, QT_NONE); + assert_eq!(physical.admissible_edges_into(readout, 0).len(), 2 + 4); + assert!(physical.admissible_edges_into(readout, 1).is_empty()); + for (id, edge) in &edges { + let admissible = edge.satisfies_logical_requirement && edge.satisfies_physical_requirement; + assert_eq!( + physical + .admissible_edges_into(edge.consumer_candidate_id, edge.input_index_of_consumer) + .contains(id), + admissible + ); + assert_eq!( + physical + .admissible_edges_out_of(edge.producer_candidate_id) + .contains(id), + admissible + ); + } +} + +/// A logical edge whose producer does not satisfy the consumer's logical +/// requirement keeps its physical edges with $\alpha^{\log}$ false; with no +/// other consumer, the producer's alternatives reach no query output. +#[test] +fn an_unsatisfied_logical_requirement_is_not_admissible() { + let example = example(false); + let physical = stage2(&example.logical); + assert!(offered(&physical, example.unwindowed_state).is_empty()); + assert!(pruned(&physical, example.unwindowed_state) + .iter() + .any(|&(state, reason)| state == properties(QT_NONE) + && reason == PruneReason::ReachesNoQueryOutput)); + let readout = id_of(&physical, example.readout, QT_NONE); + assert_eq!(physical.admissible_edges_into(readout, 0).len(), 4); + // Pruned producers have no physical edges. + assert!(physical.edges().all(|(_, edge)| { + physical.physical_candidates[edge.producer_candidate_id.0].logical_candidate_id + != example.unwindowed_state + })); +} + +/// $\alpha^{\mathrm{phys}}$ ignores alignment; Stage 3 checks it per +/// execution context with `delivers_on` on the emitted candidates: the +/// windowed state materialized across evaluations (10 s windows) delivers +/// to a query every 10 s at phase 0, not at phase 5 s, while its +/// unmaterialized alternative runs in any context. +#[test] +fn alignment_is_checked_per_context_with_delivers_on() { + let example = example(true); + let physical = stage2(&example.logical); + let memo = &physical.logical_candidates.memo; + let every_10s_at = |phase_ms| ExecutionContext::Evaluation { + recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { + interval: RepetitionInterval(10_000), + evaluation_phase: TimestampMs(phase_ms), + }), + range_evaluation: None, + }; + for state in [QT_ACROSS, IT_ACROSS] { + let materialized = + physical.physical_candidates[id_of(&physical, example.windowed_state, state).0]; + assert!(materialized.delivers_on(memo, &every_10s_at(0))); + assert!(!materialized.delivers_on(memo, &every_10s_at(5_000))); + } + let unmaterialized = + physical.physical_candidates[id_of(&physical, example.windowed_state, QT_NONE).0]; + assert!(unmaterialized.delivers_on(memo, &every_10s_at(5_000))); +} diff --git a/crates/stage2-physical-optimization/src/lib.rs b/crates/stage2-physical-optimization/src/lib.rs index 252aecf16..24b02fb14 100644 --- a/crates/stage2-physical-optimization/src/lib.rs +++ b/crates/stage2-physical-optimization/src/lib.rs @@ -4,7 +4,7 @@ //! The planner facade calls [`generate_physical_asap_memo_candidates`] on //! Pass 2's output (`asap_types::stages::LogicalASAPMemoCandidates`): each //! logical candidate expanded into one physical candidate per valid -//! $(\tau, \ell, \sigma)$, each logical edge into its physical edges with +//! $(\tau, \ell)$, each logical edge into its physical edges with //! $\alpha^{\log}$ and $\alpha^{\mathrm{phys}}$, and the pruned alternatives //! (`asap_types::stages::PhysicalASAPMemoCandidates`). Stage 3 selects from //! that output, and [`Stage2AssignmentRealizer`] builds the selected DAG as a @@ -12,7 +12,4 @@ mod catalog; -pub use catalog::{ - generate_physical_asap_memo_candidates, ErrorGeneratingPhysicalASAPMemoCandidates, - Stage2AssignmentRealizer, -}; +pub use catalog::{generate_physical_asap_memo_candidates, Stage2AssignmentRealizer}; From 36f4208c3831c71c9610c3db6ad58a919ca94b01 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:54:00 +0000 Subject: [PATCH 14/15] docs: Stage 2's expansion, edges and prune reasons (#1207 PR 5) --- .../design_docs/proposals/planner-layering.md | 30 ++++++------ .../asap-aware-mapping-contracts.md | 46 ++++++++++--------- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/docs/design_docs/proposals/planner-layering.md b/docs/design_docs/proposals/planner-layering.md index e4880779a..e5c797c7c 100644 --- a/docs/design_docs/proposals/planner-layering.md +++ b/docs/design_docs/proposals/planner-layering.md @@ -567,7 +567,7 @@ The sections below describe each stage. | 0. Frontends | nothing: a query that cannot be lowered fails planning with an error | — | — | | 1, Pass 1 | what the measure or the deployment cannot compute: `RequiresExact`, `NullableAvgInput`, `NoRelationalCountSketchRealization`, `UnsupportedByDeployment`, `QuantileOutsideUnitInterval`, `TopKHeapOverOneRowPerItem`; and, only when the deployment's accuracy model and evidence cannot accept an output without its guarantee, a summary no query reading it can use: `NoErrorBoundForQueryTarget` | `PerComputationLogicalMemoCandidates::pruned_candidates`, each on its aggregate's memo group | the way to compute the measure (`HowToComputeAggregateMeasure`) | | 1, Pass 2 | nothing: it only adds candidates | — | — | -| 2 | what cannot be implemented, built or materialized: `PerEntityAggregateNotExecutable` (an exact per-entity `Aggregate` the executor has no kernel for), `RawDataNotRetained` (a query-time scan of raw data the deployment does not keep), `InputHasNoBuildableProducer`, `ReachesNoQueryOutput`, `TooFewQueryReaders` (shared within one evaluation with fewer than two reading queries) | `PhysicalASAPMemoCandidates::pruned_candidates`, each on its operator node's memo group | `PhysicalCandidateKey { operator_node, properties }` | +| 2 | what cannot be built, run or read by a query output: `PerEntityAggregateNotExecutable` (an exact per-entity `Aggregate` the executor has no kernel for), `NoIngestionPipeline` (ingestion time over data at rest or of unknown arrival), `RawDataNotRetained` (a query-time scan of raw data the deployment does not keep), `QueryTimeResultsNotRetained` (query time, materialized across evaluations, when the deployment keeps no query-time results), `AcrossEvaluationsWithoutWindowLayout`, `InputHasNoBuildableProducer`, `ReachesNoQueryOutput` | `PhysicalASAPMemoCandidates::pruned_candidates`, each on its logical candidate's memo group | `PhysicalCandidateKey { logical_candidate_id, physical_properties }` | | 3 | valid candidates that lose, and the checks only a whole plan can make (accuracy, latency, cluster capacity) | its own `RejectionCause` | — | Stage 2's key names what a pruned candidate would have been, since kept @@ -1283,19 +1283,23 @@ Output: C3 — CandidatePhysicalASAPDAGs here; they determine the cost of each option, which stage 3 uses to pick the cheapest plan (the typical outcomes above). -On the memo, Stage 2 prunes per operator node and records each pruned -candidate in `PhysicalASAPMemoCandidates::pruned_candidates` -([Pruning by stage](#pruning-by-stage)). It does not generate a -configuration the deployment cannot run: an exact per-entity `Aggregate` -(a per-series range reduction over raw data samples) with more than one -measure, a measure without an exact per-entity kernel such as +On the memo, Stage 2 expands each logical candidate into its physical +properties (timing, then lifetime) and records each alternative it prunes in +`PhysicalASAPMemoCandidates::pruned_candidates` +([Pruning by stage](#pruning-by-stage)). It does not generate an +alternative the deployment or the data cannot run: an exact per-entity +`Aggregate` (a per-series range reduction over raw data samples) with more +than one measure, a measure without an exact per-entity kernel such as `stddev_over_time`, a measure filter or a `HAVING` -(`PerEntityAggregateNotExecutable`; the rule reads one node, so no whole -plan is needed), and, without raw data retained, a query-time scan of raw -data (`RawDataNotRetained`). It then removes the candidates no buildable -input, query output or reader count can use (`InputHasNoBuildableProducer`, -`ReachesNoQueryOutput`, `TooFewQueryReaders`), -repeating until nothing more is removed. Stage 2 makes no execution-scope +(`PerEntityAggregateNotExecutable`), ingestion time without an ingestion +pipeline (`NoIngestionPipeline`), a query-time scan of raw data the +deployment does not retain (`RawDataNotRetained`), and materialization +across evaluations without a window layout or, at query time, without +retained query-time results (`AcrossEvaluationsWithoutWindowLayout`, +`QueryTimeResultsNotRetained`). It then removes the alternatives with an +input no buildable producer can supply over admissible edges +(`InputHasNoBuildableProducer`) and those no query output reads +(`ReachesNoQueryOutput`). Stage 2 makes no execution-scope copies: how many times a candidate runs is Stage 3's choice, and a selection names each run as a node of its `DagOfSelectedCandidates`. Today's Stage 3 engine, whose MILP runs each candidate at most once, makes diff --git a/docs/develop_docs/asap-aware-mapping-contracts.md b/docs/develop_docs/asap-aware-mapping-contracts.md index 7c82da604..3b8ebf130 100644 --- a/docs/develop_docs/asap-aware-mapping-contracts.md +++ b/docs/develop_docs/asap-aware-mapping-contracts.md @@ -77,29 +77,33 @@ asap-stage1-pass2-reuse-candidates -p asap-stage2-physical-optimization -p asap- ## 4. Stage 2 -- Only summary builds (`SummaryAgg`, `SummaryTumblingWindowChain`) get materialization - options, each a timing and a lifetime (`PhysicalProperties`): not materialized always; - materialized at ingestion time, `AcrossEvaluations(Memory)`, when the build is - eligible to run as data arrives; materialized at query time for `OneEvaluation(Memory)` - when two or more queries read it (reused within one evaluation); materialized at - query time `AcrossEvaluations(Memory)` for a tumbling-window chain that - builds one new tumbling window per evaluation, when the deployment declares - `query_time_results_retained`. -- One `PhysicalCandidate` per assignment, the all-not-materialized one first - (`P`, then `P-m`). An assignment that puts a build at ingestion - time over one that is not is not generated. A logical candidate Stage 2 - cannot implement becomes a `PrunedCandidate` with its reason. -- On the memo (`generate_physical_asap_memo_candidates`), every physical - candidate Stage 2 prunes is recorded in - `PhysicalASAPMemoCandidates::pruned_candidates` as +- `generate_physical_asap_memo_candidates` expands every logical candidate + into one `PhysicalCandidate` per valid physical property + `PhysicalProperties { timing, lifetime }`: query time with `None`, + `OneEvaluation(Memory)` or `AcrossEvaluations(Memory)`, and ingestion time + with `None` or `AcrossEvaluations(Memory)`. `PhysicalProperties::new` + rejects ingestion time with `OneEvaluation`. Materialized results are kept + in memory only until the executor stores them on disk (#1217). +- Stage 2 does not generate an alternative the data or the deployment cannot + run, and records it in `PhysicalASAPMemoCandidates::pruned_candidates` as `PrunedCandidate` with a `PruneReason`: an exact per-entity `Aggregate` the executor cannot run - (`PerEntityAggregateNotExecutable`) and, without raw data retained, a - query-time scan of raw data (`RawDataNotRetained`) are not generated; a - candidate no buildable input, query output or reader count can use is - removed (`InputHasNoBuildableProducer`, `ReachesNoQueryOutput`, - `TooFewQueryReaders`). Stage 2 emits one candidate per operator node, - timing and lifetime; it makes no execution-scope copies. + (`PerEntityAggregateNotExecutable`), ingestion time when the data is at rest + or its arrival unknown (`NoIngestionPipeline`), a query-time scan of raw + data the deployment does not retain (`RawDataNotRetained`), materialization + across evaluations without a window layout + (`AcrossEvaluationsWithoutWindowLayout`) or, at query time, without + `query_time_results_retained` (`QueryTimeResultsNotRetained`). +- Each logical edge becomes one `PhysicalEdge` per pair of the producer's and + the consumer's alternatives, with `satisfies_logical_requirement` copied + from the logical edge and `satisfies_physical_requirement` from + `producer_satisfies_physical_requirements` (timing and lifetime only). + Alignment with each execution context is Stage 3's check + (`PhysicalCandidate::delivers_on`). +- An alternative with an input no buildable producer supplies over admissible + edges (`InputHasNoBuildableProducer`), or that no query output reads over + admissible edges (`ReachesNoQueryOutput`), is pruned too. A query reads its + output at query time. Stage 2 makes no execution-scope copies. ## 5. Stage 3 From d11f12e10e1848d62ec39789a45258bff54adcb5 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:01:12 +0000 Subject: [PATCH 15/15] fix(stage2): review fixes: doc name enumerate_candidate_configurations, one pruned-candidate builder, terminology; tests for a scalar root, an unbuildable input and a root read at ingestion time (#1207 PR 5) --- .../src/catalog/configurations.rs | 4 +- .../src/catalog/mod.rs | 56 +++--- .../src/catalog/tests.rs | 177 ++++++++++++++++-- .../design_docs/proposals/planner-layering.md | 10 +- .../asap-aware-mapping-contracts.md | 2 +- 5 files changed, 199 insertions(+), 50 deletions(-) diff --git a/crates/stage2-physical-optimization/src/catalog/configurations.rs b/crates/stage2-physical-optimization/src/catalog/configurations.rs index 2bec5e49c..b28c91086 100644 --- a/crates/stage2-physical-optimization/src/catalog/configurations.rs +++ b/crates/stage2-physical-optimization/src/catalog/configurations.rs @@ -34,7 +34,7 @@ const LIFETIMES: [Lifetime; 3] = [ /// [`LIFETIMES`] order: `Ok` for an offered one, `Err` with the reason for /// a pruned one. The invalid (IngestionTime, OneEvaluation) is not a /// physical property, so it is neither. -pub(super) fn physical_properties_of( +pub(super) fn enumerate_candidate_configurations( candidate: &CandidateForMemoGroup, deployment: &Deployment, ) -> Vec> { @@ -47,7 +47,7 @@ pub(super) fn physical_properties_of( }; let prune = |reason| Err((properties, reason)); alternatives.push(if !executor_can_run(operator) { - // Level 1, kept in Stage 2: Pass 1 and Pass 2 record no + // Level 1, checked in Stage 2: Pass 1 and Pass 2 record no // pruned candidates. prune(PruneReason::PerEntityAggregateNotExecutable) } else if timing == ExecutionTiming::IngestionTime && !deployment.has_ingestion_pipeline diff --git a/crates/stage2-physical-optimization/src/catalog/mod.rs b/crates/stage2-physical-optimization/src/catalog/mod.rs index df7073ace..8bf374bde 100644 --- a/crates/stage2-physical-optimization/src/catalog/mod.rs +++ b/crates/stage2-physical-optimization/src/catalog/mod.rs @@ -19,11 +19,11 @@ use std::collections::HashMap; use asap_types::stages::{ producer_satisfies_physical_requirements, CandidateForMemoGroupId, LogicalASAPMemoCandidates, LogicalEdge, MemoGroupId, PhysicalASAPMemoCandidates, PhysicalCandidate, PhysicalCandidateId, - PhysicalCandidateKey, PhysicalEdge, PrunedCandidate, + PhysicalCandidateKey, PhysicalEdge, PruneReason, PrunedCandidate, }; use asap_types::workload::DataWorkload; -use configurations::{physical_properties_of, Deployment}; +use configurations::{enumerate_candidate_configurations, Deployment}; /// Stage 2: expand every logical candidate of `logical` into its physical /// alternatives (decision-tree levels 4–5: timing, then lifetime) that the @@ -63,23 +63,21 @@ pub fn generate_physical_asap_memo_candidates( let ids = alternatives_of_logical_candidate .entry(logical_candidate_id) .or_default(); - for alternative in physical_properties_of(candidate, &deployment) { - match alternative { - Ok(physical_properties) => { + for configuration in enumerate_candidate_configurations(candidate, &deployment) { + let (physical_properties, prune_reason) = match configuration { + Ok(physical_properties) => (physical_properties, None), + Err((physical_properties, reason)) => (physical_properties, Some(reason)), + }; + let alternative = PhysicalCandidate { + logical_candidate_id, + physical_properties, + }; + match prune_reason { + Some(reason) => pruned_candidates.push(pruned(alternative, reason)), + None => { ids.push(alternatives.len()); - alternatives.push(PhysicalCandidate { - logical_candidate_id, - physical_properties, - }); + alternatives.push(alternative); } - Err((physical_properties, reason)) => pruned_candidates.push(PrunedCandidate { - group: logical_candidate_id.group, - candidate: PhysicalCandidateKey { - logical_candidate_id, - physical_properties, - }, - reason, - }), } } } @@ -136,14 +134,7 @@ pub fn generate_physical_asap_memo_candidates( .zip(&mut physical_candidate_id) { match reason { - Some(reason) => pruned_candidates.push(PrunedCandidate { - group: alternative.logical_candidate_id.group, - candidate: PhysicalCandidateKey { - logical_candidate_id: alternative.logical_candidate_id, - physical_properties: alternative.physical_properties, - }, - reason, - }), + Some(reason) => pruned_candidates.push(pruned(*alternative, reason)), None => { *id = Some(PhysicalCandidateId(physical_candidates.len())); physical_candidates.push(*alternative); @@ -172,6 +163,21 @@ pub fn generate_physical_asap_memo_candidates( physical } +/// `alternative`, pruned for `reason`, named by its content. +fn pruned( + alternative: PhysicalCandidate, + reason: PruneReason, +) -> PrunedCandidate { + PrunedCandidate { + group: alternative.logical_candidate_id.group, + candidate: PhysicalCandidateKey { + logical_candidate_id: alternative.logical_candidate_id, + physical_properties: alternative.physical_properties, + }, + reason, + } +} + /// The logical edges into each (consumer, input), in `edges` order. fn logical_edges_into_input( edges: &[LogicalEdge], diff --git a/crates/stage2-physical-optimization/src/catalog/tests.rs b/crates/stage2-physical-optimization/src/catalog/tests.rs index 049325995..d43bc71df 100644 --- a/crates/stage2-physical-optimization/src/catalog/tests.rs +++ b/crates/stage2-physical-optimization/src/catalog/tests.rs @@ -4,14 +4,15 @@ //! alternatives; no other stage crate is called. use std::collections::BTreeSet; -use asap_types::ir::operator::operator_properties::{Reduction, Source}; +use asap_types::ir::operator::operator_properties::{BinaryOpKind, Reduction, Source}; use asap_types::ir::operator::AggIntent; +use asap_types::ir::scalar::expr_ir::ArithmeticOpKind; use asap_types::ir::scalar::ColumnRef; use asap_types::ir::schema::{ DataType, Field, FieldDataType, GroupingStrategy, Schema, SketchAlgorithm, SketchParams, SketchStatistic, SummaryUpdate, }; -use asap_types::ir::{ASAPOp, NonASAPOp, Operator, QueryRoot}; +use asap_types::ir::{ASAPOp, BinaryOperator, NonASAPOp, Operator, QueryRoot, ScalarExpr}; use asap_types::physical::{ExecutionTiming, Lifetime, PhysicalProperties, StorageMedium}; use asap_types::stages::{ parameter_list, CandidateExpression, CandidateForMemoGroup, CandidateForMemoGroupId, @@ -99,6 +100,14 @@ struct Example { } fn example(unwindowed_state_satisfies_the_readout: bool) -> Example { + example_with_root(unwindowed_state_satisfies_the_readout, QueryRoot::Operator) +} + +/// [`example`] with the query's root `root(readout group)`. +fn example_with_root( + unwindowed_state_satisfies_the_readout: bool, + root: impl Fn(MemoGroupId) -> QueryRoot, +) -> Example { let mut memo = Memo::default(); let scan = group(&mut memo, scan_operator()); let build = Operator::ASAP(ASAPOp::SummaryAgg { @@ -126,7 +135,7 @@ fn example(unwindowed_state_satisfies_the_readout: bool) -> Example { query: SketchStatistic::Quantile { q: 0.95 }, }), ); - memo.add_query(QueryRoot::Operator(readout.group)); + memo.add_query(root(readout.group)); let edge = |producer, consumer, satisfies_logical_requirement| LogicalEdge { producer_candidate_id: producer, consumer_candidate_id: consumer, @@ -161,10 +170,54 @@ fn ingesting() -> DataWorkload { } } +/// Stage 2 over `logical`, checking what Stage 3 relies on: every emitted +/// candidate has an admissible edge into each input, and the edges are +/// grouped by (consumer, input) over emitted candidates. +fn generate( + logical: &LogicalASAPMemoCandidates, + data: &DataWorkload, + query_time_results_retained: bool, + raw_data_retained: bool, +) -> PhysicalASAPMemoCandidates { + let physical = generate_physical_asap_memo_candidates( + logical, + data, + query_time_results_retained, + raw_data_retained, + ); + let count = physical.physical_candidates.len(); + let consumer_inputs: Vec<_> = physical + .edges() + .map(|(_, edge)| { + assert!(edge.producer_candidate_id.0 < count && edge.consumer_candidate_id.0 < count); + (edge.consumer_candidate_id, edge.input_index_of_consumer) + }) + .collect(); + assert!(consumer_inputs.is_sorted(), "grouped by (consumer, input)"); + for (id, candidate) in physical.physical_candidates.iter().enumerate() { + let inputs = logical + .memo + .candidate(candidate.logical_candidate_id) + .expression + .operator + .children() + .len(); + for input in 0..inputs { + assert!( + !physical + .admissible_edges_into(PhysicalCandidateId(id), input) + .is_empty(), + "{candidate:?} input {input}" + ); + } + } + physical +} + /// The deployment of most tests: ingesting data, query-time results /// retained, raw data retained. fn stage2(logical: &LogicalASAPMemoCandidates) -> PhysicalASAPMemoCandidates { - generate_physical_asap_memo_candidates(logical, &ingesting(), true, true) + generate(logical, &ingesting(), true, true) } fn properties((timing, lifetime): (ExecutionTiming, Lifetime)) -> PhysicalProperties { @@ -344,7 +397,7 @@ fn ingestion_time_needs_an_ingestion_pipeline() { arrival, ..DataWorkload::default() }; - let physical = generate_physical_asap_memo_candidates(&example.logical, &data, true, true); + let physical = generate(&example.logical, &data, true, true); assert_eq!( physical .physical_candidates @@ -374,8 +427,7 @@ fn ingestion_time_needs_an_ingestion_pipeline() { #[test] fn query_time_across_evaluations_needs_retained_query_time_results() { let example = example(true); - let physical = - generate_physical_asap_memo_candidates(&example.logical, &ingesting(), false, true); + let physical = generate(&example.logical, &ingesting(), false, true); assert_eq!( offered(&physical, example.windowed_state), vec![ @@ -396,8 +448,7 @@ fn query_time_across_evaluations_needs_retained_query_time_results() { #[test] fn without_raw_data_only_ingestion_time_builds_read_the_scan() { let example = example(true); - let physical = - generate_physical_asap_memo_candidates(&example.logical, &ingesting(), true, false); + let physical = generate(&example.logical, &ingesting(), true, false); assert_eq!(offered(&physical, example.scan), vec![properties(IT_NONE)]); for state in [QT_NONE, QT_ONE] { assert!(pruned(&physical, example.scan) @@ -473,13 +524,6 @@ fn physical_edges_carry_both_compatibilities() { // Scan (3 alternatives) → unwindowed state (2) and windowed state (4); // states (2 + 4) → readout (2). assert_eq!(edges.len(), 3 * 2 + 3 * 4 + (2 + 4) * 2); - let mut consumer_inputs: Vec<_> = edges - .iter() - .map(|(_, edge)| (edge.consumer_candidate_id, edge.input_index_of_consumer)) - .collect(); - let in_order = consumer_inputs.clone(); - consumer_inputs.sort(); - assert_eq!(in_order, consumer_inputs, "grouped by (consumer, input)"); for (_, edge) in &edges { let producer = physical.physical_candidates[edge.producer_candidate_id.0]; let consumer = physical.physical_candidates[edge.consumer_candidate_id.0]; @@ -576,3 +620,104 @@ fn alignment_is_checked_per_context_with_delivers_on() { physical.physical_candidates[id_of(&physical, example.windowed_state, QT_NONE).0]; assert!(unmaterialized.delivers_on(memo, &every_10s_at(5_000))); } + +// ── Pruning ──────────────────────────────────────────────────────────────── + +/// A query whose root group is also read at ingestion time: the scan +/// group's unmaterialized ingestion-time alternative is not read by the +/// query, but by the windowed state's ingestion-time build, so it is +/// emitted. +#[test] +fn a_root_group_read_at_ingestion_time_keeps_its_ingestion_time_alternative() { + let mut example = example(true); + example + .logical + .memo + .add_query(QueryRoot::Operator(example.scan.group)); + let physical = stage2(&example.logical); + assert_eq!( + offered(&physical, example.scan), + vec![properties(QT_NONE), properties(QT_ONE), properties(IT_NONE)] + ); +} + +/// A scalar root reaches the groups it reads: the readout under +/// `scalar(…)` is offered as under an operator root. +#[test] +fn a_scalar_root_reaches_the_groups_it_reads() { + let example = example_with_root(true, |readout| { + QueryRoot::Scalar(ScalarExpr::PromqlScalarFromVector(readout)) + }); + let physical = stage2(&example.logical); + assert_eq!( + offered(&physical, example.readout), + vec![properties(QT_NONE), properties(QT_ONE)] + ); +} + +/// A two-input consumer whose second input group has no candidate cannot +/// be built, nor can the readout of that empty group; the first input's +/// producers stay, read by the first query. +#[test] +fn a_consumer_without_a_producer_for_one_input_cannot_be_built() { + let mut example = example(true); + let memo = &mut example.logical.memo; + let empty_states = memo + .find_or_add_group(Operator::ASAP(ASAPOp::SummaryAgg { + child: example.scan.group, + family: FieldDataType::Sketch(SketchAlgorithm::DDSketch, GroupingStrategy::default()), + input: SummaryUpdate::column(ColumnRef::SampleValue), + reduction: Reduction::PerEntity, + grouping: GroupingStrategy::default(), + filter: None, + })) + .unwrap(); + let median = group( + memo, + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: empty_states, + query: SketchStatistic::Quantile { q: 0.5 }, + }), + ); + let ratio = group( + 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: example.readout.group, + rhs: median.group, + }), + ); + memo.add_query(QueryRoot::Operator(ratio.group)); + for (producer, input_index_of_consumer) in [(example.readout, 0), (median, 1)] { + example.logical.edges.push(LogicalEdge { + producer_candidate_id: producer, + consumer_candidate_id: ratio, + input_index_of_consumer, + satisfies_logical_requirement: true, + }); + } + let physical = stage2(&example.logical); + assert!(offered(&physical, median).is_empty()); + assert!(offered(&physical, ratio).is_empty()); + for unbuildable in [median, ratio] { + assert_eq!( + pruned(&physical, unbuildable) + .into_iter() + .filter(|(_, reason)| *reason == PruneReason::InputHasNoBuildableProducer) + .map(|(state, _)| state) + .collect::>(), + vec![properties(QT_NONE), properties(QT_ONE), properties(IT_NONE)], + "{unbuildable:?}" + ); + } + assert_eq!( + offered(&physical, example.readout), + vec![properties(QT_NONE), properties(QT_ONE)] + ); +} diff --git a/docs/design_docs/proposals/planner-layering.md b/docs/design_docs/proposals/planner-layering.md index e5c797c7c..b80798ae5 100644 --- a/docs/design_docs/proposals/planner-layering.md +++ b/docs/design_docs/proposals/planner-layering.md @@ -570,13 +570,11 @@ The sections below describe each stage. | 2 | what cannot be built, run or read by a query output: `PerEntityAggregateNotExecutable` (an exact per-entity `Aggregate` the executor has no kernel for), `NoIngestionPipeline` (ingestion time over data at rest or of unknown arrival), `RawDataNotRetained` (a query-time scan of raw data the deployment does not keep), `QueryTimeResultsNotRetained` (query time, materialized across evaluations, when the deployment keeps no query-time results), `AcrossEvaluationsWithoutWindowLayout`, `InputHasNoBuildableProducer`, `ReachesNoQueryOutput` | `PhysicalASAPMemoCandidates::pruned_candidates`, each on its logical candidate's memo group | `PhysicalCandidateKey { logical_candidate_id, physical_properties }` | | 3 | valid candidates that lose, and the checks only a whole plan can make (accuracy, latency, cluster capacity) | its own `RejectionCause` | — | -Stage 2's key names what a pruned candidate would have been, since kept +Stage 2's key names what a pruned candidate would have been, since emitted candidates are renumbered after pruning and a pruned one has no -`PhysicalCandidateId`. Stage 2 emits one candidate per operator node, -timing and lifetime, and makes no copies of it, so (`operator_node`, -`properties`) is unique. Operator -nodes are kept whole, so `operator_node` indexes the output's -`operator_nodes_of_candidates_for_memo_groups`. Pass 1's accuracy pruning +`PhysicalCandidateId`. Stage 2 emits one candidate per logical candidate, +timing and lifetime, and makes no copies of it, so (`logical_candidate_id`, +`physical_properties`) is unique. Pass 1's accuracy pruning (`NoErrorBoundForQueryTarget`) runs inside Pass 1's entry, before the candidates are attached to the memo groups, and records a `PrunedCandidate` like every other Pass 1 prune. diff --git a/docs/develop_docs/asap-aware-mapping-contracts.md b/docs/develop_docs/asap-aware-mapping-contracts.md index 3b8ebf130..a87f8e696 100644 --- a/docs/develop_docs/asap-aware-mapping-contracts.md +++ b/docs/develop_docs/asap-aware-mapping-contracts.md @@ -82,7 +82,7 @@ asap-stage1-pass2-reuse-candidates -p asap-stage2-physical-optimization -p asap- `PhysicalProperties { timing, lifetime }`: query time with `None`, `OneEvaluation(Memory)` or `AcrossEvaluations(Memory)`, and ingestion time with `None` or `AcrossEvaluations(Memory)`. `PhysicalProperties::new` - rejects ingestion time with `OneEvaluation`. Materialized results are kept + rejects ingestion time with `OneEvaluation`. Materialized results are stored in memory only until the executor stores them on disk (#1217). - Stage 2 does not generate an alternative the data or the deployment cannot run, and records it in `PhysicalASAPMemoCandidates::pruned_candidates` as