From f0b614cf56088c51a3b0e4783cbb62d8a5c10e6b Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:05:32 +0000 Subject: [PATCH 1/8] feat(accuracy): a distinct count over a source's own rows binds its failure probability at the source's distinct bound (#1207 PR 8) distinct_count_guarantee_over_source reads classic HLL's and a distinct-count UnivMon's guarantees (kept dead since PR 4 for this) and binds their failure probability at SourceFacts::max_distinct_per_evaluation, for Stage 3's (K). --- crates/accuracy/src/estimators/hll.rs | 1 - crates/accuracy/src/estimators/mod.rs | 75 +++++++++++++++++++++++ crates/accuracy/src/estimators/univmon.rs | 2 - 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/accuracy/src/estimators/hll.rs b/crates/accuracy/src/estimators/hll.rs index 6aa82ac57..277ef3792 100644 --- a/crates/accuracy/src/estimators/hll.rs +++ b/crates/accuracy/src/estimators/hll.rs @@ -81,7 +81,6 @@ pub fn parse_classic_failure_statistic(statistic: &str) -> Option<(u8, f64, &str /// distinct bound ([`classic_failure_statistic`]). The failure probability /// is per output row: each group's estimate fails with at most it. `None` /// for a precision outside the contract's. -#[allow(dead_code)] // Read again in #1207 PR 8: δ of a distinct count over a source's own rows, with θ. pub(crate) fn classic_guarantee( precision: u8, relative_error: Option, diff --git a/crates/accuracy/src/estimators/mod.rs b/crates/accuracy/src/estimators/mod.rs index 131e09d49..886438343 100644 --- a/crates/accuracy/src/estimators/mod.rs +++ b/crates/accuracy/src/estimators/mod.rs @@ -86,6 +86,49 @@ pub fn local_guarantee( | FieldDataType::StatModel(..) => None, } } +/// The guarantee of a distinct count read from a summary with parameters +/// `parameters` built over the own rows of one source `source` (behind +/// filters and time windows at most), whose distinct items per evaluation +/// are at most `max_distinct` +/// (`SourceFacts::max_distinct_per_evaluation`): classic HLL's +/// ([`hll::classic_guarantee`]) or a distinct-count UnivMon's +/// ([`univmon::distinct_guarantee`]), with its failure probability bound +/// at `max_distinct` (1, no certificate, where the contract does not cover +/// it). `None` for other parameters and for a shape the contracts do not +/// cover; the generic guarantee of [`local_guarantee`] then applies, whose +/// failure probability is unknown. +pub fn distinct_count_guarantee_over_source( + parameters: &SketchParams, + source: &str, + max_distinct: u64, +) -> Option { + let guarantee = match parameters { + SketchParams::Hll { + precision, + relative_error, + } => hll::classic_guarantee(*precision, *relative_error, source)?, + SketchParams::UnivMon { .. } => univmon::distinct_guarantee(parameters, source)?, + _ => return None, + }; + Some(guarantee.bind(&|statistic| { + if let Some((precision, relative_error, _)) = + hll::parse_classic_failure_statistic(statistic) + { + return Some(hll::classic_failure_probability( + precision, + relative_error, + max_distinct, + )); + } + let (heap_size, layers, _) = univmon::parse_incomplete_top_layer_statistic(statistic)?; + Some(univmon::incomplete_top_layer_probability( + heap_size, + layers, + max_distinct, + )) + })) +} + /// `params` with the parameters its per-value failure probability depends /// on enlarged, never reduced, so that each value it answers fails with /// probability at most `delta` and its error bound is unchanged or smaller @@ -587,4 +630,36 @@ mod tests { assert!(local_guarantee(&exact, None, &total).is_some(), "{exact:?}"); } } + + /// A distinct count over a source's own rows gets classic HLL's or a + /// distinct-count UnivMon's failure probability at the source's + /// distinct bound: certified (below 1) for a small bound, 1 (no + /// certificate) past the contract's range; other parameters get none. + #[test] + fn a_distinct_count_over_a_source_binds_its_failure_probability() { + let hll = asap_types::stages::parameter_list(SketchAlgorithm::Hll)[0].clone(); + let univmon = asap_types::stages::parameter_list(SketchAlgorithm::UnivMon) + .iter() + .find(|p| { + matches!( + p, + SketchParams::UnivMon { + distinct_failure_probability: Some(_), + .. + } + ) + }) + .unwrap() + .clone(); + for parameters in [&hll, &univmon] { + let small = distinct_count_guarantee_over_source(parameters, "flows", 100).unwrap(); + assert_eq!(small.spec.accuracy_metric, AccuracyMetric::Relative); + let delta = small.spec.failure_probability.evaluate().unwrap(); + assert!(delta < 1.0, "{parameters:?}: {delta}"); + let huge = distinct_count_guarantee_over_source(parameters, "flows", u64::MAX).unwrap(); + assert_eq!(huge.spec.failure_probability.evaluate(), Some(1.0)); + } + let kll = asap_types::stages::parameter_list(SketchAlgorithm::Kll)[0].clone(); + assert!(distinct_count_guarantee_over_source(&kll, "flows", 1_000).is_none()); + } } diff --git a/crates/accuracy/src/estimators/univmon.rs b/crates/accuracy/src/estimators/univmon.rs index 6b34b8864..22f629f56 100644 --- a/crates/accuracy/src/estimators/univmon.rs +++ b/crates/accuracy/src/estimators/univmon.rs @@ -219,7 +219,6 @@ fn distinct_heap(eps: f64, delta: f64) -> u32 { /// The statistic a UnivMon distinct count over `source` leaves its /// top-layer failure probability as. -#[allow(dead_code)] // Read again in #1207 PR 8: δ of a distinct count over a source's own rows, with θ. pub(crate) fn incomplete_top_layer_statistic(heap_size: u32, layers: u8, source: &str) -> String { format!("{INCOMPLETE_TOP_LAYER}[k={heap_size},layers={layers}]({MAX_DISTINCT}[{source}])") } @@ -264,7 +263,6 @@ pub fn incomplete_top_layer_probability(heap_size: u32, layers: u8, max_distinct /// `δ_c` plus the chance of an incomplete top layer, left symbolic in the /// source's distinct bound. `None` for a shape that was not sized for a /// distinct count, a heap under 8, or a shape the library would not build. -#[allow(dead_code)] // Read again in #1207 PR 8: δ of a distinct count over a source's own rows, with θ. pub(crate) fn distinct_guarantee(params: &SketchParams, source: &str) -> Option { let SketchParams::UnivMon { heap_size, From 2ecb770deb1abdd7def7c452b3434ba73c8d6636 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:05:33 +0000 Subject: [PATCH 2/8] feat(stage3): constraint (K), accuracy at each query root (#1207 PR 8) CandidateErrorModel (milp/error_bounds.rs): each physical candidate's own guarantee (a sketch state's: the largest error per metric and failure probability of the statistics read from its group; none for a read whose bound needs values not proven non-negative, a windowed or merged state that does not keep the guarantee, or a family without an error model), its error contract per metric (error_propagation), and ceilings of E and D used as M. AccuracyVariables E/F per execution and metric, D/H, U (no guarantee); accuracy_constraints: E >= eps_loc + sum beta F, F >= E_u - M(1-y), the metric rule and validity caps on selected edges, D by a union bound, and the root checks E_v <= eps_q, D_v <= delta_q / n, U_v = 0 when z = 1, for every query the accuracy model does not accept regardless of its guarantee. After the solve, each query's composed guarantee is checked with the model and evidence; an output none accepts is SelectedPlanFailedCheck. AccuracyCheckInputs carries the front ends' query roots (G_q and the accept-regardless policy). Tests: #1207's problem example, the DDSketch sign condition, failure probabilities adding, big-M only on selected edges, and the reference enumeration under (K). --- crates/devtools/src/planner_fuzz/mod.rs | 4 +- crates/devtools/src/planning_stages.rs | 2 +- crates/planner/src/pass.rs | 11 +- crates/planner/src/run.rs | 2 +- crates/stage3-plan-selection/src/lib.rs | 8 +- .../src/milp/error_bounds.rs | 716 ++++++++++++++++++ crates/stage3-plan-selection/src/milp/mod.rs | 1 + .../stage3-plan-selection/src/milp/model.rs | 286 ++++++- .../src/milp/model/tests.rs | 628 ++++++++++++++- crates/stage3-plan-selection/src/models.rs | 15 +- crates/stage3-plan-selection/src/report.rs | 15 +- crates/stage3-plan-selection/src/select.rs | 245 ++++-- .../design_docs/proposals/planner-layering.md | 45 +- 13 files changed, 1847 insertions(+), 131 deletions(-) create mode 100644 crates/stage3-plan-selection/src/milp/error_bounds.rs diff --git a/crates/devtools/src/planner_fuzz/mod.rs b/crates/devtools/src/planner_fuzz/mod.rs index 457bea70a..9823c4b8b 100644 --- a/crates/devtools/src/planner_fuzz/mod.rs +++ b/crates/devtools/src/planner_fuzz/mod.rs @@ -458,8 +458,8 @@ impl Planner { /// summary at the θ its consumers' parameter floors allow. The cost is /// that plan's, and `derived_root` the nodes of the realized plan only /// the last root reads. `last_root_is_key_filter` is accepted for the - /// relations' interface; accuracy is not composed again here (constraint - /// (K), #1207 PR 8). + /// relations' interface; Stage 3 checks accuracy at the roots + /// (constraint (K)). pub fn price( &self, workload: &FuzzWorkload, diff --git a/crates/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs index 8fcd9e678..9e6b24c51 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -348,7 +348,7 @@ pub fn run_stages_until( &physical, entries, data, - &models.accuracy_check_inputs(), + &models.accuracy_check_inputs(&roots), models.cost_inputs, &realizer, ) { diff --git a/crates/planner/src/pass.rs b/crates/planner/src/pass.rs index d8b310c66..3728308d3 100644 --- a/crates/planner/src/pass.rs +++ b/crates/planner/src/pass.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::rc::Rc; -use asap_types::ir::OperatorNode; +use asap_types::ir::{OperatorNode, QueryRoot}; use asap_types::workload::parsed_workload::ParsedWorkload; use asap_types::workload::WorkloadError; @@ -105,9 +105,13 @@ impl<'a> PlanningModels<'a> { } } - /// Stage 3's accuracy check inputs: `evidence`, and `accuracy`, or + /// Stage 3's accuracy check inputs over the front ends' `query_roots` + /// (by query): `evidence`, and `accuracy`, or /// [`IgnoreAccuracyGuarantees`] with `ignore_accuracy_guarantees`. - pub fn accuracy_check_inputs(&self) -> AccuracyCheckInputs<'a> { + pub fn accuracy_check_inputs<'b>(&self, query_roots: &'b [QueryRoot]) -> AccuracyCheckInputs<'b> + where + 'a: 'b, + { AccuracyCheckInputs { model: if self.ignore_accuracy_guarantees { &IGNORE_ACCURACY_GUARANTEES @@ -115,6 +119,7 @@ impl<'a> PlanningModels<'a> { self.accuracy }, evidence: self.evidence, + query_roots, } } diff --git a/crates/planner/src/run.rs b/crates/planner/src/run.rs index 0d473f80b..cdf388487 100644 --- a/crates/planner/src/run.rs +++ b/crates/planner/src/run.rs @@ -179,7 +179,7 @@ pub fn run_planning( physical, entries, data, - &models.accuracy_check_inputs(), + &models.accuracy_check_inputs(&roots), models.cost_inputs, &realizer, ); diff --git a/crates/stage3-plan-selection/src/lib.rs b/crates/stage3-plan-selection/src/lib.rs index 744552c43..49a4ac395 100644 --- a/crates/stage3-plan-selection/src/lib.rs +++ b/crates/stage3-plan-selection/src/lib.rs @@ -12,8 +12,12 @@ //! $y$ exists only on admissible edges) and (R). It decodes $\{x = 1\}$ and //! $\{y = 1\}$ into a `DagOfSelectedCandidates` (one node per selected //! execution), which the injected realizer builds. Accuracy at each query -//! root, constraint (K), with per-candidate error bounds $E_c$ and $D_c$ -//! (section 4), is #1207 PR 8. Costs come from `asap-cost`. +//! root is constraint (K) (section 4): error bounds $E_{\kappa,c,m}$ per +//! execution and metric and failure probabilities $D_{\kappa,c}$, big-M on +//! the selected edges, root checks against each query's target +//! ([`crate::milp::error_bounds`]); the selected plan's composed guarantee +//! is checked again with the deployment's accuracy model. Costs come from +//! `asap-cost`. mod execution_cost; mod milp; diff --git a/crates/stage3-plan-selection/src/milp/error_bounds.rs b/crates/stage3-plan-selection/src/milp/error_bounds.rs new file mode 100644 index 000000000..b7dffe8fd --- /dev/null +++ b/crates/stage3-plan-selection/src/milp/error_bounds.rs @@ -0,0 +1,716 @@ +//! What constraint (K) reads (concepts doc, section 4): each physical +//! candidate's own guarantee $\varepsilon^{\mathrm{loc}}_c$, +//! $\delta^{\mathrm{loc}}_c$, its error contract $\beta_{c,i}$, $b_{c,i}$ +//! per metric (`LogicalRequirements::error_propagation`), the largest +//! error bound it can carry (the big-M constants $M$), and each query's +//! limits at its root ($\varepsilon_q$, $\delta_q$). +//! +//! Errors are kept per metric (absolute, absolute in either stream norm, +//! relative, rank): $E_{c,m}$. A consumer's contract in one metric says +//! nothing about another, so an input error in a metric its contract does +//! not carry must be zero when the edge is selected (the metric rule), and +//! a root accepts only the metrics its target accepts. +//! +//! A sketch state's own guarantee is the error of reading it: the +//! statistics read from its group, directly or through merges +//! ([`statistics_read`]), each through `asap_accuracy`'s local guarantee +//! of its $\boldsymbol\theta$; per metric the largest of them, and the +//! largest failure probability. A read whose guarantee needs a fact the +//! data workload does not declare (non-negative values for a Count-Min +//! count or a DDSketch interior quantile) has no guarantee. + +use asap_accuracy::estimators::{distinct_count_guarantee_over_source, local_guarantee}; +use asap_accuracy::{AccuracyModel, BuiltinAccuracyModel}; +use asap_types::ir::operator::AggIntent; +use asap_types::ir::properties::{AccuracyMetric, AccuracyTarget}; +use asap_types::ir::scalar::ColumnRef; +use asap_types::ir::schema::{ + FamilyCapability, FieldDataType, MergedError, SketchAlgorithm, SketchParams, SketchStatistic, + SummaryInputExpr, SummaryUpdate, WeightDomain, +}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, QueryRoot, ScalarExpr}; +use asap_types::stages::{ + DagOfSelectedCandidates, ErrorPropagation, LogicalRequirements, Memo, MemoGroupId, + PhysicalASAPMemoCandidates, PhysicalCandidateId, +}; +use asap_types::workload::bounds::{ + confirmed_weights, declared_non_negative, series_read, source_name, values_per_result, +}; +use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; + +/// The metrics errors are kept in, indexed by position. +pub(crate) const METRICS: [AccuracyMetric; METRIC_COUNT] = [ + AccuracyMetric::ABSOLUTE, + AccuracyMetric::ABSOLUTE_STREAM_L1, + AccuracyMetric::ABSOLUTE_STREAM_L2, + AccuracyMetric::Relative, + AccuracyMetric::Rank, +]; +pub(crate) const METRIC_COUNT: usize = 5; + +/// The relative tolerance of the built-in target check +/// (`asap_accuracy`'s `satisfies`): a $\boldsymbol\theta$ sized to meet +/// $\varepsilon$ exactly is not rejected by floating-point noise. +const TARGET_TOLERANCE: f64 = 1e-9; + +/// The largest error bound or failure probability a ceiling is carried +/// to: a larger one only rejects more plans (concepts doc: $M$ is any +/// upper bound; a smaller one can only make (K) stricter). +const CEILING_CAP: f64 = 1e6; + +/// The index of `metric` in [`METRICS`]. +fn metric_index(metric: AccuracyMetric) -> usize { + METRICS + .iter() + .position(|&known| known == metric) + .expect("every metric a guarantee states is in METRICS") +} + +/// One candidate's own guarantee. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct OwnGuarantee { + /// $\varepsilon^{\mathrm{loc}}_{c,m}$, by metric index. + pub(crate) error_bound: [f64; METRIC_COUNT], + /// $\delta^{\mathrm{loc}}_c$; `None` when its guarantee leaves it + /// unknown (counted as 1 by (K)). + pub(crate) failure_probability: Option, + /// Its output carries an approximation without a guarantee the + /// planner can state (a read whose family has no error model, a merge + /// that does not keep the guarantee, an unproven sign). + pub(crate) without_guarantee: bool, +} + +impl OwnGuarantee { + const EXACT: Self = Self { + error_bound: [0.0; METRIC_COUNT], + failure_probability: Some(0.0), + without_guarantee: false, + }; +} + +/// Each physical candidate's error model, by [`PhysicalCandidateId`] +/// ([`Self::new`]). +pub(crate) struct CandidateErrorModel { + own: Vec, + /// `error_propagation(c, m)` by metric index: $\beta_{c,i}$ and + /// $b_{c,i}$, `None` without a contract in that metric. + propagation: Vec<[Option; METRIC_COUNT]>, + /// Carries every metric with factor 1 on every input: the only + /// consumers that carry an output without a guarantee on. + value_preserving: Vec, + /// $|\mathcal I(c)|$. + input_count: Vec, + /// The largest $E_{c,m}$ any selection gives (capped at + /// [`CEILING_CAP`]); 0 when `c` never carries an `m` error. + error_bound_ceiling: Vec<[f64; METRIC_COUNT]>, + /// The largest $D_c$ any selection gives (capped). + failure_probability_ceiling: Vec, + /// Whether some selection gives `c`'s output a part without a + /// guarantee. + may_lack_guarantee: Vec, +} + +impl CandidateErrorModel { + /// The error model of `physical`'s candidates, the data workload + /// `data` answering the facts their guarantees need (values' signs, + /// a source's distinct bound). + pub(crate) fn new(physical: &PhysicalASAPMemoCandidates, data: &DataWorkload) -> Self { + let memo = &physical.logical_candidates.memo; + let operator = |c: usize| { + &memo + .candidate(physical.physical_candidates[c].logical_candidate_id) + .expression + .operator + }; + let count = physical.physical_candidates.len(); + let mut own = Vec::with_capacity(count); + let mut propagation = Vec::with_capacity(count); + let mut value_preserving = Vec::with_capacity(count); + let mut input_count = Vec::with_capacity(count); + for (c, candidate) in physical.physical_candidates.iter().enumerate() { + let logical = memo.candidate(candidate.logical_candidate_id); + own.push(own_guarantee( + memo, + candidate.logical_candidate_id.group, + operator(c), + logical.logical_properties.parameters(), + logical.logical_properties.window_layout().is_some(), + data, + )); + let contracts: [Option; METRIC_COUNT] = std::array::from_fn(|m| { + LogicalRequirements::error_propagation(operator(c), METRICS[m]) + }); + value_preserving.push(contracts.iter().all(|contract| { + contract + .as_ref() + .is_some_and(|p| p.factor_per_input.iter().all(|&factor| factor == 1.0)) + })); + propagation.push(contracts); + input_count.push(operator(c).children().len()); + } + let mut model = Self { + own, + propagation, + value_preserving, + input_count, + error_bound_ceiling: Vec::new(), + failure_probability_ceiling: Vec::new(), + may_lack_guarantee: Vec::new(), + }; + model.set_ceilings(physical); + model + } + + /// The ceilings over `physical`'s admissible edges: rounds of + /// $\bar E_{c,m} = \varepsilon^{\mathrm{loc}}_{c,m} + \sum_i + /// \beta_{c,i,m} \max_{u \in P_{c,i}} \bar E_{u,m}$ (an input whose + /// contract does not carry $m$ adds nothing: (K) makes it zero), and + /// likewise for $D$ and the lack of a guarantee, until nothing changes. + /// A selection is acyclic, so its error is reached within as many + /// rounds as there are candidates; the rounds stop there. + fn set_ceilings(&mut self, physical: &PhysicalASAPMemoCandidates) { + let count = self.own.len(); + let edges: Vec<_> = physical.edges().map(|(_, edge)| edge).collect(); + let mut error = vec![[0.0; METRIC_COUNT]; count]; + let mut failure = vec![0.0; count]; + let mut lacks = vec![false; count]; + for _ in 0..count.max(1) { + let mut changed = false; + for c in 0..count { + let own = &self.own[c]; + let mut next_error = own.error_bound; + let mut next_failure = own.failure_probability.unwrap_or(1.0); + let mut next_lacks = own.without_guarantee; + for input in 0..self.input_count[c] { + let producers = physical + .admissible_edges_into(PhysicalCandidateId(c), input) + .iter() + .map(|edge| edges[edge.0].producer_candidate_id.0); + let mut largest_error = [0.0_f64; METRIC_COUNT]; + let mut largest_failure = 0.0_f64; + let mut any_lacks = false; + for u in producers { + for m in 0..METRIC_COUNT { + largest_error[m] = largest_error[m].max(error[u][m]); + } + largest_failure = largest_failure.max(failure[u]); + any_lacks |= lacks[u]; + } + for (m, largest) in largest_error.iter().enumerate() { + next_error[m] += self.factor(c, input, m).unwrap_or(0.0) * largest; + } + if self.propagates_failure(c, input) { + next_failure += largest_failure; + } + next_lacks |= any_lacks && self.value_preserving[c]; + } + for m in 0..METRIC_COUNT { + let next = next_error[m].min(CEILING_CAP); + if next > error[c][m] { + error[c][m] = next; + changed = true; + } + } + let next_failure = next_failure.min(CEILING_CAP); + if next_failure > failure[c] { + failure[c] = next_failure; + changed = true; + } + if next_lacks && !lacks[c] { + lacks[c] = true; + changed = true; + } + } + if !changed { + break; + } + } + self.error_bound_ceiling = error; + self.failure_probability_ceiling = failure; + self.may_lack_guarantee = lacks; + } + + /// $\beta_{c,i}$ in metric `m`, `None` without a contract in it. + pub(crate) fn factor(&self, c: usize, input: usize, m: usize) -> Option { + self.propagation[c][m] + .as_ref() + .map(|p| p.factor_per_input[input]) + } + + /// $b_{c,i}$ in metric `m`: the input error the linear bound holds + /// under, `None` when it holds everywhere or there is no contract. + pub(crate) fn validity_cap(&self, c: usize, input: usize, m: usize) -> Option { + self.propagation[c][m] + .as_ref() + .and_then(|p| p.validity_cap_per_input[input]) + } + + /// Whether a failure of input `input`'s producer can fail `c`'s + /// output: some metric reaches it with a positive factor, or `c` + /// carries an output without a guarantee on. + pub(crate) fn propagates_failure(&self, c: usize, input: usize) -> bool { + self.value_preserving[c] + || (0..METRIC_COUNT).any(|m| self.factor(c, input, m).is_some_and(|f| f > 0.0)) + } + + pub(crate) fn own(&self, c: usize) -> &OwnGuarantee { + &self.own[c] + } + + pub(crate) fn value_preserving(&self, c: usize) -> bool { + self.value_preserving[c] + } + + /// $M$ of $E_{c,m}$; 0 when `c` never carries an `m` error. + pub(crate) fn error_bound_ceiling(&self, c: usize, m: usize) -> f64 { + self.error_bound_ceiling[c][m] + } + + /// $M$ of $D_c$. + pub(crate) fn failure_probability_ceiling(&self, c: usize) -> f64 { + self.failure_probability_ceiling[c] + } + + pub(crate) fn may_lack_guarantee(&self, c: usize) -> bool { + self.may_lack_guarantee[c] + } +} + +impl CandidateErrorModel { + /// The composed error of every node of `selected` (section 4: the + /// root conditions over the selected edges, with no big-M), by + /// `SelectedNodeId`: its own guarantee plus $\beta$ times each input's, + /// failure probabilities by a union bound. An input error the + /// consumer's contract does not carry (the metric rule), or one over its + /// validity cap, leaves the output without a guarantee. + pub(crate) fn composed_errors(&self, selected: &DagOfSelectedCandidates) -> Vec { + let nodes = selected.candidate_of_node.len(); + let mut producers: Vec> = vec![Vec::new(); nodes]; + for edge in &selected.edges { + producers[edge.consumer.0].push((edge.input_index_of_consumer, edge.producer.0)); + } + let mut composed: Vec> = vec![None; nodes]; + // Depth first, inputs before their consumer; the selection is acyclic. + for start in 0..nodes { + let mut stack = vec![(start, false)]; + while let Some((node, inputs_done)) = stack.pop() { + if composed[node].is_some() { + continue; + } + if !inputs_done { + stack.push((node, true)); + stack.extend( + producers[node] + .iter() + .filter(|&&(_, producer)| composed[producer].is_none()) + .map(|&(_, producer)| (producer, false)), + ); + continue; + } + let c = selected.candidate_of_node[node].0; + let mut error = self.own[c].clone(); + for &(input, producer) in &producers[node] { + let read = composed[producer] + .as_ref() + .expect("an acyclic selection composes inputs first"); + for m in 0..METRIC_COUNT { + if read.error_bound[m] == 0.0 { + continue; + } + match self.factor(c, input, m) { + Some(factor) => error.error_bound[m] += factor * read.error_bound[m], + None => error.without_guarantee = true, + } + if self + .validity_cap(c, input, m) + .is_some_and(|cap| read.error_bound[m] > cap) + { + error.without_guarantee = true; + } + } + if self.propagates_failure(c, input) { + error.failure_probability = error + .failure_probability + .zip(read.failure_probability) + .map(|(own, read)| own + read); + } + if read.without_guarantee { + error.without_guarantee = true; + } + } + composed[node] = Some(error); + } + } + composed + .into_iter() + .map(|error| error.expect("every node composed")) + .collect() + } +} + +/// The own guarantee of the candidate `operator` of group `group`, with +/// $\boldsymbol\theta$ `parameters`, built over tumbling windows when +/// `windowed`: +/// - a sketch build: for each statistic read from its group +/// ([`statistics_read`]), the guarantee of reading it +/// ([`read_guarantee`]); per metric the largest error, the largest +/// failure probability; no guarantee when one read has none; +/// - a build of a sample, wavelet or model state: no guarantee (no error +/// model); +/// - a merge of sketch states whose family's merged state does not keep +/// the guarantee of a statistic read from it: no guarantee; +/// - every other candidate: exact (its error comes from its inputs). +fn own_guarantee( + memo: &Memo, + group: MemoGroupId, + operator: &Operator, + parameters: Option<&SketchParams>, + windowed: bool, + data: &DataWorkload, +) -> OwnGuarantee { + let without_guarantee = OwnGuarantee { + without_guarantee: true, + ..OwnGuarantee::EXACT + }; + match operator { + Operator::ASAP( + ASAPOp::SummaryAgg { + family, + input, + child, + .. + } + | ASAPOp::SummaryTumblingWindowChain { + family, + input, + child, + .. + }, + ) => match family { + FieldDataType::Sketch(..) => { + let windowed = windowed + || matches!( + operator, + Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }) + ); + let mut own = OwnGuarantee::EXACT; + for statistic in statistics_read(memo, group) { + let Some(read) = read_guarantee( + memo, family, parameters, windowed, input, *child, &statistic, data, + ) else { + return without_guarantee; + }; + let m = metric_index(read.metric); + own.error_bound[m] = own.error_bound[m].max(read.error_bound); + own.failure_probability = + match (own.failure_probability, read.failure_probability) { + (Some(a), Some(b)) => Some(a.max(b)), + _ => None, + }; + } + own + } + FieldDataType::ExactAggregate(..) | FieldDataType::Plain(_) => OwnGuarantee::EXACT, + FieldDataType::SummarySample(..) + | FieldDataType::Wavelet(..) + | FieldDataType::StatModel(..) => without_guarantee, + }, + Operator::ASAP(ASAPOp::SummaryMerge { children, .. }) => { + let statistics = statistics_read(memo, group); + let keeps = children.iter().all(|&child| { + match state_family(&memo.groups()[child.0 as usize].expression) { + Some(family @ FieldDataType::Sketch(..)) => statistics.iter().all(|s| { + matches!( + FamilyCapability::of(family).merged_error(s), + Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) + ) + }), + // An exact state merges exactly; a merge over a merge + // or another state is judged at its own level. + _ => true, + } + }); + if keeps { + OwnGuarantee::EXACT + } else { + without_guarantee + } + } + _ => OwnGuarantee::EXACT, + } +} + +/// The family a state build's expression builds; `None` for any other. +fn state_family(operator: &Operator) -> Option<&FieldDataType> { + match operator { + Operator::ASAP( + ASAPOp::SummaryAgg { family, .. } | ASAPOp::SummaryTumblingWindowChain { family, .. }, + ) => Some(family), + _ => None, + } +} + +/// The statistics read from state group `state`: by the readouts of it, +/// and of each group that merges it, transitively; each once. +fn statistics_read(memo: &Memo, state: MemoGroupId) -> Vec { + let mut statistics: Vec = Vec::new(); + let mut pending = vec![state]; + let mut seen = vec![state]; + while let Some(group) = pending.pop() { + for &parent in memo.groups()[group.0 as usize].parent_groups() { + for consumer in &memo.groups()[parent.0 as usize].candidates { + match &consumer.expression.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input, + query, + }) if *summary_input == group => { + if !statistics.contains(query) { + statistics.push(query.clone()); + } + } + Operator::ASAP(ASAPOp::SummaryMerge { children, .. }) + if children.contains(&group) && !seen.contains(&parent) => + { + seen.push(parent); + pending.push(parent); + } + _ => {} + } + } + } + } + statistics +} + +/// The guarantee of one read of a sketch state. +struct ReadGuarantee { + metric: AccuracyMetric, + error_bound: f64, + failure_probability: Option, +} + +/// The guarantee of reading `statistic` from a sketch build of `family` +/// with $\boldsymbol\theta$ `parameters` over `update`s of group `rows` +/// (merged from its tumbling windows when `windowed`); `None` when it has +/// none: +/// - a windowed build whose family's merged state does not keep the +/// statistic's guarantee (the family-capability table); +/// - no local guarantee (`asap_accuracy::estimators::local_guarantee`), or +/// an error bound it leaves unknown; +/// - a Count-Min count, or a DDSketch interior quantile, over values not +/// proven non-negative ([`proven_non_negative`]): the bound assumes them. +/// +/// A distinct count (HLL, UnivMon) over one source's own rows whose +/// distinct bound the data workload declares gets its failure probability +/// at that bound (`distinct_count_guarantee_over_source`). +#[allow(clippy::too_many_arguments)] +fn read_guarantee( + memo: &Memo, + family: &FieldDataType, + parameters: Option<&SketchParams>, + windowed: bool, + update: &SummaryUpdate, + rows: MemoGroupId, + statistic: &SketchStatistic, + data: &DataWorkload, +) -> Option { + let FieldDataType::Sketch(algorithm, _) = family else { + return None; + }; + if windowed + && !matches!( + FamilyCapability::of(family).merged_error(statistic), + Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) + ) + { + return None; + } + let mut guarantee = local_guarantee(family, parameters, statistic)?; + let needs_non_negative = match algorithm { + SketchAlgorithm::Cms | SketchAlgorithm::CmsWithHeap => true, + SketchAlgorithm::DDSketch => { + matches!(statistic, SketchStatistic::Quantile { q } if *q > 0.0 && *q < 1.0) + } + _ => false, + }; + if needs_non_negative && !proven_non_negative(memo, update, rows, data) { + return None; + } + if matches!(statistic, SketchStatistic::Cardinality) { + if let (Some(parameters), Some((source, max_distinct))) = + (parameters, source_distinct_bound(memo, rows, data)) + { + if let Some(over_source) = + distinct_count_guarantee_over_source(parameters, &source, max_distinct) + { + guarantee = over_source; + } + } + } + Some(ReadGuarantee { + metric: guarantee.spec.accuracy_metric, + error_bound: guarantee.spec.error.evaluate()?, + failure_probability: guarantee.spec.failure_probability.evaluate(), + }) +} + +/// Whether the values `update` adds over group `rows` are proven +/// non-negative: its weight domain once the data workload's declared facts +/// answer it (`confirmed_weights`), or, for raw sample values, the metric +/// whose raw data samples `rows` are ([`raw_data_samples_of_metric`]) +/// declared non-negative. +fn proven_non_negative( + memo: &Memo, + update: &SummaryUpdate, + rows: MemoGroupId, + data: &DataWorkload, +) -> bool { + matches!( + confirmed_weights(&update.weight_domain, data), + WeightDomain::NonNegative { .. } + ) || (update.weight == SummaryInputExpr::Column(ColumnRef::SampleValue) + && raw_data_samples_of_metric(memo, rows) + .is_some_and(|metric| declared_non_negative(data, &metric))) +} + +/// The metric whose raw data samples, or plain sums of them, are every +/// value group `group` outputs: through a time range, an `offset`-only +/// time shift and a plain `sum`, to a time-series scan. +fn raw_data_samples_of_metric(memo: &Memo, group: MemoGroupId) -> Option { + match &memo.groups()[group.0 as usize].expression { + Operator::NonASAP(NonASAPOp::Scan { + source: asap_types::ir::operator::operator_properties::Source::TimeSeries { metric }, + .. + }) => Some(metric.clone()), + Operator::NonASAP(NonASAPOp::TimeRange { child, .. }) => { + raw_data_samples_of_metric(memo, *child) + } + Operator::NonASAP(NonASAPOp::TimeShift { shift, child }) if shift.at.is_none() => { + raw_data_samples_of_metric(memo, *child) + } + Operator::NonASAP(NonASAPOp::Aggregate { + measures, child, .. + }) if matches!(measures.as_slice(), [AggIntent::Sum { col: None }]) => { + raw_data_samples_of_metric(memo, *child) + } + _ => None, + } +} + +/// The source whose own rows group `group` is (a scan behind filters, +/// time ranges and time shifts at most), with its declared bound on +/// distinct items per evaluation. +fn source_distinct_bound( + memo: &Memo, + group: MemoGroupId, + data: &DataWorkload, +) -> Option<(String, u64)> { + match &memo.groups()[group.0 as usize].expression { + Operator::NonASAP(NonASAPOp::Scan { source, .. }) => { + let name = source_name(source); + let bound = data.sources.get(name)?.max_distinct_per_evaluation?; + Some((name.to_string(), bound)) + } + Operator::NonASAP( + NonASAPOp::Filter { child, .. } + | NonASAPOp::TimeRange { child, .. } + | NonASAPOp::TimeShift { child, .. }, + ) => source_distinct_bound(memo, *child, data), + _ => None, + } +} + +/// One query's limits at its root, (K): $E_{v,m} \le$ +/// `error_bound_limit[m]` and $D_v \le$ `failure_probability_limit` +/// for its output candidate $v$, and no part without a guarantee. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RootAccuracyLimits { + /// $\varepsilon_q$ (with the built-in check's tolerance) in a metric + /// the target accepts, 0 in any other. + pub(crate) error_bound_limit: [f64; METRIC_COUNT], + /// $\delta_q$ over the values the target's scope covers, per value: + /// $\delta_q / n$; 0 when $n$ is not bound; `None` without a + /// $\delta$ target. + pub(crate) failure_probability_limit: Option, +} + +impl RootAccuracyLimits { + /// The limits of `target` for a result of `values_per_result` values + /// ($G_q$; `None` when not bound). A metric is accepted as the + /// built-in check accepts it (`AccuracyModel::metric_can_meet`); an + /// exact target accepts no error. + pub(crate) fn of(target: &AccuracyTarget, values_per_result: Option) -> Self { + let error_bound_limit = std::array::from_fn(|m| { + if BuiltinAccuracyModel.metric_can_meet(METRICS[m], target) { + target.max_error * (1.0 + TARGET_TOLERANCE) + f64::EPSILON + } else { + 0.0 + } + }); + let failure_probability = if target.is_exact() { + Some(0.0) + } else { + target.failure_probability + }; + let failure_probability_limit = failure_probability.map(|delta| { + match target.accuracy_scope.max_values_covered(values_per_result) { + Some(values) if values > 0 => { + delta * (1.0 + TARGET_TOLERANCE) / values as f64 + f64::EPSILON + } + _ => 0.0, + } + }); + Self { + error_bound_limit, + failure_probability_limit, + } + } + + /// No error at all: the limits of an output whose composition has no + /// error contract (a scalar root combining several outputs). + pub(crate) const EXACT: Self = Self { + error_bound_limit: [0.0; METRIC_COUNT], + failure_probability_limit: Some(0.0), + }; +} + +/// Each query's root limits, by query: `None` when (K) does not apply to +/// it, because the deployment's accuracy model accepts its output +/// whatever its guarantee (`AccuracyModel::accepts_output_regardless_of_guarantee` +/// on the root the front ends lowered, `query_roots[q]`; e.g. +/// `IgnoreAccuracyGuarantees`). $G_q$ is `values_per_result` of that root. +pub(crate) fn root_accuracy_limits( + entries: &[QueryWorkloadEntry], + query_roots: &[QueryRoot], + data: &DataWorkload, + model: &dyn AccuracyModel, +) -> Vec> { + entries + .iter() + .enumerate() + .map(|(query, entry)| { + let target = &entry.requirements.accuracy; + let root = query_roots.get(query)?; + if model.accepts_output_regardless_of_guarantee(root, target) { + return None; + } + let values = values_per_result(root, series_read(root, data), data); + Some(RootAccuracyLimits::of(target, values)) + }) + .collect() +} + +/// Whether query root `root` returns its one output reference's values +/// unchanged: an operator root, or a scalar root that only reads one +/// operator's value (`scalar(v)`, a scalar subquery). Any other scalar +/// root combines outputs with no error contract, so its references must +/// be exact. +pub(crate) fn returns_its_output_unchanged(root: &QueryRoot) -> bool { + match root { + QueryRoot::Operator(_) => true, + QueryRoot::Scalar(expression) => matches!( + expression, + ScalarExpr::PromqlScalarFromVector(_) | ScalarExpr::ScalarSubquery(_) + ), + } +} diff --git a/crates/stage3-plan-selection/src/milp/mod.rs b/crates/stage3-plan-selection/src/milp/mod.rs index 8a535ee7b..d8b2ba04d 100644 --- a/crates/stage3-plan-selection/src/milp/mod.rs +++ b/crates/stage3-plan-selection/src/milp/mod.rs @@ -1,4 +1,5 @@ //! Stage 3's selection MILP (concepts doc, sections 3–5): its variables, //! one function per constraint family, and the execution contexts. +pub(crate) mod error_bounds; pub(crate) mod execution_contexts; pub(crate) mod model; diff --git a/crates/stage3-plan-selection/src/milp/model.rs b/crates/stage3-plan-selection/src/milp/model.rs index 20e8059df..5b8a61ee1 100644 --- a/crates/stage3-plan-selection/src/milp/model.rs +++ b/crates/stage3-plan-selection/src/milp/model.rs @@ -5,7 +5,7 @@ //! |---|---|---| //! | baseline (section 3) | $z_{q,v}$, $t_v$ ([`SelectionVariables`]) | (A) [`input_satisfaction_constraints`], (B) [`producer_selection_constraints`], (C)/(D) [`query_coverage_constraints`], (E) [`acyclicity_constraints`], (F) [`utilization_constraints`]; (G), (H): $y$ exists only on admissible edges | //! | execution contexts (section 5) | $x^{\mathrm{mat}}_v$, $x_{\kappa,v}$, $y_{\kappa,u,c,i}$, $h_v$ ([`ExecutionContextVariables`]) | (R) [`retention_constraints`] | -//! | accuracy (section 4) | $E_c$, $F_{c,i}$, $D_c$, $H_{c,i}$ ([`AccuracyVariables`]) | (K) [`accuracy_constraints`] | +//! | accuracy (section 4) | $E_{\kappa,c,m}$, $F_{\kappa,c,i,m}$, $D_{\kappa,c}$, $H_{\kappa,c,i}$, $U_{\kappa,c}$ ([`AccuracyVariables`]) | (K) [`accuracy_constraints`] | //! //! The execution-context variables replace the baseline's $x_v$ and //! unindexed $y_{u,c,i}$: a candidate with $\ell$ = None or `OneEvaluation` @@ -23,8 +23,8 @@ //! $$\min \sum_v W^{\mathrm{mat}}_v x^{\mathrm{mat}}_v + \sum_{\kappa,v} //! \rho_\kappa c_{\kappa,v} x_{\kappa,v} + \sum_v \pi_v h_v$$ //! with the costs from [`crate::execution_cost`]. The accuracy family is -//! `todo!()` until #1207 PR 8; nothing calls it yet, hence its `dead_code` -//! allowances. +//! built only when (K) applies to some query +//! ([`crate::milp::error_bounds::root_accuracy_limits`]). use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::time::Instant; @@ -41,6 +41,9 @@ use asap_types::stages::{ }; use crate::execution_cost::ExecutionCosts; +use crate::milp::error_bounds::{ + returns_its_output_unchanged, CandidateErrorModel, RootAccuracyLimits, METRIC_COUNT, +}; use crate::milp::execution_contexts::{Execution, ExecutionContexts, TumblingWindowChainGeometry}; /// A query, by index in the workload. @@ -85,11 +88,16 @@ impl ExecutionContextVariables { /// What the MILP is built over: Stage 2's candidates and admissible edges, /// the workload's execution contexts with each execution and edge they /// allow, the objective's costs (an execution without one is left out), -/// and indexes built once ([`Self::new`]). +/// the candidates' error model and each query's root limits for (K), and +/// indexes built once ([`Self::new`]). pub(crate) struct SelectionInputs<'a> { pub(crate) physical: &'a PhysicalASAPMemoCandidates, pub(crate) contexts: &'a ExecutionContexts, pub(crate) costs: &'a ExecutionCosts, + pub(crate) errors: &'a CandidateErrorModel, + /// Each query's limits at its root, by query; `None` where (K) does not + /// apply. + pub(crate) root_limits: &'a [Option], /// $\mathcal E$, by [`PhysicalEdgeId`]. edges: Vec, /// The physical candidates of each memo group, ascending. @@ -104,6 +112,8 @@ impl<'a> SelectionInputs<'a> { physical: &'a PhysicalASAPMemoCandidates, contexts: &'a ExecutionContexts, costs: &'a ExecutionCosts, + errors: &'a CandidateErrorModel, + root_limits: &'a [Option], ) -> Self { let mut candidates_of_group: HashMap> = HashMap::new(); @@ -123,6 +133,8 @@ impl<'a> SelectionInputs<'a> { physical, contexts, costs, + errors, + root_limits, edges: physical.edges().map(|(_, edge)| edge).collect(), candidates_of_group, query_output_groups, @@ -198,6 +210,21 @@ impl<'a> SelectionInputs<'a> { &self.query_output_groups } + /// The limits (K) puts on output reference `(query, group)`'s output: + /// its query's ([`RootAccuracyLimits`]), or none at all + /// ([`RootAccuracyLimits::EXACT`]) when its scalar root combines + /// outputs with no error contract; `None` when (K) does not apply. + fn root_limits_of(&self, query: QueryId) -> Option<&RootAccuracyLimits> { + let limits = self.root_limits.get(query.0)?.as_ref()?; + Some( + if returns_its_output_unchanged(&self.physical.query_roots[query.0]) { + limits + } else { + &RootAccuracyLimits::EXACT + }, + ) + } + /// The execution of `v` that gives query `query`'s output, when it can /// be costed. fn output_execution(&self, query: QueryId, v: PhysicalCandidateId) -> Option { @@ -540,13 +567,14 @@ pub(crate) struct SolvedSelection { #[derive(Debug, Clone, PartialEq)] pub(crate) enum SelectionMilpError { /// No assignment meets the constraints: some query output has no - /// execution whose inputs all have costable producers it can read. + /// execution whose inputs all have costable producers it can read, or + /// none meets its query's accuracy target (K). Infeasible, /// HiGHS failed. Solver(String), } -/// Build the MILP over `inputs` ((A)–(F) and (R) per execution, the +/// Build the MILP over `inputs` ((A)–(F), (R) and (K) per execution, the /// objective of section 5), solve it to proven optimality with HiGHS, and /// decode $\{x = 1\}$, $\{y = 1\}$ and $\{z = 1\}$ into a /// [`DagOfSelectedCandidates`]: one node per selected execution, in @@ -569,6 +597,12 @@ pub(crate) fn solve_selection( let started = Instant::now(); let mut problem = ProblemVariables::new(); let (selection, variables) = selection_variables(inputs, &mut problem); + // (K) only when it applies to some query. + let accuracy = inputs + .root_limits + .iter() + .any(Option::is_some) + .then(|| accuracy_variables(inputs, &variables, &mut problem)); let constraints: Vec = [ input_satisfaction_constraints(inputs, &variables), producer_selection_constraints(inputs, &variables), @@ -576,6 +610,9 @@ pub(crate) fn solve_selection( acyclicity_constraints(inputs, &selection, &variables), utilization_constraints(inputs, &selection, &variables), retention_constraints(inputs, &selection, &variables), + accuracy.as_ref().map_or_else(Vec::new, |accuracy| { + accuracy_constraints(inputs, &selection, &variables, accuracy) + }), ] .into_iter() .flatten() @@ -803,30 +840,231 @@ fn decode( (selected_dag, execution_of_node) } -#[allow(dead_code)] // #1207 PR 8. -/// The accuracy variables of constraint (K) (section 4), all continuous. -struct AccuracyVariables { - /// $E_c$, by [`PhysicalCandidateId`]. - error_bound: Vec, - /// $F_{c,i}$: the chosen producer's error bound on input $i$ of $c$. - error_bound_of_input: HashMap<(PhysicalCandidateId, usize), Variable>, - /// $D_c$, by [`PhysicalCandidateId`]. - failure_probability: Vec, - /// $H_{c,i}$: the chosen producer's failure probability on input $i$ of $c$. - failure_probability_of_input: HashMap<(PhysicalCandidateId, usize), Variable>, +/// The accuracy variables of constraint (K) (section 4), all continuous +/// and non-negative, per execution (section 5: $E_{\kappa,c}$) and per +/// error metric $m$ (index into +/// [`METRICS`](crate::milp::error_bounds::METRICS)), each only where the +/// error model says the execution may carry it. +pub(crate) struct AccuracyVariables { + /// $E_{\kappa,c,m}$: the error bound of the execution's output in + /// metric $m$. + error_bound: HashMap<(Execution, usize), Variable>, + /// $F_{\kappa,c,i,m}$: the chosen producer's $E$ on input $i$. + error_bound_of_input: HashMap<(Execution, usize, usize), Variable>, + /// $D_{\kappa,c}$: the failure probability of its output. + failure_probability: HashMap, + /// $H_{\kappa,c,i}$: the chosen producer's $D$ on input $i$. + failure_probability_of_input: HashMap<(Execution, usize), Variable>, + /// $U_{\kappa,c} \in [0, 1]$: 1 when part of its output is an + /// approximation without a guarantee the planner can state. + without_guarantee: HashMap, +} + +/// The accuracy variables over `inputs`' costed executions and the $y$ +/// edges into them: $E$, $D$, $U$ per execution that may carry them; +/// $F$ per (execution, input, metric) whose producer's error the +/// consumer's contract carries with a positive factor; $H$ per +/// (execution, input) a producer's failure can fail. +fn accuracy_variables( + inputs: &SelectionInputs<'_>, + variables: &ExecutionContextVariables, + problem: &mut ProblemVariables, +) -> AccuracyVariables { + let errors = inputs.errors; + let mut accuracy = AccuracyVariables { + error_bound: HashMap::new(), + error_bound_of_input: HashMap::new(), + failure_probability: HashMap::new(), + failure_probability_of_input: HashMap::new(), + without_guarantee: HashMap::new(), + }; + for &execution in inputs.contexts.executions() { + if variables.x(execution).is_none() { + continue; + } + let c = execution.candidate().0; + for m in 0..METRIC_COUNT { + if errors.error_bound_ceiling(c, m) > 0.0 { + accuracy + .error_bound + .insert((execution, m), problem.add(variable().min(0))); + } + } + if errors.failure_probability_ceiling(c) > 0.0 { + accuracy + .failure_probability + .insert(execution, problem.add(variable().min(0))); + } + if errors.may_lack_guarantee(c) { + accuracy + .without_guarantee + .insert(execution, problem.add(variable().min(0).max(1))); + } + } + for (&(context, edge), _) in sorted(&variables.y_in_execution_context) { + let (producer, consumer) = inputs.executions_of_edge(context, edge); + let (u, c) = (producer.candidate().0, consumer.candidate().0); + let input = inputs.edges()[edge.0].input_index_of_consumer; + for m in 0..METRIC_COUNT { + if accuracy.error_bound.contains_key(&(producer, m)) + && errors + .factor(c, input, m) + .is_some_and(|factor| factor > 0.0) + { + accuracy + .error_bound_of_input + .entry((consumer, input, m)) + .or_insert_with(|| problem.add(variable().min(0))); + } + } + if errors.failure_probability_ceiling(u) > 0.0 && errors.propagates_failure(c, input) { + accuracy + .failure_probability_of_input + .entry((consumer, input)) + .or_insert_with(|| problem.add(variable().min(0))); + } + } + accuracy } -#[allow(dead_code)] // #1207 PR 8. -/// (K): $E_c$ and $D_c$ per candidate, big-M on selected edges, and the -/// root checks $E_v \le \varepsilon_q + M_q(1 - z_{q,v})$, -/// $D_v \le \delta_q + (1 - z_{q,v})$. +/// (K) over `inputs` (section 4), per execution and metric, big-M on the +/// selected edges with $M$ the producer's ceiling +/// ([`CandidateErrorModel`]): +/// - $E_{c,m} \ge \varepsilon^{\mathrm{loc}}_{c,m} + \sum_i +/// \beta_{c,i,m} F_{c,i,m}$, $F_{c,i,m} \ge E_{u,m} - M(1 - y)$; +/// - the metric rule: an input error in a metric $c$'s contract does not +/// carry is zero on a selected edge, $E_{u,m} \le M(1 - y)$; the validity +/// cap: $E_{u,m} \le b_{c,i} + M(1 - y)$; +/// - $D_c \ge \delta^{\mathrm{loc}}_c + \sum_i H_{c,i}$, $H_{c,i} \ge D_u - +/// M(1 - y)$ (an unknown $\delta^{\mathrm{loc}}$ counts as 1); +/// - $U_c \ge 1$ for an own approximation without a guarantee, +/// $U_c \ge U_u - (1 - y)$ through a value-preserving consumer, and +/// $U_u \le 1 - y$ into any other; +/// - the root checks, per query $q$ (K) applies to and $v \in G_q$: +/// $E_{v,m} \le \varepsilon_{q,m} + M(1 - z_{q,v})$ ($\varepsilon_{q,m}$ +/// is 0 in a metric the target does not accept), +/// $D_v \le \delta_q / n + M(1 - z_{q,v})$ ($n$ the values the target's +/// scope covers), $U_v \le 1 - z_{q,v}$. +/// +/// A constraint whose big-M side already holds at its ceiling is left out. fn accuracy_constraints( - physical: &PhysicalASAPMemoCandidates, + inputs: &SelectionInputs<'_>, selection: &SelectionVariables, + variables: &ExecutionContextVariables, accuracy: &AccuracyVariables, ) -> Vec { - let _ = (physical, selection, accuracy); - todo!("#1207 PR 8: constraint (K)") + let errors = inputs.errors; + let mut constraints = Vec::new(); + for (&(context, edge), &y) in sorted(&variables.y_in_execution_context) { + let (producer, consumer) = inputs.executions_of_edge(context, edge); + let (u, c) = (producer.candidate().0, consumer.candidate().0); + let input = inputs.edges()[edge.0].input_index_of_consumer; + for m in 0..METRIC_COUNT { + let Some(&e_u) = accuracy.error_bound.get(&(producer, m)) else { + continue; + }; + let ceiling = errors.error_bound_ceiling(u, m); + match errors.factor(c, input, m) { + Some(factor) if factor > 0.0 => { + let f = accuracy.error_bound_of_input[&(consumer, input, m)]; + constraints.push(constraint!(f >= e_u - ceiling * (1 - y))); + } + Some(_) => {} + None => constraints.push(constraint!(e_u <= ceiling * (1 - y))), + } + if let Some(cap) = errors.validity_cap(c, input, m) { + if ceiling > cap { + constraints.push(constraint!(e_u <= cap + (ceiling - cap) * (1 - y))); + } + } + } + if let Some(&u_u) = accuracy.without_guarantee.get(&producer) { + if errors.value_preserving(c) { + let u_c = accuracy.without_guarantee[&consumer]; + constraints.push(constraint!(u_c >= u_u - (1 - y))); + } else { + constraints.push(constraint!(u_u <= 1 - y)); + } + } + if let (Some(&d_u), Some(&h)) = ( + accuracy.failure_probability.get(&producer), + accuracy + .failure_probability_of_input + .get(&(consumer, input)), + ) { + let ceiling = errors.failure_probability_ceiling(u); + constraints.push(constraint!(h >= d_u - ceiling * (1 - y))); + } + } + // Each execution's own bound plus its inputs'. + let mut inputs_error: BTreeMap<(Execution, usize), Expression> = BTreeMap::new(); + for (&(consumer, input, m), &f) in sorted(&accuracy.error_bound_of_input) { + let factor = errors + .factor(consumer.candidate().0, input, m) + .expect("F only where a contract carries the metric"); + *inputs_error.entry((consumer, m)).or_default() += factor * f; + } + for (&(execution, m), &e) in sorted(&accuracy.error_bound) { + let own = errors.own(execution.candidate().0).error_bound[m]; + let carried = inputs_error.remove(&(execution, m)).unwrap_or_default(); + constraints.push(constraint!(e >= own + carried)); + } + let mut inputs_failure: BTreeMap = BTreeMap::new(); + for (&(consumer, _), &h) in sorted(&accuracy.failure_probability_of_input) { + *inputs_failure.entry(consumer).or_default() += h; + } + for (&execution, &d) in sorted(&accuracy.failure_probability) { + let own = errors + .own(execution.candidate().0) + .failure_probability + .unwrap_or(1.0); + let carried = inputs_failure.remove(&execution).unwrap_or_default(); + constraints.push(constraint!(d >= own + carried)); + } + for (&execution, &u) in sorted(&accuracy.without_guarantee) { + if errors.own(execution.candidate().0).without_guarantee { + constraints.push(constraint!(u >= 1)); + } + } + // The root checks. + for &(query, group) in inputs.query_output_groups() { + let Some(limits) = inputs.root_limits_of(query) else { + continue; + }; + for &v in inputs.candidates_of_group(group) { + let (Some(&z), Some(execution)) = ( + selection.z.get(&(query, v)), + inputs.output_execution(query, v), + ) else { + continue; + }; + for m in 0..METRIC_COUNT { + let Some(&e) = accuracy.error_bound.get(&(execution, m)) else { + continue; + }; + let (limit, ceiling) = ( + limits.error_bound_limit[m], + errors.error_bound_ceiling(v.0, m), + ); + if ceiling > limit { + constraints.push(constraint!(e <= limit + (ceiling - limit) * (1 - z))); + } + } + if let (Some(limit), Some(&d)) = ( + limits.failure_probability_limit, + accuracy.failure_probability.get(&execution), + ) { + let ceiling = errors.failure_probability_ceiling(v.0); + if ceiling > limit { + constraints.push(constraint!(d <= limit + (ceiling - limit) * (1 - z))); + } + } + if let Some(&u) = accuracy.without_guarantee.get(&execution) { + constraints.push(constraint!(u <= 1 - z)); + } + } + } + constraints } #[cfg(test)] diff --git a/crates/stage3-plan-selection/src/milp/model/tests.rs b/crates/stage3-plan-selection/src/milp/model/tests.rs index 4421e41e4..adb9f16af 100644 --- a/crates/stage3-plan-selection/src/milp/model/tests.rs +++ b/crates/stage3-plan-selection/src/milp/model/tests.rs @@ -6,13 +6,17 @@ use std::collections::{BTreeMap, BTreeSet}; use std::time::Duration; +use asap_accuracy::estimators::local_guarantee; +use asap_types::ir::operator::operator_properties::BinaryOpKind; use asap_types::ir::operator::operator_properties::{Reduction, Source}; +use asap_types::ir::properties::{AccuracyMetric, AccuracyScope, AccuracyTarget}; +use asap_types::ir::scalar::expr_ir::ArithmeticOpKind; use asap_types::ir::scalar::ColumnRef; use asap_types::ir::schema::{ DataType, Field, FieldDataType, GroupingStrategy, Schema, SketchAlgorithm, SketchParams, SketchStatistic, SummaryUpdate, }; -use asap_types::ir::{ASAPOp, NonASAPOp, Operator, QueryRoot, TimeRangeKind}; +use asap_types::ir::{ASAPOp, BinaryOperator, NonASAPOp, Operator, QueryRoot, TimeRangeKind}; use asap_types::physical::{ExecutionTiming, Lifetime, PhysicalProperties, StorageMedium}; use asap_types::stages::{ parameter_list, producer_satisfies_physical_requirements, CandidateExpression, @@ -21,11 +25,12 @@ use asap_types::stages::{ PhysicalASAPMemoCandidates, PhysicalCandidate, PhysicalCandidateId, PhysicalEdge, WindowLayout, }; use asap_types::workload::{ - QueryRecurrence, QueryRequirements, QueryWorkloadEntry, RangeEvaluation, RepeatedDemand, - RepetitionInterval, TimestampMs, + DataWorkload, QueryRecurrence, QueryRequirements, QueryWorkloadEntry, RangeEvaluation, + RepeatedDemand, RepetitionInterval, SourceFacts, TimestampMs, }; use crate::execution_cost::ExecutionCosts; +use crate::milp::error_bounds::{CandidateErrorModel, RootAccuracyLimits, METRIC_COUNT}; use super::*; @@ -212,15 +217,49 @@ fn uniform_costs( execution_costs } -fn solve_with_retention( +/// (K)'s inputs: the data workload the guarantees read, and each query's +/// root limits (`None`: (K) does not apply to it). +struct Accuracy<'a> { + data: &'a DataWorkload, + root_limits: &'a [Option], +} + +static NO_DATA: std::sync::LazyLock = std::sync::LazyLock::new(DataWorkload::default); + +/// No query under (K): the tests of (A)–(I) and (R). +fn no_accuracy() -> Accuracy<'static> { + Accuracy { + data: &NO_DATA, + root_limits: &[], + } +} + +fn solve_under( physical: &PhysicalASAPMemoCandidates, costs: &[Option], retention: &[(usize, f64)], entries: &[QueryWorkloadEntry], + accuracy: &Accuracy<'_>, ) -> Result { let contexts = ExecutionContexts::new(physical, entries); let costs = uniform_costs(&contexts, costs, retention); - solve_selection(&SelectionInputs::new(physical, &contexts, &costs)) + let errors = CandidateErrorModel::new(physical, accuracy.data); + solve_selection(&SelectionInputs::new( + physical, + &contexts, + &costs, + &errors, + accuracy.root_limits, + )) +} + +fn solve_with_retention( + physical: &PhysicalASAPMemoCandidates, + costs: &[Option], + retention: &[(usize, f64)], + entries: &[QueryWorkloadEntry], +) -> Result { + solve_under(physical, costs, retention, entries, &no_accuracy()) } fn solve( @@ -394,16 +433,26 @@ fn strongly_connected_components_group_cycles_only() { /// of every reached execution (the edges the contexts allow), without /// cycles; each DAG's cost the sum of its distinct executions' costs plus, /// per `AcrossEvaluations` candidate, its retention cost per window times -/// the largest need of its edges. Returns the cheapest cost. +/// the largest need of its edges. Under (K), a DAG counts only when each +/// query's output meets its root limits by its composed error +/// (`CandidateErrorModel::composed_errors`). Returns the cheapest cost. fn reference_minimum( physical: &PhysicalASAPMemoCandidates, costs: &[Option], retention: &[(usize, f64)], entries: &[QueryWorkloadEntry], + accuracy: &Accuracy<'_>, ) -> Option { let contexts = ExecutionContexts::new(physical, entries); let execution_costs = uniform_costs(&contexts, costs, retention); - let inputs = SelectionInputs::new(physical, &contexts, &execution_costs); + let errors = CandidateErrorModel::new(physical, accuracy.data); + let inputs = SelectionInputs::new( + physical, + &contexts, + &execution_costs, + &errors, + accuracy.root_limits, + ); let costed = |execution: Execution| inputs.cost_per_second(execution).is_some(); // Per (consumer execution, input), its producer executions, each with // the edge's context. @@ -433,8 +482,55 @@ fn reference_minimum( best: Option, } impl Search<'_, '_> { - fn expand(&mut self, dag: Dag, roots: &BTreeSet, mut pending: Vec) { + /// Whether every output of `outputs` meets its query's root limits + /// in `dag`. + fn meets_root_limits(&self, dag: &Dag, outputs: &[(QueryId, Execution)]) -> bool { + let nodes: Vec = dag.keys().copied().collect(); + let node = |execution: Execution| nodes.iter().position(|&n| n == execution).unwrap(); + let selected = DagOfSelectedCandidates { + candidate_of_node: nodes.iter().map(|e| e.candidate()).collect(), + edges: dag + .iter() + .flat_map(|(&consumer, choice)| { + choice + .iter() + .enumerate() + .map(move |(input, &(producer, _))| (consumer, input, producer)) + }) + .map(|(consumer, input, producer)| SelectedEdge { + producer: SelectedNodeId(node(producer)), + consumer: SelectedNodeId(node(consumer)), + input_index_of_consumer: input, + }) + .collect(), + node_of_output_reference: Vec::new(), + }; + let composed = self.inputs.errors.composed_errors(&selected); + outputs.iter().all(|&(query, execution)| { + let Some(limits) = self.inputs.root_limits_of(query) else { + return true; + }; + let error = &composed[node(execution)]; + !error.without_guarantee + && (0..METRIC_COUNT) + .all(|m| error.error_bound[m] <= limits.error_bound_limit[m]) + && limits + .failure_probability_limit + .is_none_or(|limit| error.failure_probability.unwrap_or(1.0) <= limit) + }) + } + + fn expand( + &mut self, + dag: Dag, + roots: &BTreeSet, + outputs: &[(QueryId, Execution)], + mut pending: Vec, + ) { let Some(consumer) = pending.pop() else { + if !self.meets_root_limits(&dag, outputs) { + return; + } let mut retained: BTreeMap = BTreeMap::new(); for choices in dag.values() { for &(producer, context) in choices { @@ -492,12 +588,12 @@ fn reference_minimum( next_pending.push(u); } } - self.expand(next, roots, next_pending); + self.expand(next, roots, outputs, next_pending); } } } // Every choice of one output execution per query output. - let mut root_choices: Vec> = vec![BTreeSet::new()]; + let mut root_choices: Vec> = vec![Vec::new()]; for &(query, group) in inputs.query_output_groups() { let options: Vec = inputs .candidates_of_group(group) @@ -509,7 +605,7 @@ fn reference_minimum( .flat_map(|prefix| { options.iter().map(move |&option| { let mut next = prefix.clone(); - next.insert(option); + next.push((query, option)); next }) }) @@ -520,8 +616,14 @@ fn reference_minimum( producers: &producers, best: None, }; - for roots in root_choices { - search.expand(BTreeMap::new(), &roots, roots.iter().copied().collect()); + for outputs in root_choices { + let roots: BTreeSet = outputs.iter().map(|&(_, execution)| execution).collect(); + search.expand( + BTreeMap::new(), + &roots, + &outputs, + roots.iter().copied().collect(), + ); } search.best } @@ -545,10 +647,20 @@ fn agrees_with_the_reference( retention: &[(usize, f64)], entries: &[QueryWorkloadEntry], ) { - let reference = reference_minimum(physical, costs, retention, entries); + agrees_with_the_reference_under(physical, costs, retention, entries, &no_accuracy()); +} + +fn agrees_with_the_reference_under( + physical: &PhysicalASAPMemoCandidates, + costs: &[Option], + retention: &[(usize, f64)], + entries: &[QueryWorkloadEntry], + accuracy: &Accuracy<'_>, +) { + let reference = reference_minimum(physical, costs, retention, entries, accuracy); match ( reference, - solve_with_retention(physical, costs, retention, entries), + solve_under(physical, costs, retention, entries, accuracy), ) { (Some(reference), Ok(solved)) => { assert!( @@ -1033,3 +1145,489 @@ fn execution_costs_follow_the_context_of_each_execution() { assert!(retention > 0.0 && retention.is_finite()); } } + +// ── Constraint (K) ────────────────────────────────────────────────────── + +/// `memo`'s candidates, each at query time and not materialized, with +/// every edge from a candidate of an input group admissible. +fn all_at_query_time(memo: Memo) -> PhysicalASAPMemoCandidates { + let mut logical = Vec::new(); + for (g, group) in memo.groups().iter().enumerate() { + for index_in_group in 0..group.candidates.len() { + logical.push(CandidateForMemoGroupId { + group: MemoGroupId(g as u32), + index_in_group, + }); + } + } + let id_of = |candidate: CandidateForMemoGroupId| { + PhysicalCandidateId(logical.iter().position(|&c| c == candidate).unwrap()) + }; + let mut edges = Vec::new(); + for (c, &consumer) in logical.iter().enumerate() { + let children = memo.candidate(consumer).expression.operator.children(); + for (input, &child) in children.into_iter().enumerate() { + for index_in_group in 0..memo.groups()[child.0 as usize].candidates.len() { + edges.push(PhysicalEdge { + producer_candidate_id: id_of(CandidateForMemoGroupId { + group: child, + index_in_group, + }), + consumer_candidate_id: PhysicalCandidateId(c), + input_index_of_consumer: input, + satisfies_logical_requirement: true, + satisfies_physical_requirement: true, + }); + } + } + } + let physical_candidates = logical + .iter() + .map(|&logical_candidate_id| PhysicalCandidate { + logical_candidate_id, + physical_properties: PhysicalProperties::new(QT_NONE.0, QT_NONE.1).unwrap(), + }) + .collect(); + let query_roots = memo.query_roots.clone(); + PhysicalASAPMemoCandidates::new( + LogicalASAPMemoCandidates { + memo, + edges: Vec::new(), + }, + physical_candidates, + query_roots, + edges, + ) +} + +/// A sketch build of `algorithm` over the scan `rows`, per series. +fn sketch_build(rows: MemoGroupId, algorithm: SketchAlgorithm) -> Operator { + Operator::ASAP(ASAPOp::SummaryAgg { + child: rows, + family: FieldDataType::Sketch(algorithm, GroupingStrategy::default()), + input: SummaryUpdate::column(ColumnRef::SampleValue), + reduction: Reduction::PerEntity, + grouping: GroupingStrategy::default(), + filter: None, + }) +} + +fn quantile(state: MemoGroupId, q: f64) -> Operator { + Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: state, + query: SketchStatistic::Quantile { q }, + }) +} + +/// The scan group of `latency` (one candidate). +fn latency_scan(memo: &mut Memo) -> MemoGroupId { + let scan = Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { + metric: "latency".into(), + }, + predicates: vec![], + schema: Schema::with_time_index( + vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("value", DataType::Float64, false), + ], + 0, + vec![], + ), + }); + let group = memo.find_or_add_group(scan.clone()).unwrap(); + add(memo, group, scan, None, None); + group +} + +/// The state group of `algorithm` over `rows`, one candidate per +/// `parameters`, in order. +fn state_group( + memo: &mut Memo, + rows: MemoGroupId, + algorithm: SketchAlgorithm, + parameters: &[SketchParams], +) -> MemoGroupId { + let build = sketch_build(rows, algorithm); + let group = memo.find_or_add_group(build.clone()).unwrap(); + for parameters in parameters { + add(memo, group, build.clone(), Some(parameters.clone()), None); + } + group +} + +/// The quantile `q` group, read from the KLL state `kll` (candidate 0) +/// or the DDSketch state `ddsketch` (candidate 1). +fn quantile_group(memo: &mut Memo, kll: MemoGroupId, ddsketch: MemoGroupId, q: f64) -> MemoGroupId { + let group = memo.find_or_add_group(quantile(kll, q)).unwrap(); + add(memo, group, quantile(kll, q), None, None); + add(memo, group, quantile(ddsketch, q), None, None); + group +} + +const KLL_SIZES: usize = 4; +const DDSKETCH_ALPHAS: [f64; 4] = [0.1, 0.05, 0.02, 0.01]; + +/// #1207's problem example: query A, `quantile_over_time(0.95, +/// latency[1m])`, and query B, the ratio of the 0.99 and 0.50 quantiles of +/// `latency[1m]`. Candidates: 0 the scan; 1–4 the KLL state, `k` from the +/// first four listed sizes; 5–8 the DDSketch state, `alpha` from +/// [`DDSKETCH_ALPHAS`]; A's p95 read from KLL (9) or DDSketch (10); B's +/// p99 (11, 12) and p50 (13, 14) likewise; 15 B's ratio. +fn problem_example() -> PhysicalASAPMemoCandidates { + let mut memo = Memo::default(); + let scan = latency_scan(&mut memo); + let kll = state_group( + &mut memo, + scan, + SketchAlgorithm::Kll, + ¶meter_list(SketchAlgorithm::Kll)[..KLL_SIZES], + ); + let ddsketch_parameters: Vec = DDSKETCH_ALPHAS + .iter() + .map(|&alpha| SketchParams::DDSketch { alpha }) + .collect(); + let ddsketch = state_group( + &mut memo, + scan, + SketchAlgorithm::DDSketch, + &ddsketch_parameters, + ); + let p95 = quantile_group(&mut memo, kll, ddsketch, 0.95); + let p99 = quantile_group(&mut memo, kll, ddsketch, 0.99); + let p50 = quantile_group(&mut memo, kll, ddsketch, 0.50); + let ratio = Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), + vector_match: None, + }, + return_bool: false, + lhs: p99, + rhs: p50, + }); + let ratio_group = memo.find_or_add_group(ratio.clone()).unwrap(); + add(&mut memo, ratio_group, ratio, None, None); + memo.add_query(QueryRoot::Operator(p95)); + memo.add_query(QueryRoot::Operator(ratio_group)); + all_at_query_time(memo) +} + +const P95_FROM_KLL: usize = 9; +const P95_FROM_DDSKETCH: usize = 10; +const RATIO: usize = 15; + +/// Costs of the problem example: a larger sketch costs more, and a +/// DDSketch less than a KLL of the same rank. +fn problem_example_costs() -> Vec> { + let mut costs = vec![Some(1.0)]; + costs.extend((0..KLL_SIZES).map(|size| Some(10.0 + 10.0 * size as f64))); + costs.extend((0..DDSKETCH_ALPHAS.len()).map(|size| Some(5.0 + 10.0 * size as f64))); + costs.extend(std::iter::repeat_n(Some(1.0), 7)); + costs +} + +/// A query every 10 s at phase 0 with `accuracy`. +fn entry_with(accuracy: AccuracyTarget) -> QueryWorkloadEntry { + QueryWorkloadEntry { + requirements: QueryRequirements { + accuracy, + max_latency_ms: None, + }, + ..entry(0) + } +} + +/// `latency`'s values declared non-negative: a DDSketch interior quantile +/// keeps its relative error. +fn non_negative_latency() -> DataWorkload { + let mut data = DataWorkload::default(); + data.sources.insert( + "latency".into(), + SourceFacts { + values_non_negative: true, + ..Default::default() + }, + ); + data +} + +/// The `parameters` the selected candidates of `states` (candidate ids) +/// have, in candidate order. +fn selected_parameters( + physical: &PhysicalASAPMemoCandidates, + solved: &SolvedSelection, + states: std::ops::RangeInclusive, +) -> Vec { + selected(solved) + .into_iter() + .filter(|candidate| states.contains(candidate)) + .map(|candidate| { + physical + .logical_candidates + .memo + .candidate(physical.physical_candidates[candidate].logical_candidate_id) + .logical_properties + .parameters() + .unwrap() + .clone() + }) + .collect() +} + +/// The cheapest of `list` whose rank error for a quantile is at most +/// `limit`. +fn smallest_kll_within(limit: f64) -> SketchParams { + parameter_list(SketchAlgorithm::Kll) + .iter() + .find(|parameters| { + local_guarantee( + &FieldDataType::Sketch(SketchAlgorithm::Kll, GroupingStrategy::default()), + Some(parameters), + &SketchStatistic::Quantile { q: 0.95 }, + ) + .and_then(|g| g.spec.error.evaluate()) + .is_some_and(|error| error <= limit) + }) + .unwrap() + .clone() +} + +/// #1207's problem example under (K): A's rank target 0.01 is met by a +/// KLL state (its DDSketch readout, cheaper, is relative error, which a +/// rank target does not accept: the metric rule at the root), and B's +/// ratio within relative error 0.03 reads one DDSketch state through both +/// operands, its cost paid once. B's per-operand bound is +/// $(\alpha + \alpha) / (1 - 0.1) \le 0.03$, so `alpha` 0.02 (each operand +/// alone well within 0.03) is too weak: (K) picks 0.01. Without (K) the +/// weakest sizes win. +#[test] +fn the_problem_example_meets_each_query_target_at_its_root() { + let physical = problem_example(); + let entries = [ + entry_with(AccuracyTarget::max_error(0.01).accuracy_metric(AccuracyMetric::Rank)), + entry_with(AccuracyTarget::max_error(0.03).accuracy_metric(AccuracyMetric::Relative)), + ]; + let data = non_negative_latency(); + let root_limits: Vec<_> = entries + .iter() + .map(|entry| { + Some(RootAccuracyLimits::of( + &entry.requirements.accuracy, + Some(1), + )) + }) + .collect(); + let accuracy = Accuracy { + data: &data, + root_limits: &root_limits, + }; + let costs = problem_example_costs(); + let solved = solve_under(&physical, &costs, &[], &entries, &accuracy).unwrap(); + let chosen = selected(&solved); + assert!(chosen.contains(&P95_FROM_KLL) && !chosen.contains(&P95_FROM_DDSKETCH)); + assert!(chosen.contains(&RATIO)); + assert_eq!( + selected_parameters(&physical, &solved, 1..=KLL_SIZES), + vec![smallest_kll_within(0.01)] + ); + // One DDSketch state, alpha 0.01, read by both of B's operands. + assert_eq!( + selected_parameters(&physical, &solved, 5..=8), + vec![SketchParams::DDSketch { alpha: 0.01 }] + ); + let ddsketch_node = solved + .selected_dag + .candidate_of_node + .iter() + .position(|&c| (5..=8).contains(&c.0)) + .unwrap(); + assert_eq!( + solved + .selected_dag + .edges + .iter() + .filter(|edge| edge.producer.0 == ddsketch_node) + .count(), + 2 + ); + // Without (K): the cheapest sizes, A from DDSketch. + let unchecked = solve_under(&physical, &costs, &[], &entries, &no_accuracy()).unwrap(); + assert!(selected(&unchecked).contains(&P95_FROM_DDSKETCH)); + assert_eq!( + selected_parameters(&physical, &unchecked, 5..=8), + vec![SketchParams::DDSketch { alpha: 0.1 }] + ); +} + +/// A DDSketch interior quantile keeps its relative error only over values +/// proven non-negative: without the declaration B has no guarantee and, +/// its ratio reading no KLL (rank error into a relative-error contract), +/// no plan meets (K). +#[test] +fn a_ddsketch_interior_quantile_needs_non_negative_values() { + let physical = problem_example(); + let entries = [ + entry_with(AccuracyTarget::max_error(0.01).accuracy_metric(AccuracyMetric::Rank)), + entry_with(AccuracyTarget::max_error(0.03).accuracy_metric(AccuracyMetric::Relative)), + ]; + let root_limits: Vec<_> = entries + .iter() + .map(|entry| { + Some(RootAccuracyLimits::of( + &entry.requirements.accuracy, + Some(1), + )) + }) + .collect(); + let accuracy = Accuracy { + data: &NO_DATA, + root_limits: &root_limits, + }; + assert_eq!( + solve_under( + &physical, + &problem_example_costs(), + &[], + &entries, + &accuracy + ) + .unwrap_err(), + SelectionMilpError::Infeasible + ); +} + +/// Failure probabilities add along the selected edges (a union bound), +/// a producer reached twice counted twice: a `Concat` of p95 and p50 read +/// from one KLL state fails with twice its δ. A per-value target of 1.5δ +/// is infeasible, 2δ is met; the error limit is loose in both. +#[test] +fn failure_probabilities_add_along_the_selected_edges() { + let mut memo = Memo::default(); + let scan = latency_scan(&mut memo); + let kll_parameters = parameter_list(SketchAlgorithm::Kll)[KLL_SIZES - 1].clone(); + let kll = state_group( + &mut memo, + scan, + SketchAlgorithm::Kll, + std::slice::from_ref(&kll_parameters), + ); + let readouts: Vec = [0.95, 0.5] + .into_iter() + .map(|q| { + let group = memo.find_or_add_group(quantile(kll, q)).unwrap(); + add(&mut memo, group, quantile(kll, q), None, None); + group + }) + .collect(); + let both = Operator::NonASAP(NonASAPOp::Concat { children: readouts }); + let both_group = memo.find_or_add_group(both.clone()).unwrap(); + add(&mut memo, both_group, both, None, None); + memo.add_query(QueryRoot::Operator(both_group)); + let physical = all_at_query_time(memo); + let delta = local_guarantee( + &FieldDataType::Sketch(SketchAlgorithm::Kll, GroupingStrategy::default()), + Some(&kll_parameters), + &SketchStatistic::Quantile { q: 0.95 }, + ) + .unwrap() + .spec + .failure_probability + .evaluate() + .unwrap(); + assert!(delta > 0.0); + let costs = vec![Some(1.0); physical.physical_candidates.len()]; + for (failure_probability, feasible) in [(1.5 * delta, false), (2.0 * delta, true)] { + let target = AccuracyTarget::max_error(0.5) + .failure_probability(failure_probability) + .accuracy_scope(AccuracyScope::PerValue); + let entries = [entry_with(target.clone())]; + let root_limits = [Some(RootAccuracyLimits::of(&target, None))]; + let accuracy = Accuracy { + data: &NO_DATA, + root_limits: &root_limits, + }; + assert_eq!( + solve_under(&physical, &costs, &[], &entries, &accuracy).is_ok(), + feasible, + "δ target {failure_probability}" + ); + agrees_with_the_reference_under(&physical, &costs, &[], &entries, &accuracy); + } +} + +/// Big-M on the selected edge only: an input with an exact producer and +/// a cheap approximate one whose error is over the target. The +/// approximate producer's bound binds only when its edge is selected, so +/// the exact one is chosen under a tight target and the cheap one under a +/// loose target. +#[test] +fn a_producer_s_error_counts_only_on_its_selected_edge() { + let physical = problem_example(); + let data = non_negative_latency(); + let costs = problem_example_costs(); + // Only A; B is exact by every target here except through DDSketch, so + // it is left without (K). + for (target, expected_kll) in [ + (0.01, smallest_kll_within(0.01)), + (0.5, parameter_list(SketchAlgorithm::Kll)[0].clone()), + ] { + let entries = [ + entry_with(AccuracyTarget::max_error(target).accuracy_metric(AccuracyMetric::Rank)), + entry(0), + ]; + let root_limits = [ + Some(RootAccuracyLimits::of( + &entries[0].requirements.accuracy, + Some(1), + )), + None, + ]; + let accuracy = Accuracy { + data: &data, + root_limits: &root_limits, + }; + let solved = solve_under(&physical, &costs, &[], &entries, &accuracy).unwrap(); + assert_eq!( + selected_parameters(&physical, &solved, 1..=KLL_SIZES), + vec![expected_kll], + "target {target}" + ); + } +} + +/// On the problem example under (K), with many cost assignments and +/// targets, the MILP's optimum equals the cheapest DAG of the reference +/// enumeration whose composed errors meet every root limit. +#[test] +fn the_milp_agrees_with_the_reference_enumeration_under_accuracy() { + let physical = problem_example(); + let data = non_negative_latency(); + let mut next = pseudo_random_costs(); + for round in 0..12 { + let costs: Vec> = (0..physical.physical_candidates.len()) + .map(|_| Some(next())) + .collect(); + let a = [0.005, 0.01, 0.05][round % 3]; + let b = [0.03, 0.05, 0.25, 0.01][round % 4]; + let entries = [ + entry_with(AccuracyTarget::max_error(a).accuracy_metric(AccuracyMetric::Rank)), + entry_with(AccuracyTarget::max_error(b)), + ]; + let root_limits: Vec<_> = entries + .iter() + .map(|entry| { + Some(RootAccuracyLimits::of( + &entry.requirements.accuracy, + Some(1), + )) + }) + .collect(); + let accuracy = Accuracy { + data: &data, + root_limits: &root_limits, + }; + agrees_with_the_reference_under(&physical, &costs, &[], &entries, &accuracy); + } +} diff --git a/crates/stage3-plan-selection/src/models.rs b/crates/stage3-plan-selection/src/models.rs index 763476654..f8fd5b0eb 100644 --- a/crates/stage3-plan-selection/src/models.rs +++ b/crates/stage3-plan-selection/src/models.rs @@ -1,10 +1,12 @@ //! The accuracy models Stage 3 checks with: [`AccuracyCheckInputs`]. use asap_accuracy::{AccuracyEvidenceProvider, AccuracyModel}; +use asap_types::ir::QueryRoot; -/// Inputs of the composed-accuracy check. `raw_data_retained` is a Stage 2 -/// input only; to ignore accuracy guarantees the facade passes an -/// accept-all `model` ([`asap_accuracy::IgnoreAccuracyGuarantees`]). +/// Inputs of the composed-accuracy check, constraint (K). To ignore +/// accuracy guarantees the facade passes an accept-all `model` +/// ([`asap_accuracy::IgnoreAccuracyGuarantees`]): (K) then applies to no +/// query. #[derive(Clone, Copy)] pub struct AccuracyCheckInputs<'a> { /// Decides whether a candidate's accuracy guarantee meets its query's @@ -14,4 +16,11 @@ pub struct AccuracyCheckInputs<'a> { /// Deployment facts the guarantees need (operand domains, observed /// guarantees). pub evidence: &'a dyn AccuracyEvidenceProvider, + /// Each query's root as the front ends lowered it, by query index: + /// Stage 3 reads how many values one evaluation returns ($G_q$, + /// `asap_types::workload::bounds::values_per_result`), which a + /// whole-result failure probability covers, and asks `model` whether + /// it accepts the output regardless of its guarantee, in which case + /// (K) does not apply to the query. + pub query_roots: &'a [QueryRoot], } diff --git a/crates/stage3-plan-selection/src/report.rs b/crates/stage3-plan-selection/src/report.rs index 01f308b5c..2b7956a49 100644 --- a/crates/stage3-plan-selection/src/report.rs +++ b/crates/stage3-plan-selection/src/report.rs @@ -86,9 +86,8 @@ pub enum RejectionCause { /// It ties with the selected candidate and comes later in enumeration /// order. TiedLostOnEnumerationOrder, - /// No plan meets every query's accuracy target, latency bound and the - /// catalog's consumer constraints together: the selection MILP is - /// infeasible. The reason names a query when it has no plan even alone. + /// No plan meets constraints (A)–(I), (R) and every query's accuracy + /// target (K) together: the selection MILP is infeasible. Infeasible, } @@ -302,10 +301,12 @@ pub enum ErrorSelectingPlan { objective_per_second: f64, plan_cost_per_second: f64, }, - /// The selected plan fails a check the selection MILP encodes - /// (accuracy through the budget shares, latency): a bug in the - /// accuracy weights, the sizing rule or a latency term, never a reason - /// to re-solve. + /// The selected plan's composed guarantee for a query misses its + /// target by the deployment's accuracy model, its evidence does not + /// meet it either, and the model does not accept the output regardless + /// of its guarantee: a bug in constraint (K)'s error model, or a model + /// stricter than the built-in rule (K) encodes; never a reason to + /// re-solve. #[error("the selected plan fails a check the MILP encodes ({cause:?}): {reason}")] SelectedPlanFailedCheck { cause: RejectionCause, diff --git a/crates/stage3-plan-selection/src/select.rs b/crates/stage3-plan-selection/src/select.rs index 50df9037f..68f518bec 100644 --- a/crates/stage3-plan-selection/src/select.rs +++ b/crates/stage3-plan-selection/src/select.rs @@ -9,20 +9,26 @@ use std::collections::BTreeMap; -use asap_accuracy::AccuracyModel; use asap_cost::analytical::AnalyticalCostError; use asap_cost::execution_demand::evaluation_rate; use asap_cost::{materialized_tumbling_window_chains, node_map, plan_cost, CostInputs, CostModel}; +use asap_types::ir::properties::{AccuracyGuarantee, BoundExpr, ProbabilityExpr}; use asap_types::ir::schema::FieldDataType; use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, QueryRoot}; use asap_types::physical::{ExecutionTiming, Lifetime, StorageMedium}; use asap_types::stages::{ - AssignmentRealizer, ExecutionContext, ExecutionContextId, PhysicalASAPMemoCandidates, - PhysicalCandidateError, PhysicalCandidateId, PruneReason, PrunedCandidate, RealizedPlan, + output_references, AssignmentRealizer, DagOfSelectedCandidates, ExecutionContext, + ExecutionContextId, PhysicalASAPMemoCandidates, PhysicalCandidateError, PhysicalCandidateId, + PruneReason, PrunedCandidate, RealizedPlan, }; +use asap_types::workload::bounds::{series_read, values_per_result}; use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; use crate::execution_cost::{execution_costs, ExecutionCosts}; +use crate::milp::error_bounds::{ + returns_its_output_unchanged, root_accuracy_limits, CandidateErrorModel, OwnGuarantee, METRICS, + METRIC_COUNT, +}; use crate::milp::execution_contexts::{Execution, ExecutionContexts}; use crate::milp::model::{solve_selection, SelectionInputs, SelectionMilpError, SolvedSelection}; use crate::models::AccuracyCheckInputs; @@ -62,15 +68,17 @@ const QUERY_TIME_SCAN: &str = "a query-time scan: plan_cost reads the windows of /// and costed under `cost_inputs` (cost model 1 only). `entries[q]` is /// query `q`'s workload entry; `data` the data workload with its bounds. /// -/// Accuracy: until constraint (K) (#1207 PR 8), a query's accuracy rests on -/// the parameter floors of its edges, which Pass 2 derived from the query's -/// target ((G): an edge from a producer below its floor is not admissible); -/// `accuracy_checks.model` may accept an output regardless of its -/// guarantee, which the report records. +/// Accuracy: constraint (K) checks each query's composed error at its root +/// (concepts doc, section 4), unless `accuracy_checks.model` accepts the +/// query's output regardless of its guarantee. After the solve, each +/// query's composed guarantee in the selected plan is checked again with +/// the model and the deployment's evidence ([`accepted_accuracy`]). /// /// Err: invalid or unsupported cost inputs, an infeasible catalog, a -/// solver failure, or the realizer rejected the selected plan -/// ([`ErrorSelectingPlan::RealizerRejected`]: a Stage 2 catalog bug). +/// solver failure, the realizer rejected the selected plan +/// ([`ErrorSelectingPlan::RealizerRejected`]: a Stage 2 catalog bug), or +/// the selected plan fails the accuracy check +/// ([`ErrorSelectingPlan::SelectedPlanFailedCheck`]). pub fn select_plan( physical: &PhysicalASAPMemoCandidates, entries: &[QueryWorkloadEntry], @@ -107,15 +115,26 @@ pub fn select_plan( let contexts = ExecutionContexts::new(physical, entries); let costs = execution_costs(physical, &contexts, data, cost_inputs, formula_scale); let pruned_candidates = uncostable_candidates(physical, &contexts, &costs); - let solved = - solve_selection(&SelectionInputs::new(physical, &contexts, &costs)).map_err(|error| { - match error { - SelectionMilpError::Infeasible => { - ErrorSelectingPlan::NoValidCandidate(vec![infeasible(&costs)]) - } - SelectionMilpError::Solver(reason) => ErrorSelectingPlan::Solver(reason), - } - })?; + let errors = CandidateErrorModel::new(physical, data); + let root_limits = root_accuracy_limits( + entries, + accuracy_checks.query_roots, + data, + accuracy_checks.model, + ); + let solved = solve_selection(&SelectionInputs::new( + physical, + &contexts, + &costs, + &errors, + &root_limits, + )) + .map_err(|error| match error { + SelectionMilpError::Infeasible => { + ErrorSelectingPlan::NoValidCandidate(vec![infeasible(&costs)]) + } + SelectionMilpError::Solver(reason) => ErrorSelectingPlan::Solver(reason), + })?; let validation_started = std::time::Instant::now(); solved.selected_dag.validate(physical).map_err(|reason| { ErrorSelectingPlan::Solver(format!("the decoded selection is invalid: {reason}")) @@ -181,7 +200,15 @@ pub fn select_plan( }) } }; - let accepted_accuracy_per_query = accepted_accuracy(&realized, entries, accuracy_checks.model); + let accepted_accuracy_per_query = accepted_accuracy( + physical, + &errors, + &solved.selected_dag, + &realized, + entries, + data, + accuracy_checks, + )?; let validation_ms = validation_started.elapsed().as_millis() as u64; Ok(Selection { selected_dag: solved.selected_dag, @@ -208,8 +235,8 @@ pub fn select_plan( }) } -/// The rejection of an infeasible selection: no plan meets (A)–(I) and -/// (R), with the reasons `asap-cost` could not cost the executions the +/// The rejection of an infeasible selection: no plan meets (A)–(I), (R) +/// and (K), with the reasons `asap-cost` could not cost the executions the /// model left out, each once in execution order: those executions may be /// why no plan is left. fn infeasible(costs: &ExecutionCosts) -> Rejection { @@ -220,7 +247,8 @@ fn infeasible(costs: &ExecutionCosts) -> Rejection { } } let mut reason = "no plan gives every query output a candidate whose every input has an \ - admissible producer (the selection MILP is infeasible)" + admissible producer and meets its accuracy target at the root, \ + constraint (K) (the selection MILP is infeasible)" .to_string(); if !reasons.is_empty() { reason += &format!( @@ -403,38 +431,155 @@ fn inexact_terms( terms } -/// Per query, the accuracy accepted for the selected plan. An output that -/// reads no sketch is exact. Otherwise, until constraint (K) (#1207 PR 8) -/// checks the composed bound at the root, the output is accepted in its -/// target's metric through the parameter floors of its edges (Pass 2's -/// metric rule admits only edges whose metric converts to it), unless the -/// deployment's model accepts it regardless of its guarantee. +/// Per query, the accuracy accepted for the selected plan: its composed +/// guarantee in `selected` ([`composed_guarantees`]), stated over the +/// values its target's scope covers, checked with the deployment's model +/// (`AccuracyModel::satisfies`), then its evidence for the realized output +/// (`AccuracyEvidenceProvider::output_guarantee`). An output the model +/// accepts regardless of its guarantee is accepted, recording the check's +/// result. Err ([`ErrorSelectingPlan::SelectedPlanFailedCheck`]) for an +/// output none accepts: a bug in the error model of (K), or a model +/// stricter than the built-in rule (K) encodes. fn accepted_accuracy( + physical: &PhysicalASAPMemoCandidates, + errors: &CandidateErrorModel, + selected: &DagOfSelectedCandidates, realized: &RealizedPlan, entries: &[QueryWorkloadEntry], - model: &dyn AccuracyModel, -) -> Vec { - realized - .plan - .roots - .iter() - .zip(entries) - .map(|(root, entry)| { - let target = &entry.requirements.accuracy; - let sketches = sketches_read(root); - let exact = sketches.is_empty(); - let regardless = !exact && model.accepts_output_regardless_of_guarantee(root, target); - AcceptedAccuracy { + data: &asap_types::workload::DataWorkload, + accuracy_checks: &AccuracyCheckInputs<'_>, +) -> Result, ErrorSelectingPlan> { + let composed = errors.composed_errors(selected); + let references = output_references(&physical.query_roots); + let model = accuracy_checks.model; + let mut accepted = Vec::with_capacity(entries.len()); + for (query, (root, entry)) in realized.plan.roots.iter().zip(entries).enumerate() { + let target = &entry.requirements.accuracy; + let sketches = sketches_read(root); + let outputs: Vec<&OwnGuarantee> = references + .iter() + .zip(&selected.node_of_output_reference) + .filter(|((reference_query, _), _)| *reference_query == query) + .map(|(_, node)| &composed[node.0]) + .collect(); + let values = values_per_result(root, series_read(root, data), data); + let guarantees = composed_guarantees( + &outputs, + returns_its_output_unchanged(&physical.query_roots[query]), + ) + .map(|guarantees| { + guarantees + .into_iter() + .map(|g| g.over_scope(target.accuracy_scope, values)) + .collect::>() + }); + let met_metric = match &guarantees { + Some(guarantees) if guarantees.iter().all(|g| model.satisfies(g, target)) => Some( + guarantees + .iter() + .find(|g| !g.is_exact()) + .map(|g| g.spec.accuracy_metric), + ), + _ => accuracy_checks + .evidence + .output_guarantee(root) + .map(|g| g.over_scope(target.accuracy_scope, values)) + .filter(|g| model.satisfies(g, target)) + .map(|g| (!g.is_exact()).then_some(g.spec.accuracy_metric)), + }; + let rejection = match (&met_metric, &guarantees) { + (Some(_), _) => None, + (None, None) => Some(RejectionCause::NoAccuracyGuarantee), + (None, Some(_)) => Some(RejectionCause::AccuracyTargetMissed), + }; + if model.accepts_output_regardless_of_guarantee(root, target) { + accepted.push(AcceptedAccuracy { target_metric: target.accuracy_metric, - accepted_metric: (!exact && !regardless) - .then_some(target.accuracy_metric) - .flatten(), - accepted_regardless_of_guarantee: regardless, - guarantee_check_rejection: None, + accepted_metric: None, + accepted_regardless_of_guarantee: true, + guarantee_check_rejection: rejection, sketches, - } - }) - .collect() + }); + continue; + } + let Some(accepted_metric) = met_metric else { + let cause = rejection.expect("not met"); + return Err(ErrorSelectingPlan::SelectedPlanFailedCheck { + cause, + reason: match guarantees { + None => format!( + "q{query}: the selected plan's output has no guarantee, and the \ + deployment has no evidence that it meets target {target:?}" + ), + Some(guarantees) => format!( + "q{query}: the selected plan's output guarantees {:?}, which the \ + deployment's accuracy model finds misses target {target:?}", + guarantees + .iter() + .map(|g| ( + g.spec.accuracy_metric, + g.spec.error.evaluate(), + g.spec.failure_probability.evaluate() + )) + .collect::>() + ), + }, + }); + }; + accepted.push(AcceptedAccuracy { + target_metric: target.accuracy_metric, + accepted_metric, + accepted_regardless_of_guarantee: false, + guarantee_check_rejection: None, + sketches, + }); + } + Ok(accepted) +} + +/// The per-value guarantees of one query's output from the composed +/// errors of its output references' nodes: one per metric with an error +/// (the exact guarantee when there is none); `None` when part of it has no +/// guarantee, or when `unchanged` is false (a scalar root combining +/// outputs, which has no error contract) and an output is not exact. +fn composed_guarantees( + outputs: &[&OwnGuarantee], + unchanged: bool, +) -> Option> { + let exact = |output: &OwnGuarantee| { + !output.without_guarantee && output.error_bound.iter().all(|&e| e == 0.0) + }; + if outputs.iter().all(|output| exact(output)) { + return Some(vec![AccuracyGuarantee::exact("no approximation")]); + } + let [output] = outputs else { + return None; + }; + if output.without_guarantee || !unchanged { + return None; + } + let failure_probability = match output.failure_probability { + Some(0.0) => ProbabilityExpr::Zero, + Some(delta) => ProbabilityExpr::Constant { value: delta }, + None => ProbabilityExpr::Unknown { + statistic: "failure probability".into(), + }, + }; + Some( + (0..METRIC_COUNT) + .filter(|&m| output.error_bound[m] > 0.0) + .map(|m| { + AccuracyGuarantee::new( + METRICS[m], + BoundExpr::Constant { + value: output.error_bound[m], + }, + failure_probability.clone(), + Vec::new(), + ) + }) + .collect(), + ) } /// The sketch algorithms of the summary builds `root` reads, in plan order diff --git a/docs/design_docs/proposals/planner-layering.md b/docs/design_docs/proposals/planner-layering.md index 5c4a55a89..57860efc5 100644 --- a/docs/design_docs/proposals/planner-layering.md +++ b/docs/design_docs/proposals/planner-layering.md @@ -840,26 +840,18 @@ Output: Out — candidate replacement sub-DAGs for s 17: return Out ``` -Every size is decided in Stage 1; Stage 3 only checks the composed accuracy. -Algorithm 1 checks whether a family can meet `acc(s)` at all and -yields the family unsized (`HowToComputeAggregateMeasure::Summary`). When -Pass 1 attaches the family to its memo group, it creates one sized candidate -per split of the accuracy budget: `accuracy_budget_parts` from 1 to the most -approximate inputs of a query reaching the group, each sized by the accuracy -model for its share. Pass 2 never resizes a summary for its own readers: a -tumbling-window chain inherits the size of the build it replaces (a merge of equal-size -tumbling windows keeps the guarantee when the family's merged error is `Exact` or -`AsIfBuiltOverUnion`), and a build shared by several readers takes the -strictest reader's size, one state group per distinct size. The failure -probability is part of Pass 1's size too -(`pass1_memo`): each approximate input -of a candidate with `n` budget parts is sized for `δ / (n · V)`, the smallest -over the queries reaching its group, where `V` is the number of values the -query's accuracy scope covers (1 per value, at most `⌊1 / share⌋` for a record -share, and `G` from the workload-derived bounds for the whole result), and a -classic HLL's precision is raised for its source's declared distinct bound. -Stage 3 never resizes: a candidate whose sizes miss the composed accuracy -target is rejected. +Every size comes from a predefined list: Pass 1 offers each summary family +with every $\boldsymbol\theta$ of its list (`parameter_list`), each with its +precomputed error bound and failure probability per value. Pass 2 sets each +consumer's parameter floor from the query targets passed down through the +error contracts (a pruning cap: it removes only sizes no query can use), +and Stage 3 decides the sizes: constraint (K) composes each query's error at +its root over the selected edges and keeps the error and failure probability +within the target (concepts doc, section 4). The failure probability is per +value; a whole-result target over `G` values (from the workload-derived +bounds) allows `δ / G` per value, a record-share target `δ / ⌊1 / share⌋`. +A distinct count over one source's own rows takes its failure probability at +the source's declared distinct bound. Pass 1 creates no merge nodes: each candidate summarizes exactly its own window, and merging across windows comes from the window-composition rule in Pass 2. @@ -1360,9 +1352,16 @@ flowchart TB ### 3. Plan selection -Selection rejects every candidate that misses an accuracy target or a latency -bound, or that is over the cluster's capacity, and then picks the -cheapest remaining plan. It is the only stage that uses the deployment's cost +Selection picks the cheapest plan that meets every query's accuracy target +(constraint (K): each selected candidate's error bound per metric and +failure probability, composed over the selected edges through each +consumer's error contract, within the target at the query's root) unless the +deployment's accuracy model accepts the query's output regardless of its +guarantee (`IgnoreAccuracyGuarantees`). After the solve it checks each +query's composed guarantee again with the deployment's accuracy model and +evidence; an output none accepts is an error +(`ErrorSelectingPlan::SelectedPlanFailedCheck`). Latency bounds and cluster +capacity are not encoded (cost model 1 only). It is the only stage that uses the deployment's cost and accuracy models, and the only stage that discards valid candidates; earlier stages only prune provably invalid ones. Accuracy is estimated by the deployment's accuracy model, not assumed from a summary's From 87badf57e9a4743c942d0136f575438e12be4a09 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:09:08 +0000 Subject: [PATCH 3/8] test(stage3): root limits per metric and scope; a state's own guarantee per metric; an exact target's limits are exact (#1207 PR 8) --- .../src/milp/error_bounds.rs | 194 +++++++++++++++++- 1 file changed, 188 insertions(+), 6 deletions(-) diff --git a/crates/stage3-plan-selection/src/milp/error_bounds.rs b/crates/stage3-plan-selection/src/milp/error_bounds.rs index b7dffe8fd..fb66e0c7b 100644 --- a/crates/stage3-plan-selection/src/milp/error_bounds.rs +++ b/crates/stage3-plan-selection/src/milp/error_bounds.rs @@ -640,6 +640,9 @@ impl RootAccuracyLimits { /// built-in check accepts it (`AccuracyModel::metric_can_meet`); an /// exact target accepts no error. pub(crate) fn of(target: &AccuracyTarget, values_per_result: Option) -> Self { + if target.is_exact() { + return Self::EXACT; + } let error_bound_limit = std::array::from_fn(|m| { if BuiltinAccuracyModel.metric_can_meet(METRICS[m], target) { target.max_error * (1.0 + TARGET_TOLERANCE) + f64::EPSILON @@ -647,12 +650,7 @@ impl RootAccuracyLimits { 0.0 } }); - let failure_probability = if target.is_exact() { - Some(0.0) - } else { - target.failure_probability - }; - let failure_probability_limit = failure_probability.map(|delta| { + let failure_probability_limit = target.failure_probability.map(|delta| { match target.accuracy_scope.max_values_covered(values_per_result) { Some(values) if values > 0 => { delta * (1.0 + TARGET_TOLERANCE) / values as f64 + f64::EPSILON @@ -714,3 +712,187 @@ pub(crate) fn returns_its_output_unchanged(root: &QueryRoot) -> bool { ), } } + +#[cfg(test)] +mod tests { + use super::*; + use asap_types::ir::operator::operator_properties::{Reduction, Source}; + use asap_types::ir::properties::AccuracyScope; + use asap_types::ir::schema::{DataType, Field, GroupingStrategy, Schema}; + use asap_types::stages::{ + parameter_list, CandidateExpression, CandidateForMemoGroup, LogicalProperties, + OriginOfCandidateForMemoGroup, + }; + use asap_types::workload::SourceFacts; + + /// A rank target accepts only rank errors; a whole-result δ over `G` + /// values allows δ / G per value, none when `G` is not bound; a + /// per-value target allows δ; no δ target sets no limit; an exact + /// target allows nothing. + #[test] + fn root_limits_accept_the_target_s_metric_and_divide_delta_over_its_scope() { + let rank = AccuracyTarget::max_error(0.01) + .accuracy_metric(AccuracyMetric::Rank) + .failure_probability(0.01); + let limits = RootAccuracyLimits::of(&rank, Some(1_000)); + for (m, &metric) in METRICS.iter().enumerate() { + let expected = if metric == AccuracyMetric::Rank { + 0.01 + } else { + 0.0 + }; + assert!( + (limits.error_bound_limit[m] - expected).abs() < 1e-9, + "{metric:?}" + ); + } + let per_value_delta = limits.failure_probability_limit.unwrap(); + assert!((per_value_delta - 1e-5).abs() < 1e-9); + assert_eq!( + RootAccuracyLimits::of(&rank, None).failure_probability_limit, + Some(0.0) + ); + let per_value = rank.clone().accuracy_scope(AccuracyScope::PerValue); + assert!( + (RootAccuracyLimits::of(&per_value, None) + .failure_probability_limit + .unwrap() + - 0.01) + .abs() + < 1e-9 + ); + let any_metric = AccuracyTarget::max_error(0.05); + let limits = RootAccuracyLimits::of(&any_metric, None); + assert!(limits.error_bound_limit.iter().all(|&limit| limit >= 0.05)); + assert_eq!(limits.failure_probability_limit, None); + assert_eq!( + RootAccuracyLimits::of(&AccuracyTarget::exact(), Some(10)), + RootAccuracyLimits::EXACT + ); + } + + fn add( + memo: &mut Memo, + group: MemoGroupId, + operator: Operator, + parameters: Option, + ) { + let expression = CandidateExpression { operator }; + let logical_properties = LogicalProperties::new(&expression, parameters, None).unwrap(); + let input_requirements = + vec![LogicalRequirements::new(None); expression.operator.children().len()]; + memo.add_candidate( + group, + CandidateForMemoGroup { + expression, + logical_properties, + input_requirements, + fused_groups: Vec::new(), + origin: OriginOfCandidateForMemoGroup::Pass1, + }, + ) + .unwrap(); + } + + /// The memo of `latency` scanned, one `algorithm` state with + /// `parameters` over it, and a readout group per quantile of + /// `quantiles`; returns it with the state group. + fn state_read_at( + algorithm: SketchAlgorithm, + parameters: &SketchParams, + quantiles: &[f64], + ) -> (Memo, MemoGroupId) { + let mut memo = Memo::default(); + let scan = Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { + metric: "latency".into(), + }, + predicates: vec![], + schema: Schema::with_time_index( + vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("value", DataType::Float64, false), + ], + 0, + vec![], + ), + }); + let rows = memo.find_or_add_group(scan.clone()).unwrap(); + add(&mut memo, rows, scan, None); + let build = Operator::ASAP(ASAPOp::SummaryAgg { + child: rows, + family: FieldDataType::Sketch(algorithm, GroupingStrategy::default()), + input: SummaryUpdate::column(ColumnRef::SampleValue), + reduction: Reduction::PerEntity, + grouping: GroupingStrategy::default(), + filter: None, + }); + let state = memo.find_or_add_group(build.clone()).unwrap(); + add(&mut memo, state, build, Some(parameters.clone())); + for &q in quantiles { + let readout = Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: state, + query: SketchStatistic::Quantile { q }, + }); + let group = memo.find_or_add_group(readout.clone()).unwrap(); + add(&mut memo, group, readout, None); + } + (memo, state) + } + + fn own_of_state(memo: &Memo, state: MemoGroupId, data: &DataWorkload) -> OwnGuarantee { + let candidate = &memo.groups()[state.0 as usize].candidates[0]; + own_guarantee( + memo, + state, + &candidate.expression.operator, + candidate.logical_properties.parameters(), + false, + data, + ) + } + + /// A KLL state read at p50 and p99 has its rank error once; a DDSketch + /// state read at an interior quantile has a relative error only over + /// values declared non-negative, at the maximum (an extremum) always. + #[test] + fn a_state_s_own_guarantee_is_its_reads_largest_error_per_metric() { + let kll = parameter_list(SketchAlgorithm::Kll)[2].clone(); + let (memo, state) = state_read_at(SketchAlgorithm::Kll, &kll, &[0.5, 0.99]); + let own = own_of_state(&memo, state, &DataWorkload::default()); + let expected = local_guarantee( + &FieldDataType::Sketch(SketchAlgorithm::Kll, GroupingStrategy::default()), + Some(&kll), + &SketchStatistic::Quantile { q: 0.5 }, + ) + .unwrap(); + assert!(!own.without_guarantee); + assert_eq!( + own.error_bound[metric_index(AccuracyMetric::Rank)], + expected.spec.error.evaluate().unwrap() + ); + assert_eq!( + own.failure_probability, + expected.spec.failure_probability.evaluate() + ); + let ddsketch = SketchParams::DDSketch { alpha: 0.01 }; + let (memo, state) = state_read_at(SketchAlgorithm::DDSketch, &ddsketch, &[0.5]); + assert!(own_of_state(&memo, state, &DataWorkload::default()).without_guarantee); + let mut declared = DataWorkload::default(); + declared.sources.insert( + "latency".into(), + SourceFacts { + values_non_negative: true, + ..Default::default() + }, + ); + let own = own_of_state(&memo, state, &declared); + assert!(!own.without_guarantee); + assert_eq!( + own.error_bound[metric_index(AccuracyMetric::Relative)], + 0.01 + ); + let (memo, state) = state_read_at(SketchAlgorithm::DDSketch, &ddsketch, &[1.0]); + assert!(!own_of_state(&memo, state, &DataWorkload::default()).without_guarantee); + } +} From 99c1fa8915a925bcc5c57bb6802902d6847ac347 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:11:39 +0000 Subject: [PATCH 4/8] fix(stage3): an infeasible selection names (K) only when it applies to some query (#1207 PR 8) --- crates/stage3-plan-selection/src/select.rs | 29 ++++++++++++++-------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/stage3-plan-selection/src/select.rs b/crates/stage3-plan-selection/src/select.rs index 68f518bec..4cf7dc0ee 100644 --- a/crates/stage3-plan-selection/src/select.rs +++ b/crates/stage3-plan-selection/src/select.rs @@ -130,9 +130,10 @@ pub fn select_plan( &root_limits, )) .map_err(|error| match error { - SelectionMilpError::Infeasible => { - ErrorSelectingPlan::NoValidCandidate(vec![infeasible(&costs)]) - } + SelectionMilpError::Infeasible => ErrorSelectingPlan::NoValidCandidate(vec![infeasible( + &costs, + root_limits.iter().any(Option::is_some), + )]), SelectionMilpError::Solver(reason) => ErrorSelectingPlan::Solver(reason), })?; let validation_started = std::time::Instant::now(); @@ -236,20 +237,26 @@ pub fn select_plan( } /// The rejection of an infeasible selection: no plan meets (A)–(I), (R) -/// and (K), with the reasons `asap-cost` could not cost the executions the -/// model left out, each once in execution order: those executions may be -/// why no plan is left. -fn infeasible(costs: &ExecutionCosts) -> Rejection { +/// and, when `accuracy_checked` (it applies to some query), (K), with the +/// reasons `asap-cost` could not cost the executions the model left out, +/// each once in execution order: those executions may be why no plan is +/// left. +fn infeasible(costs: &ExecutionCosts, accuracy_checked: bool) -> Rejection { let mut reasons: Vec<&str> = Vec::new(); for reason in costs.uncostable.values() { if !reasons.contains(&reason.as_str()) { reasons.push(reason); } } - let mut reason = "no plan gives every query output a candidate whose every input has an \ - admissible producer and meets its accuracy target at the root, \ - constraint (K) (the selection MILP is infeasible)" - .to_string(); + let mut reason = format!( + "no plan gives every query output a candidate whose every input has an admissible \ + producer{} (the selection MILP is infeasible)", + if accuracy_checked { + " and meets its query's accuracy target at the root, constraint (K)" + } else { + "" + } + ); if !reasons.is_empty() { reason += &format!( "; executions left out because they could not be costed: {}", From 331f80384b77883a090b33b031191e9abeb6db2a Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:12:31 +0000 Subject: [PATCH 5/8] test(planner): constraint (K) end to end on #1207's problem example; ignore mode; a whole-result failure probability over 1,000 series (#1207 PR 8) --- .../planner/tests/accuracy_at_query_roots.rs | 160 ++++++++++++++++++ crates/planner/tests/main.rs | 1 + 2 files changed, 161 insertions(+) create mode 100644 crates/planner/tests/accuracy_at_query_roots.rs diff --git a/crates/planner/tests/accuracy_at_query_roots.rs b/crates/planner/tests/accuracy_at_query_roots.rs new file mode 100644 index 000000000..78fbfddd1 --- /dev/null +++ b/crates/planner/tests/accuracy_at_query_roots.rs @@ -0,0 +1,160 @@ +//! #1207 PR 8: constraint (K), each query's composed accuracy at its root, +//! end to end through the facade on #1207's problem example. + +use asap_frontend_promql::promql_metric_names; +use asap_planner::{e2e_plan, FrontendInput, PlanOutput, PlanningModels, UserInput}; +use asap_types::ir::properties::{AccuracyMetric, AccuracyScope, AccuracyTarget}; +use asap_types::ir::schema::SketchParams; +use asap_types::workload::{ + DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, + QueryRequirements, QueryWorkload, RepeatedDemand, RepeatingEntry, RepetitionInterval, + TimestampMs, +}; + +/// PromQL queries with their accuracy targets, every 10 s at phase 0, over +/// stored samples of `latency` (1,000 series sampled every 15 s, values +/// declared non-negative). +fn workload(queries: &[(&str, AccuracyTarget)]) -> PlanningWorkload { + let mut data = DataWorkload { + arrival: DataArrival::AtRest, + ..DataWorkload::sampled_every( + queries + .iter() + .flat_map(|(query, _)| promql_metric_names(query).unwrap_or_default()), + DurationMs(15_000), + ) + .with_series_count(1_000) + }; + for facts in data.sources.values_mut() { + facts.values_non_negative = true; + } + PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: None, + repeating_queries: Some( + queries + .iter() + .map(|(query, accuracy)| RepeatingEntry { + query: Query((*query).into()), + demand: RepeatedDemand::FixedIntervalAt { + interval: RepetitionInterval(10_000), + evaluation_phase: TimestampMs(0), + }, + requirements: QueryRequirements { + accuracy: accuracy.clone(), + max_latency_ms: None, + }, + predictability: Predictability::Unknown, + range_evaluation: None, + }) + .collect(), + ), + }, + data_workload: Some(data), + } +} + +async fn plan(queries: &[(&str, AccuracyTarget)], ignore_accuracy_guarantees: bool) -> PlanOutput { + let workload = workload(queries); + let models = + PlanningModels::builtin().with_ignore_accuracy_guarantees(ignore_accuracy_guarantees); + e2e_plan(UserInput::new(&workload, FrontendInput::Promql, models)) + .await + .expect("workload plans") +} + +/// #1207's problem example: A, the p95 at rank error 0.01, and B, the +/// ratio of the p99 and the p50 at relative error 0.03. +fn problem_example() -> [(&'static str, AccuracyTarget); 2] { + [ + ( + "quantile_over_time(0.95, latency[1m])", + AccuracyTarget::max_error(0.01).accuracy_metric(AccuracyMetric::Rank), + ), + ( + "quantile_over_time(0.99, latency[1m]) / quantile_over_time(0.5, latency[1m])", + AccuracyTarget::max_error(0.03).accuracy_metric(AccuracyMetric::Relative), + ), + ] +} + +/// The θ of the selected plan's sketch builds. +fn sketch_parameters(output: &PlanOutput) -> Vec { + output + .realized_plan + .as_ref() + .expect("a realized plan") + .plan + .dag + .nodes + .iter() + .filter_map(|node| node.parameters.clone()) + .collect() +} + +/// A meets rank error 0.01 with a KLL (a DDSketch readout's relative error +/// does not meet a rank target); B reads one DDSketch through both +/// operands, with `alpha` small enough that the ratio's composed bound +/// $(\alpha + \alpha) / (1 - 0.1)$ is within 0.03, which `alpha` 0.02 (each +/// operand alone within 0.03) is not. +#[tokio::test] +async fn each_query_meets_its_target_at_its_root() { + let output = plan(&problem_example(), false).await; + let selection = output.selection.as_ref().expect("a plan is selected"); + let accepted = &selection.accepted_accuracy_per_query; + assert_eq!(accepted[0].accepted_metric, Some(AccuracyMetric::Rank)); + assert_eq!(accepted[0].sketches, ["Kll"]); + assert_eq!(accepted[1].accepted_metric, Some(AccuracyMetric::Relative)); + assert_eq!(accepted[1].sketches, ["DDSketch"]); + let parameters = sketch_parameters(&output); + let alphas: Vec = parameters + .iter() + .filter_map(|p| match p { + SketchParams::DDSketch { alpha } => Some(*alpha), + _ => None, + }) + .collect(); + assert_eq!( + alphas.len(), + 1, + "one DDSketch for both operands: {parameters:?}" + ); + assert!((alphas[0] + alphas[0]) / (1.0 - 0.1) <= 0.03, "{alphas:?}"); +} + +/// Ignoring accuracy guarantees, (K) applies to no query: every output is +/// accepted regardless of its guarantee. +#[tokio::test] +async fn ignoring_accuracy_guarantees_drops_k() { + let ignored = plan(&problem_example(), true).await; + let selection = ignored.selection.expect("a plan"); + assert!(selection + .accepted_accuracy_per_query + .iter() + .all(|accepted| accepted.accepted_regardless_of_guarantee)); +} + +/// The listed sizes fail with probability 0.01 per value, so a +/// whole-result target of 0.01 over 1,000 series allows 1e-5 per value: +/// no sketch meets it, and the plan reads the raw data exactly. +#[tokio::test] +async fn a_whole_result_failure_probability_is_divided_over_the_values() { + let output = plan( + &[( + "quantile_over_time(0.95, latency[1m])", + AccuracyTarget::max_error(0.01) + .accuracy_metric(AccuracyMetric::Rank) + .failure_probability(0.01) + .accuracy_scope(AccuracyScope::WholeResult), + )], + false, + ) + .await; + let selection = output.selection.as_ref().expect("a plan is selected"); + assert_eq!( + selection.accepted_accuracy_per_query[0].accepted_metric, + None + ); + assert!(sketch_parameters(&output).is_empty()); +} diff --git a/crates/planner/tests/main.rs b/crates/planner/tests/main.rs index 59c6f63fb..0a851a501 100644 --- a/crates/planner/tests/main.rs +++ b/crates/planner/tests/main.rs @@ -4,6 +4,7 @@ mod time_series; +mod accuracy_at_query_roots; mod columnar_batches; mod e2e_plan; mod execution_contexts; From bd56dfaa6fc190d86662b6921cd611ce0b03c4eb Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:13:40 +0000 Subject: [PATCH 6/8] docs(stage3): errors are tracked per metric (wording) (#1207 PR 8) --- crates/stage3-plan-selection/src/milp/error_bounds.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/stage3-plan-selection/src/milp/error_bounds.rs b/crates/stage3-plan-selection/src/milp/error_bounds.rs index fb66e0c7b..772a0f5d0 100644 --- a/crates/stage3-plan-selection/src/milp/error_bounds.rs +++ b/crates/stage3-plan-selection/src/milp/error_bounds.rs @@ -5,7 +5,7 @@ //! error bound it can carry (the big-M constants $M$), and each query's //! limits at its root ($\varepsilon_q$, $\delta_q$). //! -//! Errors are kept per metric (absolute, absolute in either stream norm, +//! Errors are tracked per metric (absolute, absolute in either stream norm, //! relative, rank): $E_{c,m}$. A consumer's contract in one metric says //! nothing about another, so an input error in a metric its contract does //! not carry must be zero when the edge is selected (the metric rule), and @@ -38,7 +38,7 @@ use asap_types::workload::bounds::{ }; use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; -/// The metrics errors are kept in, indexed by position. +/// The metrics errors are tracked in, indexed by position. pub(crate) const METRICS: [AccuracyMetric; METRIC_COUNT] = [ AccuracyMetric::ABSOLUTE, AccuracyMetric::ABSOLUTE_STREAM_L1, From 7148280855c61cfb1f3b2d7ee9b2c5d42b6799bb Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:23:17 +0000 Subject: [PATCH 7/8] fix(stage3): review fixes: a read's error is its own statistic's, on its edge; (K) applies unless the model or evidence may accept outputs regardless; one G per query for (K) and the check; no ceiling cap; tighter HiGHS tolerances under (K) (#1207 PR 8) - The error of reading a state is a constant per edge (u, c) for the consumer's statistic(s) on the producer's theta (E_c >= sum eps_uc y + ...), so one query's read no longer changes another's (a DDSketch read at the maximum keeps its guarantee when a p50 read of the same state has none). - root_accuracy_limits follows the AccuracyModel/AccuracyEvidenceProvider contract (may_accept_regardless_of_guarantee, may_provide_evidence): (K) applies to every query or none; the per-query pre-ASAP accept-regardless call is gone. - values_per_result is computed once from the lowered roots and used by both (K) and the check after the solve; a short query_roots slice is an EntryCount error. - Ceilings stop after one round per execution (a valid M for every acyclic selection) with no cap, which could make unrelated plans infeasible. - OwnGuarantee renamed ErrorBounds (read errors and composed errors). - Tests: the regression test above; the DDSketch sign test checks the declared case too; the big-M test's description matches it. --- .../src/milp/error_bounds.rs | 564 ++++++++++-------- .../stage3-plan-selection/src/milp/model.rs | 73 ++- .../src/milp/model/tests.rs | 106 +++- crates/stage3-plan-selection/src/models.rs | 15 +- crates/stage3-plan-selection/src/select.rs | 43 +- 5 files changed, 484 insertions(+), 317 deletions(-) diff --git a/crates/stage3-plan-selection/src/milp/error_bounds.rs b/crates/stage3-plan-selection/src/milp/error_bounds.rs index 772a0f5d0..93cd7fbe5 100644 --- a/crates/stage3-plan-selection/src/milp/error_bounds.rs +++ b/crates/stage3-plan-selection/src/milp/error_bounds.rs @@ -1,26 +1,32 @@ -//! What constraint (K) reads (concepts doc, section 4): each physical -//! candidate's own guarantee $\varepsilon^{\mathrm{loc}}_c$, -//! $\delta^{\mathrm{loc}}_c$, its error contract $\beta_{c,i}$, $b_{c,i}$ -//! per metric (`LogicalRequirements::error_propagation`), the largest -//! error bound it can carry (the big-M constants $M$), and each query's -//! limits at its root ($\varepsilon_q$, $\delta_q$). +//! What constraint (K) reads (concepts doc, section 4): the error of +//! reading a summary state through each edge ($\varepsilon^{\mathrm{loc}}$, +//! $\delta^{\mathrm{loc}}$), each candidate's error contract +//! $\beta_{c,i}$, $b_{c,i}$ per metric +//! (`LogicalRequirements::error_propagation`), the largest error bound it +//! can carry (the big-M constants $M$), and each query's limits at its +//! root ($\varepsilon_q$, $\delta_q$). //! -//! Errors are tracked per metric (absolute, absolute in either stream norm, -//! relative, rank): $E_{c,m}$. A consumer's contract in one metric says -//! nothing about another, so an input error in a metric its contract does -//! not carry must be zero when the edge is selected (the metric rule), and -//! a root accepts only the metrics its target accepts. +//! Errors are tracked per metric (absolute, absolute in either stream +//! norm, relative, rank): $E_{c,m}$. A consumer's contract in one metric +//! says nothing about another, so an input error in a metric its contract +//! does not carry must be zero when the edge is selected (the metric rule), +//! and a root accepts only the metrics its target accepts. //! -//! A sketch state's own guarantee is the error of reading it: the -//! statistics read from its group, directly or through merges -//! ([`statistics_read`]), each through `asap_accuracy`'s local guarantee -//! of its $\boldsymbol\theta$; per metric the largest of them, and the -//! largest failure probability. A read whose guarantee needs a fact the -//! data workload does not declare (non-negative values for a Count-Min -//! count or a DDSketch interior quantile) has no guarantee. +//! An approximation enters where a state is read: a readout of a statistic, +//! or a merge, over a summary build with parameters $\boldsymbol\theta$. +//! Its bound depends on both the producer's $\boldsymbol\theta$ and the +//! statistic the consumer reads, so it is a constant per edge $(u, c)$ +//! ([`CandidateErrorModel::read_error_on_edge`]): $E_c \ge \sum_u +//! \varepsilon^{\mathrm{loc}}_{u,c}\, y_{u,c,i} + \sum_i \beta_{c,i} +//! F_{c,i}$, linear since one $y$ per input is 1. A read whose guarantee +//! needs a fact the data workload does not declare (non-negative values +//! for a Count-Min count or a DDSketch interior quantile) has none. + +use std::collections::HashMap; use asap_accuracy::estimators::{distinct_count_guarantee_over_source, local_guarantee}; -use asap_accuracy::{AccuracyModel, BuiltinAccuracyModel}; +use asap_accuracy::BuiltinAccuracyModel; +use asap_accuracy::{AccuracyEvidenceProvider, AccuracyModel}; use asap_types::ir::operator::AggIntent; use asap_types::ir::properties::{AccuracyMetric, AccuracyTarget}; use asap_types::ir::scalar::ColumnRef; @@ -53,11 +59,6 @@ pub(crate) const METRIC_COUNT: usize = 5; /// $\varepsilon$ exactly is not rejected by floating-point noise. const TARGET_TOLERANCE: f64 = 1e-9; -/// The largest error bound or failure probability a ceiling is carried -/// to: a larger one only rejects more plans (concepts doc: $M$ is any -/// upper bound; a smaller one can only make (K) stricter). -const CEILING_CAP: f64 = 1e6; - /// The index of `metric` in [`METRICS`]. fn metric_index(metric: AccuracyMetric) -> usize { METRICS @@ -66,32 +67,44 @@ fn metric_index(metric: AccuracyMetric) -> usize { .expect("every metric a guarantee states is in METRICS") } -/// One candidate's own guarantee. +/// An error bound per metric and a failure probability, or no guarantee: +/// the error of reading a state through one edge, or a selected node's +/// composed error. #[derive(Debug, Clone, PartialEq)] -pub(crate) struct OwnGuarantee { - /// $\varepsilon^{\mathrm{loc}}_{c,m}$, by metric index. +pub(crate) struct ErrorBounds { + /// The error bound in each metric, by index into [`METRICS`]. pub(crate) error_bound: [f64; METRIC_COUNT], - /// $\delta^{\mathrm{loc}}_c$; `None` when its guarantee leaves it - /// unknown (counted as 1 by (K)). + /// The failure probability; `None` when the guarantee leaves it + /// unknown ((K) counts it as 1). pub(crate) failure_probability: Option, - /// Its output carries an approximation without a guarantee the - /// planner can state (a read whose family has no error model, a merge - /// that does not keep the guarantee, an unproven sign). + /// Part of the output is an approximation without a guarantee the + /// planner can state (a family without an error model, a merge or + /// tumbling windows that do not keep the guarantee, an unproven sign). pub(crate) without_guarantee: bool, } -impl OwnGuarantee { - const EXACT: Self = Self { +impl ErrorBounds { + pub(crate) const EXACT: Self = Self { error_bound: [0.0; METRIC_COUNT], failure_probability: Some(0.0), without_guarantee: false, }; + + const WITHOUT_GUARANTEE: Self = Self { + without_guarantee: true, + ..Self::EXACT + }; + + /// Adds `other`'s failure probability (a union bound; unknown stays + /// unknown). + fn add_failure_probability(&mut self, other: Option) { + self.failure_probability = self.failure_probability.zip(other).map(|(a, b)| a + b); + } } /// Each physical candidate's error model, by [`PhysicalCandidateId`] /// ([`Self::new`]). pub(crate) struct CandidateErrorModel { - own: Vec, /// `error_propagation(c, m)` by metric index: $\beta_{c,i}$ and /// $b_{c,i}$, `None` without a contract in that metric. propagation: Vec<[Option; METRIC_COUNT]>, @@ -100,10 +113,15 @@ pub(crate) struct CandidateErrorModel { value_preserving: Vec, /// $|\mathcal I(c)|$. input_count: Vec, - /// The largest $E_{c,m}$ any selection gives (capped at - /// [`CEILING_CAP`]); 0 when `c` never carries an `m` error. + /// $\varepsilon^{\mathrm{loc}}_{u,c}$, $\delta^{\mathrm{loc}}_{u,c}$ + /// of each admissible edge on which consumer `c` reads a state that + /// summary build `u` builds, by (producer, consumer) id; absent when + /// the read is exact. + read_error_on_edge: HashMap<(usize, usize), ErrorBounds>, + /// The largest $E_{c,m}$ any selection gives; 0 when `c` never + /// carries an `m` error. error_bound_ceiling: Vec<[f64; METRIC_COUNT]>, - /// The largest $D_c$ any selection gives (capped). + /// The largest $D_c$ any selection gives. failure_probability_ceiling: Vec, /// Whether some selection gives `c`'s output a part without a /// guarantee. @@ -112,33 +130,25 @@ pub(crate) struct CandidateErrorModel { impl CandidateErrorModel { /// The error model of `physical`'s candidates, the data workload - /// `data` answering the facts their guarantees need (values' signs, - /// a source's distinct bound). - pub(crate) fn new(physical: &PhysicalASAPMemoCandidates, data: &DataWorkload) -> Self { + /// `data` answering the facts the guarantees need (values' signs, a + /// source's distinct bound). `longest_path` bounds the edges of a + /// selected path (the executions), for the ceilings. + pub(crate) fn new( + physical: &PhysicalASAPMemoCandidates, + data: &DataWorkload, + longest_path: usize, + ) -> Self { let memo = &physical.logical_candidates.memo; - let operator = |c: usize| { - &memo - .candidate(physical.physical_candidates[c].logical_candidate_id) - .expression - .operator - }; + let logical = + |c: usize| memo.candidate(physical.physical_candidates[c].logical_candidate_id); let count = physical.physical_candidates.len(); - let mut own = Vec::with_capacity(count); let mut propagation = Vec::with_capacity(count); let mut value_preserving = Vec::with_capacity(count); let mut input_count = Vec::with_capacity(count); - for (c, candidate) in physical.physical_candidates.iter().enumerate() { - let logical = memo.candidate(candidate.logical_candidate_id); - own.push(own_guarantee( - memo, - candidate.logical_candidate_id.group, - operator(c), - logical.logical_properties.parameters(), - logical.logical_properties.window_layout().is_some(), - data, - )); + for c in 0..count { + let operator = &logical(c).expression.operator; let contracts: [Option; METRIC_COUNT] = std::array::from_fn(|m| { - LogicalRequirements::error_propagation(operator(c), METRICS[m]) + LogicalRequirements::error_propagation(operator, METRICS[m]) }); value_preserving.push(contracts.iter().all(|contract| { contract @@ -146,72 +156,110 @@ impl CandidateErrorModel { .is_some_and(|p| p.factor_per_input.iter().all(|&factor| factor == 1.0)) })); propagation.push(contracts); - input_count.push(operator(c).children().len()); + input_count.push(operator.children().len()); + } + // The reads, once per (producer, consumer) and statistics read. + let mut statistics_of_group: HashMap> = HashMap::new(); + let mut read_error_on_edge = HashMap::new(); + for (_, edge) in physical.edges() { + if !(edge.satisfies_logical_requirement && edge.satisfies_physical_requirement) { + continue; + } + let (u, c) = (edge.producer_candidate_id.0, edge.consumer_candidate_id.0); + let statistics = match &logical(c).expression.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { query, .. }) => vec![query.clone()], + Operator::ASAP(ASAPOp::SummaryMerge { .. }) => { + let group = physical.physical_candidates[c].logical_candidate_id.group; + statistics_of_group + .entry(group) + .or_insert_with(|| statistics_read(memo, group)) + .clone() + } + _ => continue, + }; + let producer = logical(u); + let read = read_bounds( + memo, + &producer.expression.operator, + producer.logical_properties.parameters(), + producer.logical_properties.window_layout().is_some(), + &statistics, + matches!( + logical(c).expression.operator, + Operator::ASAP(ASAPOp::SummaryMerge { .. }) + ), + data, + ); + if read != ErrorBounds::EXACT { + read_error_on_edge.insert((u, c), read); + } } let mut model = Self { - own, propagation, value_preserving, input_count, + read_error_on_edge, error_bound_ceiling: Vec::new(), failure_probability_ceiling: Vec::new(), may_lack_guarantee: Vec::new(), }; - model.set_ceilings(physical); + model.set_ceilings(physical, longest_path); model } /// The ceilings over `physical`'s admissible edges: rounds of - /// $\bar E_{c,m} = \varepsilon^{\mathrm{loc}}_{c,m} + \sum_i - /// \beta_{c,i,m} \max_{u \in P_{c,i}} \bar E_{u,m}$ (an input whose - /// contract does not carry $m$ adds nothing: (K) makes it zero), and - /// likewise for $D$ and the lack of a guarantee, until nothing changes. - /// A selection is acyclic, so its error is reached within as many - /// rounds as there are candidates; the rounds stop there. - fn set_ceilings(&mut self, physical: &PhysicalASAPMemoCandidates) { - let count = self.own.len(); + /// $\bar E_{c,m} = \sum_i \max_{u \in P_{c,i}} (\beta_{c,i,m} \bar + /// E_{u,m} + \varepsilon^{\mathrm{loc}}_{u,c,m})$ (an input whose + /// contract does not carry $m$ passes nothing on: (K) makes it zero), + /// likewise for $D$ (an unknown failure probability as 1) and the lack + /// of a guarantee, until nothing changes. After $k$ rounds a ceiling + /// bounds every selected path of at most $k$ edges, so stopping after + /// `longest_path` rounds bounds every acyclic selection: each is a valid + /// $M$. (On a cycle that carries error the ceilings grow with the + /// rounds; none occurs in the workloads tested.) + fn set_ceilings(&mut self, physical: &PhysicalASAPMemoCandidates, longest_path: usize) { + let count = self.input_count.len(); let edges: Vec<_> = physical.edges().map(|(_, edge)| edge).collect(); let mut error = vec![[0.0; METRIC_COUNT]; count]; let mut failure = vec![0.0; count]; let mut lacks = vec![false; count]; - for _ in 0..count.max(1) { + for _ in 0..longest_path.max(1) { let mut changed = false; for c in 0..count { - let own = &self.own[c]; - let mut next_error = own.error_bound; - let mut next_failure = own.failure_probability.unwrap_or(1.0); - let mut next_lacks = own.without_guarantee; + let mut next_error = [0.0; METRIC_COUNT]; + let mut next_failure = 0.0; + let mut next_lacks = false; for input in 0..self.input_count[c] { - let producers = physical - .admissible_edges_into(PhysicalCandidateId(c), input) - .iter() - .map(|edge| edges[edge.0].producer_candidate_id.0); let mut largest_error = [0.0_f64; METRIC_COUNT]; let mut largest_failure = 0.0_f64; - let mut any_lacks = false; - for u in producers { + for edge in physical.admissible_edges_into(PhysicalCandidateId(c), input) { + let u = edges[edge.0].producer_candidate_id.0; + let read = self.read_error_on_edge(u, c); for m in 0..METRIC_COUNT { - largest_error[m] = largest_error[m].max(error[u][m]); + let carried = self.factor(c, input, m).unwrap_or(0.0) * error[u][m]; + largest_error[m] = largest_error[m].max(carried + read.error_bound[m]); } - largest_failure = largest_failure.max(failure[u]); - any_lacks |= lacks[u]; - } - for (m, largest) in largest_error.iter().enumerate() { - next_error[m] += self.factor(c, input, m).unwrap_or(0.0) * largest; + let carried = if self.propagates_failure(c, input) { + failure[u] + } else { + 0.0 + }; + largest_failure = + largest_failure.max(carried + read.failure_probability.unwrap_or(1.0)); + next_lacks |= + read.without_guarantee || (lacks[u] && self.value_preserving[c]); } - if self.propagates_failure(c, input) { - next_failure += largest_failure; + for m in 0..METRIC_COUNT { + next_error[m] += largest_error[m]; } - next_lacks |= any_lacks && self.value_preserving[c]; + next_failure += largest_failure; } for m in 0..METRIC_COUNT { - let next = next_error[m].min(CEILING_CAP); - if next > error[c][m] { - error[c][m] = next; + if next_error[m] > error[c][m] { + error[c][m] = next_error[m]; changed = true; } } - let next_failure = next_failure.min(CEILING_CAP); if next_failure > failure[c] { failure[c] = next_failure; changed = true; @@ -230,6 +278,15 @@ impl CandidateErrorModel { self.may_lack_guarantee = lacks; } + /// $\varepsilon^{\mathrm{loc}}_{u,c}$, $\delta^{\mathrm{loc}}_{u,c}$ of + /// consumer `c` reading producer `u`: exact unless `c` reads a state + /// `u` builds. + pub(crate) fn read_error_on_edge(&self, u: usize, c: usize) -> &ErrorBounds { + self.read_error_on_edge + .get(&(u, c)) + .unwrap_or(&ErrorBounds::EXACT) + } + /// $\beta_{c,i}$ in metric `m`, `None` without a contract in it. pub(crate) fn factor(&self, c: usize, input: usize, m: usize) -> Option { self.propagation[c][m] @@ -253,10 +310,6 @@ impl CandidateErrorModel { || (0..METRIC_COUNT).any(|m| self.factor(c, input, m).is_some_and(|f| f > 0.0)) } - pub(crate) fn own(&self, c: usize) -> &OwnGuarantee { - &self.own[c] - } - pub(crate) fn value_preserving(&self, c: usize) -> bool { self.value_preserving[c] } @@ -274,22 +327,20 @@ impl CandidateErrorModel { pub(crate) fn may_lack_guarantee(&self, c: usize) -> bool { self.may_lack_guarantee[c] } -} -impl CandidateErrorModel { /// The composed error of every node of `selected` (section 4: the /// root conditions over the selected edges, with no big-M), by - /// `SelectedNodeId`: its own guarantee plus $\beta$ times each input's, - /// failure probabilities by a union bound. An input error the - /// consumer's contract does not carry (the metric rule), or one over its - /// validity cap, leaves the output without a guarantee. - pub(crate) fn composed_errors(&self, selected: &DagOfSelectedCandidates) -> Vec { + /// `SelectedNodeId`: per input, its read error plus $\beta$ times the + /// producer's error, failure probabilities by a union bound. An input + /// error the consumer's contract does not carry (the metric rule), or + /// one over its validity cap, leaves the output without a guarantee. + pub(crate) fn composed_errors(&self, selected: &DagOfSelectedCandidates) -> Vec { let nodes = selected.candidate_of_node.len(); let mut producers: Vec> = vec![Vec::new(); nodes]; for edge in &selected.edges { producers[edge.consumer.0].push((edge.input_index_of_consumer, edge.producer.0)); } - let mut composed: Vec> = vec![None; nodes]; + let mut composed: Vec> = vec![None; nodes]; // Depth first, inputs before their consumer; the selection is acyclic. for start in 0..nodes { let mut stack = vec![(start, false)]; @@ -308,35 +359,36 @@ impl CandidateErrorModel { continue; } let c = selected.candidate_of_node[node].0; - let mut error = self.own[c].clone(); + let mut error = ErrorBounds::EXACT; for &(input, producer) in &producers[node] { - let read = composed[producer] + let u = selected.candidate_of_node[producer].0; + let produced = composed[producer] .as_ref() .expect("an acyclic selection composes inputs first"); + let read = self.read_error_on_edge(u, c); for m in 0..METRIC_COUNT { - if read.error_bound[m] == 0.0 { + error.error_bound[m] += read.error_bound[m]; + if produced.error_bound[m] == 0.0 { continue; } match self.factor(c, input, m) { - Some(factor) => error.error_bound[m] += factor * read.error_bound[m], + Some(factor) => { + error.error_bound[m] += factor * produced.error_bound[m] + } None => error.without_guarantee = true, } if self .validity_cap(c, input, m) - .is_some_and(|cap| read.error_bound[m] > cap) + .is_some_and(|cap| produced.error_bound[m] > cap) { error.without_guarantee = true; } } + error.add_failure_probability(read.failure_probability); if self.propagates_failure(c, input) { - error.failure_probability = error - .failure_probability - .zip(read.failure_probability) - .map(|(own, read)| own + read); - } - if read.without_guarantee { - error.without_guarantee = true; + error.add_failure_probability(produced.failure_probability); } + error.without_guarantee |= read.without_guarantee || produced.without_guarantee; } composed[node] = Some(error); } @@ -348,105 +400,78 @@ impl CandidateErrorModel { } } -/// The own guarantee of the candidate `operator` of group `group`, with -/// $\boldsymbol\theta$ `parameters`, built over tumbling windows when -/// `windowed`: -/// - a sketch build: for each statistic read from its group -/// ([`statistics_read`]), the guarantee of reading it -/// ([`read_guarantee`]); per metric the largest error, the largest -/// failure probability; no guarantee when one read has none; -/// - a build of a sample, wavelet or model state: no guarantee (no error -/// model); -/// - a merge of sketch states whose family's merged state does not keep -/// the guarantee of a statistic read from it: no guarantee; -/// - every other candidate: exact (its error comes from its inputs). -fn own_guarantee( +/// The error of consumer reads of `statistics` (a readout's one, or every +/// statistic read from a merge's group, when `merge`) from producer +/// `operator` with $\boldsymbol\theta$ `parameters`, built over tumbling +/// windows when `windowed`: exact unless `operator` builds a summary state; +/// for a sketch build, per metric the largest error and the largest +/// failure probability of the reads ([`read_guarantee`]); no guarantee +/// when one read has none, when a merge's family does not keep a +/// statistic's guarantee, or for a sample, wavelet or model state (no +/// error model). +fn read_bounds( memo: &Memo, - group: MemoGroupId, operator: &Operator, parameters: Option<&SketchParams>, windowed: bool, + statistics: &[SketchStatistic], + merge: bool, data: &DataWorkload, -) -> OwnGuarantee { - let without_guarantee = OwnGuarantee { - without_guarantee: true, - ..OwnGuarantee::EXACT - }; - match operator { - Operator::ASAP( - ASAPOp::SummaryAgg { - family, - input, - child, - .. - } - | ASAPOp::SummaryTumblingWindowChain { - family, - input, - child, - .. - }, - ) => match family { - FieldDataType::Sketch(..) => { - let windowed = windowed - || matches!( - operator, - Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }) - ); - let mut own = OwnGuarantee::EXACT; - for statistic in statistics_read(memo, group) { - let Some(read) = read_guarantee( - memo, family, parameters, windowed, input, *child, &statistic, data, - ) else { - return without_guarantee; - }; - let m = metric_index(read.metric); - own.error_bound[m] = own.error_bound[m].max(read.error_bound); - own.failure_probability = - match (own.failure_probability, read.failure_probability) { - (Some(a), Some(b)) => Some(a.max(b)), - _ => None, - }; - } - own - } - FieldDataType::ExactAggregate(..) | FieldDataType::Plain(_) => OwnGuarantee::EXACT, - FieldDataType::SummarySample(..) - | FieldDataType::Wavelet(..) - | FieldDataType::StatModel(..) => without_guarantee, +) -> ErrorBounds { + let Operator::ASAP( + ASAPOp::SummaryAgg { + family, + input, + child, + .. + } + | ASAPOp::SummaryTumblingWindowChain { + family, + input, + child, + .. }, - Operator::ASAP(ASAPOp::SummaryMerge { children, .. }) => { - let statistics = statistics_read(memo, group); - let keeps = children.iter().all(|&child| { - match state_family(&memo.groups()[child.0 as usize].expression) { - Some(family @ FieldDataType::Sketch(..)) => statistics.iter().all(|s| { - matches!( - FamilyCapability::of(family).merged_error(s), - Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) - ) - }), - // An exact state merges exactly; a merge over a merge - // or another state is judged at its own level. - _ => true, - } - }); - if keeps { - OwnGuarantee::EXACT - } else { - without_guarantee + ) = operator + else { + return ErrorBounds::EXACT; + }; + match family { + FieldDataType::ExactAggregate(..) | FieldDataType::Plain(_) => ErrorBounds::EXACT, + FieldDataType::SummarySample(..) + | FieldDataType::Wavelet(..) + | FieldDataType::StatModel(..) => ErrorBounds::WITHOUT_GUARANTEE, + FieldDataType::Sketch(..) => { + let windowed = windowed + || matches!( + operator, + Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }) + ); + let mut bounds = ErrorBounds { + failure_probability: Some(0.0), + ..ErrorBounds::EXACT + }; + for statistic in statistics { + let Some(read) = read_guarantee( + memo, + family, + parameters, + windowed || merge, + input, + *child, + statistic, + data, + ) else { + return ErrorBounds::WITHOUT_GUARANTEE; + }; + let m = metric_index(read.metric); + bounds.error_bound[m] = bounds.error_bound[m].max(read.error_bound); + bounds.failure_probability = bounds + .failure_probability + .zip(read.failure_probability) + .map(|(a, b)| a.max(b)); } + bounds } - _ => OwnGuarantee::EXACT, - } -} - -/// The family a state build's expression builds; `None` for any other. -fn state_family(operator: &Operator) -> Option<&FieldDataType> { - match operator { - Operator::ASAP( - ASAPOp::SummaryAgg { family, .. } | ASAPOp::SummaryTumblingWindowChain { family, .. }, - ) => Some(family), - _ => None, } } @@ -491,10 +516,10 @@ struct ReadGuarantee { /// The guarantee of reading `statistic` from a sketch build of `family` /// with $\boldsymbol\theta$ `parameters` over `update`s of group `rows` -/// (merged from its tumbling windows when `windowed`); `None` when it has -/// none: -/// - a windowed build whose family's merged state does not keep the -/// statistic's guarantee (the family-capability table); +/// (merged, from its tumbling windows or by a merge, when `merged`); +/// `None` when it has none: +/// - a merged state whose family does not keep the statistic's guarantee +/// (the family-capability table); /// - no local guarantee (`asap_accuracy::estimators::local_guarantee`), or /// an error bound it leaves unknown; /// - a Count-Min count, or a DDSketch interior quantile, over values not @@ -508,7 +533,7 @@ fn read_guarantee( memo: &Memo, family: &FieldDataType, parameters: Option<&SketchParams>, - windowed: bool, + merged: bool, update: &SummaryUpdate, rows: MemoGroupId, statistic: &SketchStatistic, @@ -517,7 +542,7 @@ fn read_guarantee( let FieldDataType::Sketch(algorithm, _) = family else { return None; }; - if windowed + if merged && !matches!( FamilyCapability::of(family).merged_error(statistic), Some(MergedError::Exact | MergedError::AsIfBuiltOverUnion) @@ -672,28 +697,38 @@ impl RootAccuracyLimits { }; } -/// Each query's root limits, by query: `None` when (K) does not apply to -/// it, because the deployment's accuracy model accepts its output -/// whatever its guarantee (`AccuracyModel::accepts_output_regardless_of_guarantee` -/// on the root the front ends lowered, `query_roots[q]`; e.g. -/// `IgnoreAccuracyGuarantees`). $G_q$ is `values_per_result` of that root. -pub(crate) fn root_accuracy_limits( - entries: &[QueryWorkloadEntry], +/// How many values one evaluation of each query returns ($G_q$), by +/// query: `values_per_result` of the root the front ends lowered. +pub(crate) fn values_per_result_per_query( query_roots: &[QueryRoot], data: &DataWorkload, +) -> Vec> { + query_roots + .iter() + .map(|root| values_per_result(root, series_read(root, data), data)) + .collect() +} + +/// Each query's root limits, by query, `values_per_result[q]` its $G_q$; +/// every one `None` ((K) applies to no query) when the deployment's model +/// may accept an output regardless of its guarantee or its evidence may +/// meet a target (`AccuracyModel::may_accept_regardless_of_guarantee`, +/// `AccuracyEvidenceProvider::may_provide_evidence`, e.g. +/// `IgnoreAccuracyGuarantees`): their contract is that plan selection then +/// keeps every candidate a guarantee would rule out, and the check after +/// the solve decides. +pub(crate) fn root_accuracy_limits( + entries: &[QueryWorkloadEntry], + values_per_result: &[Option], model: &dyn AccuracyModel, + evidence: &dyn AccuracyEvidenceProvider, ) -> Vec> { + let applies = !model.may_accept_regardless_of_guarantee() && !evidence.may_provide_evidence(); entries .iter() - .enumerate() - .map(|(query, entry)| { - let target = &entry.requirements.accuracy; - let root = query_roots.get(query)?; - if model.accepts_output_regardless_of_guarantee(root, target) { - return None; - } - let values = values_per_result(root, series_read(root, data), data); - Some(RootAccuracyLimits::of(target, values)) + .zip(values_per_result) + .map(|(entry, &values)| { + applies.then(|| RootAccuracyLimits::of(&entry.requirements.accuracy, values)) }) .collect() } @@ -840,44 +875,75 @@ mod tests { (memo, state) } - fn own_of_state(memo: &Memo, state: MemoGroupId, data: &DataWorkload) -> OwnGuarantee { + /// The error of reading `statistics` from `state`'s first candidate, + /// by a readout (`merge` false) or a merge. + fn read( + memo: &Memo, + state: MemoGroupId, + statistics: &[SketchStatistic], + merge: bool, + data: &DataWorkload, + ) -> ErrorBounds { let candidate = &memo.groups()[state.0 as usize].candidates[0]; - own_guarantee( + read_bounds( memo, - state, &candidate.expression.operator, candidate.logical_properties.parameters(), false, + statistics, + merge, data, ) } - /// A KLL state read at p50 and p99 has its rank error once; a DDSketch - /// state read at an interior quantile has a relative error only over - /// values declared non-negative, at the maximum (an extremum) always. + fn quantile(q: f64) -> SketchStatistic { + SketchStatistic::Quantile { q } + } + + /// A read's error is its own statistic's on the producer's θ: a KLL + /// read has its rank error; a DDSketch read at an interior quantile has + /// a relative error only over values declared non-negative, while a + /// read of the same state at the maximum (an extremum) always has one. + /// A merge reading several statistics takes the largest per metric and + /// has no guarantee when one of them has none. #[test] - fn a_state_s_own_guarantee_is_its_reads_largest_error_per_metric() { + fn a_read_s_error_is_its_statistic_s_on_the_producer_s_parameters() { let kll = parameter_list(SketchAlgorithm::Kll)[2].clone(); let (memo, state) = state_read_at(SketchAlgorithm::Kll, &kll, &[0.5, 0.99]); - let own = own_of_state(&memo, state, &DataWorkload::default()); let expected = local_guarantee( &FieldDataType::Sketch(SketchAlgorithm::Kll, GroupingStrategy::default()), Some(&kll), - &SketchStatistic::Quantile { q: 0.5 }, + &quantile(0.5), ) .unwrap(); - assert!(!own.without_guarantee); + let bounds = read( + &memo, + state, + &[quantile(0.5)], + false, + &DataWorkload::default(), + ); + assert!(!bounds.without_guarantee); assert_eq!( - own.error_bound[metric_index(AccuracyMetric::Rank)], + bounds.error_bound[metric_index(AccuracyMetric::Rank)], expected.spec.error.evaluate().unwrap() ); assert_eq!( - own.failure_probability, + bounds.failure_probability, expected.spec.failure_probability.evaluate() ); let ddsketch = SketchParams::DDSketch { alpha: 0.01 }; - let (memo, state) = state_read_at(SketchAlgorithm::DDSketch, &ddsketch, &[0.5]); - assert!(own_of_state(&memo, state, &DataWorkload::default()).without_guarantee); + let (memo, state) = state_read_at(SketchAlgorithm::DDSketch, &ddsketch, &[0.5, 1.0]); + let undeclared = DataWorkload::default(); + assert!(read(&memo, state, &[quantile(0.5)], false, &undeclared).without_guarantee); + let at_the_maximum = read(&memo, state, &[quantile(1.0)], false, &undeclared); + assert!(!at_the_maximum.without_guarantee); + assert_eq!( + at_the_maximum.error_bound[metric_index(AccuracyMetric::Relative)], + 0.01 + ); + let both = [quantile(0.5), quantile(1.0)]; + assert!(read(&memo, state, &both, true, &undeclared).without_guarantee); let mut declared = DataWorkload::default(); declared.sources.insert( "latency".into(), @@ -886,13 +952,11 @@ mod tests { ..Default::default() }, ); - let own = own_of_state(&memo, state, &declared); - assert!(!own.without_guarantee); + let merged = read(&memo, state, &both, true, &declared); + assert!(!merged.without_guarantee); assert_eq!( - own.error_bound[metric_index(AccuracyMetric::Relative)], + merged.error_bound[metric_index(AccuracyMetric::Relative)], 0.01 ); - let (memo, state) = state_read_at(SketchAlgorithm::DDSketch, &ddsketch, &[1.0]); - assert!(!own_of_state(&memo, state, &DataWorkload::default()).without_guarantee); } } diff --git a/crates/stage3-plan-selection/src/milp/model.rs b/crates/stage3-plan-selection/src/milp/model.rs index 5b8a61ee1..09bac355e 100644 --- a/crates/stage3-plan-selection/src/milp/model.rs +++ b/crates/stage3-plan-selection/src/milp/model.rs @@ -661,6 +661,12 @@ pub(crate) fn solve_selection( model.set_option("mip_abs_gap", 0.0); model.set_option("output_flag", false); model.set_option("threads", 1); + if accuracy.is_some() { + // (K)'s big-M rows: an integrality slack of 1e-6 on y would relax + // a bound by 1e-6 M; the check after the solve compares exactly. + model.set_option("mip_feasibility_tolerance", 1e-9); + model.set_option("primal_feasibility_tolerance", 1e-9); + } let construction_ms = started.elapsed().as_millis() as u64; let started = Instant::now(); let solved = model @@ -930,16 +936,19 @@ fn accuracy_variables( /// (K) over `inputs` (section 4), per execution and metric, big-M on the /// selected edges with $M$ the producer's ceiling /// ([`CandidateErrorModel`]): -/// - $E_{c,m} \ge \varepsilon^{\mathrm{loc}}_{c,m} + \sum_i -/// \beta_{c,i,m} F_{c,i,m}$, $F_{c,i,m} \ge E_{u,m} - M(1 - y)$; +/// - $E_{c,m} \ge \sum_{u} \varepsilon^{\mathrm{loc}}_{u,c,m}\, y_{u,c,i} + +/// \sum_i \beta_{c,i,m} F_{c,i,m}$ (the read error of each producer a +/// consumer may read a state from, on its edge; one $y$ per input is 1), +/// $F_{c,i,m} \ge E_{u,m} - M(1 - y)$; /// - the metric rule: an input error in a metric $c$'s contract does not /// carry is zero on a selected edge, $E_{u,m} \le M(1 - y)$; the validity /// cap: $E_{u,m} \le b_{c,i} + M(1 - y)$; -/// - $D_c \ge \delta^{\mathrm{loc}}_c + \sum_i H_{c,i}$, $H_{c,i} \ge D_u - -/// M(1 - y)$ (an unknown $\delta^{\mathrm{loc}}$ counts as 1); -/// - $U_c \ge 1$ for an own approximation without a guarantee, -/// $U_c \ge U_u - (1 - y)$ through a value-preserving consumer, and -/// $U_u \le 1 - y$ into any other; +/// - $D_c \ge \sum_u \delta^{\mathrm{loc}}_{u,c}\, y_{u,c,i} + \sum_i +/// H_{c,i}$, $H_{c,i} \ge D_u - M(1 - y)$ (an unknown +/// $\delta^{\mathrm{loc}}$ counts as 1); +/// - $U_c \ge y$ for a read without a guarantee, $U_c \ge U_u - (1 - y)$ +/// through a value-preserving consumer, and $U_u \le 1 - y$ into any +/// other; /// - the root checks, per query $q$ (K) applies to and $v \in G_q$: /// $E_{v,m} \le \varepsilon_{q,m} + M(1 - z_{q,v})$ ($\varepsilon_{q,m}$ /// is 0 in a metric the target does not accept), @@ -955,10 +964,27 @@ fn accuracy_constraints( ) -> Vec { let errors = inputs.errors; let mut constraints = Vec::new(); + // Each execution's read errors and its inputs' carried errors. + let mut error_terms: BTreeMap<(Execution, usize), Expression> = BTreeMap::new(); + let mut failure_terms: BTreeMap = BTreeMap::new(); for (&(context, edge), &y) in sorted(&variables.y_in_execution_context) { let (producer, consumer) = inputs.executions_of_edge(context, edge); let (u, c) = (producer.candidate().0, consumer.candidate().0); let input = inputs.edges()[edge.0].input_index_of_consumer; + let read = errors.read_error_on_edge(u, c); + for (m, &error) in read.error_bound.iter().enumerate() { + if error > 0.0 { + *error_terms.entry((consumer, m)).or_default() += error * y; + } + } + let read_failure = read.failure_probability.unwrap_or(1.0); + if read_failure > 0.0 { + *failure_terms.entry(consumer).or_default() += read_failure * y; + } + if read.without_guarantee { + let u_c = accuracy.without_guarantee[&consumer]; + constraints.push(constraint!(u_c >= y)); + } for m in 0..METRIC_COUNT { let Some(&e_u) = accuracy.error_bound.get(&(producer, m)) else { continue; @@ -996,36 +1022,31 @@ fn accuracy_constraints( constraints.push(constraint!(h >= d_u - ceiling * (1 - y))); } } - // Each execution's own bound plus its inputs'. - let mut inputs_error: BTreeMap<(Execution, usize), Expression> = BTreeMap::new(); for (&(consumer, input, m), &f) in sorted(&accuracy.error_bound_of_input) { let factor = errors .factor(consumer.candidate().0, input, m) .expect("F only where a contract carries the metric"); - *inputs_error.entry((consumer, m)).or_default() += factor * f; + *error_terms.entry((consumer, m)).or_default() += factor * f; } for (&(execution, m), &e) in sorted(&accuracy.error_bound) { - let own = errors.own(execution.candidate().0).error_bound[m]; - let carried = inputs_error.remove(&(execution, m)).unwrap_or_default(); - constraints.push(constraint!(e >= own + carried)); + let terms = error_terms.remove(&(execution, m)).unwrap_or_default(); + constraints.push(constraint!(e >= terms)); } - let mut inputs_failure: BTreeMap = BTreeMap::new(); + debug_assert!( + error_terms.is_empty(), + "E exists wherever an error may arrive" + ); for (&(consumer, _), &h) in sorted(&accuracy.failure_probability_of_input) { - *inputs_failure.entry(consumer).or_default() += h; + *failure_terms.entry(consumer).or_default() += h; } for (&execution, &d) in sorted(&accuracy.failure_probability) { - let own = errors - .own(execution.candidate().0) - .failure_probability - .unwrap_or(1.0); - let carried = inputs_failure.remove(&execution).unwrap_or_default(); - constraints.push(constraint!(d >= own + carried)); - } - for (&execution, &u) in sorted(&accuracy.without_guarantee) { - if errors.own(execution.candidate().0).without_guarantee { - constraints.push(constraint!(u >= 1)); - } + let terms = failure_terms.remove(&execution).unwrap_or_default(); + constraints.push(constraint!(d >= terms)); } + debug_assert!( + failure_terms.is_empty(), + "D exists wherever a failure may arrive" + ); // The root checks. for &(query, group) in inputs.query_output_groups() { let Some(limits) = inputs.root_limits_of(query) else { diff --git a/crates/stage3-plan-selection/src/milp/model/tests.rs b/crates/stage3-plan-selection/src/milp/model/tests.rs index adb9f16af..7fd5e0cea 100644 --- a/crates/stage3-plan-selection/src/milp/model/tests.rs +++ b/crates/stage3-plan-selection/src/milp/model/tests.rs @@ -243,7 +243,7 @@ fn solve_under( ) -> Result { let contexts = ExecutionContexts::new(physical, entries); let costs = uniform_costs(&contexts, costs, retention); - let errors = CandidateErrorModel::new(physical, accuracy.data); + let errors = CandidateErrorModel::new(physical, accuracy.data, contexts.executions().len()); solve_selection(&SelectionInputs::new( physical, &contexts, @@ -445,7 +445,7 @@ fn reference_minimum( ) -> Option { let contexts = ExecutionContexts::new(physical, entries); let execution_costs = uniform_costs(&contexts, costs, retention); - let errors = CandidateErrorModel::new(physical, accuracy.data); + let errors = CandidateErrorModel::new(physical, accuracy.data, contexts.executions().len()); let inputs = SelectionInputs::new( physical, &contexts, @@ -1496,6 +1496,22 @@ fn a_ddsketch_interior_quantile_needs_non_negative_values() { .unwrap_err(), SelectionMilpError::Infeasible ); + // Declared non-negative: B reads one DDSketch. + let data = non_negative_latency(); + let declared = Accuracy { + data: &data, + root_limits: &root_limits, + }; + let solved = solve_under( + &physical, + &problem_example_costs(), + &[], + &entries, + &declared, + ) + .unwrap(); + assert!(selected(&solved).contains(&RATIO)); + assert_eq!(selected_parameters(&physical, &solved, 5..=8).len(), 1); } /// Failure probabilities add along the selected edges (a union bound), @@ -1557,33 +1573,33 @@ fn failure_probabilities_add_along_the_selected_edges() { } } -/// Big-M on the selected edge only: an input with an exact producer and -/// a cheap approximate one whose error is over the target. The -/// approximate producer's bound binds only when its edge is selected, so -/// the exact one is chosen under a tight target and the cheap one under a -/// loose target. +/// Big-M on the selected edge only: A's p95 may read any of four KLL +/// sizes, the smaller ones with a rank error over a tight target. Their +/// bounds bind only on a selected edge, so a tight target is met by the +/// cheapest size within it and a loose one by the cheapest of all; B's +/// target is loose. #[test] fn a_producer_s_error_counts_only_on_its_selected_edge() { let physical = problem_example(); let data = non_negative_latency(); let costs = problem_example_costs(); - // Only A; B is exact by every target here except through DDSketch, so - // it is left without (K). for (target, expected_kll) in [ (0.01, smallest_kll_within(0.01)), (0.5, parameter_list(SketchAlgorithm::Kll)[0].clone()), ] { let entries = [ entry_with(AccuracyTarget::max_error(target).accuracy_metric(AccuracyMetric::Rank)), - entry(0), - ]; - let root_limits = [ - Some(RootAccuracyLimits::of( - &entries[0].requirements.accuracy, - Some(1), - )), - None, + entry_with(AccuracyTarget::max_error(0.5).accuracy_metric(AccuracyMetric::Relative)), ]; + let root_limits: Vec<_> = entries + .iter() + .map(|entry| { + Some(RootAccuracyLimits::of( + &entry.requirements.accuracy, + Some(1), + )) + }) + .collect(); let accuracy = Accuracy { data: &data, root_limits: &root_limits, @@ -1631,3 +1647,59 @@ fn the_milp_agrees_with_the_reference_enumeration_under_accuracy() { agrees_with_the_reference_under(&physical, &costs, &[], &entries, &accuracy); } } + +/// A read is judged on its own statistic: one DDSketch state read at the +/// p50 (no relative guarantee over values not declared non-negative) and +/// at the maximum (always one). The p50 query reads a KLL instead; the +/// maximum's query still reads the DDSketch, whose other read lacks a +/// guarantee. +#[test] +fn a_read_without_a_guarantee_does_not_affect_another_read_of_the_same_state() { + let mut memo = Memo::default(); + let scan = latency_scan(&mut memo); + let kll = state_group( + &mut memo, + scan, + SketchAlgorithm::Kll, + ¶meter_list(SketchAlgorithm::Kll)[..KLL_SIZES], + ); + let ddsketch = state_group( + &mut memo, + scan, + SketchAlgorithm::DDSketch, + &[SketchParams::DDSketch { alpha: 0.01 }], + ); + let p50 = quantile_group(&mut memo, kll, ddsketch, 0.5); + let maximum = memo.find_or_add_group(quantile(ddsketch, 1.0)).unwrap(); + add(&mut memo, maximum, quantile(ddsketch, 1.0), None, None); + memo.add_query(QueryRoot::Operator(p50)); + memo.add_query(QueryRoot::Operator(maximum)); + let physical = all_at_query_time(memo); + let entries = [ + entry_with(AccuracyTarget::max_error(0.05)), + entry_with(AccuracyTarget::max_error(0.05).accuracy_metric(AccuracyMetric::Relative)), + ]; + let root_limits: Vec<_> = entries + .iter() + .map(|entry| { + Some(RootAccuracyLimits::of( + &entry.requirements.accuracy, + Some(1), + )) + }) + .collect(); + let accuracy = Accuracy { + data: &NO_DATA, + root_limits: &root_limits, + }; + let costs = vec![Some(1.0); physical.physical_candidates.len()]; + let solved = solve_under(&physical, &costs, &[], &entries, &accuracy).unwrap(); + // The DDSketch (candidate 5) is read for the maximum (candidate 8); + // the p50 reads a KLL (candidate 6). + let chosen = selected(&solved); + assert!( + chosen.contains(&5) && chosen.contains(&8) && chosen.contains(&6), + "{chosen:?}" + ); + agrees_with_the_reference_under(&physical, &costs, &[], &entries, &accuracy); +} diff --git a/crates/stage3-plan-selection/src/models.rs b/crates/stage3-plan-selection/src/models.rs index f8fd5b0eb..3863daf95 100644 --- a/crates/stage3-plan-selection/src/models.rs +++ b/crates/stage3-plan-selection/src/models.rs @@ -3,10 +3,11 @@ use asap_accuracy::{AccuracyEvidenceProvider, AccuracyModel}; use asap_types::ir::QueryRoot; -/// Inputs of the composed-accuracy check, constraint (K). To ignore -/// accuracy guarantees the facade passes an accept-all `model` -/// ([`asap_accuracy::IgnoreAccuracyGuarantees`]): (K) then applies to no -/// query. +/// Inputs of the composed-accuracy check, constraint (K). (K) applies to +/// every query unless `model` may accept an output regardless of its +/// guarantee or `evidence` may meet a target (their `may_…` methods); to +/// ignore accuracy guarantees the facade passes an accept-all `model` +/// ([`asap_accuracy::IgnoreAccuracyGuarantees`]). #[derive(Clone, Copy)] pub struct AccuracyCheckInputs<'a> { /// Decides whether a candidate's accuracy guarantee meets its query's @@ -18,9 +19,7 @@ pub struct AccuracyCheckInputs<'a> { pub evidence: &'a dyn AccuracyEvidenceProvider, /// Each query's root as the front ends lowered it, by query index: /// Stage 3 reads how many values one evaluation returns ($G_q$, - /// `asap_types::workload::bounds::values_per_result`), which a - /// whole-result failure probability covers, and asks `model` whether - /// it accepts the output regardless of its guarantee, in which case - /// (K) does not apply to the query. + /// `asap_types::workload::bounds::values_per_result`), over which a + /// whole-result failure probability is divided. pub query_roots: &'a [QueryRoot], } diff --git a/crates/stage3-plan-selection/src/select.rs b/crates/stage3-plan-selection/src/select.rs index 4cf7dc0ee..240cfe53a 100644 --- a/crates/stage3-plan-selection/src/select.rs +++ b/crates/stage3-plan-selection/src/select.rs @@ -21,13 +21,12 @@ use asap_types::stages::{ ExecutionContextId, PhysicalASAPMemoCandidates, PhysicalCandidateError, PhysicalCandidateId, PruneReason, PrunedCandidate, RealizedPlan, }; -use asap_types::workload::bounds::{series_read, values_per_result}; use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; use crate::execution_cost::{execution_costs, ExecutionCosts}; use crate::milp::error_bounds::{ - returns_its_output_unchanged, root_accuracy_limits, CandidateErrorModel, OwnGuarantee, METRICS, - METRIC_COUNT, + returns_its_output_unchanged, root_accuracy_limits, values_per_result_per_query, + CandidateErrorModel, ErrorBounds, METRICS, METRIC_COUNT, }; use crate::milp::execution_contexts::{Execution, ExecutionContexts}; use crate::milp::model::{solve_selection, SelectionInputs, SelectionMilpError, SolvedSelection}; @@ -111,16 +110,25 @@ pub fn select_plan( entries: entries.len(), }); } + if entries.len() != accuracy_checks.query_roots.len() { + return Err(ErrorSelectingPlan::EntryCount { + candidate: "the accuracy check's query roots".into(), + roots: accuracy_checks.query_roots.len(), + entries: entries.len(), + }); + } let formula_scale = cost_inputs.frozen_calibration_factor.unwrap_or(1.0); let contexts = ExecutionContexts::new(physical, entries); let costs = execution_costs(physical, &contexts, data, cost_inputs, formula_scale); let pruned_candidates = uncostable_candidates(physical, &contexts, &costs); - let errors = CandidateErrorModel::new(physical, data); + let errors = CandidateErrorModel::new(physical, data, contexts.executions().len()); + // G_q, read by (K) and by the check after the solve. + let values_per_result = values_per_result_per_query(accuracy_checks.query_roots, data); let root_limits = root_accuracy_limits( entries, - accuracy_checks.query_roots, - data, + &values_per_result, accuracy_checks.model, + accuracy_checks.evidence, ); let solved = solve_selection(&SelectionInputs::new( physical, @@ -207,7 +215,7 @@ pub fn select_plan( &solved.selected_dag, &realized, entries, - data, + &values_per_result, accuracy_checks, )?; let validation_ms = validation_started.elapsed().as_millis() as u64; @@ -440,20 +448,23 @@ fn inexact_terms( /// Per query, the accuracy accepted for the selected plan: its composed /// guarantee in `selected` ([`composed_guarantees`]), stated over the -/// values its target's scope covers, checked with the deployment's model -/// (`AccuracyModel::satisfies`), then its evidence for the realized output +/// values its target's scope covers (`values_per_result[q]`, as (K)), +/// checked with the deployment's model (`AccuracyModel::satisfies`), then +/// its evidence for the realized output /// (`AccuracyEvidenceProvider::output_guarantee`). An output the model /// accepts regardless of its guarantee is accepted, recording the check's /// result. Err ([`ErrorSelectingPlan::SelectedPlanFailedCheck`]) for an -/// output none accepts: a bug in the error model of (K), or a model -/// stricter than the built-in rule (K) encodes. +/// output none accepts: a bug in the error model of (K), a model stricter +/// than the built-in rule (K) encodes, or, when (K) did not apply (a model +/// or evidence that may accept outputs regardless), an output they did not +/// accept. fn accepted_accuracy( physical: &PhysicalASAPMemoCandidates, errors: &CandidateErrorModel, selected: &DagOfSelectedCandidates, realized: &RealizedPlan, entries: &[QueryWorkloadEntry], - data: &asap_types::workload::DataWorkload, + values_per_result: &[Option], accuracy_checks: &AccuracyCheckInputs<'_>, ) -> Result, ErrorSelectingPlan> { let composed = errors.composed_errors(selected); @@ -463,13 +474,13 @@ fn accepted_accuracy( for (query, (root, entry)) in realized.plan.roots.iter().zip(entries).enumerate() { let target = &entry.requirements.accuracy; let sketches = sketches_read(root); - let outputs: Vec<&OwnGuarantee> = references + let outputs: Vec<&ErrorBounds> = references .iter() .zip(&selected.node_of_output_reference) .filter(|((reference_query, _), _)| *reference_query == query) .map(|(_, node)| &composed[node.0]) .collect(); - let values = values_per_result(root, series_read(root, data), data); + let values = values_per_result[query]; let guarantees = composed_guarantees( &outputs, returns_its_output_unchanged(&physical.query_roots[query]), @@ -550,10 +561,10 @@ fn accepted_accuracy( /// guarantee, or when `unchanged` is false (a scalar root combining /// outputs, which has no error contract) and an output is not exact. fn composed_guarantees( - outputs: &[&OwnGuarantee], + outputs: &[&ErrorBounds], unchanged: bool, ) -> Option> { - let exact = |output: &OwnGuarantee| { + let exact = |output: &ErrorBounds| { !output.without_guarantee && output.error_bound.iter().all(|&e| e == 0.0) }; if outputs.iter().all(|output| exact(output)) { From 55a138469780d223df0d4d725106f20f567603a4 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:23:26 +0000 Subject: [PATCH 8/8] docs: when (K) applies (#1207 PR 8) --- docs/design_docs/proposals/planner-layering.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/design_docs/proposals/planner-layering.md b/docs/design_docs/proposals/planner-layering.md index 57860efc5..62dbc1cfb 100644 --- a/docs/design_docs/proposals/planner-layering.md +++ b/docs/design_docs/proposals/planner-layering.md @@ -1356,8 +1356,10 @@ Selection picks the cheapest plan that meets every query's accuracy target (constraint (K): each selected candidate's error bound per metric and failure probability, composed over the selected edges through each consumer's error contract, within the target at the query's root) unless the -deployment's accuracy model accepts the query's output regardless of its -guarantee (`IgnoreAccuracyGuarantees`). After the solve it checks each +deployment's accuracy model may accept an output regardless of its guarantee +or its evidence may meet a target (`IgnoreAccuracyGuarantees`, or a custom +model or evidence provider): then (K) applies to no query and the check +after the solve decides. After the solve it checks each query's composed guarantee again with the deployment's accuracy model and evidence; an output none accepts is an error (`ErrorSelectingPlan::SelectedPlanFailedCheck`). Latency bounds and cluster