From f47792fbfbd16506fbeeea3a8f482dae343a471b Mon Sep 17 00:00:00 2001 From: Zeying Zhu Date: Sat, 18 Apr 2026 14:01:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(sketch-db):=20=C2=A76.4=20=E2=80=94=20Accu?= =?UTF-8?q?racyProfile=20derived=20from=20AggregationConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the §6.4 hook that was stubbed since Phase 2a: every schema now knows the theoretical error / confidence bound of any query answer computed from its sketch, derived from the pinned `aggregation_type` + `parameters`. Users and controllers see "±ε with probability 1-δ" as first-class schema metadata instead of having to rederive from the sketch literature. ## What's landed `src/stores/sketch_db/accuracy.rs`: * `AccuracyKind` enum: `Exact` / `AdditiveFrequency` / `RelativeCardinality` / `RankQuantile` / `RelativeQuantile` — documents how to interpret ε in each case. * `AccuracyProfile { epsilon, delta, kind }` with serde support (snake_case kind tag). * `AccuracyProfile::derive(&AggregationConfig)` — pure function that reads the config and returns the textbook bound for: | Sketch | `kind` | ε formula | δ formula | |---|---|---|---| | Sum / Min / Max / Increase | `Exact` | 0 | 0 | | CountMinSketch(w, d) | `AdditiveFrequency` | e/w | 1/2^d | | CountSketch(w, d) | `AdditiveFrequency` | 1/√w | 1/2^d | | HLL(p) | `RelativeCardinality` | 1.04/√(2^p) | — (Gaussian std-dev) | | KLL(k) | `RankQuantile` | 2.296/√k | 0.01 | | DDSketch(α) | `RelativeQuantile` | α | 0 | Sources cited inline in each branch: Cormode-Muthukrishnan, Flajolet, Karnin-Lang-Liberty, Masson-Rim-Lee. ## Integration * `AggSchema::accuracy_profile()` — thin delegate to `AccuracyProfile::derive(&self.config)`. * `GET /api/v1/db/schemas` response includes an `accuracy_profile: {epsilon, delta, kind}` object per schema. The existing schemas endpoint test was extended to assert the field is present and correct for Sum aggs. Future work: `QueryResult` could carry an `AccuracyProfile` so every query answer self-describes its precision. Deferred because it's a wider change touching SimpleEngine's result surface; this PR lands the primitive so that wire-up is pure plumbing. ## Test plan - [x] 15 new unit tests covering: * Sum / Min / Max / Increase / Set / DeltaSet → exact * CMS default + explicit (w, d), bound = e/w + 1/2^d * CountSketch default + explicit, bound = 1/√w + 1/2^d * HLL default (p=14) + explicit, bound = 1.04/√(2^p) * KLL and HydraKLL, bound = 2.296/√k with δ=0.01 * DDSketch default (α=0.01) + explicit α * Legacy `SingleSubpopulation` / `MultipleSubpopulation` wrappers fall back to exact * serde roundtrip preserves `kind` tag as "relative_cardinality" * CMS vs CountSketch epsilon sanity-check for same (w, d) - [x] HTTP endpoint test extended to verify `accuracy_profile` shape on every entry. - [x] 692 lib tests pass (up from 677). - [x] clippy `--workspace --all-targets --tests -- -D warnings` clean. - [x] `cargo fmt -- --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/drivers/query/servers/http.rs | 11 + .../src/stores/sketch_db/accuracy.rs | 449 ++++++++++++++++++ asap-query-engine/src/stores/sketch_db/mod.rs | 2 + .../src/stores/sketch_db/schema.rs | 11 + 4 files changed, 473 insertions(+) create mode 100644 asap-query-engine/src/stores/sketch_db/accuracy.rs diff --git a/asap-query-engine/src/drivers/query/servers/http.rs b/asap-query-engine/src/drivers/query/servers/http.rs index 5ad577c2..977eff55 100644 --- a/asap-query-engine/src/drivers/query/servers/http.rs +++ b/asap-query-engine/src/drivers/query/servers/http.rs @@ -1251,6 +1251,15 @@ aggregations: assert_eq!(entries[1]["agg_id"], 2); assert_eq!(entries[1]["status"], "retired"); assert!(entries[1]["retired_at_ms"].is_u64()); + // Phase 6.4: accuracy_profile present on every schema. Sum + // is exact → ε = δ = 0, kind = "exact". + for e in entries { + let ap = &e["accuracy_profile"]; + assert!(ap.is_object(), "accuracy_profile should be an object"); + assert_eq!(ap["kind"], "exact", "Sum agg → exact"); + assert_eq!(ap["epsilon"], 0.0); + assert_eq!(ap["delta"], 0.0); + } // Filter: active only. let resp = client @@ -1938,6 +1947,7 @@ async fn handle_get_schemas( let mut entries: Vec = Vec::new(); for status in statuses { for s in schemas.list_by_status(*status) { + let accuracy = s.accuracy_profile(); entries.push(serde_json::json!({ "agg_id": s.agg_id, "metric_name": s.metric_name, @@ -1946,6 +1956,7 @@ async fn handle_get_schemas( "retired_at_ms": s.retired_at_ms, "expires_at_ms": s.expires_at_ms, "aggregation_type": format!("{:?}", s.config.aggregation_type), + "accuracy_profile": accuracy, })); } } diff --git a/asap-query-engine/src/stores/sketch_db/accuracy.rs b/asap-query-engine/src/stores/sketch_db/accuracy.rs new file mode 100644 index 00000000..32f93ca4 --- /dev/null +++ b/asap-query-engine/src/stores/sketch_db/accuracy.rs @@ -0,0 +1,449 @@ +//! `AccuracyProfile` — derived error / confidence bound for each +//! `AggregationConfig`. +//! +//! Implements §6.4 of the sketch DB design +//! ([`design-sketch-db.md`](../../../../../docs/design-sketch-db.md)). +//! Given the `aggregation_type` + `parameters` pinned on an +//! `AggSchema`, the registry can expose the theoretical accuracy +//! bound of every query answer computed from it — so users and +//! controllers see "this quantile is within ε relative error with +//! probability 1 - δ" as a first-class part of the schema, not a +//! number they have to rederive from the sketch literature. +//! +//! ## Scope of this module +//! +//! Pure derivation: `AccuracyProfile::derive(&AggregationConfig)` +//! looks at `aggregation_type` and the relevant entries in +//! `config.parameters` and returns an `AccuracyProfile`. No +//! runtime measurement, no sampling — just the textbook bound. +//! +//! These bounds are asymptotic / probabilistic worst-case +//! guarantees from the original sketch papers. Real error +//! distributions are often tighter; see §19 of the design doc +//! for empirical vs theoretical. For user-facing renderings +//! ("how far off might this answer be?") the theoretical bound +//! is the honest upper envelope. +//! +//! ## Bounds we encode +//! +//! | Sketch | `kind` | ε formula | δ formula | +//! |---|---|---|---| +//! | Sum / Min / Max / Increase | `Exact` | 0 | 0 | +//! | CountMinSketch(w, d) | `AdditiveFrequency` | e / w | 1 / 2^d | +//! | CountSketch(w, d) | `AdditiveFrequency` | 1 / √w | 1 / 2^d | +//! | HLL(p) | `RelativeCardinality` | 1.04 / √(2^p) | — (Gaussian std-dev) | +//! | KLL(k) | `RankQuantile` | ≈ 2.296 / √k (worst-case constant) | 1 / 100 (fixed) | +//! | DDSketch(α) | `RelativeQuantile` | α | 0 (deterministic α guarantee) | +//! +//! Constants are chosen to match the tighter published bounds +//! rather than loose textbook versions; sources are cited inline +//! in each branch of [`AccuracyProfile::derive`]. + +use serde::{Deserialize, Serialize}; + +use asap_types::aggregation_config::AggregationConfig; +use promql_utilities::query_logics::enums::AggregationType; + +/// How to interpret [`AccuracyProfile::epsilon`]. +/// +/// The kind drives the user-facing rendering ("±ε counts" vs +/// "±ε relative" vs "rank error ±ε·N" etc). It does NOT alter +/// the numerical ε; callers / UI layers format based on `kind`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccuracyKind { + /// ε = δ = 0. Answer is the true value. + Exact, + /// Additive frequency error, scaled by the stream's total N: + /// `|f̂(x) - f(x)| ≤ ε · N` with probability ≥ `1 - δ`. + /// Applies to CountMinSketch, CountSketch. + AdditiveFrequency, + /// Relative cardinality error: `|ĉ - c| / c ≤ ε` with + /// probability ≥ `1 - δ`. Applies to HLL. + RelativeCardinality, + /// Rank-based quantile error: the returned quantile's RANK + /// position differs from the true rank by at most `ε · N`. + /// Applies to KLL. + RankQuantile, + /// Relative quantile error: the returned quantile value is + /// within `ε · q_true` of the true quantile, where `q_true` + /// is the true value. Applies to DDSketch. + RelativeQuantile, +} + +/// Theoretical accuracy bound for an [`AggSchema`](super::AggSchema). +/// Attached to every schema; surfaced via HTTP endpoints and +/// (in a follow-up) the `QueryResult` that SimpleEngine returns. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct AccuracyProfile { + pub epsilon: f64, + pub delta: f64, + pub kind: AccuracyKind, +} + +impl AccuracyProfile { + /// ε = δ = 0. Used by exact aggregates (Sum, Min, Max, Increase). + pub fn exact() -> Self { + Self { + epsilon: 0.0, + delta: 0.0, + kind: AccuracyKind::Exact, + } + } + + /// Derive an [`AccuracyProfile`] from a pinned + /// [`AggregationConfig`]. Reads `aggregation_type` and any + /// necessary entries in `parameters`; falls back to exact for + /// unknown / legacy variants (harmless — the caller just gets + /// "0 error" rather than a panic). + pub fn derive(config: &AggregationConfig) -> Self { + match config.aggregation_type { + // Exact aggregates. + AggregationType::Sum + | AggregationType::Increase + | AggregationType::MinMax + | AggregationType::MultipleSum + | AggregationType::MultipleIncrease + | AggregationType::MultipleMinMax + | AggregationType::SetAggregator + | AggregationType::DeltaSetAggregator => Self::exact(), + + // CountMinSketch: classic Cormode-Muthukrishnan bound. + // ε = e/w, δ = 1/2^d with w = width, d = depth. We + // pull w from `parameters["col_num"]` and d from + // `parameters["row_num"]` because that's how the + // existing `accumulator_factory::cms_params` names + // them; fall back to the factory's (rows=4, cols=1000) + // defaults if absent. + AggregationType::CountMinSketch | AggregationType::CountMinSketchWithHeap => { + let (rows, cols) = cms_params(config); + // Using natural e ≈ 2.71828 for tighter bound. + // Source: Cormode & Muthukrishnan, "An improved + // data stream summary: the count-min sketch and + // its applications," J. Algorithms 55(1) 2005. + let epsilon = std::f64::consts::E / (cols as f64).max(1.0); + let delta = 0.5_f64.powi(rows as i32); + Self { + epsilon, + delta, + kind: AccuracyKind::AdditiveFrequency, + } + } + + // CountSketch: ε = 1/√w, δ = 1/2^d (Charikar-Chen- + // Farach-Colton). Signed counters → tighter epsilon + // than CMS but same confidence ramp with depth. + AggregationType::CountSketch => { + let (rows, cols) = cms_params(config); + let epsilon = 1.0 / (cols as f64).max(1.0).sqrt(); + let delta = 0.5_f64.powi(rows as i32); + Self { + epsilon, + delta, + kind: AccuracyKind::AdditiveFrequency, + } + } + + // HLL: std-dev ≈ 1.04/√m, m = 2^precision. Report + // this as relative error ε; δ is the Gaussian + // std-dev convention (stored as 0 because our δ + // field is "confidence parameter" not "variance"; + // future AccuracyKind::RelativeCardinality variant + // could carry the Gaussian flavor explicitly). + // Source: Flajolet et al., "HyperLogLog: the analysis + // of a near-optimal cardinality estimation algorithm," + // DMTCS 2007. + AggregationType::HLL => { + let p = hll_precision(config); + let m = (1u64 << p) as f64; + Self { + epsilon: 1.04 / m.sqrt(), + delta: 0.0, + kind: AccuracyKind::RelativeCardinality, + } + } + + // KLL: rank error ε = C/√k with δ ≤ 0.01 (fixed + // confidence; KLL's theoretical guarantee). Empirical + // C ≈ 2.296 for the standard floating-point KLL + // variant implemented here. + // Source: Karnin, Lang, Liberty. "Optimal quantile + // approximation in streams," FOCS 2016. + AggregationType::DatasketchesKLL | AggregationType::HydraKLL => { + let k = kll_k(config); + Self { + epsilon: 2.296 / (k as f64).max(1.0).sqrt(), + delta: 0.01, + kind: AccuracyKind::RankQuantile, + } + } + + // DDSketch: α is the relative quantile error directly + // — it's a design parameter of the sketch, not a + // probabilistic bound. δ = 0 (deterministic). + // Source: Masson, Rim, Lee. "DDSketch: a fast and + // fully-mergeable quantile sketch with relative-error + // guarantees," VLDB 2019. + AggregationType::DDSketch => { + let alpha = ddsketch_alpha(config); + Self { + epsilon: alpha, + delta: 0.0, + kind: AccuracyKind::RelativeQuantile, + } + } + + // Legacy / wrapper variants. Return exact — they are + // config-shape placeholders that dispatch to concrete + // aggregator types elsewhere; their accuracy profile + // depends on the sub_type, which the factory resolves + // at updater-construction time. Phase 6.4 v2 can walk + // sub_type to give a tighter answer. + AggregationType::SingleSubpopulation | AggregationType::MultipleSubpopulation => { + Self::exact() + } + } + } +} + +// Parameter extraction helpers. Kept file-local (not pub) because +// they duplicate tiny bits of `precompute_engine::accumulator_factory` +// and the backfill-vs-live separation rule (see that module's doc) +// says it's OK for them to drift — this module is the single +// authority on *accuracy*, not on *construction*. + +fn cms_params(config: &AggregationConfig) -> (u64, u64) { + let rows = config + .parameters + .get("row_num") + .and_then(|v| v.as_u64()) + .unwrap_or(4); + let cols = config + .parameters + .get("col_num") + .and_then(|v| v.as_u64()) + .unwrap_or(1000); + (rows, cols) +} + +fn hll_precision(config: &AggregationConfig) -> u32 { + config + .parameters + .get("precision") + .or_else(|| config.parameters.get("p")) + .and_then(|v| v.as_u64()) + .and_then(|v| u32::try_from(v).ok()) + .unwrap_or(14) +} + +fn kll_k(config: &AggregationConfig) -> u32 { + config + .parameters + .get("K") + .or_else(|| config.parameters.get("k")) + .and_then(|v| v.as_u64()) + .and_then(|v| u32::try_from(v).ok()) + .unwrap_or(200) +} + +fn ddsketch_alpha(config: &AggregationConfig) -> f64 { + config + .parameters + .get("alpha") + .and_then(|v| v.as_f64()) + .unwrap_or(0.01) +} + +#[cfg(test)] +mod tests { + use super::*; + use asap_types::enums::WindowType; + use promql_utilities::data_model::key_by_label_names::KeyByLabelNames; + use serde_json::{json, Value}; + use std::collections::HashMap; + + fn base_config(agg_type: AggregationType, params: HashMap) -> AggregationConfig { + AggregationConfig::new( + 1, + agg_type, + String::new(), + params, + KeyByLabelNames::empty(), + KeyByLabelNames::empty(), + KeyByLabelNames::empty(), + String::new(), + 60, + 60, + WindowType::Tumbling, + String::new(), + "m".to_string(), + None, + None, + None, + None, + ) + } + + #[test] + fn sum_is_exact() { + let p = AccuracyProfile::derive(&base_config(AggregationType::Sum, HashMap::new())); + assert_eq!(p.kind, AccuracyKind::Exact); + assert_eq!(p.epsilon, 0.0); + assert_eq!(p.delta, 0.0); + } + + #[test] + fn min_max_increase_are_exact() { + for t in [AggregationType::MinMax, AggregationType::Increase] { + let p = AccuracyProfile::derive(&base_config(t, HashMap::new())); + assert_eq!(p.kind, AccuracyKind::Exact); + } + } + + #[test] + fn cms_epsilon_is_e_over_w() { + let mut params = HashMap::new(); + params.insert("row_num".to_string(), json!(5)); + params.insert("col_num".to_string(), json!(2718)); + let p = AccuracyProfile::derive(&base_config(AggregationType::CountMinSketch, params)); + // e / 2718 ≈ 0.0010001 — very close to 0.001. + assert_eq!(p.kind, AccuracyKind::AdditiveFrequency); + assert!((p.epsilon - std::f64::consts::E / 2718.0).abs() < 1e-12); + // δ = 1/2^5 = 0.03125 + assert!((p.delta - 0.03125).abs() < 1e-12); + } + + #[test] + fn cms_uses_defaults_when_params_absent() { + let p = AccuracyProfile::derive(&base_config( + AggregationType::CountMinSketch, + HashMap::new(), + )); + // Defaults rows=4, cols=1000 per accumulator_factory. + assert!((p.epsilon - std::f64::consts::E / 1000.0).abs() < 1e-12); + assert!((p.delta - 0.0625).abs() < 1e-12); // 1/16 + } + + #[test] + fn countsketch_epsilon_is_one_over_sqrt_w() { + let mut params = HashMap::new(); + params.insert("row_num".to_string(), json!(4)); + params.insert("col_num".to_string(), json!(100)); + let p = AccuracyProfile::derive(&base_config(AggregationType::CountSketch, params)); + assert_eq!(p.kind, AccuracyKind::AdditiveFrequency); + assert!((p.epsilon - 0.1).abs() < 1e-9); // 1/√100 = 0.1 + assert!((p.delta - 0.0625).abs() < 1e-12); // 1/2^4 + } + + #[test] + fn hll_epsilon_matches_flajolet_bound() { + let mut params = HashMap::new(); + params.insert("precision".to_string(), json!(14)); + let p = AccuracyProfile::derive(&base_config(AggregationType::HLL, params)); + assert_eq!(p.kind, AccuracyKind::RelativeCardinality); + // 1.04 / √16384 = 1.04 / 128 = 0.008125 + assert!((p.epsilon - 0.008125).abs() < 1e-9); + } + + #[test] + fn hll_uses_default_precision_14() { + let p = AccuracyProfile::derive(&base_config(AggregationType::HLL, HashMap::new())); + assert!((p.epsilon - 0.008125).abs() < 1e-9); + } + + #[test] + fn kll_epsilon_matches_karnin_lang_liberty_bound() { + let mut params = HashMap::new(); + params.insert("K".to_string(), json!(200)); + let p = AccuracyProfile::derive(&base_config(AggregationType::DatasketchesKLL, params)); + assert_eq!(p.kind, AccuracyKind::RankQuantile); + // 2.296 / √200 ≈ 0.16235 + assert!((p.epsilon - 2.296 / 200.0_f64.sqrt()).abs() < 1e-12); + assert!((p.delta - 0.01).abs() < 1e-12); + } + + #[test] + fn hydra_kll_follows_the_same_kll_bound() { + let mut params = HashMap::new(); + params.insert("k".to_string(), json!(400)); + let p = AccuracyProfile::derive(&base_config(AggregationType::HydraKLL, params)); + // 2.296 / √400 = 2.296 / 20 = 0.1148 + assert!((p.epsilon - 0.1148).abs() < 1e-9); + } + + #[test] + fn ddsketch_epsilon_is_alpha_directly() { + let mut params = HashMap::new(); + params.insert("alpha".to_string(), json!(0.02)); + let p = AccuracyProfile::derive(&base_config(AggregationType::DDSketch, params)); + assert_eq!(p.kind, AccuracyKind::RelativeQuantile); + assert_eq!(p.epsilon, 0.02); + assert_eq!(p.delta, 0.0); + } + + #[test] + fn ddsketch_uses_default_alpha_0_01() { + let p = AccuracyProfile::derive(&base_config(AggregationType::DDSketch, HashMap::new())); + assert_eq!(p.epsilon, 0.01); + } + + #[test] + fn set_aggregators_are_exact() { + for t in [ + AggregationType::SetAggregator, + AggregationType::DeltaSetAggregator, + ] { + let p = AccuracyProfile::derive(&base_config(t, HashMap::new())); + assert_eq!(p.kind, AccuracyKind::Exact); + } + } + + #[test] + fn legacy_wrapper_types_fall_back_to_exact() { + // `SingleSubpopulation` / `MultipleSubpopulation` are + // config-shape wrappers whose real type is in sub_type. + // Without resolving sub_type we return exact (harmless + // lower bound). Phase 6.4 v2 may tighten this. + for t in [ + AggregationType::SingleSubpopulation, + AggregationType::MultipleSubpopulation, + ] { + let p = AccuracyProfile::derive(&base_config(t, HashMap::new())); + assert_eq!(p.kind, AccuracyKind::Exact); + } + } + + #[test] + fn accuracy_profile_roundtrips_through_serde() { + let input = AccuracyProfile { + epsilon: 0.008125, + delta: 0.0, + kind: AccuracyKind::RelativeCardinality, + }; + let json = serde_json::to_string(&input).unwrap(); + assert!(json.contains("\"relative_cardinality\"")); + let back: AccuracyProfile = serde_json::from_str(&json).unwrap(); + assert_eq!(back, input); + } + + #[test] + fn cms_and_countsketch_differ_by_sqrt_e() { + // Sanity check: for the same (w, d), CountSketch's ε is + // smaller by a factor of √e / √w × 1/√w = 1/(√e) — + // i.e. CountSketch is a factor ~1.65 tighter than CMS on + // epsilon alone. Confirms the bounds are not copy-pasted. + let mut params = HashMap::new(); + params.insert("row_num".to_string(), json!(4)); + params.insert("col_num".to_string(), json!(10000)); + let cms = AccuracyProfile::derive(&base_config( + AggregationType::CountMinSketch, + params.clone(), + )); + let cs = AccuracyProfile::derive(&base_config(AggregationType::CountSketch, params)); + // cms.epsilon = e/10000 ≈ 2.718e-4 + // cs.epsilon = 1/√10000 = 0.01 = 1e-2 + // So cms < cs (for w=10000). They cross at w = e. + assert!(cms.epsilon < cs.epsilon); + assert!(cs.epsilon > 0.0); + } +} diff --git a/asap-query-engine/src/stores/sketch_db/mod.rs b/asap-query-engine/src/stores/sketch_db/mod.rs index 4f872b2b..1bb5b530 100644 --- a/asap-query-engine/src/stores/sketch_db/mod.rs +++ b/asap-query-engine/src/stores/sketch_db/mod.rs @@ -28,6 +28,7 @@ //! real rebuild logic (5e), and coverage integration with the //! query path (5f). +pub mod accuracy; pub mod backfill; pub mod backfill_processor; pub mod backfill_service; @@ -38,6 +39,7 @@ pub mod raw_sample_reader; pub mod schema; pub mod simple_map_store; +pub use accuracy::{AccuracyKind, AccuracyProfile}; pub use backfill::{ BackfillJob, BackfillRegistry, BackfillSource, BackfillStatus, Coverage, CreateError, }; diff --git a/asap-query-engine/src/stores/sketch_db/schema.rs b/asap-query-engine/src/stores/sketch_db/schema.rs index 153313ab..3a854f08 100644 --- a/asap-query-engine/src/stores/sketch_db/schema.rs +++ b/asap-query-engine/src/stores/sketch_db/schema.rs @@ -166,6 +166,17 @@ impl AggSchema { pub fn is_writable(&self) -> bool { matches!(self.status(), AggStatus::Active) } + + /// §6.4 accuracy profile: theoretical error / confidence bound + /// of any query answer computed from this schema's sketch, + /// derived from `config.aggregation_type` + `config.parameters`. + /// Exposed so HTTP endpoints and future `QueryResult` + /// enrichment can return "±ε with probability 1 - δ" as a + /// first-class answer attribute, instead of the user having to + /// rederive the bound from the sketch literature. + pub fn accuracy_profile(&self) -> super::accuracy::AccuracyProfile { + super::accuracy::AccuracyProfile::derive(&self.config) + } } /// Default retention for retired schemas — one hour. A future