diff --git a/crates/asap-aware-mapping/src/accuracy.rs b/crates/asap-aware-mapping/src/accuracy.rs deleted file mode 100644 index 4ffdca32..00000000 --- a/crates/asap-aware-mapping/src/accuracy.rs +++ /dev/null @@ -1,1867 +0,0 @@ -//! Planning-time accuracy algebra (issue #172): the [`AccuracyModel`] -//! extension point, its conservative default, and end-to-end -//! accuracy-budget allocation. -//! -//! ## Why a second trait next to `CostModel` -//! -//! Accuracy legality and cost ranking are different responsibilities. -//! [`crate::cost_model::CostModel`] answers "which legal candidate is -//! cheapest"; this module answers "which candidates are legal at all". The -//! pipeline [`crate::replacement`] runs is, in order: -//! -//! ```text -//! candidate generation -//! -> guarantee propagation (AccuracyModel::propagate) -//! -> AccuracyTarget satisfaction (AccuracyModel::satisfies) -//! -> legal candidates only (illegal ones become TargetSubDAGCandidates::rejected) -//! -> cost ranking / global selection (CostModel) -//! ``` -//! -//! A `CostModel` only ever sees the survivors, so it cannot override a -//! legality decision — the same "permutation only, never prune" contract -//! `CostModel::rank_candidates` already has, applied one stage earlier. -//! -//! ## What the default model admits -//! -//! [`DefaultAccuracyModel`] is deliberately conservative and fail-closed: -//! -//! | operator | rule | result | -//! |---|---|---| -//! | any, all inputs exact | exact input | the local guarantee (or exact) | -//! | `ApproximateAggregate`, all `AbsoluteValue` | additive | `Σ B`, `δ` by union bound | -//! | `ApproximateAggregate`, all `RelativeValue`, values known non-negative | multiplicative | `ε_in + ε_out + ε_in·ε_out`, `δ` by union bound | -//! | `Lipschitz { L }`, one `AbsoluteValue` input | Lipschitz | `L·B_in + B_local`, `δ` by union bound | -//! | `ExactSum`, value-like inputs | sum | `Σ B_i` (`AbsoluteValue`), `δ` by union bound over inputs | -//! | `ExactAverage`, `AbsoluteValue` inputs | average | `max B_i`, `δ` by union bound over inputs | -//! | `ExactExtremum`, `AbsoluteValue` inputs | max/min | `max B_i`, `δ` by union bound over inputs | -//! | anything else | — | [`AccuracyError::UnsupportedComposition`] | -//! -//! Cross-metric compositions (a `Rank` error under a value-additive rule, -//! a `Cardinality` error under a `Frequency` sketch, …) have no registered -//! rule and are rejected. The child is **never** treated as exact. Nothing -//! assumes independence: every probability combinator is the union bound. -//! A statistic the rule needs but [`PropagationStats`] does not supply -//! (an input row count, a stream's L1 norm) stays a -//! [`BoundExpr::Unknown`] leaf — the guarantee is still produced, but it -//! cannot satisfy any target until something instantiates the statistic. -//! -//! ## Precedence between root and per-node targets -//! -//! - A root `QueryRequirements.accuracy`, when supplied to -//! [`crate::replacement::search_workload_with_targets`], is the -//! end-to-end target for that query's root value. It is checked against -//! the root group's candidates *before* cost ranking; a candidate whose -//! guarantee is unknown, or misses the target, is moved to -//! `TargetSubDAGCandidates::rejected`. -//! - For an approximate node over an **exact** child, the node's own -//! `AggIntent.accuracy` sizes its sketch, exactly as before this module -//! existed, and the readout's guarantee is that sketch's local guarantee. -//! - For an approximate node over an **approximate** child, the outer -//! node's `AggIntent.accuracy` is the end-to-end target *for that value*. -//! The inner node's `AggIntent.accuracy` is only its declared local -//! requirement: the as-declared composition is evaluated and kept only if -//! it satisfies the outer target, and the [`AccuracyBudgetAllocator`] -//! additionally proposes re-sized splits of the outer target. A front end -//! that copied the same target onto every node has therefore *not* -//! produced a valid end-to-end allocation — the composed guarantee is -//! what decides. -//! - `AccuracyTarget::Exact` on a node admits only exact realizations -//! (unchanged), and an approximate layer can never satisfy it. - -use asap_types::post_asap::{ - AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, ExactOperation, GuaranteeSource, - ProbabilityExpr, ResultGuarantee, SketchAlgorithm, SketchParams, SketchQuery, - SummaryFamilyType, -}; -#[cfg(test)] -use asap_types::pre_asap::AggIntent; -use asap_types::types::AccuracyTarget; - -/// An enforced domain for every sample of a direct quantile operand, in every -/// evaluation window. The provider promises a nonempty population containing -/// only finite values in this interval. Sampled min/max statistics are not a -/// proof: the contract must be enforced by the source or execution layer. -#[derive(Debug, Clone, PartialEq)] -pub struct QuantileInputDomain { - pub lower: f64, - pub upper: f64, - /// Upper bound on samples per evaluation, matching the pinned readout's - /// exact Float64 rank limit. The population must also be nonempty. - pub max_samples: u64, - pub contract: String, -} - -impl QuantileInputDomain { - pub(crate) fn supports_ddsketch(&self, alpha: f64) -> bool { - if !alpha.is_finite() - || alpha <= 0.0 - || alpha >= 1.0 - || !self.lower.is_finite() - || !self.upper.is_finite() - || self.lower > self.upper - || self.max_samples == 0 - || self.max_samples > (1u64 << 53) - || self.contract.trim().is_empty() - { - return false; - } - let (min, max) = asap_sketchlib::sketches::ddsketch::ddsketch_indexable_bounds(alpha); - // Same-sign interpolation preserves relative error. Zero alone is - // exact; an interval touching zero also admits tiny zero-mapped values. - (self.lower >= min && self.upper <= max) - || (self.upper <= -min && self.lower >= -max) - || (self.lower == 0.0 && self.upper == 0.0) - } -} - -/// Statistics a propagation rule may consult. Every field is optional and -/// defaults to "unknown": a rule that needs a missing statistic emits a -/// [`BoundExpr::Unknown`] leaf (or rejects) rather than guessing. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct PropagationStats { - /// Certified finite ranges for the true numerator and denominator values. - /// Quantile ratios obtain these from their enforced input domains. - pub division_operand_domains: Option<[QuantileInputDomain; 2]>, - /// Provenance for supplied evidence (source, observation identity, etc.). - pub 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 values_non_negative: 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 input_row_count: Option, - /// Fresh key-frequency distribution evidence from the data workload. - /// Built-in rules preserve it for deployment-specific accuracy models; - /// they do not assume a favorable distribution when it is absent. - pub data_distribution: Option, - /// Lower confidence bound of the kth selected TopK item, after widening - /// the interval by the sketch's own estimation error. - pub 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 topk_excluded_upper_bound: Option, - /// Union-bound failure probability of all intervals used by the margin - /// certificate. - pub topk_interval_failure_probability: Option, - /// Hydra shared-grid collision error in the inner guarantee's metric. - pub hydra_shared_grid_collision_bound: Option, - /// Failure probability assigned to the Hydra shared-grid term. - pub hydra_shared_grid_failure_probability: Option, -} - -/// Supplies typed planning-time evidence required by propagation rules. -pub trait AccuracyEvidenceProvider { - /// Proof scoped to this complete quantile expression, including its source, - /// filters, grouping and window. `None` means unknown, including emptiness. - fn quantile_input_domain( - &self, - _operand: &asap_types::pre_asap::query_expr::QueryExpr, - ) -> Option { - None - } - - fn propagation_stats( - &self, - _op: &CompositionOperator, - _family: &SummaryFamilyType, - _query: Option<&SketchQuery>, - ) -> PropagationStats { - PropagationStats::default() - } -} - -#[derive(Debug, Default, Clone, Copy)] -pub struct NoAccuracyEvidence; - -impl AccuracyEvidenceProvider for NoAccuracyEvidence {} - -/// Accuracy evidence backed by the normalized data workload. Freshness is -/// checked at the planning time before values reach any accuracy rule. -#[derive(Debug, Clone, Copy)] -pub struct WorkloadAccuracyEvidence<'a> { - pub data: &'a asap_types::workload::DataWorkload, - pub now_ms: u64, -} - -impl AccuracyEvidenceProvider for WorkloadAccuracyEvidence<'_> { - fn propagation_stats( - &self, - _op: &CompositionOperator, - _family: &SummaryFamilyType, - _query: Option<&SketchQuery>, - ) -> PropagationStats { - PropagationStats { - input_row_count: self.data.input_cardinality.value_at(self.now_ms).copied(), - data_distribution: self.data.distribution.value_at(self.now_ms).cloned(), - ..PropagationStats::default() - } - } -} - -/// The deployment-extensible accuracy algebra. `asap-aware-mapping` ships -/// [`DefaultAccuracyModel`]; a deployment with a proof for a composition the -/// default rejects (a registered cross-metric conversion, say) implements -/// this trait and passes it to -/// [`crate::replacement::SketchAlgorithmStrategy::new_with_planning_inputs`]. -pub trait AccuracyModel { - /// The definition-registered rule for applying `operation` to an - /// approximate input. `None` means the function is exact only over exact - /// inputs; callers must fail closed for approximate input. - fn exact_operation_rule(&self, _operation: &ExactOperation) -> Option { - None - } - - /// The guarantee of reading `query` out of a summary of family `family` - /// built over an **exact** input — derived from the family's committed - /// parameters by inverting the same sizing formulas - /// [`crate::replacement::default_size_params`] uses. `None` when this - /// model has no error model for the family (the default has none for - /// `Sample`/`Wavelet`/`StatModel`). - fn local_guarantee( - &self, - family: &SummaryFamilyType, - query: &SketchQuery, - ) -> Option; - - /// Compose `inputs`' guarantees (in the parent's child order) with the - /// parent's own `local` guarantee under `op`. `Err` is the fail-closed - /// answer: no registered rule, or a missing input guarantee. - fn propagate( - &self, - op: &CompositionOperator, - inputs: &[ResultGuarantee], - local: Option<&ResultGuarantee>, - stats: &PropagationStats, - ) -> Result; - - /// Does `guarantee` meet `target`? An unevaluable bound or probability - /// never satisfies anything. - fn satisfies(&self, guarantee: &ResultGuarantee, target: &AccuracyTarget) -> bool; -} - -/// The conservative, fail-closed default — see the module docs' table. -#[derive(Debug, Default, Clone, Copy)] -pub struct DefaultAccuracyModel; - -/// Small relative tolerance for comparing an evaluated bound against a -/// target, so a parameter sized by `⌈·⌉` to *exactly* meet ε is not rejected -/// by floating-point noise. -const SATISFACTION_TOLERANCE: f64 = 1e-9; - -pub(crate) const KLL_RANK_ERROR_COEFFICIENT_99: f64 = 2.296; -pub(crate) const KLL_RANK_ERROR_EXPONENT_99: f64 = 0.9723; - -pub(crate) fn kll_rank_error_99(k: u32) -> f64 { - KLL_RANK_ERROR_COEFFICIENT_99 / f64::from(k).powf(KLL_RANK_ERROR_EXPONENT_99) -} - -fn count_sketch_failure_probability(depth: u32) -> Option { - if depth == 0 || depth.is_multiple_of(2) { - return None; - } - Some((-f64::from(depth) / 18.0).exp()) -} - -impl DefaultAccuracyModel { - /// The local guarantee of one sketch `(algorithm, params)` for `query` - /// — each arm inverts the matching formula in - /// [`crate::replacement::default_size_params`]. - pub fn sketch_guarantee( - algorithm: &SketchAlgorithm, - params: &SketchParams, - query: &SketchQuery, - ) -> Option { - let (metric, bound, delta) = match params { - SketchParams::UnivMon { .. } => { - return matches!(query, SketchQuery::PointCount { value: None, .. }) - .then(|| ResultGuarantee::exact("univmon_unit_update_total")); - } - // Apache DataSketches' single-sided KLL fit is the empirical 99th - // percentile normalized rank error for quantile/rank queries. - // Tighter confidence needs an amplification contract. - SketchParams::Kll { k } => ( - ErrorMetric::Rank, - kll_rank_error_99(*k), - ProbabilityExpr::Constant { value: 0.01 }, - ), - // DDSketch: deterministic relative value error α. - SketchParams::DDSketch { alpha } => { - (ErrorMetric::RelativeValue, *alpha, ProbabilityExpr::Zero) - } - // HLL parameters encode precision, not a confidence-level budget. - // They therefore provide an RSE magnitude here but no failure - // probability against true cardinality. - SketchParams::Hll { precision } => ( - ErrorMetric::Cardinality, - 1.04 / 2f64.powi(i32::from(*precision)).sqrt(), - ProbabilityExpr::Unknown { - statistic: "hll_estimator_failure_probability".into(), - }, - ), - // KMV / Theta: RSE <= 1/√(k-2); the same Chebyshev conversion - // gives a conservative parameter-derived 99% confidence bound. - SketchParams::Kmv { k } | SketchParams::Theta { k } => ( - ErrorMetric::Cardinality, - 10.0 / f64::from(k.saturating_sub(2).max(1)).sqrt(), - ProbabilityExpr::Constant { value: 0.01 }, - ), - // CMS: over-count ≤ (e/w)·‖f‖₁ with probability ≥ 1 − e^{−d}. - SketchParams::Cms { width, depth } | SketchParams::CmsWithHeap { width, depth, .. } => { - ( - ErrorMetric::Frequency, - std::f64::consts::E / f64::from(*width), - ProbabilityExpr::Constant { - value: (-f64::from(*depth)).exp(), - }, - ) - } - // CountSketch: one row has variance at most ‖f‖₂²/w. With - // ε=√(3/w), Chebyshev makes a row bad with probability <=1/3; - // the median across independent odd-depth rows has the binomial - // tail bounded by Hoeffding below. - SketchParams::CountSketch { width, depth } - | SketchParams::CountSketchWithHeap { width, depth, .. } => ( - ErrorMetric::L2Frequency, - (3.0 / f64::from(*width)).sqrt(), - ProbabilityExpr::Constant { - value: count_sketch_failure_probability(*depth)?, - }, - ), - }; - let provenance = vec![GuaranteeSource::SketchReadout { - algorithm: format!("{algorithm:?}"), - contract: match params { - SketchParams::UnivMon { .. } => unreachable!("handled before bounded estimators"), - SketchParams::Kll { .. } => "apache_datasketches_kll_empirical_99_a9b42755072b", - SketchParams::DDSketch { .. } => "ddsketch_relative_error_alpha_v1", - SketchParams::Hll { .. } => "generic_hll_rse_only_no_confidence_v1", - SketchParams::Kmv { .. } => "kmv_unbiased_variance_chebyshev_99_v1", - SketchParams::Theta { .. } => "theta_variance_chebyshev_99_v1", - SketchParams::Cms { .. } | SketchParams::CmsWithHeap { .. } => { - "count_min_l1_markov_v1" - } - SketchParams::CountSketch { .. } | SketchParams::CountSketchWithHeap { .. } => { - "count_sketch_l2_median_hoeffding_v1" - } - } - .into(), - params: serde_json::to_value(params).unwrap_or(serde_json::Value::Null), - query: format!("{query:?}"), - }]; - Some(ResultGuarantee { - metric, - bound: BoundExpr::Constant { value: bound }, - failure_probability: delta, - provenance, - }) - } - - fn additive( - op: &CompositionOperator, - inputs: &[ResultGuarantee], - local: &ResultGuarantee, - rule: &str, - ) -> ResultGuarantee { - let mut terms: Vec = inputs.iter().map(|g| g.bound.clone()).collect(); - terms.push(local.bound.clone()); - let mut deltas: Vec = inputs - .iter() - .map(|g| g.failure_probability.clone()) - .collect(); - deltas.push(local.failure_probability.clone()); - ResultGuarantee { - metric: local.metric, - bound: BoundExpr::Sum { terms }, - failure_probability: ProbabilityExpr::UnionBound { terms: deltas }, - provenance: 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 tree is exact for any input count. - fn multiplicative( - op: &CompositionOperator, - inputs: &[ResultGuarantee], - local: &ResultGuarantee, - ) -> ResultGuarantee { - let factors: Vec<&BoundExpr> = inputs - .iter() - .map(|g| &g.bound) - .chain(std::iter::once(&local.bound)) - .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.failure_probability.clone()) - .collect(); - deltas.push(local.failure_probability.clone()); - ResultGuarantee { - metric: ErrorMetric::RelativeValue, - bound: BoundExpr::Sum { terms }, - failure_probability: ProbabilityExpr::UnionBound { terms: deltas }, - provenance: composed_provenance(op, inputs, local, "relative_cross_term_union_bound"), - } - } - - fn lipschitz( - op: &CompositionOperator, - constant: f64, - inputs: &[ResultGuarantee], - local: Option<&ResultGuarantee>, - ) -> ResultGuarantee { - let input = &inputs[0]; - let scaled = BoundExpr::Scaled { - factor: constant, - inner: Box::new(input.bound.clone()), - }; - let (bound, delta) = match local { - Some(local) => ( - BoundExpr::Sum { - terms: vec![scaled, local.bound.clone()], - }, - ProbabilityExpr::UnionBound { - terms: vec![ - input.failure_probability.clone(), - local.failure_probability.clone(), - ], - }, - ), - None => (scaled, input.failure_probability.clone()), - }; - let exact_local = ResultGuarantee::exact("deterministic Lipschitz transformation"); - ResultGuarantee { - metric: ErrorMetric::AbsoluteValue, - bound, - failure_probability: delta, - provenance: 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: &[ResultGuarantee], - stats: &PropagationStats, - ) -> Result { - 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.metric).collect(), - local_metric: None, - reason: format!( - "input {i} carries a {:?} guarantee, which has no registered \ - conversion to an absolute value error", - input.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.failure_probability.clone()); - } - let count = row_count(stats, &mut provenance); - let exact_local = ResultGuarantee::exact("ExactAggregate(Sum)"); - provenance.extend(composed_provenance( - op, - inputs, - &exact_local, - "exact_sum_union_bound", - )); - Ok(ResultGuarantee { - metric: ErrorMetric::AbsoluteValue, - bound: BoundExpr::Product { - factors: vec![count.clone(), BoundExpr::Sum { terms }], - }, - failure_probability: 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: &[ResultGuarantee], - stats: &PropagationStats, - ) -> Result { - if inputs - .iter() - .any(|input| input.metric != ErrorMetric::AbsoluteValue) - { - return Err(AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: inputs.iter().map(|g| g.metric).collect(), - local_metric: None, - reason: "exact average requires AbsoluteValue input guarantees".into(), - }); - } - let mut provenance = Vec::new(); - let count = row_count(stats, &mut provenance); - let exact_local = ResultGuarantee::exact("ExactAggregate(Average)"); - provenance.extend(composed_provenance( - op, - inputs, - &exact_local, - "exact_average_union_bound", - )); - Ok(ResultGuarantee { - metric: ErrorMetric::AbsoluteValue, - bound: BoundExpr::Max { - terms: inputs.iter().map(|g| g.bound.clone()).collect(), - }, - failure_probability: ProbabilityExpr::Scaled { - count, - inner: Box::new(ProbabilityExpr::UnionBound { - terms: inputs - .iter() - .map(|g| g.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: &[ResultGuarantee], - stats: &PropagationStats, - ) -> Result { - let metric = inputs[0].metric; - if inputs - .iter() - .any(|g| g.metric != ErrorMetric::AbsoluteValue) - { - return Err(AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: inputs.iter().map(|g| g.metric).collect(), - local_metric: None, - reason: "exact max/min requires AbsoluteValue input guarantees".into(), - }); - } - let mut provenance = Vec::new(); - let count = row_count(stats, &mut provenance); - let exact_local = ResultGuarantee::exact("ExactAggregate(Max)"); - provenance.extend(composed_provenance( - op, - inputs, - &exact_local, - "exact_extremum_union_bound", - )); - Ok(ResultGuarantee { - metric, - bound: BoundExpr::Max { - terms: inputs.iter().map(|g| g.bound.clone()).collect(), - }, - failure_probability: ProbabilityExpr::Scaled { - count, - inner: Box::new(ProbabilityExpr::UnionBound { - terms: inputs - .iter() - .map(|g| g.failure_probability.clone()) - .collect(), - }), - }, - provenance, - }) - } - - /// Exact division of two relative-value estimates. If the numerator is - /// within `a` and the denominator within `b`, their ratio is within - /// `(a + b) / (1 - b)`. DDSketch supplies those deterministic bounds. - fn exact_division( - op: &CompositionOperator, - inputs: &[ResultGuarantee], - stats: &PropagationStats, - ) -> Result { - let unsupported = |reason: String| AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: inputs.iter().map(|g| g.metric).collect(), - local_metric: None, - reason, - }; - if inputs.len() != 2 - || inputs - .iter() - .any(|input| input.metric != ErrorMetric::RelativeValue) - { - return Err(unsupported( - "division needs exactly two RelativeValue guarantees".into(), - )); - } - let Some(numerator) = inputs[0].bound.evaluate() else { - return Err(unsupported( - "numerator relative bound is unavailable".into(), - )); - }; - let Some(denominator) = inputs[1].bound.evaluate() else { - return Err(unsupported( - "denominator relative bound is unavailable".into(), - )); - }; - if !(numerator.is_finite() - && denominator.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(), - )); - } - let Some(domains) = &stats.division_operand_domains else { - return Err(unsupported( - "division needs finite operand domains and a nonzero denominator proof".into(), - )); - }; - for domain in domains { - if !domain.lower.is_finite() - || !domain.upper.is_finite() - || domain.lower > domain.upper - || domain.contract.trim().is_empty() - { - return Err(unsupported("invalid division operand domain".into())); - } - } - if domains[1].lower <= 0.0 && domains[1].upper >= 0.0 { - return Err(unsupported("denominator domain includes zero".into())); - } - // Keep the true and perturbed quotients finite and out of the - // subnormal range, where Float64 division loses relative accuracy. - // A nonzero numerator interval touching zero cannot prove this. - let num = &domains[0]; - if num.lower <= 0.0 && num.upper >= 0.0 && (num.lower != 0.0 || num.upper != 0.0) { - return Err(unsupported( - "numerator domain cannot exclude underflow near zero".into(), - )); - } - for n in [num.lower, num.upper] { - for d in [domains[1].lower, domains[1].upper] { - for nf in [1.0 - numerator, 1.0 + numerator] { - for df in [1.0 - denominator, 1.0 + denominator] { - let quotient = (n * nf) / (d * df); - if !(n * nf).is_finite() - || !(d * df).is_finite() - || !quotient.is_finite() - || (n != 0.0 && quotient.abs() < f64::MIN_POSITIVE) - { - return Err(unsupported( - "division may overflow or underflow Float64".into(), - )); - } - } - } - } - } - Ok(ResultGuarantee { - metric: ErrorMetric::RelativeValue, - bound: BoundExpr::Constant { - value: (numerator + denominator) / (1.0 - denominator), - }, - failure_probability: ProbabilityExpr::UnionBound { - terms: inputs - .iter() - .map(|input| input.failure_probability.clone()) - .collect(), - }, - provenance: inputs - .iter() - .enumerate() - .map(|(input_index, guarantee)| GuaranteeSource::ChildGuarantee { - input_index, - guarantee: Box::new(guarantee.clone()), - }) - .chain(domains.iter().enumerate().map(|(input_index, domain)| { - GuaranteeSource::InputValueDomain { - input_index, - lower: domain.lower, - upper: domain.upper, - max_samples: domain.max_samples, - contract: domain.contract.clone(), - } - })) - .chain(std::iter::once(GuaranteeSource::CompositionStep { - operator: op.clone(), - rule: "relative_division".into(), - })) - .collect(), - }) - } -} - -/// `stats.input_row_count` as a bound factor, or an `Unknown` leaf (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 => { - provenance.push(GuaranteeSource::UnavailableStatistic { - statistic: "input_row_count".into(), - }); - BoundExpr::Unknown { - statistic: "input_row_count".into(), - } - } - } -} - -/// `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`, `TopKMembership`). -fn absolute_bound(input: &ResultGuarantee) -> Option { - let normalizer = match input.metric { - ErrorMetric::AbsoluteValue => return Some(input.bound.clone()), - ErrorMetric::RelativeValue => "true_value_magnitude", - ErrorMetric::Cardinality => "true_cardinality", - ErrorMetric::Frequency => "stream_l1_norm", - ErrorMetric::L2Frequency => "stream_l2_norm", - // `Rank` has no distribution-free conversion to a value error; a - // metric this crate does not know has no registered conversion. - ErrorMetric::Rank | ErrorMetric::TopKMembership | _ => return None, - }; - if input.bound.is_zero() { - return Some(BoundExpr::Zero); - } - Some(BoundExpr::Product { - factors: vec![ - input.bound.clone(), - BoundExpr::Unknown { - statistic: normalizer.into(), - }, - ], - }) -} - -fn composed_provenance( - op: &CompositionOperator, - inputs: &[ResultGuarantee], - local: &ResultGuarantee, - 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 -} - -impl AccuracyModel for DefaultAccuracyModel { - fn exact_operation_rule(&self, operation: &ExactOperation) -> Option { - let ExactOperation::Aggregate { measures, .. } = operation else { - return None; - }; - match measures.as_slice() { - [intent] => crate::function_rules::function_rules(intent).map(|rules| rules.accuracy), - // The remaining functions are exact over exact samples, but have - // no definition-backed rule over approximate values yet. - _ => None, - } - } - - fn local_guarantee( - &self, - family: &SummaryFamilyType, - query: &SketchQuery, - ) -> Option { - match family { - SummaryFamilyType::Plain(_) => Some(ResultGuarantee::exact("Plain value")), - SummaryFamilyType::ExactAggregate(kind, _) => { - Some(ResultGuarantee::exact(format!("ExactAggregate({kind:?})"))) - } - SummaryFamilyType::Sketch(kind, _) => { - Self::sketch_guarantee(kind.algorithm(), kind.params(), query) - } - // No error model is registered for these families. - SummaryFamilyType::Sample(..) - | SummaryFamilyType::Wavelet(..) - | SummaryFamilyType::StatModel(..) => None, - } - } - - fn propagate( - &self, - op: &CompositionOperator, - inputs: &[ResultGuarantee], - local: Option<&ResultGuarantee>, - stats: &PropagationStats, - ) -> Result { - // Exact input: only the local guarantee remains (or the value is exact). - if inputs.iter().all(ResultGuarantee::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 = ResultGuarantee::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.metric).collect(); - let unsupported = |reason: String| AccuracyError::UnsupportedComposition { - operator: op.clone(), - input_metrics: input_metrics.clone(), - local_metric: local.map(|g| g.metric), - reason, - }; - // An exact input is compatible with every metric; only approximate - // inputs constrain the rule. - let approximate: Vec<&ResultGuarantee> = inputs.iter().filter(|g| !g.is_exact()).collect(); - let same_metric = |metric: ErrorMetric| approximate.iter().all(|g| g.metric == metric); - - match op { - CompositionOperator::CheckedRelativeDivision => { - if inputs.len() != 2 || local.is_some() || !same_metric(ErrorMetric::RelativeValue) - { - return Err(unsupported( - "checked division requires two exact/relative-value operands".into(), - )); - } - let a = inputs[0] - .bound - .evaluate() - .ok_or_else(|| unsupported("unknown numerator bound".into()))?; - let b = inputs[1] - .bound - .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(ResultGuarantee { - metric: ErrorMetric::RelativeValue, - bound: BoundExpr::Constant { - value: (a + b) / (1.0 - b) + 4.0 * f64::EPSILON, - }, - failure_probability: ProbabilityExpr::UnionBound { - terms: inputs - .iter() - .map(|g| g.failure_probability.clone()) - .collect(), - }, - provenance: composed_provenance( - op, - inputs, - &ResultGuarantee::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.metric) { - return Err(unsupported(format!( - "no registered cross-metric rule from {input_metrics:?} to {:?}", - local.metric - ))); - } - match local.metric { - ErrorMetric::AbsoluteValue => { - Ok(Self::additive(op, inputs, local, "additive_union_bound")) - } - ErrorMetric::RelativeValue => { - 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(ResultGuarantee { - metric: ErrorMetric::RelativeValue, - bound: BoundExpr::Unknown { - statistic: "values_non_negative".into(), - }, - failure_probability: ProbabilityExpr::Unknown { - statistic: "values_non_negative".into(), - }, - provenance, - }); - } - Ok(Self::multiplicative(op, inputs, local)) - } - ErrorMetric::Rank - | ErrorMetric::Cardinality - | ErrorMetric::Frequency - | ErrorMetric::L2Frequency - | ErrorMetric::TopKMembership - | _ => Err(unsupported(format!( - "no registered same-metric composition rule for {:?} over {:?}", - local.metric, local.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(ErrorMetric::AbsoluteValue) { - return Err(unsupported( - "Lipschitz rule is registered for exactly one AbsoluteValue input".into(), - )); - } - if local.is_some_and(|g| g.metric != ErrorMetric::AbsoluteValue) { - return Err(unsupported( - "Lipschitz rule needs an AbsoluteValue local guarantee".into(), - )); - } - Ok(Self::lipschitz(op, *constant, inputs, local)) - } - CompositionOperator::ExactSum => Self::exact_sum(op, inputs, stats), - CompositionOperator::ExactAverage => Self::exact_average(op, inputs, stats), - CompositionOperator::ExactExtremum => Self::exact_extremum(op, inputs, stats), - CompositionOperator::ExactDivision => Self::exact_division(op, inputs, stats), - 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(); - Ok(ResultGuarantee { - metric: ErrorMetric::TopKMembership, - bound: if certified { - BoundExpr::Zero - } else { - BoundExpr::Unknown { - statistic: "topk_membership_margin".into(), - } - }, - failure_probability: 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())), - } - } - - fn satisfies(&self, guarantee: &ResultGuarantee, target: &AccuracyTarget) -> bool { - let within = |value: Option, limit: f64| { - value.is_some_and(|v| v <= limit * (1.0 + SATISFACTION_TOLERANCE) + f64::EPSILON) - }; - match target { - AccuracyTarget::Exact => guarantee.is_exact(), - AccuracyTarget::Epsilon(eps) => within(guarantee.bound.evaluate(), *eps), - AccuracyTarget::EpsilonDelta { epsilon, delta } => { - within(guarantee.bound.evaluate(), *epsilon) - && within(guarantee.failure_probability.evaluate(), *delta) - } - } - } -} - -// ── Budget allocation ─────────────────────────────────────────────────────── - -/// The shape of a composition an allocator splits a budget across. -#[derive(Debug, Clone, PartialEq)] -pub struct CompositionShape { - /// The metric the composed guarantee will carry — decides whether the - /// budget composes additively (`Σ ε_i ≤ ε`) or multiplicatively - /// (`Π(1+ε_i) ≤ 1+ε`). - pub metric: ErrorMetric, - /// How many approximate layers share the budget (≥ 1). - pub approximate_layer_count: usize, -} - -/// One way of splitting an end-to-end target across a composition's -/// approximate layers. `layers[0]` is the outermost layer's local target; -/// the remainder are the inner layers', outermost first. -#[derive(Debug, Clone, PartialEq)] -pub struct AccuracyAllocation { - pub allocator: &'static str, - pub layers: Vec, -} - -impl AccuracyAllocation { - /// The end-to-end budget left for everything below `layers[0]` — what - /// the inner subtree must satisfy as a whole (it re-splits internally). - /// `None` for a single-layer allocation. - pub fn inner_target(&self, shape: &CompositionShape) -> Option { - let inner = &self.layers[1..]; - if inner.is_empty() { - return None; - } - let (eps, delta): (Vec, Vec>) = inner - .iter() - .map(|t| match t { - AccuracyTarget::Exact => (0.0, Some(0.0)), - AccuracyTarget::Epsilon(e) => (*e, None), - AccuracyTarget::EpsilonDelta { epsilon, delta } => (*epsilon, Some(*delta)), - }) - .unzip(); - let epsilon = match shape.metric { - ErrorMetric::RelativeValue => eps.iter().map(|e| 1.0 + e).product::() - 1.0, - _ => eps.iter().sum(), - }; - Some(match delta.iter().copied().sum::>() { - Some(delta) => AccuracyTarget::EpsilonDelta { epsilon, delta }, - None => AccuracyTarget::Epsilon(epsilon), - }) - } -} - -/// Enumerates the finite set of budget splits the search tries for one -/// composition. Exposed as its own hook because equal splitting is rarely -/// cost-optimal; a deployment can return several candidate splits and let -/// cost ranking pick among the legal ones. -pub trait AccuracyBudgetAllocator { - fn allocations( - &self, - target: &AccuracyTarget, - composition: &CompositionShape, - ) -> Vec; -} - -/// The initial deterministic allocator: every approximate layer gets an -/// equal share — `ε_i = ε / n`, `δ_i = δ / n` for an additively composed -/// metric, and `ε_i = (1 + ε)^{1/n} − 1` for a multiplicatively composed -/// one — so the composed bound meets the target exactly with no slack. -/// `AccuracyTarget::Exact` yields no allocation: no approximate layer can -/// meet it. -#[derive(Debug, Default, Clone, Copy)] -pub struct EqualSplitAllocator; - -impl AccuracyBudgetAllocator for EqualSplitAllocator { - fn allocations( - &self, - target: &AccuracyTarget, - composition: &CompositionShape, - ) -> Vec { - let n = composition.approximate_layer_count.max(1); - let (epsilon, delta) = match target { - AccuracyTarget::Exact => return Vec::new(), - AccuracyTarget::Epsilon(e) => (*e, None), - AccuracyTarget::EpsilonDelta { epsilon, delta } => (*epsilon, Some(*delta)), - }; - if !(epsilon.is_finite() && epsilon > 0.0) { - return Vec::new(); - } - let local_epsilon = match composition.metric { - ErrorMetric::RelativeValue => (1.0 + epsilon).powf(1.0 / n as f64) - 1.0, - _ => epsilon / n as f64, - }; - let layer = match delta { - Some(delta) => AccuracyTarget::EpsilonDelta { - epsilon: local_epsilon, - delta: delta / n as f64, - }, - None => AccuracyTarget::Epsilon(local_epsilon), - }; - vec![AccuracyAllocation { - allocator: "EqualSplitAllocator", - layers: vec![layer; n], - }] - } -} - -#[cfg(test)] -mod tests { - use super::*; - use asap_types::post_asap::{GroupingStrategy, SketchKind}; - use asap_types::workload::{DataDistribution, DataWorkload, Evidence, EvidenceSource}; - - // Rank error cannot certify a numeric ratio; a same-sketch identity is not cancellation evidence. - #[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 = DefaultAccuracyModel - .propagate(&op, &inputs, None, &Default::default()) - .unwrap(); - assert!((g.bound.evaluate().unwrap() - 0.02 / 0.99).abs() < 1e-14); - let mut rank = inputs[0].clone(); - rank.metric = ErrorMetric::Rank; - assert!(DefaultAccuracyModel - .propagate(&op, &[rank.clone(), rank], None, &Default::default()) - .is_err()); - assert!(DefaultAccuracyModel - .propagate(&op, &[rel(0.01), rel(1.0)], None, &Default::default()) - .is_err()); - } - - fn abs(bound: f64, delta: f64) -> ResultGuarantee { - ResultGuarantee { - metric: ErrorMetric::AbsoluteValue, - bound: BoundExpr::Constant { value: bound }, - failure_probability: ProbabilityExpr::Constant { value: delta }, - provenance: vec![], - } - } - - fn rel(bound: f64) -> ResultGuarantee { - ResultGuarantee { - metric: ErrorMetric::RelativeValue, - bound: BoundExpr::Constant { value: bound }, - failure_probability: ProbabilityExpr::Zero, - provenance: vec![], - } - } - - fn domain(lower: f64, upper: f64) -> QuantileInputDomain { - QuantileInputDomain { - lower, - upper, - max_samples: 1000, - contract: "enforced test population".into(), - } - } - - /// Relative bounds alone do not establish that a quotient is defined. - #[test] - fn relative_division_requires_operand_domains() { - assert!(DefaultAccuracyModel - .propagate( - &CompositionOperator::ExactDivision, - &[rel(0.01), rel(0.01)], - None, - &PropagationStats::default() - ) - .is_err()); - } - - /// The algebra works for either sign and records the domain contracts. - #[test] - fn relative_division_preserves_asymmetric_bound_and_domain_provenance() { - for denominator in [domain(1., 10.), domain(-10., -1.)] { - let stats = PropagationStats { - division_operand_domains: Some([domain(-20., -2.), denominator]), - ..Default::default() - }; - let got = DefaultAccuracyModel - .propagate( - &CompositionOperator::ExactDivision, - &[rel(0.02), rel(0.03)], - None, - &stats, - ) - .unwrap(); - assert!((got.bound.evaluate().unwrap() - 0.05 / 0.97).abs() < 1e-14); - assert_eq!(got.failure_probability.evaluate(), Some(0.)); - assert_eq!( - got.provenance - .iter() - .filter(|p| matches!(p, GuaranteeSource::InputValueDomain { .. })) - .count(), - 2 - ); - } - } - - /// Zero, nonfinite and unrepresentable quotients fail closed even with a supplied range. - #[test] - fn relative_division_rejects_zero_special_and_extreme_domains() { - for domains in [ - [domain(1., 2.), domain(0., 0.)], - [domain(1., 2.), domain(-1., 1.)], - [domain(f64::NAN, 2.), domain(1., 2.)], - [domain(1., 2.), domain(1., f64::INFINITY)], - [domain(1e250, 1e250), domain(1e-250, 1e-250)], - [domain(1e-250, 1e-250), domain(1e250, 1e250)], - ] { - let stats = PropagationStats { - division_operand_domains: Some(domains), - ..Default::default() - }; - assert!(DefaultAccuracyModel - .propagate( - &CompositionOperator::ExactDivision, - &[rel(0.01), rel(0.01)], - None, - &stats - ) - .is_err()); - } - let stats = PropagationStats { - division_operand_domains: Some([domain(1., 2.), domain(1., 2.)]), - ..Default::default() - }; - for bound in [1., f64::NAN, f64::INFINITY, -0.1] { - assert!(DefaultAccuracyModel - .propagate( - &CompositionOperator::ExactDivision, - &[rel(0.01), rel(bound)], - None, - &stats - ) - .is_err()); - } - } - - fn with_metric(metric: ErrorMetric, bound: f64) -> ResultGuarantee { - ResultGuarantee { - metric, - ..abs(bound, 0.0) - } - } - - #[test] - fn workload_accuracy_evidence_uses_only_fresh_data_characteristics() { - let data = DataWorkload { - input_cardinality: Evidence { - value: Some(42), - source: EvidenceSource::Observed, - observed_at_ms: Some(1_000), - valid_for_ms: Some(500), - }, - distribution: Evidence { - value: Some(DataDistribution::Bursty), - source: EvidenceSource::Observed, - observed_at_ms: Some(1_000), - valid_for_ms: Some(500), - }, - ..Default::default() - }; - let provider = WorkloadAccuracyEvidence { - data: &data, - now_ms: 1_500, - }; - let fresh = provider.propagation_stats( - &CompositionOperator::ExactSum, - &SummaryFamilyType::ExactAggregate( - asap_types::post_asap::ExactKind::Sum, - asap_types::post_asap::ExactParams::Sum, - ), - None, - ); - assert_eq!(fresh.input_row_count, Some(42)); - assert_eq!(fresh.data_distribution, Some(DataDistribution::Bursty)); - - let stale = WorkloadAccuracyEvidence { - data: &data, - now_ms: 1_501, - } - .propagation_stats( - &CompositionOperator::ExactSum, - &SummaryFamilyType::ExactAggregate( - asap_types::post_asap::ExactKind::Sum, - asap_types::post_asap::ExactParams::Sum, - ), - None, - ); - assert_eq!(stale.input_row_count, None); - assert_eq!(stale.data_distribution, None); - } - - #[test] - fn exact_child_contributes_zero_error() { - let local = abs(0.05, 0.01); - let out = DefaultAccuracyModel - .propagate( - &CompositionOperator::ApproximateAggregate, - &[ResultGuarantee::exact("sum")], - Some(&local), - &PropagationStats::default(), - ) - .unwrap(); - assert_eq!(out.bound.evaluate(), Some(0.05)); - assert_eq!(out.failure_probability.evaluate(), Some(0.01)); - assert_eq!(out.metric, ErrorMetric::AbsoluteValue); - } - - #[test] - fn additive_bounds_and_delta_union_bound_compose() { - let out = DefaultAccuracyModel - .propagate( - &CompositionOperator::ApproximateAggregate, - &[abs(0.02, 0.01)], - Some(&abs(0.03, 0.02)), - &PropagationStats::default(), - ) - .unwrap(); - assert!((out.bound.evaluate().unwrap() - 0.05).abs() < 1e-12); - // Union bound, not 1 − (1−0.01)(1−0.02) = 0.0298. - assert!((out.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 = DefaultAccuracyModel - .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.bound.evaluate().unwrap() - 0.32).abs() < 1e-12); - assert_eq!(out.metric, ErrorMetric::RelativeValue); - } - - #[test] - fn relative_error_without_sign_knowledge_remains_symbolic() { - let unknown = DefaultAccuracyModel - .propagate( - &CompositionOperator::ApproximateAggregate, - &[rel(0.1)], - Some(&rel(0.2)), - &PropagationStats::default(), - ) - .unwrap(); - assert!(unknown.has_unknown()); - let signed = DefaultAccuracyModel.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 = DefaultAccuracyModel - .propagate( - &CompositionOperator::ApproximateAggregate, - &[with_metric(ErrorMetric::Cardinality, 0.01)], - Some(&with_metric(ErrorMetric::Frequency, 0.01)), - &PropagationStats::default(), - ) - .unwrap_err(); - assert!(matches!( - err, - AccuracyError::UnsupportedComposition { - input_metrics, - local_metric: Some(ErrorMetric::Frequency), - .. - } if input_metrics == vec![ErrorMetric::Cardinality] - )); - // Quantile rank error under value-additive logic. - let err = DefaultAccuracyModel - .propagate( - &CompositionOperator::ApproximateAggregate, - &[with_metric(ErrorMetric::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 = DefaultAccuracyModel - .propagate( - &CompositionOperator::ApproximateAggregate, - &[with_metric(ErrorMetric::Rank, 0.01)], - Some(&with_metric(ErrorMetric::Rank, 0.01)), - &PropagationStats::default(), - ) - .unwrap_err(); - assert!(matches!(err, AccuracyError::UnsupportedComposition { .. })); - } - - #[test] - fn lipschitz_scales_the_input_bound() { - let out = DefaultAccuracyModel - .propagate( - &CompositionOperator::Lipschitz { constant: 3.0 }, - &[abs(0.1, 0.01)], - Some(&abs(0.05, 0.02)), - &PropagationStats::default(), - ) - .unwrap(); - assert!((out.bound.evaluate().unwrap() - 0.35).abs() < 1e-12); - assert!((out.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 = DefaultAccuracyModel - .propagate( - &CompositionOperator::ExactSum, - &[abs(0.1, 0.01)], - None, - &PropagationStats::default(), - ) - .unwrap(); - assert_eq!(out.metric, ErrorMetric::AbsoluteValue); - assert_eq!( - out.bound.evaluate(), - None, - "unknown row count stays unknown" - ); - assert!(out.provenance.iter().any(|s| matches!( - s, - GuaranteeSource::UnavailableStatistic { statistic } if statistic == "input_row_count" - ))); - assert!(!DefaultAccuracyModel.satisfies(&out, &AccuracyTarget::Epsilon(1.0))); - - let known = PropagationStats { - input_row_count: Some(4), - ..Default::default() - }; - let out = DefaultAccuracyModel - .propagate( - &CompositionOperator::ExactSum, - &[abs(0.1, 0.01)], - None, - &known, - ) - .unwrap(); - assert!((out.bound.evaluate().unwrap() - 0.4).abs() < 1e-12); - assert!((out.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 = DefaultAccuracyModel - .propagate( - &CompositionOperator::ExactExtremum, - &[abs(0.1, 0.01), abs(0.3, 0.01)], - None, - &known, - ) - .unwrap(); - assert!((out.bound.evaluate().unwrap() - 0.3).abs() < 1e-12); - assert!((out.failure_probability.evaluate().unwrap() - 0.04).abs() < 1e-12); - } - - #[test] - fn exact_average_has_its_own_absolute_error_rule() { - let out = DefaultAccuracyModel - .propagate( - &CompositionOperator::ExactAverage, - &[abs(0.25, 0.01)], - None, - &PropagationStats { - input_row_count: Some(4), - ..PropagationStats::default() - }, - ) - .unwrap(); - assert_eq!(out.metric, ErrorMetric::AbsoluteValue); - assert_eq!(out.bound.evaluate(), Some(0.25)); - assert_eq!(out.failure_probability.evaluate(), Some(0.04)); - } - - #[test] - fn counter_functions_have_distinct_definition_rules() { - let operation = |intent| ExactOperation::Aggregate { - reduction: asap_types::pre_asap::Reduction::PerEntity, - measures: vec![intent], - output_names: vec![], - having: None, - }; - assert_eq!( - DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::Rate)), - Some(CompositionOperator::CounterRate) - ); - assert_eq!( - DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::IRate)), - Some(CompositionOperator::InstantCounterRate) - ); - assert_eq!( - DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::Increase)), - Some(CompositionOperator::CounterIncrease) - ); - } - - #[test] - fn topk_selection_requires_a_separated_margin_certificate() { - let unknown = DefaultAccuracyModel - .propagate( - &CompositionOperator::TopKSelection, - &[abs(0.1, 0.01)], - None, - &PropagationStats::default(), - ) - .unwrap(); - assert!(unknown.has_unknown()); - - let certified = DefaultAccuracyModel - .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.metric, ErrorMetric::TopKMembership); - assert_eq!(certified.bound.evaluate(), Some(0.0)); - assert_eq!(certified.failure_probability.evaluate(), Some(0.005)); - - let overlapping = DefaultAccuracyModel.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 = DefaultAccuracyModel - .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.failure_probability.evaluate(), Some(0.005)); - - let invalid_partial = DefaultAccuracyModel.propagate( - &CompositionOperator::TopKSelection, - &[abs(0.1, 0.01)], - None, - &PropagationStats { - topk_selected_lower_bound: Some(f64::NAN), - ..Default::default() - }, - ); - assert!(invalid_partial.is_err()); - } - - #[test] - fn local_guarantee_inverts_the_sizing_formulas() { - use crate::replacement::default_size_params; - use asap_types::pre_asap::agg_intent::{default_cardinality, default_quantile}; - - let q = default_quantile(0.99); - let params = default_size_params(SketchAlgorithm::Kll, &q, 0.01, 0.01); - let g = DefaultAccuracyModel - .local_guarantee( - &SummaryFamilyType::Sketch( - SketchKind::new(SketchAlgorithm::Kll, params), - GroupingStrategy::default(), - ), - &SketchQuery::Quantile { q: 0.99 }, - ) - .unwrap(); - assert_eq!(g.metric, ErrorMetric::Rank); - assert!(DefaultAccuracyModel.satisfies(&g, &AccuracyTarget::Epsilon(0.01))); - assert_eq!(g.failure_probability.evaluate(), Some(0.01)); - assert!(DefaultAccuracyModel.satisfies( - &g, - &AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - } - )); - assert_eq!(g.approximate_layer_count(), 1); - assert!(g.provenance.iter().any(|source| matches!( - source, - GuaranteeSource::SketchReadout { contract, .. } - if contract == "apache_datasketches_kll_empirical_99_a9b42755072b" - ))); - - let c = default_cardinality(); - let params = default_size_params(SketchAlgorithm::Hll, &c, 0.01, 0.01); - let g = DefaultAccuracyModel - .local_guarantee( - &SummaryFamilyType::Sketch( - SketchKind::new(SketchAlgorithm::Hll, params), - GroupingStrategy::default(), - ), - &SketchQuery::Cardinality, - ) - .unwrap(); - assert_eq!(g.metric, ErrorMetric::Cardinality); - assert_eq!(g.failure_probability.evaluate(), None); - assert!(DefaultAccuracyModel.satisfies(&g, &AccuracyTarget::Epsilon(0.01))); - assert!(!DefaultAccuracyModel.satisfies( - &g, - &AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - } - )); - - let params = default_size_params(SketchAlgorithm::Cms, &c, 0.01, 0.001); - let g = DefaultAccuracyModel - .local_guarantee( - &SummaryFamilyType::Sketch( - SketchKind::new(SketchAlgorithm::Cms, params), - GroupingStrategy::default(), - ), - &SketchQuery::Cardinality, - ) - .unwrap(); - assert_eq!(g.metric, ErrorMetric::Frequency); - assert!(DefaultAccuracyModel.satisfies( - &g, - &AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.001 - } - )); - } - - #[test] - fn count_sketch_uses_an_l2_guarantee() { - use crate::replacement::default_size_params; - use asap_types::pre_asap::agg_intent::default_cardinality; - - let intent = default_cardinality(); - let count_sketch = default_size_params(SketchAlgorithm::CountSketch, &intent, 0.01, 0.01); - let guarantee = DefaultAccuracyModel - .local_guarantee( - &SummaryFamilyType::Sketch( - SketchKind::new(SketchAlgorithm::CountSketch, count_sketch), - GroupingStrategy::default(), - ), - &SketchQuery::PointCount { - key: asap_types::pre_asap::expr_ir::ColumnRef::SampleValue, - value: None, - }, - ) - .expect("CountSketch has a parameter-derived L2 guarantee"); - assert_eq!(guarantee.metric, ErrorMetric::L2Frequency); - assert!(DefaultAccuracyModel.satisfies( - &guarantee, - &AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - } - )); - - let cms_heap = SketchParams::CmsWithHeap { - width: 272, - depth: 5, - heap_size: 10, - }; - let topk_frequency = DefaultAccuracyModel - .local_guarantee( - &SummaryFamilyType::Sketch( - SketchKind::new(SketchAlgorithm::CmsWithHeap, cms_heap), - GroupingStrategy::default(), - ), - &SketchQuery::TopK { k: 10 }, - ) - .expect("heap sketch still provides per-key frequency intervals"); - assert_eq!(topk_frequency.metric, ErrorMetric::Frequency); - } - - #[test] - fn satisfies_is_fail_closed_on_unknowns_and_exact() { - let unknown = ResultGuarantee { - bound: BoundExpr::Unknown { - statistic: "x".into(), - }, - ..abs(0.0, 0.0) - }; - assert!(!DefaultAccuracyModel.satisfies(&unknown, &AccuracyTarget::Epsilon(1.0))); - assert!(!DefaultAccuracyModel.satisfies(&abs(0.0, 0.01), &AccuracyTarget::Exact)); - assert!( - DefaultAccuracyModel.satisfies(&ResultGuarantee::exact("x"), &AccuracyTarget::Exact) - ); - } - - #[test] - fn equal_split_respects_the_root_epsilon_and_delta() { - let target = AccuracyTarget::EpsilonDelta { - epsilon: 0.1, - delta: 0.02, - }; - let shape = CompositionShape { - metric: ErrorMetric::AbsoluteValue, - approximate_layer_count: 2, - }; - let allocations = EqualSplitAllocator.allocations(&target, &shape); - assert_eq!(allocations.len(), 1); - let layers = &allocations[0].layers; - assert_eq!(layers.len(), 2); - let (eps, deltas): (Vec, Vec) = layers - .iter() - .map(|t| match t { - AccuracyTarget::EpsilonDelta { epsilon, delta } => (*epsilon, *delta), - other => panic!("unexpected {other:?}"), - }) - .unzip(); - assert!((eps.iter().sum::() - 0.1).abs() < 1e-12); - assert!((deltas.iter().sum::() - 0.02).abs() < 1e-12); - assert_eq!( - allocations[0].inner_target(&shape), - Some(AccuracyTarget::EpsilonDelta { - epsilon: 0.05, - delta: 0.01 - }) - ); - - // Multiplicative composition: (1+ε_i)^2 = 1+ε, not 2ε_i = ε. - let rel_shape = CompositionShape { - metric: ErrorMetric::RelativeValue, - approximate_layer_count: 2, - }; - let allocations = - EqualSplitAllocator.allocations(&AccuracyTarget::Epsilon(0.21), &rel_shape); - let AccuracyTarget::Epsilon(e) = allocations[0].layers[0] else { - panic!() - }; - assert!((e - 0.1).abs() < 1e-12); - - assert!(EqualSplitAllocator - .allocations(&AccuracyTarget::Exact, &shape) - .is_empty()); - } -} diff --git a/crates/asap-aware-mapping/src/accuracy/allocation.rs b/crates/asap-aware-mapping/src/accuracy/allocation.rs new file mode 100644 index 00000000..e06761ec --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/allocation.rs @@ -0,0 +1,156 @@ +//! Allocate end-to-end error and failure budgets across approximate layers. +use super::*; + +/// The shape of a composition an allocator splits a budget across. +#[derive(Debug, Clone, PartialEq)] +pub struct CompositionShape { + /// The metric the composed guarantee will carry — decides whether the + /// budget composes additively (`Σ ε_i ≤ ε`) or multiplicatively + /// (`Π(1+ε_i) ≤ 1+ε`). + pub metric: ErrorMetric, + /// How many approximate layers share the budget (≥ 1). + pub approximate_layer_count: usize, +} + +/// One way of splitting an end-to-end target across a composition's +/// approximate layers. `layers[0]` is the outermost layer's local target; +/// the remainder are the inner layers', outermost first. +#[derive(Debug, Clone, PartialEq)] +pub struct AccuracyAllocation { + pub allocator: &'static str, + pub layers: Vec, +} + +impl AccuracyAllocation { + /// The end-to-end budget left for everything below `layers[0]` — what + /// the inner subtree must satisfy as a whole (it re-splits internally). + /// `None` for a single-layer allocation. + pub fn inner_target(&self, shape: &CompositionShape) -> Option { + let inner = &self.layers[1..]; + if inner.is_empty() { + return None; + } + let (eps, delta): (Vec, Vec>) = inner + .iter() + .map(|t| match t { + AccuracyTarget::Exact => (0.0, Some(0.0)), + AccuracyTarget::Epsilon(e) => (*e, None), + AccuracyTarget::EpsilonDelta { epsilon, delta } => (*epsilon, Some(*delta)), + }) + .unzip(); + let epsilon = match shape.metric { + ErrorMetric::RelativeValue => eps.iter().map(|e| 1.0 + e).product::() - 1.0, + _ => eps.iter().sum(), + }; + Some(match delta.iter().copied().sum::>() { + Some(delta) => AccuracyTarget::EpsilonDelta { epsilon, delta }, + None => AccuracyTarget::Epsilon(epsilon), + }) + } +} + +/// Enumerates the finite set of budget splits the search tries for one +/// composition. Exposed as its own hook because equal splitting is rarely +/// cost-optimal; a deployment can return several candidate splits and let +/// cost ranking pick among the legal ones. +pub trait AccuracyBudgetAllocator { + fn allocations( + &self, + target: &AccuracyTarget, + composition: &CompositionShape, + ) -> Vec; +} + +/// The initial deterministic allocator: every approximate layer gets an +/// equal share — `ε_i = ε / n`, `δ_i = δ / n` for an additively composed +/// metric, and `ε_i = (1 + ε)^{1/n} − 1` for a multiplicatively composed +/// one — so the composed bound meets the target exactly with no slack. +/// `AccuracyTarget::Exact` yields no allocation: no approximate layer can +/// meet it. +#[derive(Debug, Default, Clone, Copy)] +pub struct EqualSplitAllocator; + +impl AccuracyBudgetAllocator for EqualSplitAllocator { + fn allocations( + &self, + target: &AccuracyTarget, + composition: &CompositionShape, + ) -> Vec { + let n = composition.approximate_layer_count.max(1); + let (epsilon, delta) = match target { + AccuracyTarget::Exact => return Vec::new(), + AccuracyTarget::Epsilon(e) => (*e, None), + AccuracyTarget::EpsilonDelta { epsilon, delta } => (*epsilon, Some(*delta)), + }; + if !(epsilon.is_finite() && epsilon > 0.0) { + return Vec::new(); + } + let local_epsilon = match composition.metric { + ErrorMetric::RelativeValue => (1.0 + epsilon).powf(1.0 / n as f64) - 1.0, + _ => epsilon / n as f64, + }; + let layer = match delta { + Some(delta) => AccuracyTarget::EpsilonDelta { + epsilon: local_epsilon, + delta: delta / n as f64, + }, + None => AccuracyTarget::Epsilon(local_epsilon), + }; + vec![AccuracyAllocation { + allocator: "EqualSplitAllocator", + layers: vec![layer; n], + }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn equal_split_respects_the_root_epsilon_and_delta() { + let target = AccuracyTarget::EpsilonDelta { + epsilon: 0.1, + delta: 0.02, + }; + let shape = CompositionShape { + metric: ErrorMetric::AbsoluteValue, + approximate_layer_count: 2, + }; + let allocations = EqualSplitAllocator.allocations(&target, &shape); + assert_eq!(allocations.len(), 1); + let layers = &allocations[0].layers; + assert_eq!(layers.len(), 2); + let (eps, deltas): (Vec, Vec) = layers + .iter() + .map(|t| match t { + AccuracyTarget::EpsilonDelta { epsilon, delta } => (*epsilon, *delta), + other => panic!("unexpected {other:?}"), + }) + .unzip(); + assert!((eps.iter().sum::() - 0.1).abs() < 1e-12); + assert!((deltas.iter().sum::() - 0.02).abs() < 1e-12); + assert_eq!( + allocations[0].inner_target(&shape), + Some(AccuracyTarget::EpsilonDelta { + epsilon: 0.05, + delta: 0.01 + }) + ); + + // Multiplicative composition: (1+ε_i)^2 = 1+ε, not 2ε_i = ε. + let rel_shape = CompositionShape { + metric: ErrorMetric::RelativeValue, + approximate_layer_count: 2, + }; + let allocations = + EqualSplitAllocator.allocations(&AccuracyTarget::Epsilon(0.21), &rel_shape); + let AccuracyTarget::Epsilon(e) = allocations[0].layers[0] else { + panic!() + }; + assert!((e - 0.1).abs() < 1e-12); + + assert!(EqualSplitAllocator + .allocations(&AccuracyTarget::Exact, &shape) + .is_empty()); + } +} diff --git a/crates/asap-aware-mapping/src/accuracy/composition.rs b/crates/asap-aware-mapping/src/accuracy/composition.rs new file mode 100644 index 00000000..a284ab32 --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/composition.rs @@ -0,0 +1,1121 @@ +//! Registered propagation rules for computations over uncertain inputs. +use super::*; + +impl DefaultAccuracyModel { + fn additive( + op: &CompositionOperator, + inputs: &[ResultGuarantee], + local: &ResultGuarantee, + rule: &str, + ) -> ResultGuarantee { + let mut terms: Vec = inputs.iter().map(|g| g.bound.clone()).collect(); + terms.push(local.bound.clone()); + let mut deltas: Vec = inputs + .iter() + .map(|g| g.failure_probability.clone()) + .collect(); + deltas.push(local.failure_probability.clone()); + ResultGuarantee { + metric: local.metric, + bound: BoundExpr::Sum { terms }, + failure_probability: ProbabilityExpr::UnionBound { terms: deltas }, + provenance: 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 tree is exact for any input count. + fn multiplicative( + op: &CompositionOperator, + inputs: &[ResultGuarantee], + local: &ResultGuarantee, + ) -> ResultGuarantee { + let factors: Vec<&BoundExpr> = inputs + .iter() + .map(|g| &g.bound) + .chain(std::iter::once(&local.bound)) + .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.failure_probability.clone()) + .collect(); + deltas.push(local.failure_probability.clone()); + ResultGuarantee { + metric: ErrorMetric::RelativeValue, + bound: BoundExpr::Sum { terms }, + failure_probability: ProbabilityExpr::UnionBound { terms: deltas }, + provenance: composed_provenance(op, inputs, local, "relative_cross_term_union_bound"), + } + } + + fn lipschitz( + op: &CompositionOperator, + constant: f64, + inputs: &[ResultGuarantee], + local: Option<&ResultGuarantee>, + ) -> ResultGuarantee { + let input = &inputs[0]; + let scaled = BoundExpr::Scaled { + factor: constant, + inner: Box::new(input.bound.clone()), + }; + let (bound, delta) = match local { + Some(local) => ( + BoundExpr::Sum { + terms: vec![scaled, local.bound.clone()], + }, + ProbabilityExpr::UnionBound { + terms: vec![ + input.failure_probability.clone(), + local.failure_probability.clone(), + ], + }, + ), + None => (scaled, input.failure_probability.clone()), + }; + let exact_local = ResultGuarantee::exact("deterministic Lipschitz transformation"); + ResultGuarantee { + metric: ErrorMetric::AbsoluteValue, + bound, + failure_probability: delta, + provenance: 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: &[ResultGuarantee], + stats: &PropagationStats, + ) -> Result { + 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.metric).collect(), + local_metric: None, + reason: format!( + "input {i} carries a {:?} guarantee, which has no registered \ + conversion to an absolute value error", + input.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.failure_probability.clone()); + } + let count = row_count(stats, &mut provenance); + let exact_local = ResultGuarantee::exact("ExactAggregate(Sum)"); + provenance.extend(composed_provenance( + op, + inputs, + &exact_local, + "exact_sum_union_bound", + )); + Ok(ResultGuarantee { + metric: ErrorMetric::AbsoluteValue, + bound: BoundExpr::Product { + factors: vec![count.clone(), BoundExpr::Sum { terms }], + }, + failure_probability: 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: &[ResultGuarantee], + stats: &PropagationStats, + ) -> Result { + if inputs + .iter() + .any(|input| input.metric != ErrorMetric::AbsoluteValue) + { + return Err(AccuracyError::UnsupportedComposition { + operator: op.clone(), + input_metrics: inputs.iter().map(|g| g.metric).collect(), + local_metric: None, + reason: "exact average requires AbsoluteValue input guarantees".into(), + }); + } + let mut provenance = Vec::new(); + let count = row_count(stats, &mut provenance); + let exact_local = ResultGuarantee::exact("ExactAggregate(Average)"); + provenance.extend(composed_provenance( + op, + inputs, + &exact_local, + "exact_average_union_bound", + )); + Ok(ResultGuarantee { + metric: ErrorMetric::AbsoluteValue, + bound: BoundExpr::Max { + terms: inputs.iter().map(|g| g.bound.clone()).collect(), + }, + failure_probability: ProbabilityExpr::Scaled { + count, + inner: Box::new(ProbabilityExpr::UnionBound { + terms: inputs + .iter() + .map(|g| g.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: &[ResultGuarantee], + stats: &PropagationStats, + ) -> Result { + let metric = inputs[0].metric; + if inputs + .iter() + .any(|g| g.metric != ErrorMetric::AbsoluteValue) + { + return Err(AccuracyError::UnsupportedComposition { + operator: op.clone(), + input_metrics: inputs.iter().map(|g| g.metric).collect(), + local_metric: None, + reason: "exact max/min requires AbsoluteValue input guarantees".into(), + }); + } + let mut provenance = Vec::new(); + let count = row_count(stats, &mut provenance); + let exact_local = ResultGuarantee::exact("ExactAggregate(Max)"); + provenance.extend(composed_provenance( + op, + inputs, + &exact_local, + "exact_extremum_union_bound", + )); + Ok(ResultGuarantee { + metric, + bound: BoundExpr::Max { + terms: inputs.iter().map(|g| g.bound.clone()).collect(), + }, + failure_probability: ProbabilityExpr::Scaled { + count, + inner: Box::new(ProbabilityExpr::UnionBound { + terms: inputs + .iter() + .map(|g| g.failure_probability.clone()) + .collect(), + }), + }, + provenance, + }) + } + + /// Exact division of two relative-value estimates. If the numerator is + /// within `a` and the denominator within `b`, their ratio is within + /// `(a + b) / (1 - b)`. DDSketch supplies those deterministic bounds. + fn exact_division( + op: &CompositionOperator, + inputs: &[ResultGuarantee], + stats: &PropagationStats, + ) -> Result { + let unsupported = |reason: String| AccuracyError::UnsupportedComposition { + operator: op.clone(), + input_metrics: inputs.iter().map(|g| g.metric).collect(), + local_metric: None, + reason, + }; + if inputs.len() != 2 + || inputs + .iter() + .any(|input| input.metric != ErrorMetric::RelativeValue) + { + return Err(unsupported( + "division needs exactly two RelativeValue guarantees".into(), + )); + } + let Some(numerator) = inputs[0].bound.evaluate() else { + return Err(unsupported( + "numerator relative bound is unavailable".into(), + )); + }; + let Some(denominator) = inputs[1].bound.evaluate() else { + return Err(unsupported( + "denominator relative bound is unavailable".into(), + )); + }; + if !(numerator.is_finite() + && denominator.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(), + )); + } + let Some(domains) = &stats.division_operand_domains else { + return Err(unsupported( + "division needs finite operand domains and a nonzero denominator proof".into(), + )); + }; + for domain in domains { + if !domain.lower.is_finite() + || !domain.upper.is_finite() + || domain.lower > domain.upper + || domain.contract.trim().is_empty() + { + return Err(unsupported("invalid division operand domain".into())); + } + } + if domains[1].lower <= 0.0 && domains[1].upper >= 0.0 { + return Err(unsupported("denominator domain includes zero".into())); + } + // Keep the true and perturbed quotients finite and out of the + // subnormal range, where Float64 division loses relative accuracy. + // A nonzero numerator interval touching zero cannot prove this. + let num = &domains[0]; + if num.lower <= 0.0 && num.upper >= 0.0 && (num.lower != 0.0 || num.upper != 0.0) { + return Err(unsupported( + "numerator domain cannot exclude underflow near zero".into(), + )); + } + for n in [num.lower, num.upper] { + for d in [domains[1].lower, domains[1].upper] { + for nf in [1.0 - numerator, 1.0 + numerator] { + for df in [1.0 - denominator, 1.0 + denominator] { + let quotient = (n * nf) / (d * df); + if !(n * nf).is_finite() + || !(d * df).is_finite() + || !quotient.is_finite() + || (n != 0.0 && quotient.abs() < f64::MIN_POSITIVE) + { + return Err(unsupported( + "division may overflow or underflow Float64".into(), + )); + } + } + } + } + } + Ok(ResultGuarantee { + metric: ErrorMetric::RelativeValue, + bound: BoundExpr::Constant { + value: (numerator + denominator) / (1.0 - denominator), + }, + failure_probability: ProbabilityExpr::UnionBound { + terms: inputs + .iter() + .map(|input| input.failure_probability.clone()) + .collect(), + }, + provenance: inputs + .iter() + .enumerate() + .map(|(input_index, guarantee)| GuaranteeSource::ChildGuarantee { + input_index, + guarantee: Box::new(guarantee.clone()), + }) + .chain(domains.iter().enumerate().map(|(input_index, domain)| { + GuaranteeSource::InputValueDomain { + input_index, + lower: domain.lower, + upper: domain.upper, + max_samples: domain.max_samples, + contract: domain.contract.clone(), + } + })) + .chain(std::iter::once(GuaranteeSource::CompositionStep { + operator: op.clone(), + rule: "relative_division".into(), + })) + .collect(), + }) + } +} + +/// `stats.input_row_count` as a bound factor, or an `Unknown` leaf (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 => { + provenance.push(GuaranteeSource::UnavailableStatistic { + statistic: "input_row_count".into(), + }); + BoundExpr::Unknown { + statistic: "input_row_count".into(), + } + } + } +} + +/// `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`, `TopKMembership`). +fn absolute_bound(input: &ResultGuarantee) -> Option { + let normalizer = match input.metric { + ErrorMetric::AbsoluteValue => return Some(input.bound.clone()), + ErrorMetric::RelativeValue => "true_value_magnitude", + ErrorMetric::Cardinality => "true_cardinality", + ErrorMetric::Frequency => "stream_l1_norm", + ErrorMetric::L2Frequency => "stream_l2_norm", + // `Rank` has no distribution-free conversion to a value error; a + // metric this crate does not know has no registered conversion. + ErrorMetric::Rank | ErrorMetric::TopKMembership | _ => return None, + }; + if input.bound.is_zero() { + return Some(BoundExpr::Zero); + } + Some(BoundExpr::Product { + factors: vec![ + input.bound.clone(), + BoundExpr::Unknown { + statistic: normalizer.into(), + }, + ], + }) +} + +fn composed_provenance( + op: &CompositionOperator, + inputs: &[ResultGuarantee], + local: &ResultGuarantee, + 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 +} + +pub(super) fn exact_operation_rule(operation: &ExactOperation) -> Option { + let ExactOperation::Aggregate { measures, .. } = operation else { + return None; + }; + match measures.as_slice() { + [intent] => crate::function_rules::function_rules(intent).map(|rules| rules.accuracy), + // The remaining functions are exact over exact samples, but have + // no definition-backed rule over approximate values yet. + _ => None, + } +} + +pub(super) fn propagate( + op: &CompositionOperator, + inputs: &[ResultGuarantee], + local: Option<&ResultGuarantee>, + stats: &PropagationStats, +) -> Result { + // Exact input: only the local guarantee remains (or the value is exact). + if inputs.iter().all(ResultGuarantee::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 = ResultGuarantee::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.metric).collect(); + let unsupported = |reason: String| AccuracyError::UnsupportedComposition { + operator: op.clone(), + input_metrics: input_metrics.clone(), + local_metric: local.map(|g| g.metric), + reason, + }; + // An exact input is compatible with every metric; only approximate + // inputs constrain the rule. + let approximate: Vec<&ResultGuarantee> = inputs.iter().filter(|g| !g.is_exact()).collect(); + let same_metric = |metric: ErrorMetric| approximate.iter().all(|g| g.metric == metric); + + match op { + CompositionOperator::CheckedRelativeDivision => { + if inputs.len() != 2 || local.is_some() || !same_metric(ErrorMetric::RelativeValue) { + return Err(unsupported( + "checked division requires two exact/relative-value operands".into(), + )); + } + let a = inputs[0] + .bound + .evaluate() + .ok_or_else(|| unsupported("unknown numerator bound".into()))?; + let b = inputs[1] + .bound + .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(ResultGuarantee { + metric: ErrorMetric::RelativeValue, + bound: BoundExpr::Constant { + value: (a + b) / (1.0 - b) + 4.0 * f64::EPSILON, + }, + failure_probability: ProbabilityExpr::UnionBound { + terms: inputs + .iter() + .map(|g| g.failure_probability.clone()) + .collect(), + }, + provenance: composed_provenance( + op, + inputs, + &ResultGuarantee::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.metric) { + return Err(unsupported(format!( + "no registered cross-metric rule from {input_metrics:?} to {:?}", + local.metric + ))); + } + match local.metric { + ErrorMetric::AbsoluteValue => Ok(DefaultAccuracyModel::additive( + op, + inputs, + local, + "additive_union_bound", + )), + ErrorMetric::RelativeValue => { + 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(ResultGuarantee { + metric: ErrorMetric::RelativeValue, + bound: BoundExpr::Unknown { + statistic: "values_non_negative".into(), + }, + failure_probability: ProbabilityExpr::Unknown { + statistic: "values_non_negative".into(), + }, + provenance, + }); + } + Ok(DefaultAccuracyModel::multiplicative(op, inputs, local)) + } + ErrorMetric::Rank + | ErrorMetric::Cardinality + | ErrorMetric::Frequency + | ErrorMetric::L2Frequency + | ErrorMetric::TopKMembership + | _ => Err(unsupported(format!( + "no registered same-metric composition rule for {:?} over {:?}", + local.metric, local.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(ErrorMetric::AbsoluteValue) { + return Err(unsupported( + "Lipschitz rule is registered for exactly one AbsoluteValue input".into(), + )); + } + if local.is_some_and(|g| g.metric != ErrorMetric::AbsoluteValue) { + return Err(unsupported( + "Lipschitz rule needs an AbsoluteValue local guarantee".into(), + )); + } + Ok(DefaultAccuracyModel::lipschitz( + op, *constant, inputs, local, + )) + } + CompositionOperator::ExactSum => DefaultAccuracyModel::exact_sum(op, inputs, stats), + CompositionOperator::ExactAverage => DefaultAccuracyModel::exact_average(op, inputs, stats), + CompositionOperator::ExactExtremum => { + DefaultAccuracyModel::exact_extremum(op, inputs, stats) + } + CompositionOperator::ExactDivision => { + DefaultAccuracyModel::exact_division(op, inputs, stats) + } + 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(); + Ok(ResultGuarantee { + metric: ErrorMetric::TopKMembership, + bound: if certified { + BoundExpr::Zero + } else { + BoundExpr::Unknown { + statistic: "topk_membership_margin".into(), + } + }, + failure_probability: 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::*; + use asap_types::pre_asap::AggIntent; + fn abs(bound: f64, delta: f64) -> ResultGuarantee { + ResultGuarantee { + metric: ErrorMetric::AbsoluteValue, + bound: BoundExpr::Constant { value: bound }, + failure_probability: ProbabilityExpr::Constant { value: delta }, + provenance: vec![], + } + } + fn rel(bound: f64) -> ResultGuarantee { + ResultGuarantee { + metric: ErrorMetric::RelativeValue, + bound: BoundExpr::Constant { value: bound }, + failure_probability: ProbabilityExpr::Zero, + provenance: vec![], + } + } + fn domain(lower: f64, upper: f64) -> QuantileInputDomain { + QuantileInputDomain { + lower, + upper, + max_samples: 1000, + contract: "enforced test population".into(), + } + } + fn with_metric(metric: ErrorMetric, bound: f64) -> ResultGuarantee { + ResultGuarantee { + metric, + ..abs(bound, 0.0) + } + } + #[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 = DefaultAccuracyModel + .propagate(&op, &inputs, None, &Default::default()) + .unwrap(); + assert!((g.bound.evaluate().unwrap() - 0.02 / 0.99).abs() < 1e-14); + let mut rank = inputs[0].clone(); + rank.metric = ErrorMetric::Rank; + assert!(DefaultAccuracyModel + .propagate(&op, &[rank.clone(), rank], None, &Default::default()) + .is_err()); + assert!(DefaultAccuracyModel + .propagate(&op, &[rel(0.01), rel(1.0)], None, &Default::default()) + .is_err()); + } + #[test] + fn relative_division_requires_operand_domains() { + assert!(DefaultAccuracyModel + .propagate( + &CompositionOperator::ExactDivision, + &[rel(0.01), rel(0.01)], + None, + &PropagationStats::default() + ) + .is_err()); + } + #[test] + fn relative_division_preserves_asymmetric_bound_and_domain_provenance() { + for denominator in [domain(1., 10.), domain(-10., -1.)] { + let stats = PropagationStats { + division_operand_domains: Some([domain(-20., -2.), denominator]), + ..Default::default() + }; + let got = DefaultAccuracyModel + .propagate( + &CompositionOperator::ExactDivision, + &[rel(0.02), rel(0.03)], + None, + &stats, + ) + .unwrap(); + assert!((got.bound.evaluate().unwrap() - 0.05 / 0.97).abs() < 1e-14); + assert_eq!(got.failure_probability.evaluate(), Some(0.)); + assert_eq!( + got.provenance + .iter() + .filter(|p| matches!(p, GuaranteeSource::InputValueDomain { .. })) + .count(), + 2 + ); + } + } + #[test] + fn relative_division_rejects_zero_special_and_extreme_domains() { + for domains in [ + [domain(1., 2.), domain(0., 0.)], + [domain(1., 2.), domain(-1., 1.)], + [domain(f64::NAN, 2.), domain(1., 2.)], + [domain(1., 2.), domain(1., f64::INFINITY)], + [domain(1e250, 1e250), domain(1e-250, 1e-250)], + [domain(1e-250, 1e-250), domain(1e250, 1e250)], + ] { + let stats = PropagationStats { + division_operand_domains: Some(domains), + ..Default::default() + }; + assert!(DefaultAccuracyModel + .propagate( + &CompositionOperator::ExactDivision, + &[rel(0.01), rel(0.01)], + None, + &stats + ) + .is_err()); + } + let stats = PropagationStats { + division_operand_domains: Some([domain(1., 2.), domain(1., 2.)]), + ..Default::default() + }; + for bound in [1., f64::NAN, f64::INFINITY, -0.1] { + assert!(DefaultAccuracyModel + .propagate( + &CompositionOperator::ExactDivision, + &[rel(0.01), rel(bound)], + None, + &stats + ) + .is_err()); + } + } + #[test] + fn exact_child_contributes_zero_error() { + let local = abs(0.05, 0.01); + let out = DefaultAccuracyModel + .propagate( + &CompositionOperator::ApproximateAggregate, + &[ResultGuarantee::exact("sum")], + Some(&local), + &PropagationStats::default(), + ) + .unwrap(); + assert_eq!(out.bound.evaluate(), Some(0.05)); + assert_eq!(out.failure_probability.evaluate(), Some(0.01)); + assert_eq!(out.metric, ErrorMetric::AbsoluteValue); + } + #[test] + fn additive_bounds_and_delta_union_bound_compose() { + let out = DefaultAccuracyModel + .propagate( + &CompositionOperator::ApproximateAggregate, + &[abs(0.02, 0.01)], + Some(&abs(0.03, 0.02)), + &PropagationStats::default(), + ) + .unwrap(); + assert!((out.bound.evaluate().unwrap() - 0.05).abs() < 1e-12); + // Union bound, not 1 − (1−0.01)(1−0.02) = 0.0298. + assert!((out.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 = DefaultAccuracyModel + .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.bound.evaluate().unwrap() - 0.32).abs() < 1e-12); + assert_eq!(out.metric, ErrorMetric::RelativeValue); + } + #[test] + fn relative_error_without_sign_knowledge_remains_symbolic() { + let unknown = DefaultAccuracyModel + .propagate( + &CompositionOperator::ApproximateAggregate, + &[rel(0.1)], + Some(&rel(0.2)), + &PropagationStats::default(), + ) + .unwrap(); + assert!(unknown.has_unknown()); + let signed = DefaultAccuracyModel.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 = DefaultAccuracyModel + .propagate( + &CompositionOperator::ApproximateAggregate, + &[with_metric(ErrorMetric::Cardinality, 0.01)], + Some(&with_metric(ErrorMetric::Frequency, 0.01)), + &PropagationStats::default(), + ) + .unwrap_err(); + assert!(matches!( + err, + AccuracyError::UnsupportedComposition { + input_metrics, + local_metric: Some(ErrorMetric::Frequency), + .. + } if input_metrics == vec![ErrorMetric::Cardinality] + )); + // Quantile rank error under value-additive logic. + let err = DefaultAccuracyModel + .propagate( + &CompositionOperator::ApproximateAggregate, + &[with_metric(ErrorMetric::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 = DefaultAccuracyModel + .propagate( + &CompositionOperator::ApproximateAggregate, + &[with_metric(ErrorMetric::Rank, 0.01)], + Some(&with_metric(ErrorMetric::Rank, 0.01)), + &PropagationStats::default(), + ) + .unwrap_err(); + assert!(matches!(err, AccuracyError::UnsupportedComposition { .. })); + } + #[test] + fn lipschitz_scales_the_input_bound() { + let out = DefaultAccuracyModel + .propagate( + &CompositionOperator::Lipschitz { constant: 3.0 }, + &[abs(0.1, 0.01)], + Some(&abs(0.05, 0.02)), + &PropagationStats::default(), + ) + .unwrap(); + assert!((out.bound.evaluate().unwrap() - 0.35).abs() < 1e-12); + assert!((out.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 = DefaultAccuracyModel + .propagate( + &CompositionOperator::ExactSum, + &[abs(0.1, 0.01)], + None, + &PropagationStats::default(), + ) + .unwrap(); + assert_eq!(out.metric, ErrorMetric::AbsoluteValue); + assert_eq!( + out.bound.evaluate(), + None, + "unknown row count stays unknown" + ); + assert!(out.provenance.iter().any(|s| matches!( + s, + GuaranteeSource::UnavailableStatistic { statistic } if statistic == "input_row_count" + ))); + assert!(!DefaultAccuracyModel.satisfies(&out, &AccuracyTarget::Epsilon(1.0))); + + let known = PropagationStats { + input_row_count: Some(4), + ..Default::default() + }; + let out = DefaultAccuracyModel + .propagate( + &CompositionOperator::ExactSum, + &[abs(0.1, 0.01)], + None, + &known, + ) + .unwrap(); + assert!((out.bound.evaluate().unwrap() - 0.4).abs() < 1e-12); + assert!((out.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 = DefaultAccuracyModel + .propagate( + &CompositionOperator::ExactExtremum, + &[abs(0.1, 0.01), abs(0.3, 0.01)], + None, + &known, + ) + .unwrap(); + assert!((out.bound.evaluate().unwrap() - 0.3).abs() < 1e-12); + assert!((out.failure_probability.evaluate().unwrap() - 0.04).abs() < 1e-12); + } + #[test] + fn exact_average_has_its_own_absolute_error_rule() { + let out = DefaultAccuracyModel + .propagate( + &CompositionOperator::ExactAverage, + &[abs(0.25, 0.01)], + None, + &PropagationStats { + input_row_count: Some(4), + ..PropagationStats::default() + }, + ) + .unwrap(); + assert_eq!(out.metric, ErrorMetric::AbsoluteValue); + assert_eq!(out.bound.evaluate(), Some(0.25)); + assert_eq!(out.failure_probability.evaluate(), Some(0.04)); + } + #[test] + fn counter_functions_have_distinct_definition_rules() { + let operation = |intent| ExactOperation::Aggregate { + reduction: asap_types::pre_asap::Reduction::PerEntity, + measures: vec![intent], + output_names: vec![], + having: None, + }; + assert_eq!( + DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::Rate)), + Some(CompositionOperator::CounterRate) + ); + assert_eq!( + DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::IRate)), + Some(CompositionOperator::InstantCounterRate) + ); + assert_eq!( + DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::Increase)), + Some(CompositionOperator::CounterIncrease) + ); + } + #[test] + fn topk_selection_requires_a_separated_margin_certificate() { + let unknown = DefaultAccuracyModel + .propagate( + &CompositionOperator::TopKSelection, + &[abs(0.1, 0.01)], + None, + &PropagationStats::default(), + ) + .unwrap(); + assert!(unknown.has_unknown()); + + let certified = DefaultAccuracyModel + .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.metric, ErrorMetric::TopKMembership); + assert_eq!(certified.bound.evaluate(), Some(0.0)); + assert_eq!(certified.failure_probability.evaluate(), Some(0.005)); + + let overlapping = DefaultAccuracyModel.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 = DefaultAccuracyModel + .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.failure_probability.evaluate(), Some(0.005)); + + let invalid_partial = DefaultAccuracyModel.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/asap-aware-mapping/src/accuracy/estimators/cardinality.rs b/crates/asap-aware-mapping/src/accuracy/estimators/cardinality.rs new file mode 100644 index 00000000..8830be74 --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/estimators/cardinality.rs @@ -0,0 +1,30 @@ +//! KMV and Theta cardinality bounds using variance and Chebyshev at 99%. +use super::*; + +pub(super) fn guarantee( + algorithm: &SketchAlgorithm, + params: &SketchParams, + query: &SketchQuery, +) -> Option { + let (SketchParams::Kmv { k } | SketchParams::Theta { k }) = params else { + return None; + }; + Some(super::bounded_guarantee( + algorithm, + params, + query, + ErrorMetric::Cardinality, + 10.0 / f64::from(k.saturating_sub(2).max(1)).sqrt(), + ProbabilityExpr::Constant { value: 0.01 }, + match params { + SketchParams::Kmv { .. } => "kmv_unbiased_variance_chebyshev_99_v1", + _ => "theta_variance_chebyshev_99_v1", + }, + )) +} + +/// 99%-confidence KMV/Theta relative bound via Chebyshev, using +/// `RSE <= 1/sqrt(k-2)` and a ten-standard-deviation interval. +pub(crate) fn kmv_k_99(eps: f64) -> u32 { + saturating_ceil(100.0 / (eps * eps) + 2.0, 16, 1 << 26) +} diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs b/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs new file mode 100644 index 00000000..0f2f37c6 --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs @@ -0,0 +1,86 @@ +//! Count-Min Sketch L1 frequency error and parameter sizing. +use super::*; + +pub(super) fn guarantee( + algorithm: &SketchAlgorithm, + params: &SketchParams, + query: &SketchQuery, +) -> Option { + let (SketchParams::Cms { width, depth } | SketchParams::CmsWithHeap { width, depth, .. }) = + params + else { + return None; + }; + Some(super::bounded_guarantee( + algorithm, + params, + query, + ErrorMetric::Frequency, + std::f64::consts::E / f64::from(*width), + ProbabilityExpr::Constant { + value: (-f64::from(*depth)).exp(), + }, + "count_min_l1_markov_v1", + )) +} + +/// CMS: over-count ≤ ε·N with width `w = ⌈e/ε⌉` columns. +pub(crate) fn cms_width(eps: f64) -> u32 { + saturating_ceil(std::f64::consts::E / eps, 2, 1 << 26) +} + +/// CMS: failure probability ≤ δ with depth `d = ⌈ln(1/δ)⌉` rows. +/// δ = 0.01 → depth 5. +pub(crate) fn cms_depth(delta: f64) -> u32 { + saturating_ceil((1.0 / delta).ln(), 1, 32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_guarantee_inverts_frequency_sizing() { + use crate::replacement::default_size_params; + use asap_types::post_asap::{GroupingStrategy, SketchKind}; + let c = asap_types::pre_asap::agg_intent::default_cardinality(); + let params = default_size_params(SketchAlgorithm::Cms, &c, 0.01, 0.001); + let g = DefaultAccuracyModel + .local_guarantee( + &SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::Cms, params), + GroupingStrategy::default(), + ), + &SketchQuery::Cardinality, + ) + .unwrap(); + assert_eq!(g.metric, ErrorMetric::Frequency); + assert!(DefaultAccuracyModel.satisfies( + &g, + &AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.001 + } + )); + } + + #[test] + fn heap_readout_retains_frequency_metric() { + use asap_types::post_asap::{GroupingStrategy, SketchKind}; + let cms_heap = SketchParams::CmsWithHeap { + width: 272, + depth: 5, + heap_size: 10, + }; + let topk_frequency = DefaultAccuracyModel + .local_guarantee( + &SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::CmsWithHeap, cms_heap), + GroupingStrategy::default(), + ), + &SketchQuery::TopK { k: 10 }, + ) + .expect("heap sketch still provides per-key frequency intervals"); + assert_eq!(topk_frequency.metric, ErrorMetric::Frequency); + } +} diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/count_sketch.rs b/crates/asap-aware-mapping/src/accuracy/estimators/count_sketch.rs new file mode 100644 index 00000000..73af6ffc --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/estimators/count_sketch.rs @@ -0,0 +1,84 @@ +//! CountSketch L2 frequency error and odd-depth median concentration. +use super::*; + +pub(super) fn guarantee( + algorithm: &SketchAlgorithm, + params: &SketchParams, + query: &SketchQuery, +) -> Option { + let (SketchParams::CountSketch { width, depth } + | SketchParams::CountSketchWithHeap { width, depth, .. }) = params + else { + return None; + }; + Some(super::bounded_guarantee( + algorithm, + params, + query, + ErrorMetric::L2Frequency, + (3.0 / f64::from(*width)).sqrt(), + ProbabilityExpr::Constant { + value: count_sketch_failure_probability(*depth)?, + }, + "count_sketch_l2_median_hoeffding_v1", + )) +} +fn count_sketch_failure_probability(depth: u32) -> Option { + if depth == 0 || depth.is_multiple_of(2) { + return None; + } + Some((-f64::from(depth) / 18.0).exp()) +} + +/// CountSketch `L2` point-query width: ε = sqrt(3/w). +pub(crate) fn count_sketch_width(eps: f64) -> u32 { + saturating_ceil(3.0 / (eps * eps), 2, 1 << 26) +} + +/// Positive odd depth satisfying Hoeffding's median failure bound +/// `exp(-depth/18) <= delta` for per-row failure at most 1/3. +pub(crate) fn count_sketch_depth(delta: f64) -> u32 { + if !(delta.is_finite() && delta > 0.0 && delta < 1.0) { + return 255; + } + let depth = saturating_ceil(18.0 * (1.0 / delta).ln(), 1, 255); + if depth.is_multiple_of(2) { + (depth + 1).min(255) + } else { + depth + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn count_sketch_uses_an_l2_guarantee() { + use crate::replacement::default_size_params; + use asap_types::post_asap::{GroupingStrategy, SketchKind}; + use asap_types::pre_asap::agg_intent::default_cardinality; + let intent = default_cardinality(); + let count_sketch = default_size_params(SketchAlgorithm::CountSketch, &intent, 0.01, 0.01); + let guarantee = DefaultAccuracyModel + .local_guarantee( + &SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::CountSketch, count_sketch), + GroupingStrategy::default(), + ), + &SketchQuery::PointCount { + key: asap_types::pre_asap::expr_ir::ColumnRef::SampleValue, + value: None, + }, + ) + .expect("CountSketch has a parameter-derived L2 guarantee"); + assert_eq!(guarantee.metric, ErrorMetric::L2Frequency); + assert!(DefaultAccuracyModel.satisfies( + &guarantee, + &AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + } + )); + } +} diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/ddsketch.rs b/crates/asap-aware-mapping/src/accuracy/estimators/ddsketch.rs new file mode 100644 index 00000000..233ec255 --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/estimators/ddsketch.rs @@ -0,0 +1,25 @@ +//! DDSketch relative value error under its estimator/domain contract. +use super::*; + +pub(super) fn guarantee( + algorithm: &SketchAlgorithm, + params: &SketchParams, + query: &SketchQuery, +) -> Option { + let SketchParams::DDSketch { alpha } = params else { + return None; + }; + Some(super::bounded_guarantee( + algorithm, + params, + query, + ErrorMetric::RelativeValue, + *alpha, + ProbabilityExpr::Zero, + "ddsketch_relative_error_alpha_v1", + )) +} + +pub(super) fn size_params(epsilon: f64) -> SketchParams { + SketchParams::DDSketch { alpha: epsilon } +} diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/hll.rs b/crates/asap-aware-mapping/src/accuracy/estimators/hll.rs new file mode 100644 index 00000000..6a17e159 --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/estimators/hll.rs @@ -0,0 +1,258 @@ +//! Estimator-specific confidence for classic HLL's linear-counting branch. +//! +//! This is conditional on independent uniform bucket hashes and an enforced +//! upper bound on distinct items in the complete readout population (including +//! all merged panes). It is not an RSE-to-normal conversion or an ERP fit. + +use super::*; + +pub(super) fn generic_guarantee( + algorithm: &SketchAlgorithm, + params: &SketchParams, + query: &SketchQuery, +) -> Option { + let SketchParams::Hll { precision } = params else { + return None; + }; + Some(super::bounded_guarantee( + algorithm, + params, + query, + ErrorMetric::Cardinality, + 1.04 / 2f64.powi(i32::from(*precision)).sqrt(), + ProbabilityExpr::Unknown { + statistic: "hll_estimator_failure_probability".into(), + }, + "generic_hll_rse_only_no_confidence_v1", + )) +} +/// A finite-population contract for `m * ln(m / zero_registers)` with the +/// classic HLL small-range switch. Hashing is assumed independent and uniform. +/// The deployment must establish the population bound; observations alone do +/// not establish it. Unsupported precisions/populations return no certificate. +#[derive(Debug, Clone, Copy)] +pub struct ClassicHllConfidence { + max_distinct: u32, + relative_error: f64, +} + +impl ClassicHllConfidence { + pub fn new(max_distinct: u32, relative_error: f64) -> Option { + (max_distinct > 0 + && max_distinct <= 4096 + && relative_error.is_finite() + && (1e-6..1.0).contains(&relative_error)) + .then_some(Self { + max_distinct, + relative_error, + }) + } + + pub fn guarantee(&self, precision: u8) -> Option { + let delta = self.failure_probability(precision)?; + Some(ResultGuarantee { + metric: ErrorMetric::Cardinality, + bound: BoundExpr::Constant { + value: self.relative_error, + }, + failure_probability: ProbabilityExpr::Constant { value: delta }, + provenance: vec![GuaranteeSource::SketchReadout { + algorithm: "Hll".into(), + contract: "classic_hll_linear_counting_collision_bound_v1".into(), + params: serde_json::json!({"precision": precision, + "max_distinct": self.max_distinct, "relative_error": self.relative_error, + "hash_assumption": "independent_uniform_buckets", + "population_scope": "complete_readout_including_merged_panes"}), + query: "Cardinality".into(), + }], + }) + } + + pub fn precision(&self, delta: f64) -> Option { + if !delta.is_finite() || !(0.0..1.0).contains(&delta) || delta == 0.0 { + return None; + } + (4..=18).find(|&p| self.failure_probability(p).is_some_and(|d| d <= delta)) + } + + /// Finite bound, not an asymptotic RSE fit. With N distinct hashes and K + /// occupied buckets, C=N-K collision arrivals satisfy + /// P(C>=t) <= lambda^t/t!, lambda=N(N-1)/(2m): each arrival's conditional + /// collision probability is at most (i-1)/m, and a union bound over t + /// arrivals is bounded by the t-th power of their sum divided by t!. + /// + /// N<=m/2 makes the classic raw estimate <=2*alpha_m*m<2.5m, + /// so the small-range switch always uses L=-m*ln(1-K/m). Then + /// K<=L<=N + N^2/(2(m-N)). The latter bounds overestimation + /// deterministically; underestimation implies C>epsilon*N. + /// We maximize the collision bound over EVERY integer N in the contract, + /// not just its upper endpoint (small-cardinality tails matter). + fn failure_probability(&self, precision: u8) -> Option { + if !(4..=18).contains(&precision) { + return None; + } + let m = f64::from(1u32 << precision); + let max_n = f64::from(self.max_distinct); + // Reserve numerical slack; do not certify sub-floating-point error. + let eps = self.relative_error - 1e-8; + if max_n > m / 2.0 || max_n / (2.0 * (m - max_n)) > eps { + return None; + } + let mut log_factorial = vec![0.0; self.max_distinct as usize + 1]; + for i in 1..log_factorial.len() { + log_factorial[i] = log_factorial[i - 1] + (i as f64).ln(); + } + let mut worst = 0.0_f64; + for n in 2..=self.max_distinct { + let nf = f64::from(n); + // Including a boundary collision event is conservative. + let t = ((eps * nf).floor() as usize + 1).min(n as usize); + let lambda = nf * (nf - 1.0) / (2.0 * m); + let log_tail = (t as f64) * lambda.ln() - log_factorial[t]; + worst = worst.max(log_tail.min(0.0).exp()); + } + // Never return a spurious zero from underflow or numeric cancellation. + Some((worst * (1.0 + 1e-10) + 1e-12).min(1.0)) + } +} + +/// HLL RSE-magnitude inversion. Generic HLL has no modeled confidence target. +pub(crate) fn hll_precision(eps: f64) -> u8 { + saturating_ceil((1.04 / eps).powi(2).log2(), 4, 18) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A supported estimator contract supplies a probability, unlike generic HLL RSE. + #[test] + fn bounded_classic_hll_has_a_feasible_confidence_target() { + let model = ClassicHllConfidence::new(128, 0.05).unwrap(); + let precision = model.precision(0.01).expect("finite confidence-sized HLL"); + let guarantee = model.guarantee(precision).unwrap(); + assert!(!guarantee.has_unknown()); + assert!(guarantee.failure_probability.evaluate().unwrap() <= 0.01); + assert_eq!(guarantee.bound.evaluate(), Some(0.05)); + } + /// Tighter confidence must increase precision or explicitly become unavailable. + #[test] + fn sizing_and_domain_limits_are_consistent() { + let model = ClassicHllConfidence::new(128, 0.05).unwrap(); + assert!(model.precision(0.001).unwrap() > model.precision(0.01).unwrap()); + assert!(model.precision(1e-12).is_none()); + assert!(model.precision(0.0).is_none()); + assert!(model.precision(f64::NAN).is_none()); + assert!(model.guarantee(3).is_none()); + assert!(model.guarantee(19).is_none()); + assert!(model.guarantee(7).is_none()); + for (n, e) in [(0, 0.05), (4097, 0.05), (128, 0.0), (128, f64::NAN)] { + assert!(ClassicHllConfidence::new(n, e).is_none()); + } + } + + /// Exact occupancy probabilities independently check both tails for every N. + #[test] + fn probability_bound_dominates_exact_occupancy_distribution() { + for precision in 4..=10 { + let m = 1usize << precision; + let max_n = 64.min(m / 2); + for eps in [0.05, 0.2, 0.6] { + let model = ClassicHllConfidence::new(max_n as u32, eps).unwrap(); + let Some(bound) = model.failure_probability(precision) else { + continue; + }; + let mut occupancy = vec![0.0; max_n + 1]; + occupancy[0] = 1.0; + for n in 1..=max_n { + let mut next = vec![0.0; max_n + 1]; + for k in 0..n { + next[k] += occupancy[k] * k as f64 / m as f64; + next[k + 1] += occupancy[k] * (m - k) as f64 / m as f64; + } + occupancy = next; + let actual: f64 = occupancy + .iter() + .enumerate() + .filter_map(|(k, &prob)| { + let estimate = -(m as f64) * (-(k as f64) / (m as f64)).ln_1p(); + ((estimate - n as f64).abs() > eps * n as f64).then_some(prob) + }) + .sum(); + assert!( + actual <= bound + 1e-12, + "p={precision} n={n} eps={eps}: {actual}>{bound}" + ); + } + } + } + } + /// The model's readout formula matches the actual classic estimator after merge. + #[test] + fn native_classic_estimator_and_merged_registers_use_the_same_contract() { + use asap_sketchlib::sketches::hll::{Classic, HyperLogLogP16}; + let model = ClassicHllConfidence::new(128, 0.05).unwrap(); + assert!( + model + .guarantee(16) + .unwrap() + .failure_probability + .evaluate() + .unwrap() + < 0.01 + ); + let mut single = HyperLogLogP16::::new(); + let mut left = HyperLogLogP16::::new(); + let mut right = HyperLogLogP16::::new(); + for n in 0..128u64 { + // SplitMix64 supplies deterministic test hashes, not a proof of randomness. + let mut h = n.wrapping_add(0x9e3779b97f4a7c15); + h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb); + h ^= h >> 31; + single.insert_with_hash(h); + if n % 2 == 0 { + left.insert_with_hash(h); + } else { + right.insert_with_hash(h); + } + } + left.merge(&right); + assert_eq!(single.registers_as_slice(), left.registers_as_slice()); + let zeroes = left + .registers_as_slice() + .iter() + .filter(|&&r| r == 0) + .count(); + let expected = (65536.0 * (65536.0 / zeroes as f64).ln()) as usize; + assert_eq!(left.estimate(), expected); + assert!((expected as f64 - 128.0).abs() / 128.0 <= 0.05); + } + #[test] + fn generic_rse_sizing_does_not_certify_confidence() { + use crate::replacement::default_size_params; + use asap_types::post_asap::{GroupingStrategy, SketchKind}; + use asap_types::pre_asap::agg_intent::default_cardinality; + let c = default_cardinality(); + let params = default_size_params(SketchAlgorithm::Hll, &c, 0.01, 0.01); + let g = DefaultAccuracyModel + .local_guarantee( + &SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::Hll, params), + GroupingStrategy::default(), + ), + &SketchQuery::Cardinality, + ) + .unwrap(); + assert_eq!(g.metric, ErrorMetric::Cardinality); + assert_eq!(g.failure_probability.evaluate(), None); + assert!(DefaultAccuracyModel.satisfies(&g, &AccuracyTarget::Epsilon(0.01))); + assert!(!DefaultAccuracyModel.satisfies( + &g, + &AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + } + )); + } +} diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs b/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs new file mode 100644 index 00000000..005d34ad --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs @@ -0,0 +1,76 @@ +//! KLL normalized rank error at the registered empirical 99% calibration. +use super::*; + +pub(super) fn guarantee( + algorithm: &SketchAlgorithm, + params: &SketchParams, + query: &SketchQuery, +) -> Option { + let SketchParams::Kll { k } = params else { + return None; + }; + Some(super::bounded_guarantee( + algorithm, + params, + query, + ErrorMetric::Rank, + kll_rank_error_99(*k), + ProbabilityExpr::Constant { value: 0.01 }, + "apache_datasketches_kll_empirical_99_a9b42755072b", + )) +} +pub(crate) const KLL_RANK_ERROR_COEFFICIENT_99: f64 = 2.296; +pub(crate) const KLL_RANK_ERROR_EXPONENT_99: f64 = 0.9723; + +pub(crate) fn kll_rank_error_99(k: u32) -> f64 { + KLL_RANK_ERROR_COEFFICIENT_99 / f64::from(k).powf(KLL_RANK_ERROR_EXPONENT_99) +} + +/// Invert Apache DataSketches' empirical 99th-percentile, single-sided KLL +/// normalized rank-error fit: `epsilon = 2.296 / k^0.9723`. +pub(crate) fn kll_k(eps: f64) -> u32 { + saturating_ceil( + (KLL_RANK_ERROR_COEFFICIENT_99 / eps).powf(1.0 / KLL_RANK_ERROR_EXPONENT_99), + 8, + 65_535, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_guarantee_inverts_rank_sizing() { + use crate::replacement::default_size_params; + use asap_types::post_asap::{GroupingStrategy, SketchKind}; + use asap_types::pre_asap::agg_intent::default_quantile; + let q = default_quantile(0.99); + let params = default_size_params(SketchAlgorithm::Kll, &q, 0.01, 0.01); + let g = DefaultAccuracyModel + .local_guarantee( + &SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::Kll, params), + GroupingStrategy::default(), + ), + &SketchQuery::Quantile { q: 0.99 }, + ) + .unwrap(); + assert_eq!(g.metric, ErrorMetric::Rank); + assert!(DefaultAccuracyModel.satisfies(&g, &AccuracyTarget::Epsilon(0.01))); + assert_eq!(g.failure_probability.evaluate(), Some(0.01)); + assert!(DefaultAccuracyModel.satisfies( + &g, + &AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + } + )); + assert_eq!(g.approximate_layer_count(), 1); + assert!(g.provenance.iter().any(|source| matches!( + source, + GuaranteeSource::SketchReadout { contract, .. } + if contract == "apache_datasketches_kll_empirical_99_a9b42755072b" + ))); + } +} diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/mod.rs b/crates/asap-aware-mapping/src/accuracy/estimators/mod.rs new file mode 100644 index 00000000..056f097f --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/estimators/mod.rs @@ -0,0 +1,221 @@ +//! Dispatch committed estimator parameters to their accuracy models. +use super::*; +use asap_types::post_asap::GroupingStrategy; +use asap_types::pre_asap::AggIntent; + +pub mod cardinality; +pub mod cms; +pub mod count_sketch; +pub mod ddsketch; +pub mod hll; +pub mod kll; +pub mod univmon; + +pub(super) fn sketch_guarantee( + algorithm: &SketchAlgorithm, + params: &SketchParams, + query: &SketchQuery, +) -> Option { + match params { + SketchParams::Kll { .. } => kll::guarantee(algorithm, params, query), + SketchParams::DDSketch { .. } => ddsketch::guarantee(algorithm, params, query), + SketchParams::Hll { .. } => hll::generic_guarantee(algorithm, params, query), + SketchParams::Cms { .. } | SketchParams::CmsWithHeap { .. } => { + cms::guarantee(algorithm, params, query) + } + SketchParams::CountSketch { .. } | SketchParams::CountSketchWithHeap { .. } => { + count_sketch::guarantee(algorithm, params, query) + } + SketchParams::Kmv { .. } | SketchParams::Theta { .. } => { + cardinality::guarantee(algorithm, params, query) + } + SketchParams::UnivMon { .. } => univmon::guarantee(query), + } +} + +fn bounded_guarantee( + algorithm: &SketchAlgorithm, + params: &SketchParams, + query: &SketchQuery, + metric: ErrorMetric, + bound: f64, + delta: ProbabilityExpr, + contract: &str, +) -> ResultGuarantee { + ResultGuarantee { + metric, + bound: BoundExpr::Constant { value: bound }, + failure_probability: delta, + provenance: vec![GuaranteeSource::SketchReadout { + algorithm: format!("{algorithm:?}"), + contract: contract.into(), + params: serde_json::to_value(params).unwrap_or(serde_json::Value::Null), + query: format!("{query:?}"), + }], + } +} + +pub(super) fn local_guarantee( + family: &SummaryFamilyType, + query: &SketchQuery, +) -> Option { + match family { + SummaryFamilyType::Plain(_) => Some(ResultGuarantee::exact("Plain value")), + SummaryFamilyType::ExactAggregate(kind, _) => { + Some(ResultGuarantee::exact(format!("ExactAggregate({kind:?})"))) + } + SummaryFamilyType::Sketch(kind, _) => { + sketch_guarantee(kind.algorithm(), kind.params(), query) + } + // No error model is registered for these families. + SummaryFamilyType::Sample(..) + | SummaryFamilyType::Wavelet(..) + | SummaryFamilyType::StatModel(..) => None, + } +} +pub(crate) fn size_params( + kind: SketchAlgorithm, + intent: &AggIntent, + eps: f64, + delta: f64, +) -> SketchParams { + match kind { + // Baseline dimensions are candidates, not an inverted error bound. + // Empirical models may size these; no theoretical guarantee is claimed. + SketchAlgorithm::UnivMon => univmon::size_params(), + SketchAlgorithm::Kll => SketchParams::Kll { k: kll::kll_k(eps) }, + SketchAlgorithm::Cms => SketchParams::Cms { + width: cms::cms_width(eps), + depth: cms::cms_depth(delta), + }, + SketchAlgorithm::Hll => SketchParams::Hll { + precision: hll::hll_precision(eps), + }, + SketchAlgorithm::CmsWithHeap => { + let k = match intent { + AggIntent::TopK { k, .. } => *k, + _ => unreachable!("CmsWithHeap is only a TopK candidate"), + }; + SketchParams::CmsWithHeap { + width: cms::cms_width(eps), + depth: cms::cms_depth(delta), + heap_size: k as u32, + } + } + // Non-preferred candidates (DDSketch / Theta / Kmv / CountSketch / + // CountSketchWithHeap) are only reachable once a cost model picks + // them; sized here so that wiring is local. + SketchAlgorithm::DDSketch => ddsketch::size_params(eps), + SketchAlgorithm::Theta => SketchParams::Theta { + k: cardinality::kmv_k_99(eps), + }, + SketchAlgorithm::Kmv => SketchParams::Kmv { + k: cardinality::kmv_k_99(eps), + }, + SketchAlgorithm::CountSketch => SketchParams::CountSketch { + width: count_sketch::count_sketch_width(eps), + depth: count_sketch::count_sketch_depth(delta), + }, + SketchAlgorithm::CountSketchWithHeap => { + let k = match intent { + AggIntent::TopK { k, .. } => *k, + _ => unreachable!("CountSketchWithHeap is only a TopK candidate"), + }; + SketchParams::CountSketchWithHeap { + width: count_sketch::count_sketch_width(eps), + depth: count_sketch::count_sketch_depth(delta), + heap_size: k as u32, + } + } + } +} +/// `⌈x⌉` clamped to `[lo, hi]`; NaN / non-positive x saturate to `hi` +/// (a degenerate ε means "as accurate as this family goes"). +pub(crate) fn saturating_ceil(x: f64, lo: u32, hi: u32) -> u32 { + if !x.is_finite() || x <= 0.0 { + return hi; + } + (x.ceil() as u32).clamp(lo, hi) +} +pub(crate) struct EstimatorAccuracy<'a> { + base: &'a dyn AccuracyModel, + contract: Option, + epsilon: f64, + delta: f64, +} + +impl<'a> EstimatorAccuracy<'a> { + pub(crate) fn new( + base: &'a dyn AccuracyModel, + contract: Option, + target: Option<&AccuracyTarget>, + ) -> Self { + let (epsilon, delta) = target + .map(crate::replacement::accuracy_budget) + .unwrap_or((0.0, 0.0)); + Self { + base, + contract, + epsilon, + delta, + } + } + + fn hll(&self) -> Option { + let EstimatorContract::ClassicHll { + max_distinct_per_readout, + } = self.contract?; + hll::ClassicHllConfidence::new(max_distinct_per_readout, self.epsilon) + } + + pub(crate) fn size_params(&self, algorithm: &SketchAlgorithm) -> Option { + if *algorithm != SketchAlgorithm::Hll || self.contract.is_none() { + return None; + } + // Retain the strongest supported parameter for diagnostics if sizing + // is infeasible. The normal guarantee check rejects it below. + Some(SketchParams::Hll { + precision: self + .hll() + .and_then(|model| model.precision(self.delta)) + .unwrap_or(18), + }) + } +} + +impl AccuracyModel for EstimatorAccuracy<'_> { + fn exact_operation_rule(&self, operation: &ExactOperation) -> Option { + self.base.exact_operation_rule(operation) + } + fn local_guarantee( + &self, + family: &SummaryFamilyType, + query: &SketchQuery, + ) -> Option { + if let (Some(_), SummaryFamilyType::Sketch(kind, grouping), SketchQuery::Cardinality) = + (self.contract, family, query) + { + if let (SketchAlgorithm::Hll, SketchParams::Hll { precision }) = + (kind.algorithm(), kind.params()) + { + if *grouping != GroupingStrategy::PerSubpopulationInstance { + return None; + } + return self.hll()?.guarantee(*precision); + } + } + self.base.local_guarantee(family, query) + } + fn propagate( + &self, + op: &CompositionOperator, + inputs: &[ResultGuarantee], + local: Option<&ResultGuarantee>, + stats: &PropagationStats, + ) -> Result { + self.base.propagate(op, inputs, local, stats) + } + fn satisfies(&self, guarantee: &ResultGuarantee, target: &AccuracyTarget) -> bool { + self.base.satisfies(guarantee, target) + } +} diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/univmon.rs b/crates/asap-aware-mapping/src/accuracy/estimators/univmon.rs new file mode 100644 index 00000000..e832fd1a --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/estimators/univmon.rs @@ -0,0 +1,17 @@ +//! UnivMon currently certifies only its exact unit-update total readout. +use super::*; + +pub(super) fn guarantee(query: &SketchQuery) -> Option { + matches!(query, SketchQuery::PointCount { value: None, .. }) + .then(|| ResultGuarantee::exact("univmon_unit_update_total")) +} + +pub(super) fn size_params() -> SketchParams { + // Baseline dimensions are candidates, not an inverted accuracy bound. + SketchParams::UnivMon { + heap_size: 256, + sketch_rows: 5, + sketch_cols: 1024, + layers: 16, + } +} diff --git a/crates/asap-aware-mapping/src/accuracy/evidence.rs b/crates/asap-aware-mapping/src/accuracy/evidence.rs new file mode 100644 index 00000000..a915bf25 --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/evidence.rs @@ -0,0 +1,197 @@ +//! Scoped source contracts and evidence required by accuracy rules. +use super::*; + +/// A trusted source assertion scoped by `AccuracyEvidenceProvider` to one +/// complete readout. Choosing this variant asserts the estimator and hash +/// assumptions; it must not be inferred from sampled population statistics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EstimatorContract { + /// Classic HLL with independent uniform bucket hashing, including merged panes. + ClassicHll { max_distinct_per_readout: u32 }, +} + +/// An enforced domain for every sample of a direct quantile operand, in every +/// evaluation window. The provider promises a nonempty population containing +/// only finite values in this interval. Sampled min/max statistics are not a +/// proof: the contract must be enforced by the source or execution layer. +#[derive(Debug, Clone, PartialEq)] +pub struct QuantileInputDomain { + pub lower: f64, + pub upper: f64, + /// Upper bound on samples per evaluation, matching the pinned readout's + /// exact Float64 rank limit. The population must also be nonempty. + pub max_samples: u64, + pub contract: String, +} + +impl QuantileInputDomain { + pub(crate) fn supports_ddsketch(&self, alpha: f64) -> bool { + if !alpha.is_finite() + || alpha <= 0.0 + || alpha >= 1.0 + || !self.lower.is_finite() + || !self.upper.is_finite() + || self.lower > self.upper + || self.max_samples == 0 + || self.max_samples > (1u64 << 53) + || self.contract.trim().is_empty() + { + return false; + } + let (min, max) = asap_sketchlib::sketches::ddsketch::ddsketch_indexable_bounds(alpha); + // Same-sign interpolation preserves relative error. Zero alone is + // exact; an interval touching zero also admits tiny zero-mapped values. + (self.lower >= min && self.upper <= max) + || (self.upper <= -min && self.lower >= -max) + || (self.lower == 0.0 && self.upper == 0.0) + } +} + +/// Statistics a propagation rule may consult. Every field is optional and +/// defaults to "unknown": a rule that needs a missing statistic emits a +/// [`BoundExpr::Unknown`] leaf (or rejects) rather than guessing. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PropagationStats { + /// Certified finite ranges for the true numerator and denominator values. + /// Quantile ratios obtain these from their enforced input domains. + pub division_operand_domains: Option<[QuantileInputDomain; 2]>, + /// Provenance for supplied evidence (source, observation identity, etc.). + pub 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 values_non_negative: 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 input_row_count: Option, + /// Fresh key-frequency distribution evidence from the data workload. + /// Built-in rules preserve it for deployment-specific accuracy models; + /// they do not assume a favorable distribution when it is absent. + pub data_distribution: Option, + /// Lower confidence bound of the kth selected TopK item, after widening + /// the interval by the sketch's own estimation error. + pub 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 topk_excluded_upper_bound: Option, + /// Union-bound failure probability of all intervals used by the margin + /// certificate. + pub topk_interval_failure_probability: Option, + /// Hydra shared-grid collision error in the inner guarantee's metric. + pub hydra_shared_grid_collision_bound: Option, + /// Failure probability assigned to the Hydra shared-grid term. + pub hydra_shared_grid_failure_probability: Option, +} + +/// Supplies typed planning-time evidence required by propagation rules. +pub trait AccuracyEvidenceProvider { + /// Trusted estimator contract for this complete aggregate expression, + /// including source, filters, grouping and all panes in each readout. + /// An observed cardinality is not an enforced population bound. + fn estimator_contract( + &self, + _expression: &asap_types::pre_asap::QueryExpr, + ) -> Option { + None + } + + /// Proof scoped to this complete quantile expression, including its source, + /// filters, grouping and window. `None` means unknown, including emptiness. + fn quantile_input_domain( + &self, + _operand: &asap_types::pre_asap::query_expr::QueryExpr, + ) -> Option { + None + } + + fn propagation_stats( + &self, + _op: &CompositionOperator, + _family: &SummaryFamilyType, + _query: Option<&SketchQuery>, + ) -> PropagationStats { + PropagationStats::default() + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct NoAccuracyEvidence; + +impl AccuracyEvidenceProvider for NoAccuracyEvidence {} + +/// Accuracy evidence backed by the normalized data workload. Freshness is +/// checked at the planning time before values reach any accuracy rule. +#[derive(Debug, Clone, Copy)] +pub struct WorkloadAccuracyEvidence<'a> { + pub data: &'a asap_types::workload::DataWorkload, + pub now_ms: u64, +} + +impl AccuracyEvidenceProvider for WorkloadAccuracyEvidence<'_> { + fn propagation_stats( + &self, + _op: &CompositionOperator, + _family: &SummaryFamilyType, + _query: Option<&SketchQuery>, + ) -> PropagationStats { + PropagationStats { + input_row_count: self.data.input_cardinality.value_at(self.now_ms).copied(), + data_distribution: self.data.distribution.value_at(self.now_ms).cloned(), + ..PropagationStats::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use asap_types::workload::{DataDistribution, DataWorkload, Evidence, EvidenceSource}; + #[test] + fn workload_accuracy_evidence_uses_only_fresh_data_characteristics() { + let data = DataWorkload { + input_cardinality: Evidence { + value: Some(42), + source: EvidenceSource::Observed, + observed_at_ms: Some(1_000), + valid_for_ms: Some(500), + }, + distribution: Evidence { + value: Some(DataDistribution::Bursty), + source: EvidenceSource::Observed, + observed_at_ms: Some(1_000), + valid_for_ms: Some(500), + }, + ..Default::default() + }; + let provider = WorkloadAccuracyEvidence { + data: &data, + now_ms: 1_500, + }; + let fresh = provider.propagation_stats( + &CompositionOperator::ExactSum, + &SummaryFamilyType::ExactAggregate( + asap_types::post_asap::ExactKind::Sum, + asap_types::post_asap::ExactParams::Sum, + ), + None, + ); + assert_eq!(fresh.input_row_count, Some(42)); + assert_eq!(fresh.data_distribution, Some(DataDistribution::Bursty)); + + let stale = WorkloadAccuracyEvidence { + data: &data, + now_ms: 1_501, + } + .propagation_stats( + &CompositionOperator::ExactSum, + &SummaryFamilyType::ExactAggregate( + asap_types::post_asap::ExactKind::Sum, + asap_types::post_asap::ExactParams::Sum, + ), + None, + ); + assert_eq!(stale.input_row_count, None); + assert_eq!(stale.data_distribution, None); + } +} diff --git a/crates/asap-aware-mapping/src/accuracy/mod.rs b/crates/asap-aware-mapping/src/accuracy/mod.rs new file mode 100644 index 00000000..0c90ed91 --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/mod.rs @@ -0,0 +1,151 @@ +//! Planner accuracy interfaces and model dispatch. +//! +//! Estimator models derive local guarantees, composition propagates them, +//! allocation proposes local budgets, and evidence supplies scoped contracts. +//! Unknown evidence may retain a candidate but does not authorize selection. +//! See `docs/design_docs/concepts/accuracy-models.md` for the design. + +pub mod allocation; +pub mod composition; +pub mod estimators; +pub mod evidence; +pub mod reconciliation; + +pub use allocation::{ + AccuracyAllocation, AccuracyBudgetAllocator, CompositionShape, EqualSplitAllocator, +}; +pub(crate) use estimators::EstimatorAccuracy; +pub use evidence::{ + AccuracyEvidenceProvider, EstimatorContract, NoAccuracyEvidence, PropagationStats, + QuantileInputDomain, WorkloadAccuracyEvidence, +}; + +use asap_types::post_asap::{ + AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, ExactOperation, GuaranteeSource, + ProbabilityExpr, ResultGuarantee, SketchAlgorithm, SketchParams, SketchQuery, + SummaryFamilyType, +}; +use asap_types::types::AccuracyTarget; + +/// The deployment-extensible accuracy algebra. `asap-aware-mapping` ships +/// [`DefaultAccuracyModel`]; a deployment with a proof for a composition the +/// default rejects (a registered cross-metric conversion, say) implements +/// this trait and passes it to +/// [`crate::replacement::SketchAlgorithmStrategy::new_with_planning_inputs`]. +pub trait AccuracyModel { + /// The definition-registered rule for applying `operation` to an + /// approximate input. `None` means the function is exact only over exact + /// inputs; callers must fail closed for approximate input. + fn exact_operation_rule(&self, _operation: &ExactOperation) -> Option { + None + } + + /// The guarantee of reading `query` out of a summary of family `family` + /// built over an **exact** input — derived from the family's committed + /// parameters by inverting the same sizing formulas + /// [`crate::replacement::default_size_params`] uses. `None` when this + /// model has no error model for the family (the default has none for + /// `Sample`/`Wavelet`/`StatModel`). + fn local_guarantee( + &self, + family: &SummaryFamilyType, + query: &SketchQuery, + ) -> Option; + + /// Compose `inputs`' guarantees (in the parent's child order) with the + /// parent's own `local` guarantee under `op`. `Err` is the fail-closed + /// answer: no registered rule, or a missing input guarantee. + fn propagate( + &self, + op: &CompositionOperator, + inputs: &[ResultGuarantee], + local: Option<&ResultGuarantee>, + stats: &PropagationStats, + ) -> Result; + + /// Compare the dimensions requested by `target`. Unknown required + /// dimensions fail; selection separately excludes missing accuracy evidence. + fn satisfies(&self, guarantee: &ResultGuarantee, target: &AccuracyTarget) -> bool; +} + +/// The built-in estimator and composition models, with conservative target checks. +#[derive(Debug, Default, Clone, Copy)] +pub struct DefaultAccuracyModel; + +/// Small relative tolerance for comparing an evaluated bound against a +/// target, so a parameter sized by `⌈·⌉` to *exactly* meet ε is not rejected +/// by floating-point noise. +const SATISFACTION_TOLERANCE: f64 = 1e-9; + +impl DefaultAccuracyModel { + /// Derive the guarantee for the committed estimator parameters and readout. + pub fn sketch_guarantee( + algorithm: &SketchAlgorithm, + params: &SketchParams, + query: &SketchQuery, + ) -> Option { + estimators::sketch_guarantee(algorithm, params, query) + } +} + +impl AccuracyModel for DefaultAccuracyModel { + fn exact_operation_rule(&self, operation: &ExactOperation) -> Option { + composition::exact_operation_rule(operation) + } + fn local_guarantee( + &self, + family: &SummaryFamilyType, + query: &SketchQuery, + ) -> Option { + estimators::local_guarantee(family, query) + } + fn propagate( + &self, + op: &CompositionOperator, + inputs: &[ResultGuarantee], + local: Option<&ResultGuarantee>, + stats: &PropagationStats, + ) -> Result { + composition::propagate(op, inputs, local, stats) + } + fn satisfies(&self, guarantee: &ResultGuarantee, target: &AccuracyTarget) -> bool { + let within = |value: Option, limit: f64| { + value.is_some_and(|v| v <= limit * (1.0 + SATISFACTION_TOLERANCE) + f64::EPSILON) + }; + match target { + AccuracyTarget::Exact => guarantee.is_exact(), + AccuracyTarget::Epsilon(eps) => within(guarantee.bound.evaluate(), *eps), + AccuracyTarget::EpsilonDelta { epsilon, delta } => { + within(guarantee.bound.evaluate(), *epsilon) + && within(guarantee.failure_probability.evaluate(), *delta) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn abs(bound: f64, delta: f64) -> ResultGuarantee { + ResultGuarantee { + metric: ErrorMetric::AbsoluteValue, + bound: BoundExpr::Constant { value: bound }, + failure_probability: ProbabilityExpr::Constant { value: delta }, + provenance: vec![], + } + } + #[test] + fn satisfies_is_fail_closed_on_unknowns_and_exact() { + let unknown = ResultGuarantee { + bound: BoundExpr::Unknown { + statistic: "x".into(), + }, + ..abs(0.0, 0.0) + }; + assert!(!DefaultAccuracyModel.satisfies(&unknown, &AccuracyTarget::Epsilon(1.0))); + assert!(!DefaultAccuracyModel.satisfies(&abs(0.0, 0.01), &AccuracyTarget::Exact)); + assert!( + DefaultAccuracyModel.satisfies(&ResultGuarantee::exact("x"), &AccuracyTarget::Exact) + ); + } +} diff --git a/crates/asap-aware-mapping/src/accuracy_reconciliation.rs b/crates/asap-aware-mapping/src/accuracy/reconciliation.rs similarity index 100% rename from crates/asap-aware-mapping/src/accuracy_reconciliation.rs rename to crates/asap-aware-mapping/src/accuracy/reconciliation.rs diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 7a092624..420d5a2f 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -151,7 +151,6 @@ //! docs for the pipeline order and the root-vs-per-node precedence rules. pub mod accuracy; -pub mod accuracy_reconciliation; pub mod analytical_cost; pub mod cost_model; pub mod empirical_comparison; @@ -179,12 +178,12 @@ pub mod summary_maintenance_lifecycle; mod test_support; pub mod topk_reuse; +pub use accuracy::reconciliation::AccuracyReconciliationStrategy; pub use accuracy::{ AccuracyAllocation, AccuracyBudgetAllocator, AccuracyEvidenceProvider, AccuracyModel, CompositionShape, DefaultAccuracyModel, EqualSplitAllocator, NoAccuracyEvidence, PropagationStats, WorkloadAccuracyEvidence, }; -pub use accuracy_reconciliation::AccuracyReconciliationStrategy; pub use cost_model::CompleteSummaryCandidateEstimate; pub use cost_model::{ maintenance_operation_plan_cost_rate, raw_recompute_cost_rate, read_operation_plan_cost_rate, diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index a3cb2a69..7235f7bf 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -341,6 +341,10 @@ //! multi-group joint optimization beyond this per-site recurrence is left //! for whenever that changes. +use crate::accuracy::estimators::{ + cms::{cms_depth, cms_width}, + saturating_ceil, +}; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; @@ -366,12 +370,11 @@ use asap_types::workload::{DataWorkload, QueryRecurrence, QueryWorkload, Repeate use std::rc::Rc; use thiserror::Error; +use crate::accuracy::reconciliation::AccuracyReconciliationStrategy; use crate::accuracy::{ AccuracyBudgetAllocator, AccuracyEvidenceProvider, AccuracyModel, CompositionShape, - DefaultAccuracyModel, EqualSplitAllocator, NoAccuracyEvidence, KLL_RANK_ERROR_COEFFICIENT_99, - KLL_RANK_ERROR_EXPONENT_99, + DefaultAccuracyModel, EqualSplitAllocator, NoAccuracyEvidence, }; -use crate::accuracy_reconciliation::AccuracyReconciliationStrategy; use crate::cost_model::{ raw_recompute_cost_rate, Cost, CostModel, CseCandidate, DefaultCostModel, ExactCompositionCostInputs, ExactCompositionCostRequest, ShareDecision, @@ -538,7 +541,7 @@ pub enum ReplacementProvenance { CseShare, CseRecompute, LogicalRewrite, - /// [`crate::accuracy_reconciliation::AccuracyReconciliationStrategy`]'s + /// [`crate::accuracy::reconciliation::AccuracyReconciliationStrategy`]'s /// "read a strictly-tighter sibling instead of building an independent, /// looser copy" candidate (issue #273). Kept distinct from /// `LogicalRewrite` — even though both are structurally-different, @@ -1034,56 +1037,7 @@ pub fn default_size_params( eps: f64, delta: f64, ) -> SketchParams { - match kind { - // Baseline dimensions are candidates, not an inverted error bound. - // Empirical models may size these; no theoretical guarantee is claimed. - SketchAlgorithm::UnivMon => SketchParams::UnivMon { - heap_size: 256, - sketch_rows: 5, - sketch_cols: 1024, - layers: 16, - }, - SketchAlgorithm::Kll => SketchParams::Kll { k: kll_k(eps) }, - SketchAlgorithm::Cms => SketchParams::Cms { - width: cms_width(eps), - depth: cms_depth(delta), - }, - SketchAlgorithm::Hll => SketchParams::Hll { - precision: hll_precision(eps), - }, - SketchAlgorithm::CmsWithHeap => { - let k = match intent { - AggIntent::TopK { k, .. } => *k, - _ => unreachable!("CmsWithHeap is only a TopK candidate"), - }; - SketchParams::CmsWithHeap { - width: cms_width(eps), - depth: cms_depth(delta), - heap_size: k as u32, - } - } - // Non-preferred candidates (DDSketch / Theta / Kmv / CountSketch / - // CountSketchWithHeap) are only reachable once a cost model picks - // them; sized here so that wiring is local. - SketchAlgorithm::DDSketch => SketchParams::DDSketch { alpha: eps }, - SketchAlgorithm::Theta => SketchParams::Theta { k: kmv_k_99(eps) }, - SketchAlgorithm::Kmv => SketchParams::Kmv { k: kmv_k_99(eps) }, - SketchAlgorithm::CountSketch => SketchParams::CountSketch { - width: count_sketch_width(eps), - depth: count_sketch_depth(delta), - }, - SketchAlgorithm::CountSketchWithHeap => { - let k = match intent { - AggIntent::TopK { k, .. } => *k, - _ => unreachable!("CountSketchWithHeap is only a TopK candidate"), - }; - SketchParams::CountSketchWithHeap { - width: count_sketch_width(eps), - depth: count_sketch_depth(delta), - heap_size: k as u32, - } - } - } + crate::accuracy::estimators::size_params(kind, intent, eps, delta) } /// A deployment's explicit bet about how "typical" (non-adversarial) its @@ -1206,72 +1160,6 @@ pub fn posterior_aware_size_params( } } -// ── Parameter sizing ────────────────────────────────────────────────────────── -// -// Each function inverts the sketch family's standard error bound to the -// smallest parameter satisfying the target, clamped to the family's sane -// range. A non-positive ε saturates to the clamp maximum (tightest allowed). - -/// Invert Apache DataSketches' empirical 99th-percentile, single-sided KLL -/// normalized rank-error fit: `epsilon = 2.296 / k^0.9723`. -fn kll_k(eps: f64) -> u32 { - saturating_ceil( - (KLL_RANK_ERROR_COEFFICIENT_99 / eps).powf(1.0 / KLL_RANK_ERROR_EXPONENT_99), - 8, - 65_535, - ) -} - -/// HLL RSE-magnitude inversion. Generic HLL has no modeled confidence target. -fn hll_precision(eps: f64) -> u8 { - saturating_ceil((1.04 / eps).powi(2).log2(), 4, 18) as u8 -} - -/// CMS: over-count ≤ ε·N with width `w = ⌈e/ε⌉` columns. -fn cms_width(eps: f64) -> u32 { - saturating_ceil(std::f64::consts::E / eps, 2, 1 << 26) -} - -/// CMS: failure probability ≤ δ with depth `d = ⌈ln(1/δ)⌉` rows. -/// δ = 0.01 → depth 5. -fn cms_depth(delta: f64) -> u32 { - saturating_ceil((1.0 / delta).ln(), 1, 32) -} - -/// 99%-confidence KMV/Theta relative bound via Chebyshev, using -/// `RSE <= 1/sqrt(k-2)` and a ten-standard-deviation interval. -fn kmv_k_99(eps: f64) -> u32 { - saturating_ceil(100.0 / (eps * eps) + 2.0, 16, 1 << 26) -} - -/// CountSketch `L2` point-query width: ε = sqrt(3/w). -fn count_sketch_width(eps: f64) -> u32 { - saturating_ceil(3.0 / (eps * eps), 2, 1 << 26) -} - -/// Positive odd depth satisfying Hoeffding's median failure bound -/// `exp(-depth/18) <= delta` for per-row failure at most 1/3. -fn count_sketch_depth(delta: f64) -> u32 { - if !(delta.is_finite() && delta > 0.0 && delta < 1.0) { - return 255; - } - let depth = saturating_ceil(18.0 * (1.0 / delta).ln(), 1, 255); - if depth.is_multiple_of(2) { - (depth + 1).min(255) - } else { - depth - } -} - -/// `⌈x⌉` clamped to `[lo, hi]`; NaN / non-positive x saturate to `hi` -/// (a degenerate ε means "as accurate as this family goes"). -fn saturating_ceil(x: f64, lo: u32, hi: u32) -> u32 { - if !x.is_finite() || x <= 0.0 { - return hi; - } - (x.ceil() as u32).clamp(lo, hi) -} - // ── SketchAlgorithmStrategy ───────────────────────────────────────────────── /// A single static instance so [`SketchAlgorithmStrategy::default_cost_model`] @@ -2301,6 +2189,22 @@ pub(crate) fn construct_summary_with( child_target: Option<&AccuracyTarget>, allocation: Option, ) -> Result, RealizationError> { + let local_target = match allocation.as_ref() { + Some(GuaranteeSource::BudgetAllocation { local_target, .. }) => Some(local_target), + _ => accuracy_target(intent), + }; + let estimator = crate::accuracy::EstimatorAccuracy::new( + planning_inputs.accuracy, + planning_inputs.evidence.estimator_contract(expr), + local_target, + ); + let realization = match realization { + Realization::Sketch(kind) => match estimator.size_params(kind.algorithm()) { + Some(params) => Realization::Sketch(SketchKind::new(kind.algorithm().clone(), params)), + None => Realization::Sketch(kind), + }, + other => other, + }; if let QueryExpr::Aggregate { reduction, child, .. } = expr @@ -2652,12 +2556,21 @@ fn construct_summary_agg( // materialized: the local guarantee of this family's readout (or exact // accumulator) composed over the child's, under the operator this // family applies to the child's values. + let local_target = match allocation.as_ref() { + Some(GuaranteeSource::BudgetAllocation { local_target, .. }) => Some(local_target), + _ => accuracy_target(intent), + }; + let estimator = crate::accuracy::EstimatorAccuracy::new( + planning_inputs.accuracy, + planning_inputs.evidence.estimator_contract(node), + local_target, + ); let guarantee = compose_guarantee( &family, query.as_ref(), &bound_child, intent, - planning_inputs.accuracy, + &estimator, planning_inputs.evidence, allocation, )?; @@ -2983,7 +2896,7 @@ fn column_ref(column: &asap_types::pre_asap::Column) -> ColumnRef { fn summarised_input( intent: &AggIntent, child_schema: &Schema, -) -> Result { +) -> Result { let cols = intent.input_cols(); if cols.len() < 2 { return Ok(SummaryInputExpr::Column(summarised_column( @@ -2995,7 +2908,7 @@ fn summarised_input( .iter() .map(|id| child_schema.columns.get(*id).map(column_ref)) .collect::>>() - .ok_or(ImplementError::PhysicalRealization( + .ok_or(RealizationError::PhysicalRealization( "a tuple column is outside the input schema", ))?; Ok(SummaryInputExpr::Tuple( @@ -9376,7 +9289,7 @@ mod tests { assert_eq!(guarantee.metric, ErrorMetric::Rank); assert_eq!( guarantee.bound.evaluate(), - Some(crate::accuracy::kll_rank_error_99(269)) + Some(crate::accuracy::estimators::kll::kll_rank_error_99(269)) ); assert_eq!(guarantee.approximate_layer_count(), 1); assert!(guarantee.provenance.iter().any(|s| matches!( @@ -9599,4 +9512,118 @@ mod tests { .unwrap() .is_some()); } + // Source evidence alone must enable Planner-owned sizing and certification. + #[test] + fn scoped_hll_evidence_sizes_and_certifies_without_a_deployment_model() { + use crate::accuracy::EstimatorContract; + struct SourceEvidence { + expression: QueryExpr, + max_distinct: u32, + } + impl AccuracyEvidenceProvider for SourceEvidence { + fn estimator_contract(&self, expression: &QueryExpr) -> Option { + (expression == &self.expression).then_some(EstimatorContract::ClassicHll { + max_distinct_per_readout: self.max_distinct, + }) + } + } + let target = AccuracyTarget::EpsilonDelta { + epsilon: 0.05, + delta: 0.01, + }; + let root = Rc::new(agg( + vec![], + AggIntent::Cardinality { + cols: vec![], + accuracy: target.clone(), + }, + metric_scan(&[]), + )); + let evidence = SourceEvidence { + expression: (*root).clone(), + max_distinct: 128, + }; + let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + &DefaultCostModel, + &DefaultAccuracyModel, + &EqualSplitAllocator, + &evidence, + ); + let candidates = strategy.replacements(&TargetSubDAG::new(&root)); + let hll = candidates + .iter() + .find_map(|candidate| match &candidate.replacement { + Replacement::Summary(node) + if summary_family_algorithm(node) == SketchAlgorithm::Hll => + { + Some(node) + } + _ => None, + }) + .expect("HLL candidate"); + assert!(DefaultAccuracyModel + .satisfies(hll.guarantee.as_ref().expect("HLL confidence"), &target)); + let SummaryExpr::SummaryEstimate { summary_input, .. } = &hll.expr else { + panic!("readout") + }; + let SummaryExpr::SummaryAgg { + family: SummaryFamilyType::Sketch(kind, _), + .. + } = &summary_input.expr + else { + panic!("HLL state") + }; + let expected = crate::accuracy::estimators::hll::ClassicHllConfidence::new(128, 0.05) + .unwrap() + .precision(0.01) + .unwrap(); + assert_eq!( + kind.params(), + &SketchParams::Hll { + precision: expected + } + ); + let absent = + SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + assert!(!absent.iter().any(|candidate| matches!(&candidate.replacement, Replacement::Summary(node) + if summary_family_algorithm(node) == SketchAlgorithm::Hll && node.guarantee.as_ref().is_some_and(|g| DefaultAccuracyModel.satisfies(g, &target))))); + // Invalid contracts, infeasible targets and evidence for another source + // must never authorize a confidence-bearing HLL candidate. + for (max_distinct, delta, wrong_scope) in [ + (0, 0.01, false), + (4097, 0.01, false), + (128, 1e-12, false), + (128, 0.01, true), + ] { + let target = AccuracyTarget::EpsilonDelta { + epsilon: 0.05, + delta, + }; + let query = Rc::new(agg( + vec![], + AggIntent::Cardinality { + cols: vec![], + accuracy: target.clone(), + }, + metric_scan(&[]), + )); + let evidence = SourceEvidence { + expression: if wrong_scope { + metric_scan(&["other"]) + } else { + (*query).clone() + }, + max_distinct, + }; + let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + &DefaultCostModel, + &DefaultAccuracyModel, + &EqualSplitAllocator, + &evidence, + ); + assert!(!strategy.replacements(&TargetSubDAG::new(&query)).iter().any(|candidate| + matches!(&candidate.replacement, Replacement::Summary(node) + if summary_family_algorithm(node) == SketchAlgorithm::Hll && node.guarantee.as_ref().is_some_and(|g| DefaultAccuracyModel.satisfies(g, &target))))); + } + } } diff --git a/docs/design_docs/concepts/accuracy-models.md b/docs/design_docs/concepts/accuracy-models.md new file mode 100644 index 00000000..5353ef9e --- /dev/null +++ b/docs/design_docs/concepts/accuracy-models.md @@ -0,0 +1,324 @@ +# Accuracy model design + +## Purpose and ownership + +The accuracy model answers whether a candidate computation can satisfy the +query's accuracy requirement, and what parameters and evidence it needs to do +so. Its unit of reasoning is the result of a computation, including the errors +of its inputs. A locally accurate sketch does not automatically make an entire +query accurate. + +Planner owns the interpretation of accuracy targets, estimator models, error +propagation, budget allocation and candidate legality. Deployments provide +source contracts and evidence with a defensible scope. Cost models compare +resource costs; runtime implementations execute the selected semantics and +must honor the conditions attached to them. + +This design applies to exact and approximate computation, composed queries and +shared DAGs. HLL is one estimator within this design, not a separate selection +system. Ingestion time and query time change where computation runs, not what +its accuracy guarantee means. + +## Requirements, guarantees and evidence + +These three concepts have different roles: + +| Concept | Meaning | Owner | +|---|---|---| +| Accuracy target | What the caller requires of the result | Query requirements and aggregate intent | +| Result guarantee | What a particular computation can establish | Estimator and composition models in Planner | +| Evidence | Facts or trusted contracts needed to establish that guarantee | Source or deployment evidence provider | + +`AccuracyTarget` is the authoritative requirement type: + +- `Exact` requires zero error and zero failure probability. +- `Epsilon` constrains the error magnitude in the relevant metric; it does not specify a failure-probability budget. +- `EpsilonDelta` constrains both error magnitude and failure probability. + +The target at a query root applies to the entire returned result. An aggregate's +own target guides its local candidate construction. Nested local targets do not +replace the root requirement: the composed result must satisfy it as well. + +A `ResultGuarantee` contains an error metric, an error-bound expression, a +failure-probability expression and provenance. The metric is essential: + +| Metric | Quantity bounded | +|---|---| +| Absolute value | Distance from the true value, in its units | +| Relative value | Distance normalized by the magnitude of the true value | +| Rank | Quantile rank displacement normalized by population size | +| Cardinality | Relative error of a distinct count | +| Frequency | Point-frequency error normalized by the stream's L1 norm | +| L2 frequency | Point-frequency error normalized by the stream's L2 norm | +| TopK membership | Whether the selected key set equals the true TopK set | + +For example, a small quantile rank error does not imply a small error in the +quantile's numeric value. Converting metrics requires an explicit justified +rule and any evidence that rule needs. + +Bounds and probabilities can remain symbolic. An unknown population size, +normalization factor or failure probability stays unknown; it is not replaced +by zero. Provenance records estimator parameters and contracts, child +guarantees, composition steps, allocated targets and unavailable evidence. +This makes an accuracy decision explainable without reconstructing it from +query text or cost estimates. + +## Planning flow + +```mermaid +flowchart TD + Request[Query semantics and accuracy target] --> Generate[Generate candidates and size parameters] + Evidence[Scoped source contracts and evidence] --> Generate + Generate --> Local[Derive local readout guarantees] + Evidence --> Local + Local --> Compose[Propagate guarantees through the DAG] + Evidence --> Compose + Compose --> Check[Check the composed target] + Check -->|Satisfied with sufficient evidence| Eligible[Eligible for cost comparison] + Check -->|Evidence missing| Pending[Retain an uncertified candidate for explanation] + Check -->|Known violation or unsupported composition| Reject[Reject with a reason] + Eligible --> Select[Select compatible candidates across the workload] + Select --> Bind[Bind implementations and deployment resources] +``` + +Candidate generation, certification, selection and deployment binding are +separate decisions. A candidate may remain in the plan space while awaiting +evidence. Current selection excludes candidates marked as missing accuracy +evidence; being present in that space does not authorize execution. + +Planner can use an optimistic lower bound while enumerating candidates to +avoid discarding a potentially useful plan solely because evidence is missing. +That bound is a feasibility test, not the candidate's certificate. The original +unknowns remain in its guarantee. Known violations and unsupported propagation +rules are not repaired by optimistic enumeration. + +The default target comparator checks only the dimensions requested by the +target; an epsilon-only comparison does not itself check delta. The separate +missing-evidence gate still matters during selection. A successful accuracy +check also does not prove that a runtime implements the plan, that stored state +is ready, or that a complete deployment cost is available. + +## Local estimator models and parameter sizing + +A local model describes a specific readout of a specific estimator with +committed parameters and applicable assumptions. A family name or a parameter +such as HLL precision is not, by itself, a confidence certificate. + +Sizing proposes parameters for a local target. Planner then derives the +actual guarantee from those parameters and checks it. Parameter rounding, +implementation limits, or conservative probability bounds can make a proposed +configuration insufficient; sizing must not bypass that check. + +The built-in models currently include: + +| Model | Current accuracy contract and limits | +|---|---| +| Exact computation | Exact over exact inputs under the supported operation's semantics; approximate inputs still require propagation | +| KLL | Normalized rank error at the model's fixed 99% empirical calibration; tighter confidence is not inferred from increasing `k` alone | +| DDSketch | Relative value error from alpha under the supported estimator/domain contract; sensitive compositions require additional domain evidence | +| Generic HLL | RSE magnitude with unknown failure probability; not a general confidence theorem | +| Bounded Classic HLL | Source-conditioned relative-error/failure-probability bound and precision sizing for the linear-counting branch | +| CMS | L1-normalized frequency bound from width and depth; does not by itself certify TopK membership | +| CountSketch | L2-normalized frequency bound and median concentration bound, requiring valid odd depth | +| KMV / Theta | Parameter-derived cardinality bounds using the registered variance/Chebyshev model at 99% confidence | +| UnivMon | Exact unit-update total for the supported readout; no universal guarantee for all its statistics | +| Other families/readouts | No default certificate where no accuracy model is registered | + +This table describes Planner's registered contracts, not independent +mathematical verification of every estimator or permission to substitute +another implementation with the same algorithm name. In particular, a named +empirical calibration is different from an arbitrary benchmark's maximum +observed error; both its confidence and applicability must remain explicit. + +The current interfaces still expose general parameter proposal through +`CostModel::size_params`. Default sizing is dispatched to the estimator modules through the existing +public candidate-construction entry point. Accuracy validation is independent of those proposals. The new +source-contract path centralizes HLL sizing and guarantee derivation in +Planner's accuracy module, overriding the generic proposal when the applicable +contract is supplied. It does not yet move every algorithm's sizing interface +out of CostModel. The design boundary is that parameter proposals never grant +accuracy authority to the cost model. + +## Composing guarantees through a DAG + +`AccuracyModel` supplies local guarantees, propagation rules and target +satisfaction. Its default implementation is conservative: an unregistered +composition over approximate inputs is rejected rather than treated as exact. + +The principal composition rules are: + +| Computation | Accuracy reasoning | +|---|---| +| Supported computation over exact inputs | Only its own local estimator error remains; a supported exact operation remains exact | +| Additive absolute-error composition | Add compatible error bounds and union-bound their failure events | +| Relative-error composition | Include the multiplicative cross term, subject to the required sign/domain conditions | +| Registered Lipschitz transform | Scale the input bound by the registered constant and include local error | +| Exact sum over approximate values | Convert compatible errors to absolute units and account for input multiplicity; missing scale/count evidence remains unknown | +| Exact average or extremum over approximate values | Use the registered absolute-error bound and account for failures across the input population | +| Division | Require the appropriate value-error rule and denominator/domain conditions; arbitrary rank-error division is unsupported | +| Counter rate/increase over approximate samples | No general distribution-free rule for reset detection and extrapolation; exact inputs remain a distinct supported case | +| Candidate-based TopK | Require membership/completeness evidence; frequency accuracy and exact reranking alone are insufficient | + +Probability composition uses union bounds, without assuming independence +between child errors. Estimator-local assumptions, such as independent bucket +hashing, must be stated separately. Shared DAG nodes do not create independent +errors merely because several consumers reference them, and sharing does not +justify reducing the failure budget. Per-result guarantees also do not imply +a simultaneous guarantee across every query or evaluation time in a dashboard. + +For relative errors, two eligible layers with errors `e1` and `e2` compose as +`(1 + e1)(1 + e2) - 1`, not simply `e1 + e2`. Likewise, an exact sum above +approximate values retains their uncertainty: exact arithmetic does not +recover information lost below it. + +## Allocating an end-to-end budget + +`AccuracyBudgetAllocator` proposes local targets for a composition. These +allocations create candidates; they are not proofs that the candidates work. +Every proposed composition is checked again using the actual guarantees. + +The default equal-split allocator divides an additive error budget across the +approximate layers. For relative error, it uses local error +`(1 + epsilon)^(1/n) - 1` for `n` layers. When the target includes delta, it +splits that budget across the layers for union-bound composition. An exact +target does not receive an approximate allocation. + +For example, under a supported two-layer relative-error composition, a 10% +end-to-end budget gives each layer approximately 4.88%, not 5%. If the failure +budget is 1%, each layer receives 0.5%. A model with only a fixed 1% failure +contract cannot automatically satisfy that allocation; another supported +configuration or candidate is needed. + +## Evidence and trust boundaries + +`AccuracyEvidenceProvider` supplies estimator contracts, quantile input domains +and propagation evidence. The evidence must cover the population to which the +claimed guarantee applies: sources, filters, grouping, evaluation windows and +all merged panes. Evidence for a narrower population cannot silently certify +a wider one. The workload-backed provider checks freshness before exposing +its supported data characteristics. + +Source contracts are assertions that the source or deployment must establish +and enforce. They are not inferred from observed cardinality or sampled value +ranges. A source-specific deployment should supply those facts, rather than +reimplement estimator mathematics or candidate legality. + +ERP benchmark results can inform resource costs and measured behavior. +A measured maximum error does not establish a failure probability. Benchmark +preference for cost estimation must therefore remain separate from accuracy +certification. If a model uses an empirical accuracy calibration, the contract +must identify its confidence level and scope rather than silently promoting +an observation into a guarantee. + +### Example: bounded Classic HLL + +A deployment supplies `EstimatorContract::ClassicHll` for the complete aggregate +expression. It asserts the classic estimator, independent uniform bucket +hashing and an enforced maximum distinct population per readout, including +all merged panes. Planner combines this contract with the query or allocated +local target, selects a supported precision, derives the guarantee and uses +the normal propagation and selection checks. + +The current model supports maxima from 1 to 4096 and precisions from 4 to 18, +and certifies only configurations that remain in the linear-counting branch. +It bounds collisions across every integer cardinality in the declared domain +and bounds overestimation deterministically. It is not an RSE-to-normal +conversion, nor does it cover HIP/MLE or arbitrary unbounded populations. + +Missing evidence leaves generic HLL confidence unknown. Invalid or infeasible +contracts cannot authorize the result. The contract does not certify another +estimator, another expression or an unsupported shared-grid grouping. + +## Sharing across consumers + +Accuracy reconciliation considers semantically compatible consumers with +different accuracy requirements. A looser consumer may reuse a tighter +consumer's computation when the supported reconciliation rule proves that the +requirements, grouping and row identity permit it. + +This creates an explicit reuse candidate and a dependency on the tighter +computation. It does not change ordinary common-subexpression equality, merge +queries with different semantics, or weaken the tighter consumer's target. +Global selection still coordinates compatible choices and accounts for shared +cost. The current reconciliation strategy does not reconcile exact and +approximate requirements merely by ordering their epsilon values. + +### Example: two consumers of the same grouped quantile + +Suppose two queries compute the 95th-percentile latency from the same source, +filter, time window and `service` grouping. They have identical output columns +and differ only in accuracy requirements: + +| Consumer | Requested result | Rank error target | Failure-probability target | +|---|---|---|---| +| A | p95 latency per service | 1% | 1% | +| B | p95 latency per service | 5% | 1% | + +Planner can propose building the tighter KLL computation required by A and +letting B read that same result. A result certified to 1% rank error also meets +B's 5% rank-error target, at the same confidence. The `service` grouping +provides the stable row identity required by the current reconciliation rule. +These percentages bound rank displacement, not latency-value error. + +```mermaid +flowchart LR + Input[Same source, filter and window] --> Shared[KLL computation per service sized for A] + Shared --> A[Consumer A: 1% rank error] + Shared --> B[Consumer B: 5% rank error] +``` + +B's independently sized candidate remains available. Global selection compares +that candidate with the reuse candidate and preserves the dependency on A's +tighter computation if it chooses reuse. Sharing is therefore an explicit +planning choice, not an unconditional rewrite or two separately charged builds. + +This reconciliation does not apply if B instead asks for p99, uses a different +window or filter, or groups by another key: those queries differ in semantics, +not just accuracy. Nor does this example justify serving B at a failure target +of 0.1%; A's 1% failure guarantee would not meet it. A separate supported reuse +rule or a different estimator configuration would be needed in those cases. + +## Organization and extension contract + +The `asap-aware-mapping::accuracy` module separates these responsibilities: + +```text +accuracy/ +├── mod.rs # Public interfaces and unified entry point +├── evidence.rs # Evidence and source contracts +├── composition.rs # Error propagation across computations +├── allocation.rs # End-to-end budget allocation +├── reconciliation.rs # Accuracy coordination across consumers +└── estimators/ + ├── mod.rs # Family/readout dispatch and source-contract integration + ├── kll.rs + ├── ddsketch.rs + ├── hll.rs # Generic HLL and bounded Classic HLL + ├── cms.rs + ├── count_sketch.rs + ├── cardinality.rs # Shared KMV / Theta models + └── univmon.rs +``` + +Each estimator module owns its local accuracy formulas and associated sizing +formulas. The unified entry point delegates to these models; it does not +contain a second copy of their mathematics. Serializable guarantee and metric +types live in `asap-types` so planning, explanations and downstream binding +share the same contract. + +Adding an estimator or composition requires: + +1. A precisely defined error metric, estimator/readout semantics and assumptions. +2. Sizing behavior and a guarantee derived from the committed parameters, including unsupported parameter domains. +3. Explicit evidence requirements, population scope and provenance. +4. Propagation rules where supported; rejection or retained unknowns elsewhere. +5. Tests from target and evidence through sizing, composition and selection, including missing evidence and infeasible targets. + +Algorithm-specific mathematics belongs behind this common contract. It does +not require a new selection rule for every sketch. Deployment extensions to +`AccuracyModel` remain possible, but carry the same obligation to justify +metrics, assumptions and propagation. + +For implementation details, see the [accuracy module](../../../crates/asap-aware-mapping/src/accuracy/mod.rs), +[guarantee representation](../../../crates/types/src/post_asap/guarantee.rs), and +[accuracy propagation companion](../../develop_docs/end-to-end-accuracy-guarantees.md).