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/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/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/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/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/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/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs index 3dfde1c0e..8585ba38f 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -314,7 +314,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/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-pass1-per-computation-candidates/src/lib.rs b/crates/stage1-pass1-per-computation-candidates/src/lib.rs index b34f4ebbc..e5848cc7a 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 e47252177..c1d0343b2 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 @@ -21,7 +21,7 @@ use crate::per_computation_candidates::{ /// 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 0c3d2cca5..8823668f3 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 @@ -39,7 +39,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 measure filter is //! applied as `SummaryAgg.filter`. A `HAVING` is the aggregate's own 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 0b83b9a3e..cebfd8744 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 @@ -187,7 +187,7 @@ fn filter(memo: &mut Memo, child: MemoGroupId, pred: Predicate) -> /// 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() { 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..0367241c0 --- /dev/null +++ b/crates/stage1-pass2-reuse-candidates/src/logical_edges.rs @@ -0,0 +1,562 @@ +//! 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, +//! 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 +//! 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, 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::{ + 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, +}; +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). +#[derive(Debug, Clone)] +struct ErrorCap { + query: usize, + 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, &order); + 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], + ) && (produces_state(expression(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) +} + +/// 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: 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), QueryRoot::Operator(group)) = (entries.get(query), root) else { + continue; + }; + 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(); + 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(operator, input, cap, target_metric) { + add_cap(&mut caps[child.0 as usize], passed); + } + } + } + } + } + caps +} + +/// 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( + consumer: &Operator, + input: usize, + cap: &ErrorCap, + target_metric: Option, +) -> Option { + 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, + 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| 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 +/// 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. +fn add_cap(caps: &mut Vec, cap: ErrorCap) { + 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, 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((family, algorithm)) = sketch_family(expression(memo, child)) else { + continue; + }; + 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() { + Some(existing) => strongest(existing, &floor).unwrap_or(existing.clone()), + None => floor, + }; + requirements[input] = LogicalRequirements::new(Some(tightened)); + changed = true; + } + if changed { + memo.set_input_requirements(id, requirements); + } + } + } +} + +/// The sketch family a state group's expression builds. +fn sketch_family(operator: &Operator) -> Option<(&FieldDataType, SketchAlgorithm)> { + match family(operator)? { + family @ FieldDataType::Sketch(algorithm, _) => Some((family, *algorithm)), + _ => None, + } +} + +/// 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: SketchAlgorithm, + statistic: &SketchStatistic, + caps: &[ErrorCap], + entries: &[QueryWorkloadEntry], +) -> 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(), + ); + } + } + } + } + weakest_of(floors) +} + +/// The metric of reading `statistic` from a state of `family`, by its +/// first listed configuration. +fn parameter_list_metric( + family: &FieldDataType, + algorithm: 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(); + } + // 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, + }) + .collect(); + all_exact &= leaving.contains(&OutputError::Exact); + for error in leaving { + if error != OutputError::Exact { + push(&mut out, error); + } + } + } + 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, + ), + ), + 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), + }, + } + } + 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. +pub(crate) fn merged_tumbling_windows_guarantee( + family: &FieldDataType, + parameters: Option<&asap_types::ir::schema::SketchParams>, + windowed: bool, + statistic: &SketchStatistic, +) -> OutputError { + if windowed && !merge_keeps_guarantee(family, statistic) { + 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..a36c623cd 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,30 @@ 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, 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], ) -> 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..b2b00fb20 100644 --- a/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs @@ -1,19 +1,83 @@ //! 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::{ASAPOp, 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 (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, + 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 +} + +/// 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 fee9f0bfd..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 @@ -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 query.measures != 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..6d98e9cc9 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,198 @@ -//! 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 over the same range share one tumbling-window chain. 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 + // A filter's columns are its own child's: over two children it + // compares only by position. + || (filter.is_some() && child != fine_child) + || 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..5f62641ba 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,229 @@ -//! 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, produces_state, 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$, $\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 +/// 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 && 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, input_requirements) in candidates { + add_candidate( + memo, + wide, + image.clone(), + parameters, + window_layout, + Some(input_requirements), + 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, + }, + } +} + +/// `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/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") -} 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..0fa266033 --- /dev/null +++ b/crates/stage1-pass2-reuse-candidates/src/parameter_floors.rs @@ -0,0 +1,181 @@ +//! $\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; + } + 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() + .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..c28125ef2 --- /dev/null +++ b/crates/stage1-pass2-reuse-candidates/src/tests.rs @@ -0,0 +1,1022 @@ +//! 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::logical_edges::{merged_tumbling_windows_guarantee, OutputError}; +use crate::memo::window_candidates::tumbling_window_widths; +use crate::parameter_floors::{strongest, weakest}; + +/// The examples decided on 2026-11-07, as tests before their implementation. +mod decided_examples; + +// ── 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()); +} + +/// 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 +/// 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)); +} + +/// 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 +/// 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 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. +#[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 + ); +} diff --git a/crates/stage1-pass2-reuse-candidates/src/tests/decided_examples.rs b/crates/stage1-pass2-reuse-candidates/src/tests/decided_examples.rs new file mode 100644 index 000000000..7d31ab05a --- /dev/null +++ b/crates/stage1-pass2-reuse-candidates/src/tests/decided_examples.rs @@ -0,0 +1,629 @@ +//! The examples decided on 2026-11-07, written as tests before their +//! implementation (failing-test-first). Each test's doc comment states the +//! decision and the change that makes it pass; a test that already passes +//! is a regression guard. Like the other Pass 2 tests, each builds its +//! input memo through the `Memo` methods with the candidates Pass 1 would +//! add (no other stage crate is called) and asserts only on the decided +//! structure: the memo's groups and candidates, the logical edges and +//! Pass 2's pruned list. +use std::collections::BTreeSet; + +use asap_types::ir::operator::operator_properties::TimeShift; +use asap_types::ir::ProjectItem; +use asap_types::stages::CandidateError; +use asap_types::workload::TimestampMs; + +use super::*; + +// ── Fixtures ────────────────────────────────────────────────────────────── + +/// A query repeating every `interval_ms` at phase 0 (`FixedIntervalAt`) +/// with `accuracy`. +fn entry_at_phase_0(interval_ms: u32, accuracy: AccuracyTarget) -> QueryWorkloadEntry { + QueryWorkloadEntry { + recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { + interval: RepetitionInterval(interval_ms), + evaluation_phase: TimestampMs(0), + }), + ..entry(interval_ms, accuracy) + } +} + +/// `child offset `. +fn offset(memo: &mut Memo, child: MemoGroupId, seconds: i64) -> MemoGroupId { + group( + memo, + Operator::NonASAP(NonASAPOp::TimeShift { + shift: TimeShift { + offset_ms: seconds * 1000, + at: None, + }, + child, + }), + ) +} + +/// An exact accumulator state of `kind` over `window` grouped by +/// `reduction`, read by a `FinalizeExactAccumulator` group (the query +/// root): what Pass 1 offers for `sum_over_time` (per series) or for a +/// `sum by (keys)` of `count_over_time` (the count by keys). +fn exact_state( + memo: &mut Memo, + window: MemoGroupId, + kind: ExactKind, + reduction: Reduction, +) -> (MemoGroupId, MemoGroupId) { + let state = group(memo, build(window, FieldDataType::exact(kind), reduction)); + let finalized = group( + memo, + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: state }), + ); + (state, finalized) +} + +/// The readouts Pass 1 offers one quantile group, per family. +struct QuantileReadouts { + /// The `Aggregate` group `quantile_over_time(q, window)` per series. + result: MemoGroupId, + /// Per family: its state group and the readout candidate of `result`. + by_family: Vec<(SketchAlgorithm, MemoGroupId, CandidateForMemoGroupId)>, +} + +/// `quantile_over_time(q, window)` per series as Pass 1 leaves it: the +/// aggregate group with its exact candidate and, per family, a state group +/// (one build per listed θ), a readout group over it, and a candidate of +/// the aggregate group reading it (renamed by a `Project` when the column +/// names differ, as Pass 1's `add_output_candidate` does). +fn quantile_over_time_readouts( + memo: &mut Memo, + window: MemoGroupId, + q: f64, + families: &[SketchAlgorithm], +) -> QuantileReadouts { + let result = group( + memo, + Operator::NonASAP(NonASAPOp::Aggregate { + child: window, + reduction: Reduction::PerEntity, + measures: vec![AggIntent::Quantile { + col: None, + q, + accuracy: AccuracyTarget::max_error(0.05), + }], + output_names: vec![String::new()], + filters: vec![None], + having: None, + }), + ); + let mut by_family = Vec::new(); + for &algorithm in families { + let state = sketch_state(memo, window, algorithm, Reduction::PerEntity); + let readout = estimate(memo, state, SketchStatistic::Quantile { q }); + let renamed = { + let to = &memo.groups()[result.0 as usize].group_properties.schema; + Operator::NonASAP(NonASAPOp::Project { + cols: to + .fields + .iter() + .enumerate() + .map(|(column, field)| ProjectItem { + alias: Some(field.name.clone()), + expr: ScalarExpr::Column(column), + }) + .collect(), + child: readout, + }) + }; + let direct = Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: state, + query: SketchStatistic::Quantile { q }, + }); + let id = match try_candidate(memo, result, direct) { + Ok(id) => id, + Err(CandidateError::OutputDiffersFromGroup { .. }) => { + try_candidate(memo, result, renamed).expect("the renamed readout") + } + Err(error) => panic!("{error}"), + }; + by_family.push((algorithm, state, id)); + } + QuantileReadouts { result, by_family } +} + +/// Adds `operator` (no θ, no window) to `group`, as Pass 1 does. +fn try_candidate( + memo: &mut Memo, + group: MemoGroupId, + operator: Operator, +) -> Result { + let expression = CandidateExpression { operator }; + let logical_properties = LogicalProperties::new(&expression, None, 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, + }, + ) +} + +/// The tumbling-window widths that serve state group `state`, in either +/// representation, so the tests hold before and after E2's design (c): +/// a candidate with a tumbling layout (today), or a candidate +/// `SummaryMerge` over `TimeRange`/`TimeShift` groups over a +/// `SummaryTumblingWindowChain` group (design (c)). +fn tumbling_widths_serving(memo: &Memo, state: MemoGroupId) -> BTreeSet { + let mut widths = BTreeSet::new(); + for candidate in candidates_of(memo, state) { + if let Some(WindowLayout::Tumbling { width_ms }) = + candidate.logical_properties.window_layout() + { + widths.insert(*width_ms); + } + if let Operator::ASAP(ASAPOp::SummaryMerge { children, .. }) = + &candidate.expression.operator + { + for &child in children { + if let Some(width_ms) = chain_width_below(memo, child) { + widths.insert(width_ms); + } + } + } + } + widths +} + +/// The width of the tumbling-window chain `group` reads through +/// `TimeRange` and `TimeShift` groups, if any. +fn chain_width_below(memo: &Memo, mut group: MemoGroupId) -> Option { + loop { + match &memo.groups()[group.0 as usize].expression { + Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { + tumbling_window_width_ms, + .. + }) => return Some(*tumbling_window_width_ms), + Operator::NonASAP( + NonASAPOp::TimeRange { child, .. } | NonASAPOp::TimeShift { child, .. }, + ) => group = *child, + _ => return None, + } + } +} + +/// The groups 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 group whose expression is `operator`, if the memo has one. +fn group_of(memo: &Memo, operator: &Operator) -> Option { + groups_where(memo, |expression| expression == operator) + .first() + .copied() +} + +/// Pass 2's pruned list: each candidate it removed, from which group, and +/// the `Debug` name of its `PruneReason` (Stage 2's reasons plus the new +/// `BelowFloor`, `MetricRule` and `NoConsumer`). +/// +/// Interface used by these tests, to be added: a field +/// `LogicalASAPMemoCandidates::pruned: Vec>` +/// (the removed candidate itself, since its index no longer exists in the +/// memo). This body becomes +/// `logical.pruned.iter().map(|p| (p.group, p.candidate.clone(), format!("{:?}", p.reason))).collect()`. +fn pass2_pruned( + logical: &LogicalASAPMemoCandidates, +) -> Vec<(MemoGroupId, CandidateForMemoGroup, String)> { + let _ = logical; + todo!("LogicalASAPMemoCandidates::pruned is not there yet (dead-candidate elimination)") +} + +fn sketch_algorithm(family: &FieldDataType) -> Option { + match family { + FieldDataType::Sketch(algorithm, _) => Some(*algorithm), + _ => None, + } +} + +/// The sketch family of a state group (`SummaryAgg`) or chain group. +fn state_family(memo: &Memo, group: MemoGroupId) -> Option { + match &memo.groups()[group.0 as usize].expression { + Operator::ASAP( + ASAPOp::SummaryAgg { family, .. } | ASAPOp::SummaryTumblingWindowChain { family, .. }, + ) => sketch_algorithm(family), + _ => None, + } +} + +// ── (1) Rule order and reach (E3) ───────────────────────────────────────── + +/// Decided 2026-11-07 (E3, rule order): Pass 2 runs rules 2, 4, 5 and 6, +/// then the reach along candidate edges, then rule 1, so rule 1's widths +/// come from every query that reaches a state through a candidate. +/// +/// A `sum by (job, instance) (count_over_time(m[5m]))` every 60 s; B +/// `sum by (job) (count_over_time(m[5m]))` every 90 s (both at phase 0). +/// B's state rolls up A's (rule 6), so both reach A's state, whose widths +/// are the gcd closure of {60 s, 90 s} with 300 s: 30 s and 60 s. The +/// 30 s chain serves A and B's rollup (90 s is a multiple of 30 s). +/// +/// Fails until the E3 rule-order change (rule 1 after the reach): today +/// rule 1 runs first and sees only A's 60 s grid. +#[test] +fn e3_rule_one_runs_after_reach_so_a_rolled_up_query_adds_its_grid() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["job", "instance"]); + let window = range(&mut memo, input, 300); + let (by_job_instance, a) = exact_state( + &mut memo, + window, + ExactKind::Count, + Reduction::by(vec![2, 3]), + ); + let (by_job, b) = exact_state(&mut memo, window, ExactKind::Count, Reduction::by(vec![2])); + let query_a = memo.add_query(QueryRoot::Operator(a)); + let query_b = memo.add_query(QueryRoot::Operator(b)); + let exact = AccuracyTarget::exact(); + let logical = pass2( + memo, + &[ + entry_at_phase_0(60_000, exact.clone()), + entry_at_phase_0(90_000, exact), + ], + ); + let memo = &logical.memo; + // B's state rolls up A's. + assert!( + candidates_of(memo, by_job).iter().any(|c| { + c.origin == OriginOfCandidateForMemoGroup::Rollup + && c.expression.operator.children() == vec![&by_job_instance] + }), + "B's state reads A's through a rollup" + ); + // Both queries reach A's state. + let reached_by = &memo.groups()[by_job_instance.0 as usize].queries; + assert!( + reached_by.contains(&query_a) && reached_by.contains(&query_b), + "A's state is reached by A and B: {reached_by:?}" + ); + // Rule 1 saw both grids: 30 s (B's rollup and A) and 60 s (A). + let widths = tumbling_widths_serving(memo, by_job_instance); + assert!( + widths.contains(&30_000) && widths.contains(&60_000), + "widths serving A's state: {widths:?}" + ); +} + +// ── (2) Offset in the gcd (E3) ──────────────────────────────────────────── + +/// Decided 2026-11-07 (E3, offsets): a window read at an `offset` takes the +/// offset into the gcd, since its windows' edges move by it. +/// +/// `sum_over_time(m[5m] offset 45s)` every 60 s (phase 0): widths divide +/// 60 s, 300 s and 45 s, so 15 s only. +/// +/// Fails until the E3 offset-in-gcd change: today the widths are the gcd +/// of the grid and the window only (60 s). +#[test] +fn e3_an_offset_enters_the_gcd_of_the_widths() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["job"]); + let shifted = offset(&mut memo, input, 45); + let window = range(&mut memo, shifted, 300); + let (state, root) = exact_state(&mut memo, window, ExactKind::Sum, Reduction::PerEntity); + memo.add_query(QueryRoot::Operator(root)); + let logical = pass2(memo, &[entry_at_phase_0(60_000, AccuracyTarget::exact())]); + assert_eq!( + tumbling_widths_serving(&logical.memo, state), + BTreeSet::from([15_000]) + ); +} + +// ── (3) Dead-candidate elimination and Pass 2's pruned list ────────────── + +/// Decided 2026-11-07 (approved order: dead-candidate elimination, then +/// windowed builds for the survivors, with a Pass 2 pruned list). +/// +/// `quantile_over_time(0.99, m[5m]) / quantile_over_time(0.5, m[5m])`, +/// relative error 0.05, every 60 s; Pass 1 offers KLL and DDSketch to both +/// quantiles. The ratio reads no rank error, so every KLL readout is +/// pruned ("metric rule") and so is every KLL build (nothing reads them). +/// The ratio's floor is DDSketch alpha 0.02, so the builds with alpha 0.1 +/// and 0.05 are pruned ("below floor"). Every pruned candidate is in +/// Pass 2's pruned list with its reason, and rule 1 adds windowed builds +/// only for the surviving builds. +/// +/// Fails until dead-candidate elimination and the pruned list +/// (`LogicalASAPMemoCandidates::pruned`, see [`pass2_pruned`]) are added: +/// today every candidate stays in the memo. +#[test] +fn dead_candidates_are_pruned_and_listed_before_windowed_builds() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["job"]); + let window = range(&mut memo, input, 300); + let families = [SketchAlgorithm::Kll, SketchAlgorithm::DDSketch]; + let p99 = quantile_over_time_readouts(&mut memo, window, 0.99, &families); + let p50 = quantile_over_time_readouts(&mut memo, window, 0.5, &families); + let ratio = arithmetic(&mut memo, ArithmeticOpKind::Div, p99.result, p50.result); + memo.add_query(QueryRoot::Operator(ratio)); + let target = AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Relative); + let logical = pass2(memo, &[entry_at_phase_0(60_000, target)]); + let memo = &logical.memo; + let kll_state = p99.by_family[0].1; + let reads_kll = |c: &CandidateForMemoGroup| { + c.expression.operator.children().iter().any(|&&child| { + child == kll_state + || matches!( + &memo.groups()[child.0 as usize].expression, + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) + if *summary_input == kll_state + ) + }) + }; + // No KLL build or readout remains. + for (index, group) in memo.groups().iter().enumerate() { + let id = MemoGroupId(index as u32); + let kll_group = state_family(memo, id) == Some(SketchAlgorithm::Kll); + for c in &group.candidates { + assert!( + !(kll_group || reads_kll(c)), + "G{index} keeps a KLL candidate: {:?}", + c.expression.operator + ); + } + } + // Only DDSketch builds with alpha ≤ 0.02 remain, windowed ones + // included (rule 1 runs on the survivors). + for (index, _) in memo.groups().iter().enumerate() { + let id = MemoGroupId(index as u32); + if state_family(memo, id) != Some(SketchAlgorithm::DDSketch) { + continue; + } + for c in candidates_of(memo, id) { + if let Some(SketchParams::DDSketch { alpha }) = c.logical_properties.parameters() { + assert!(*alpha <= 0.02, "G{index} keeps DDSketch alpha {alpha}"); + } + } + } + // Every pruned candidate is listed with its reason. + let pruned = pass2_pruned(&logical); + let reason_of = |pattern: &dyn Fn(MemoGroupId, &CandidateForMemoGroup) -> bool| { + pruned + .iter() + .filter(|(group, c, _)| pattern(*group, c)) + .map(|(_, _, reason)| reason.as_str()) + .collect::>() + }; + for quantile in [&p99, &p50] { + let kll_readout = reason_of(&|group, c| group == quantile.result && reads_kll(c)); + assert_eq!( + kll_readout, + vec!["MetricRule"], + "KLL readout of G{}", + quantile.result.0 + ); + } + let kll_builds = reason_of(&|group, _| group == kll_state); + assert_eq!(kll_builds.len(), parameter_list(SketchAlgorithm::Kll).len()); + assert!(kll_builds + .iter() + .all(|reason| matches!(*reason, "MetricRule" | "NoConsumer"))); + let ddsketch_state = p99.by_family[1].1; + let mut weak: Vec = pruned + .iter() + .filter(|(group, _, reason)| *group == ddsketch_state && reason == "BelowFloor") + .filter_map(|(_, c, _)| match c.logical_properties.parameters() { + Some(SketchParams::DDSketch { alpha }) => Some(*alpha), + _ => None, + }) + .collect(); + weak.sort_by(f64::total_cmp); + assert_eq!(weak, vec![0.05, 0.1]); +} + +// ── (4) One range-free chain group across ranges and offsets (E2) ───────── + +/// Decided 2026-11-07 (E2 option B, design (c)): a tumbling-window chain is +/// a group of its own, `SummaryTumblingWindowChain` over the rows with a +/// width and no range, offset or window count, so every range and offset +/// of one rows-and-width reads the same chain group; a range state reads +/// it through `TimeRange` (and `TimeShift`) groups and a `SummaryMerge`. +/// +/// A `sum_over_time(m[5m])`, B `sum_over_time(m[5m] offset 1h)`, C +/// `sum_over_time(m[1h])`, each every 60 s (phase 0). Expected: one chain +/// group Gw of width 60 s over the scan of m; the groups `TimeShift(1h, +/// Gw)`, `TimeRange(5m, Gw)`, `TimeRange(5m, TimeShift(1h, Gw))` and +/// `TimeRange(1h, Gw)` (hash-consed); each state group has a +/// `SummaryMerge` candidate over its `TimeRange` group, which +/// `add_candidate` accepted (equal coverage). No candidate carries a window +/// layout (`LogicalProperties` keeps only θ). +/// +/// Not asserted (open): the chain's output kind and schema (C1), its +/// executor and realizer handling (C6), Stage 3's retention. +/// +/// Fails until the E2 chain-group change: today rule 1 adds windowed +/// builds (candidates with a layout) and no chain group. +#[test] +fn e2_one_range_free_chain_group_serves_every_range_and_offset() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["job"]); + let five_minutes = range(&mut memo, input, 300); + let shifted_input = offset(&mut memo, input, 3600); + let five_minutes_an_hour_ago = range(&mut memo, shifted_input, 300); + let one_hour = range(&mut memo, input, 3600); + let mut states = Vec::new(); + for window in [five_minutes, five_minutes_an_hour_ago, one_hour] { + let (state, root) = exact_state(&mut memo, window, ExactKind::Sum, Reduction::PerEntity); + memo.add_query(QueryRoot::Operator(root)); + states.push(state); + } + let exact = AccuracyTarget::exact(); + let logical = pass2(memo, &vec![entry_at_phase_0(60_000, exact); 3]); + let memo = &logical.memo; + let chains = groups_where(memo, |e| { + matches!(e, Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. })) + }); + let [chain] = chains[..] else { + panic!("one chain group, found {chains:?}"); + }; + let Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { + child, + tumbling_window_width_ms, + .. + }) = &memo.groups()[chain.0 as usize].expression + else { + unreachable!(); + }; + assert_eq!(*tumbling_window_width_ms, 60_000); + assert_eq!( + *child, input, + "the chain reads the scan: no range or shift below it" + ); + let range_over = |range_s: u64, child: MemoGroupId| { + group_of( + memo, + &Operator::NonASAP(NonASAPOp::TimeRange { + range: Duration::from_secs(range_s), + kind: TimeRangeKind::Range, + child, + }), + ) + }; + let shifted_chain = group_of( + memo, + &Operator::NonASAP(NonASAPOp::TimeShift { + shift: TimeShift { + offset_ms: 3_600_000, + at: None, + }, + child: chain, + }), + ) + .expect("TimeShift(1h, Gw)"); + let ranges = [ + range_over(300, chain).expect("TimeRange(5m, Gw)"), + range_over(300, shifted_chain).expect("TimeRange(5m, TimeShift(1h, Gw))"), + range_over(3600, chain).expect("TimeRange(1h, Gw)"), + ]; + for (state, range) in states.iter().zip(ranges) { + assert!( + candidates_of(memo, *state).iter().any(|c| matches!( + &c.expression.operator, + Operator::ASAP(ASAPOp::SummaryMerge { children, .. }) if *children == vec![range] + )), + "G{} reads G{} through a SummaryMerge", + state.0, + range.0 + ); + } + for (index, group) in memo.groups().iter().enumerate() { + for c in &group.candidates { + assert!( + c.logical_properties.window_layout().is_none(), + "G{index}: a candidate with a window layout" + ); + } + } +} + +// ── (5) Constant error contracts (E1) ───────────────────────────────────── + +/// Decided 2026-11-07 (E1, PR 4): a `Filter` whose predicate reads only +/// key or label columns keeps or drops whole rows, so a value's error +/// passes through it unchanged (β = 1 in every metric). +/// +/// `quantile_over_time(0.99, latency[5m])` behind a key-only filter +/// (`job = "api"`), relative error 0.05: the DDSketch readout keeps an +/// admissible edge into the filter. (The contract alone is tested in +/// `asap_types::stages`.) +/// +/// Fails until E1's constant contracts are in `error_propagation` (a +/// filter has no contract today, so no approximate input is admissible). +#[test] +fn e1_a_key_only_filter_keeps_the_ddsketch_readout_admissible() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "latency", &["job"]); + let window = range(&mut memo, input, 300); + let p99 = quantile_over_time_readouts(&mut memo, window, 0.99, &[SketchAlgorithm::DDSketch]); + let job = memo.groups()[p99.result.0 as usize] + .group_properties + .schema + .fields + .iter() + .position(|field| field.name == "job") + .expect("the result keeps the job label"); + let filtered = group( + &mut memo, + Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(job)), + op: CompareOpKind::Eq, + right: Box::new(ScalarExpr::Literal(ScalarValue::Utf8("api".into()))), + semantics: ExprSemantics::Promql, + }), + child: p99.result, + }), + ); + memo.add_query(QueryRoot::Operator(filtered)); + let target = AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Relative); + let logical = pass2(memo, &[entry_at_phase_0(60_000, target)]); + let ddsketch_readout = p99.by_family[0].2; + let edges = edges_into(&logical, own_candidate(filtered), 0); + assert!( + edges.contains(&(ddsketch_readout, true)), + "edges into the filter: {edges:?}" + ); +} + +// ── (6) H2: avg over relative errors stays exact-only ───────────────────── + +/// Decided 2026-11-07 (H2, documented behaviour): `avg` has an absolute +/// contract only; a relative bound on a mean needs the sign facts PR 8 +/// brings. So `avg(quantile_over_time(0.99, m[5m]))` with a relative +/// target has no admissible edge from any sketch readout into the `avg`: +/// it reads the exact quantile only. +/// +/// Passes today: a regression guard until PR 8 changes it on purpose. +#[test] +fn h2_avg_of_readouts_under_a_relative_target_reads_exact_only() { + let mut memo = Memo::default(); + let input = scan(&mut memo, "m", &["job"]); + let window = range(&mut memo, input, 300); + let families = [SketchAlgorithm::Kll, SketchAlgorithm::DDSketch]; + let p99 = quantile_over_time_readouts(&mut memo, window, 0.99, &families); + let avg = group( + &mut memo, + Operator::NonASAP(NonASAPOp::Aggregate { + child: p99.result, + reduction: Reduction::by(vec![]), + measures: vec![AggIntent::Avg { col: None }], + output_names: vec!["avg".into()], + filters: vec![None], + having: None, + }), + ); + memo.add_query(QueryRoot::Operator(avg)); + let target = AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Relative); + let logical = pass2(memo, &[entry_at_phase_0(60_000, target)]); + let edges = edges_into(&logical, own_candidate(avg), 0); + for (_, _, readout) in &p99.by_family { + assert!( + !edges.contains(&(*readout, true)), + "a sketch readout is admissible into avg: {edges:?}" + ); + } + assert!( + edges.contains(&(own_candidate(p99.result), true)), + "the exact quantile stays admissible: {edges:?}" + ); +} diff --git a/crates/types/src/stages/candidates.rs b/crates/types/src/stages/candidates.rs index 90166186e..275ba7c23 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$ = [`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. + /// + /// 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 = RELATIVE_OPERAND_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 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 /// within its validity cap (concepts doc, section 4; approved 2026-11-05). @@ -1613,4 +1765,276 @@ 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 = RELATIVE_OPERAND_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 + ); + } + + /// `Scan latency`: `[ts, value, job]`, `job` a label column. + fn scan_with_job(metric: &str) -> Operator { + Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { + metric: metric.into(), + }, + predicates: vec![], + schema: Schema::with_time_index( + vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("value", DataType::Float64, false), + Field::plain("job", DataType::Utf8, true), + ], + 0, + vec![], + ), + }) + } + + /// Decided 2026-11-07 (E1, PR 4): constant error contracts. A `Filter` + /// whose predicate reads only key or label columns (here `job = "api"` + /// over a scan's `job` label) keeps or drops whole rows, so a value's + /// error passes through unchanged: β = 1 for absolute, relative and + /// rank errors. A `Project` of `abs`, `clamp` or a negation of the + /// value changes a value by at most its error: β = 1 for absolute + /// errors. + /// + /// Fails until E1's constant contracts are in `error_propagation` + /// (today a filter and a computed projection have none). If the + /// contract needs the input group's schema to tell a label column from + /// a value, the call here takes the memo then. + #[test] + fn e1_key_filters_and_value_preserving_projects_have_unit_contracts() { + let mut memo = Memo::default(); + let g1 = memo.find_or_add_group(scan_with_job("latency")).unwrap(); + let filter = memo + .find_or_add_group(Operator::NonASAP(NonASAPOp::Filter { + pred: crate::ir::Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(2)), + op: crate::ir::scalar::CompareOpKind::Eq, + right: Box::new(ScalarExpr::Literal(crate::ir::scalar::ScalarValue::Utf8( + "api".into(), + ))), + semantics: crate::ir::ExprSemantics::Promql, + }), + child: g1, + })) + .unwrap(); + let filter = &memo.groups()[filter.0 as usize].expression; + for metric in [ + AccuracyMetric::ABSOLUTE, + AccuracyMetric::Relative, + AccuracyMetric::Rank, + ] { + assert_eq!( + LogicalRequirements::error_propagation(filter, metric) + .map(|propagation| propagation.factor_per_input), + Some(vec![1.0]), + "key-only filter, {metric:?}" + ); + } + let value = || Box::new(ScalarExpr::Column(1)); + let literal = |x: f64| ScalarExpr::Literal(crate::ir::scalar::ScalarValue::Float64(x)); + let computed = [ + ScalarExpr::FunctionCall { + name: "abs".into(), + args: vec![*value()], + }, + ScalarExpr::FunctionCall { + name: "clamp".into(), + args: vec![*value(), literal(0.0), literal(100.0)], + }, + ScalarExpr::Negative { + expr: value(), + semantics: crate::ir::ExprSemantics::Promql, + }, + ]; + for expr in computed { + let project = Operator::NonASAP(NonASAPOp::Project { + cols: vec![ + crate::ir::ProjectItem { + alias: None, + expr: ScalarExpr::Column(0), + }, + crate::ir::ProjectItem { + alias: Some("value".into()), + expr: expr.clone(), + }, + crate::ir::ProjectItem { + alias: None, + expr: ScalarExpr::Column(2), + }, + ], + child: g1, + }); + assert_eq!( + LogicalRequirements::error_propagation(&project, AccuracyMetric::ABSOLUTE) + .map(|propagation| propagation.factor_per_input), + Some(vec![1.0]), + "{expr:?}" + ); + } + } + + /// Decided 2026-11-07 (D3): `Memo::set_input_requirements` replaces + /// $r^{\log}$ of the one candidate it names, leaving the group's other + /// candidates as they were. + #[test] + fn set_input_requirements_replaces_only_the_named_candidates() { + let mut memo = Memo::default(); + let g1 = memo.find_or_add_group(scan("latency")).unwrap(); + let g2 = memo.find_or_add_group(kll_state(g1)).unwrap(); + for k in [200, 345] { + memo.add_candidate(g2, candidate(kll_state(g1), kll_k(k))) + .unwrap(); + } + let floor = kll_k(400); + memo.set_input_requirements( + CandidateForMemoGroupId { + group: g2, + index_in_group: 1, + }, + vec![LogicalRequirements::new(floor.clone())], + ); + let floors: Vec> = memo.groups()[g2.0 as usize] + .candidates + .iter() + .map(|c| { + c.input_requirements[0] + .parameter_floor_for_required_accuracy() + .cloned() + }) + .collect(); + assert_eq!(floors, vec![None, floor]); + } + + /// Decided 2026-11-07 (D3): one requirement per input group, or a + /// panic. + #[test] + #[should_panic(expected = "one requirement per input group")] + fn set_input_requirements_panics_on_a_length_mismatch() { + let mut memo = Memo::default(); + let g1 = memo.find_or_add_group(scan("latency")).unwrap(); + let g2 = memo.find_or_add_group(kll_state(g1)).unwrap(); + memo.add_candidate(g2, candidate(kll_state(g1), kll_k(200))) + .unwrap(); + memo.set_input_requirements( + CandidateForMemoGroupId { + group: g2, + index_in_group: 0, + }, + Vec::new(), + ); + } } diff --git a/crates/types/src/stages/mod.rs b/crates/types/src/stages/mod.rs index 49842eb39..88a49953e 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, + 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 7b61d75e2..af1d50b6e 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, 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 +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). |