From f8987a71c49ea7f41b4f796ffcd0db0d45e6d6d7 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 03:28:56 +0000 Subject: [PATCH 1/6] feat: model bounded classic HLL confidence without an RSE shortcut --- .../asap-aware-mapping/src/hll_confidence.rs | 208 ++++++++++++++++++ crates/asap-aware-mapping/src/lib.rs | 1 + 2 files changed, 209 insertions(+) create mode 100644 crates/asap-aware-mapping/src/hll_confidence.rs diff --git a/crates/asap-aware-mapping/src/hll_confidence.rs b/crates/asap-aware-mapping/src/hll_confidence.rs new file mode 100644 index 00000000..bc8f36d9 --- /dev/null +++ b/crates/asap-aware-mapping/src/hll_confidence.rs @@ -0,0 +1,208 @@ +//! 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 asap_types::post_asap::{ + BoundExpr, ErrorMetric, GuaranteeSource, ProbabilityExpr, ResultGuarantee, +}; + +/// 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 * (1.0 - 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)) + } +} + +#[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); + } +} diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 7a092624..896fc5a2 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -162,6 +162,7 @@ pub mod exact_composition; pub mod explanation; mod function_rules; pub mod grouping; +pub mod hll_confidence; pub mod pane_sharing; pub mod physical_handoff_cost; pub mod physical_operator_statistics; From 7a49a311a4da6dd8c1e28d0651642a2d512f6ecc Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 03:31:15 +0000 Subject: [PATCH 2/6] fix: reserve absolute relative-error slack for HLL arithmetic --- crates/asap-aware-mapping/src/hll_confidence.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/asap-aware-mapping/src/hll_confidence.rs b/crates/asap-aware-mapping/src/hll_confidence.rs index bc8f36d9..d7abcd26 100644 --- a/crates/asap-aware-mapping/src/hll_confidence.rs +++ b/crates/asap-aware-mapping/src/hll_confidence.rs @@ -76,7 +76,7 @@ impl ClassicHllConfidence { 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 * (1.0 - 1e-8); + let eps = self.relative_error - 1e-8; if max_n > m / 2.0 || max_n / (2.0 * (m - max_n)) > eps { return None; } From 63e1dd26fe737f6df577706dff47761a5f74159e Mon Sep 17 00:00:00 2001 From: zz_y Date: Thu, 24 Sep 2026 14:19:41 +0000 Subject: [PATCH 3/6] refactor: centralize estimator confidence and sizing in Planner accuracy --- .../src/accuracy/estimator.rs | 95 ++++++++++++ .../{hll_confidence.rs => accuracy/hll.rs} | 0 .../src/{accuracy.rs => accuracy/mod.rs} | 16 ++ .../reconciliation.rs} | 0 crates/asap-aware-mapping/src/lib.rs | 4 +- crates/asap-aware-mapping/src/replacement.rs | 145 +++++++++++++++++- docs/design_docs/concepts/accuracy-models.md | 37 +++++ 7 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 crates/asap-aware-mapping/src/accuracy/estimator.rs rename crates/asap-aware-mapping/src/{hll_confidence.rs => accuracy/hll.rs} (100%) rename crates/asap-aware-mapping/src/{accuracy.rs => accuracy/mod.rs} (99%) rename crates/asap-aware-mapping/src/{accuracy_reconciliation.rs => accuracy/reconciliation.rs} (100%) create mode 100644 docs/design_docs/concepts/accuracy-models.md diff --git a/crates/asap-aware-mapping/src/accuracy/estimator.rs b/crates/asap-aware-mapping/src/accuracy/estimator.rs new file mode 100644 index 00000000..6bb72f3d --- /dev/null +++ b/crates/asap-aware-mapping/src/accuracy/estimator.rs @@ -0,0 +1,95 @@ +//! Source contracts are evidence; sizing and guarantees remain Planner-owned. +use super::*; +use asap_types::post_asap::GroupingStrategy; + +/// 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 }, +} + +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/hll_confidence.rs b/crates/asap-aware-mapping/src/accuracy/hll.rs similarity index 100% rename from crates/asap-aware-mapping/src/hll_confidence.rs rename to crates/asap-aware-mapping/src/accuracy/hll.rs diff --git a/crates/asap-aware-mapping/src/accuracy.rs b/crates/asap-aware-mapping/src/accuracy/mod.rs similarity index 99% rename from crates/asap-aware-mapping/src/accuracy.rs rename to crates/asap-aware-mapping/src/accuracy/mod.rs index 4ffdca32..2c7f8c55 100644 --- a/crates/asap-aware-mapping/src/accuracy.rs +++ b/crates/asap-aware-mapping/src/accuracy/mod.rs @@ -68,6 +68,12 @@ //! - `AccuracyTarget::Exact` on a node admits only exact realizations //! (unchanged), and an approximate layer can never satisfy it. +mod estimator; +pub mod hll; +pub mod reconciliation; +pub(crate) use estimator::EstimatorAccuracy; +pub use estimator::EstimatorContract; + use asap_types::post_asap::{ AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, ExactOperation, GuaranteeSource, ProbabilityExpr, ResultGuarantee, SketchAlgorithm, SketchParams, SketchQuery, @@ -153,6 +159,16 @@ pub struct PropagationStats { /// 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( 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 896fc5a2..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; @@ -162,7 +161,6 @@ pub mod exact_composition; pub mod explanation; mod function_rules; pub mod grouping; -pub mod hll_confidence; pub mod pane_sharing; pub mod physical_handoff_cost; pub mod physical_operator_statistics; @@ -180,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..c1bd3841 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -366,12 +366,12 @@ 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, }; -use crate::accuracy_reconciliation::AccuracyReconciliationStrategy; use crate::cost_model::{ raw_recompute_cost_rate, Cost, CostModel, CseCandidate, DefaultCostModel, ExactCompositionCostInputs, ExactCompositionCostRequest, ShareDecision, @@ -538,7 +538,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, @@ -2301,6 +2301,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 +2668,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, )?; @@ -9599,4 +9624,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 { + col: None, + 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::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 { + col: None, + 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..85c07600 --- /dev/null +++ b/docs/design_docs/concepts/accuracy-models.md @@ -0,0 +1,37 @@ +# Accuracy models and source contracts + +Planner owns estimator accuracy, parameter sizing, propagation and candidate +legality. Deployments supply source evidence and cost information through the +existing interfaces; they do not need their own HLL accuracy or sizing model. + +The `asap-aware-mapping::accuracy` module groups the shared algebra and budget +allocation, estimator implementations (`hll`), source-contract integration, +and cross-consumer reconciliation (`reconciliation`). Algorithm-specific +mathematics is not a separate candidate-selection rule. + +## Source evidence to a physical candidate + +1. A deployment implements `AccuracyEvidenceProvider::estimator_contract` for + the complete aggregate expression it can certify. The scope includes its + source, filters, grouping, windows and the union of all merged panes. +2. Planner combines that contract with the query's accuracy target (or an + allocated local target) to size the estimator. +3. Planner derives the readout guarantee from those committed parameters, + propagates it through the existing accuracy algebra, and uses the ordinary + candidate legality check. Cost ranking cannot override that check. + +`EstimatorContract::ClassicHll` asserts classic HLL with independent uniform +bucket hashing and an enforced maximum distinct population per complete +readout. The bounded linear-counting model supports maxima from 1 to 4096 and +precisions from 4 to 18. It is not an RSE-to-normal conversion. Sampled +cardinality and ERP observed maximum error do not establish the contract. + +Missing evidence preserves an unknown HLL failure probability. Invalid or +infeasible contracts cannot authorize the approximate result. Exact execution +remains available through normal planning. A contract does not apply to a +shared-grid grouping or another estimator implementation. + +The evidence provider is a trust boundary: source owners must establish its +assertions, not merely copy observed statistics into them. Public Planner tests +exercise evidence-to-sizing-to-guarantee behavior without a deployment-specific +cost or accuracy model. From 7796681277639fb1f44bb4cd93bdbb6877ce77bd Mon Sep 17 00:00:00 2001 From: zz_y Date: Thu, 24 Sep 2026 14:23:34 +0000 Subject: [PATCH 4/6] fix: align rebased cardinality planning with current IR API --- crates/asap-aware-mapping/src/replacement.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index c1bd3841..3db7ba3e 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -3008,7 +3008,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( @@ -3020,7 +3020,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( @@ -9646,7 +9646,7 @@ mod tests { let root = Rc::new(agg( vec![], AggIntent::Cardinality { - col: None, + cols: vec![], accuracy: target.clone(), }, metric_scan(&[]), @@ -9714,7 +9714,7 @@ mod tests { let query = Rc::new(agg( vec![], AggIntent::Cardinality { - col: None, + cols: vec![], accuracy: target.clone(), }, metric_scan(&[]), From 2d1005fc588d6a20e9d7c8d682d6b47bacd74dc8 Mon Sep 17 00:00:00 2001 From: zz_y Date: Thu, 24 Sep 2026 14:48:50 +0000 Subject: [PATCH 5/6] docs: describe the complete Planner accuracy model design --- docs/design_docs/concepts/accuracy-models.md | 300 ++++++++++++++++--- 1 file changed, 266 insertions(+), 34 deletions(-) diff --git a/docs/design_docs/concepts/accuracy-models.md b/docs/design_docs/concepts/accuracy-models.md index 85c07600..c811dd06 100644 --- a/docs/design_docs/concepts/accuracy-models.md +++ b/docs/design_docs/concepts/accuracy-models.md @@ -1,37 +1,269 @@ -# Accuracy models and source contracts +# Accuracy model design -Planner owns estimator accuracy, parameter sizing, propagation and candidate -legality. Deployments supply source evidence and cost information through the -existing interfaces; they do not need their own HLL accuracy or sizing model. +## 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 formulas also remain in candidate +construction. 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. + +## Organization and extension contract The `asap-aware-mapping::accuracy` module groups the shared algebra and budget -allocation, estimator implementations (`hll`), source-contract integration, -and cross-consumer reconciliation (`reconciliation`). Algorithm-specific -mathematics is not a separate candidate-selection rule. - -## Source evidence to a physical candidate - -1. A deployment implements `AccuracyEvidenceProvider::estimator_contract` for - the complete aggregate expression it can certify. The scope includes its - source, filters, grouping, windows and the union of all merged panes. -2. Planner combines that contract with the query's accuracy target (or an - allocated local target) to size the estimator. -3. Planner derives the readout guarantee from those committed parameters, - propagates it through the existing accuracy algebra, and uses the ordinary - candidate legality check. Cost ranking cannot override that check. - -`EstimatorContract::ClassicHll` asserts classic HLL with independent uniform -bucket hashing and an enforced maximum distinct population per complete -readout. The bounded linear-counting model supports maxima from 1 to 4096 and -precisions from 4 to 18. It is not an RSE-to-normal conversion. Sampled -cardinality and ERP observed maximum error do not establish the contract. - -Missing evidence preserves an unknown HLL failure probability. Invalid or -infeasible contracts cannot authorize the approximate result. Exact execution -remains available through normal planning. A contract does not apply to a -shared-grid grouping or another estimator implementation. - -The evidence provider is a trust boundary: source owners must establish its -assertions, not merely copy observed statistics into them. Public Planner tests -exercise evidence-to-sizing-to-guarantee behavior without a deployment-specific -cost or accuracy model. +allocation, estimator/source-contract integration, algorithm-specific models +such as `hll`, and cross-consumer `reconciliation`. 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). From 3f344ba4b2eb70057f87c9c1851da1febe71e975 Mon Sep 17 00:00:00 2001 From: zz_y Date: Thu, 24 Sep 2026 14:59:15 +0000 Subject: [PATCH 6/6] refactor: organize accuracy by evidence composition and estimator models --- .../src/accuracy/allocation.rs | 156 ++ .../src/accuracy/composition.rs | 1121 +++++++++++ .../src/accuracy/estimator.rs | 95 - .../src/accuracy/estimators/cardinality.rs | 30 + .../src/accuracy/estimators/cms.rs | 86 + .../src/accuracy/estimators/count_sketch.rs | 84 + .../src/accuracy/estimators/ddsketch.rs | 25 + .../src/accuracy/{ => estimators}/hll.rs | 56 +- .../src/accuracy/estimators/kll.rs | 76 + .../src/accuracy/estimators/mod.rs | 221 ++ .../src/accuracy/estimators/univmon.rs | 17 + .../src/accuracy/evidence.rs | 197 ++ crates/asap-aware-mapping/src/accuracy/mod.rs | 1786 +---------------- crates/asap-aware-mapping/src/replacement.rs | 128 +- docs/design_docs/concepts/accuracy-models.md | 69 +- 15 files changed, 2163 insertions(+), 1984 deletions(-) create mode 100644 crates/asap-aware-mapping/src/accuracy/allocation.rs create mode 100644 crates/asap-aware-mapping/src/accuracy/composition.rs delete mode 100644 crates/asap-aware-mapping/src/accuracy/estimator.rs create mode 100644 crates/asap-aware-mapping/src/accuracy/estimators/cardinality.rs create mode 100644 crates/asap-aware-mapping/src/accuracy/estimators/cms.rs create mode 100644 crates/asap-aware-mapping/src/accuracy/estimators/count_sketch.rs create mode 100644 crates/asap-aware-mapping/src/accuracy/estimators/ddsketch.rs rename crates/asap-aware-mapping/src/accuracy/{ => estimators}/hll.rs (82%) create mode 100644 crates/asap-aware-mapping/src/accuracy/estimators/kll.rs create mode 100644 crates/asap-aware-mapping/src/accuracy/estimators/mod.rs create mode 100644 crates/asap-aware-mapping/src/accuracy/estimators/univmon.rs create mode 100644 crates/asap-aware-mapping/src/accuracy/evidence.rs 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/estimator.rs b/crates/asap-aware-mapping/src/accuracy/estimator.rs deleted file mode 100644 index 6bb72f3d..00000000 --- a/crates/asap-aware-mapping/src/accuracy/estimator.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Source contracts are evidence; sizing and guarantees remain Planner-owned. -use super::*; -use asap_types::post_asap::GroupingStrategy; - -/// 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 }, -} - -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/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/hll.rs b/crates/asap-aware-mapping/src/accuracy/estimators/hll.rs similarity index 82% rename from crates/asap-aware-mapping/src/accuracy/hll.rs rename to crates/asap-aware-mapping/src/accuracy/estimators/hll.rs index d7abcd26..6a17e159 100644 --- a/crates/asap-aware-mapping/src/accuracy/hll.rs +++ b/crates/asap-aware-mapping/src/accuracy/estimators/hll.rs @@ -4,10 +4,28 @@ //! 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 asap_types::post_asap::{ - BoundExpr, ErrorMetric, GuaranteeSource, ProbabilityExpr, ResultGuarantee, -}; +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 @@ -98,6 +116,11 @@ impl ClassicHllConfidence { } } +/// 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::*; @@ -205,4 +228,31 @@ mod tests { 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 index 2c7f8c55..0c90ed91 100644 --- a/crates/asap-aware-mapping/src/accuracy/mod.rs +++ b/crates/asap-aware-mapping/src/accuracy/mod.rs @@ -1,221 +1,32 @@ -//! Planning-time accuracy algebra (issue #172): the [`AccuracyModel`] -//! extension point, its conservative default, and end-to-end -//! accuracy-budget allocation. +//! Planner accuracy interfaces and model dispatch. //! -//! ## 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. - -mod estimator; -pub mod hll; +//! 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(crate) use estimator::EstimatorAccuracy; -pub use estimator::EstimatorContract; + +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, }; -#[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 { - /// 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() - } - } -} - /// 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 @@ -252,12 +63,12 @@ pub trait AccuracyModel { stats: &PropagationStats, ) -> Result; - /// Does `guarantee` meet `target`? An unevaluable bound or probability - /// never satisfies anything. + /// 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 conservative, fail-closed default — see the module docs' table. +/// The built-in estimator and composition models, with conservative target checks. #[derive(Debug, Default, Clone, Copy)] pub struct DefaultAccuracyModel; @@ -266,592 +77,28 @@ pub struct DefaultAccuracyModel; /// 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`]. + /// Derive the guarantee for the committed estimator parameters and readout. 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(), - } - } + estimators::sketch_guarantee(algorithm, params, query) } } -/// `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, - } + composition::exact_operation_rule(operation) } - 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, - } + estimators::local_guarantee(family, query) } - fn propagate( &self, op: &CompositionOperator, @@ -859,238 +106,8 @@ impl AccuracyModel for DefaultAccuracyModel { 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())), - } + 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) @@ -1106,135 +123,9 @@ impl AccuracyModel for DefaultAccuracyModel { } } -// ── 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, @@ -1243,581 +134,6 @@ mod tests { 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 { @@ -1832,52 +148,4 @@ mod tests { 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/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 3db7ba3e..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}; @@ -369,8 +373,7 @@ 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::cost_model::{ raw_recompute_cost_rate, Cost, CostModel, CseCandidate, DefaultCostModel, @@ -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`] @@ -9401,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!( @@ -9685,7 +9573,7 @@ mod tests { else { panic!("HLL state") }; - let expected = crate::accuracy::hll::ClassicHllConfidence::new(128, 0.05) + let expected = crate::accuracy::estimators::hll::ClassicHllConfidence::new(128, 0.05) .unwrap() .precision(0.01) .unwrap(); diff --git a/docs/design_docs/concepts/accuracy-models.md b/docs/design_docs/concepts/accuracy-models.md index c811dd06..5353ef9e 100644 --- a/docs/design_docs/concepts/accuracy-models.md +++ b/docs/design_docs/concepts/accuracy-models.md @@ -131,8 +131,8 @@ 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 formulas also remain in candidate -construction. Accuracy validation is independent of those proposals. The new +`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 @@ -243,13 +243,68 @@ 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 groups the shared algebra and budget -allocation, estimator/source-contract integration, algorithm-specific models -such as `hll`, and cross-consumer `reconciliation`. Serializable guarantee and -metric types live in `asap-types` so planning, explanations and downstream -binding share the same 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: