diff --git a/crates/plan/src/bind.rs b/crates/plan/src/bind.rs index 0dbb1aa6..777fc1d5 100644 --- a/crates/plan/src/bind.rs +++ b/crates/plan/src/bind.rs @@ -40,7 +40,8 @@ use asap_sketch::{ }; use thiserror::Error; -use crate::boundary::{realize, Realization}; +use crate::boundary::{realize_with, Realization}; +use crate::cost_model::{CostModel, DefaultCostModel}; /// Errors from the L3→L4 binding pass. #[derive(Debug, Error)] @@ -50,7 +51,10 @@ pub enum BindError { Schema(#[from] QueryExprError), } -/// Bind a single query (empty `LetBinding` scope) to the L4 IR. +/// Bind a single query (empty `LetBinding` scope) to the L4 IR. Ranks +/// candidate summaries via [`DefaultCostModel`] (`asap-plan`'s built-in +/// static preference order, unchanged); use [`bind_with`] to plug in a +/// deployment-specific [`CostModel`] instead. pub fn bind(expr: &QueryExpr) -> Result, BindError> { bind_in(expr, &BindingScope::default()) } @@ -58,6 +62,21 @@ pub fn bind(expr: &QueryExpr) -> Result, BindError> { /// Bind with an explicit `LetBinding` scope — for roots that reference /// CSE-hoisted producers via [`QueryExpr::Ref`]. pub fn bind_in(expr: &QueryExpr, scope: &BindingScope) -> Result, BindError> { + bind_in_with(expr, scope, &DefaultCostModel) +} + +/// Like [`bind`], but ranks candidate summaries via `cost_model` (see +/// [`crate::cost_model`]) instead of the built-in static preference order. +pub fn bind_with(expr: &QueryExpr, cost_model: &dyn CostModel) -> Result, BindError> { + bind_in_with(expr, &BindingScope::default(), cost_model) +} + +/// Like [`bind_in`], but ranks candidate summaries via `cost_model`. +pub fn bind_in_with( + expr: &QueryExpr, + scope: &BindingScope, + cost_model: &dyn CostModel, +) -> Result, BindError> { if let QueryExpr::Aggregate { by, aggs, @@ -69,12 +88,16 @@ pub fn bind_in(expr: &QueryExpr, scope: &BindingScope) -> Result, Bin // The bindable shape: exactly one intent, no HAVING. (Multi-intent // nodes and HAVING stay logical — see the module docs.) if let ([intent], None) = (aggs.as_slice(), having) { - match realize(intent) { + match realize_with(intent, cost_model) { Realization::Sketch { kind, params } => { - return bind_summary_agg(expr, by, intent, child, kind, params, scope, true) + return bind_summary_agg( + expr, by, intent, child, kind, params, scope, true, cost_model, + ) } Realization::ExactAccumulator { kind, params } => { - return bind_summary_agg(expr, by, intent, child, kind, params, scope, false) + return bind_summary_agg( + expr, by, intent, child, kind, params, scope, false, cost_model, + ) } Realization::PassThrough => {} } @@ -95,6 +118,7 @@ fn bind_summary_agg( params: SummaryParams, scope: &BindingScope, estimate: bool, + cost_model: &dyn CostModel, ) -> Result, BindError> { let child_schema = child.output_schema_in(scope)?; // The single canonical L3 derivation (per-series vs cross-series, name @@ -113,7 +137,7 @@ fn bind_summary_agg( let agg = Rc::new(L4Node { expr: SummaryExpr::SummaryAgg { - child: bind_in(child, scope)?, + child: bind_in_with(child, scope, cost_model)?, sketch: kind, params, col, @@ -305,6 +329,52 @@ mod tests { if matches!(**e, QueryExpr::Scan { .. }))); } + /// A deployment-supplied [`CostModel`] can override the default KLL + /// choice — `bind_with` must actually consult it, not just accept and + /// ignore it (issue: cost model interface, see `crate::cost_model`). + struct PreferDDSketch; + + impl CostModel for PreferDDSketch { + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SummaryKind], + ) -> Vec { + let mut v = candidates.to_vec(); + if let Some(pos) = v.iter().position(|k| *k == SummaryKind::DDSketch) { + let ddsketch = v.remove(pos); + v.insert(0, ddsketch); + } + v + } + } + + #[test] + fn bind_with_custom_cost_model_overrides_default_summary_choice() { + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); + + // Default: KLL (see `quantile_binds_kll_wrapped_in_estimate` above). + let default_root = bind(&q).unwrap(); + let SummaryExpr::SummaryEstimate { sketch_input, .. } = &default_root.expr else { + panic!("expected SummaryEstimate root, got {:?}", default_root.expr); + }; + let SummaryExpr::SummaryAgg { sketch, .. } = &sketch_input.expr else { + panic!("expected SummaryAgg, got {:?}", sketch_input.expr); + }; + assert_eq!(sketch, &SummaryKind::Kll); + + // With `PreferDDSketch`: DDSketch instead, same query. + let custom_root = bind_with(&q, &PreferDDSketch).unwrap(); + let SummaryExpr::SummaryEstimate { sketch_input, .. } = &custom_root.expr else { + panic!("expected SummaryEstimate root, got {:?}", custom_root.expr); + }; + let SummaryExpr::SummaryAgg { sketch, params, .. } = &sketch_input.expr else { + panic!("expected SummaryAgg, got {:?}", sketch_input.expr); + }; + assert_eq!(sketch, &SummaryKind::DDSketch); + assert_eq!(params, &SummaryParams::DDSketch { alpha: 0.01 }); + } + #[test] fn exact_sum_binds_accumulator_without_estimate() { let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); diff --git a/crates/plan/src/boundary.rs b/crates/plan/src/boundary.rs index e0158826..34bbf5db 100644 --- a/crates/plan/src/boundary.rs +++ b/crates/plan/src/boundary.rs @@ -28,6 +28,8 @@ use asap_ir::intent_algebra::agg_intent::{agg_is_mergeable, AggIntent}; use asap_ir::types::AccuracyTarget; use asap_sketch::{SummaryKind, SummaryParams}; +use crate::cost_model::{CostModel, DefaultCostModel}; + /// How an [`AggIntent`] is realised at L4. #[derive(Debug, Clone, PartialEq)] pub enum Realization { @@ -54,12 +56,12 @@ pub enum Realization { /// one. `ln(1/0.01) → depth 5`, matching the conventional CMS sizing. pub const DEFAULT_DELTA: f64 = 0.01; -/// The sketch families that can serve an intent, most-preferred first. +/// The summary families that can serve an intent, most-preferred first. /// This is the `AggIntent → SummaryKind` map of issue #98; [`realize`] binds /// the head of the list. The tail entries are the alternatives a future cost /// model (#6/#33) may pick instead — listed here so the candidate set has one /// home. -pub fn sketch_candidates(intent: &AggIntent) -> &'static [SummaryKind] { +pub fn summary_candidates(intent: &AggIntent) -> &'static [SummaryKind] { match intent { AggIntent::Quantile { .. } => &[SummaryKind::Kll, SummaryKind::DDSketch], AggIntent::Cardinality { .. } => &[SummaryKind::Hll, SummaryKind::Theta, SummaryKind::Kmv], @@ -73,8 +75,17 @@ pub fn sketch_candidates(intent: &AggIntent) -> &'static [SummaryKind] { /// /// Exhaustive over the [`AggIntent`] vocabulary — adding a variant without an /// explicit realization is a compile error, and the coverage-matrix test pins -/// each variant's category. +/// each variant's category. Ranks candidate summaries via +/// [`DefaultCostModel`] (`asap-plan`'s built-in static preference order, +/// unchanged); use [`realize_with`] to plug in a deployment-specific +/// [`CostModel`] instead. pub fn realize(intent: &AggIntent) -> Realization { + realize_with(intent, &DefaultCostModel) +} + +/// Like [`realize`], but ranks candidate summaries via `cost_model` (see +/// [`crate::cost_model`]) instead of the built-in static preference order. +pub fn realize_with(intent: &AggIntent, cost_model: &dyn CostModel) -> Realization { match intent { // ── Approximate-capable intents — the AccuracyTarget decides ──────── AggIntent::Quantile { accuracy, .. } @@ -82,7 +93,7 @@ pub fn realize(intent: &AggIntent) -> Realization { | AggIntent::Count { accuracy } | AggIntent::TopK { accuracy, .. } => match accuracy { AccuracyTarget::Exact => exact_realization(intent), - _ => bind_sketch(intent, accuracy), + _ => bind_summary_with(intent, accuracy, cost_model), }, // ── Exact mergeable accumulators ───────────────────────────────────── @@ -169,8 +180,15 @@ fn accumulator(intent: &AggIntent, kind: SummaryKind, params: SummaryParams) -> Realization::ExactAccumulator { kind, params } } -/// Bind the preferred candidate sketch, with parameters sized to the target. -fn bind_sketch(intent: &AggIntent, accuracy: &AccuracyTarget) -> Realization { +/// Bind the preferred candidate summary, with parameters sized to the +/// target, ranking [`summary_candidates`] via `cost_model` (see +/// [`crate::cost_model`]) instead of taking the static-order head +/// unconditionally. +fn bind_summary_with( + intent: &AggIntent, + accuracy: &AccuracyTarget, + cost_model: &dyn CostModel, +) -> Realization { let (eps, delta) = match accuracy { // Unreachable via `realize` (Exact routes to `exact_realization`); // degrade to the tightest parameters if called directly. @@ -178,10 +196,11 @@ fn bind_sketch(intent: &AggIntent, accuracy: &AccuracyTarget) -> Realization { AccuracyTarget::Epsilon(e) => (*e, DEFAULT_DELTA), AccuracyTarget::EpsilonDelta { epsilon, delta } => (*epsilon, *delta), }; - let kind = sketch_candidates(intent) - .first() - .expect("approximate intent has at least one candidate sketch") - .clone(); + let ranked = cost_model.rank_candidates(intent, summary_candidates(intent)); + let kind = ranked + .into_iter() + .next() + .expect("approximate intent has at least one candidate summary"); let params = match kind { SummaryKind::Kll => SummaryParams::Kll { k: kll_k(eps) }, SummaryKind::Cms => SummaryParams::Cms { @@ -535,27 +554,27 @@ mod tests { #[test] fn candidate_lists_match_the_issue_map() { assert_eq!( - sketch_candidates(&default_quantile(0.5)), + summary_candidates(&default_quantile(0.5)), &[SummaryKind::Kll, SummaryKind::DDSketch] ); assert_eq!( - sketch_candidates(&default_cardinality()), + summary_candidates(&default_cardinality()), &[SummaryKind::Hll, SummaryKind::Theta, SummaryKind::Kmv] ); assert_eq!( - sketch_candidates(&AggIntent::TopK { + summary_candidates(&AggIntent::TopK { k: 5, accuracy: eps(0.01) }), &[SummaryKind::CmsWithHeap] ); assert_eq!( - sketch_candidates(&AggIntent::Count { + summary_candidates(&AggIntent::Count { accuracy: eps(0.01) }), &[SummaryKind::Cms] ); - assert!(sketch_candidates(&AggIntent::Rate).is_empty()); + assert!(summary_candidates(&AggIntent::Rate).is_empty()); } #[test] diff --git a/crates/plan/src/cost_model.rs b/crates/plan/src/cost_model.rs index 87a59937..d668aa5d 100644 --- a/crates/plan/src/cost_model.rs +++ b/crates/plan/src/cost_model.rs @@ -1,7 +1,105 @@ -//! Cost model (stub). +//! Cost model interface (issues #6, #33). //! -//! The cost traits the optimizer consults — and, in particular, the model that -//! credits a hoisted [`cse`](crate::cse) producer once instead of per consumer. +//! `asap-plan` deliberately has no cost model *implementation* of its own — +//! ranking candidate summaries by real cost (bandwidth budget, memory +//! footprint, site count, observed drift, workload-level CSE credit, …) +//! needs knowledge this crate doesn't have and shouldn't acquire: the crate +//! doc's layering invariant is that `asap-plan` depends only on [`asap_ir`], +//! never on a runtime or a deployment model. What it *can* own is the +//! interface every deployment's cost model plugs into, so [`boundary`]'s +//! summary selection has exactly one extension point instead of forcing +//! each downstream (ASAPCollector + ASAPQuery-backend, ASAPFusion, …) to +//! fork [`boundary::bind_summary_with`]. //! -//! TODO(#6): wire workload-level CSE into a cost model. -//! TODO(#33): detect which optimizations are applicable to a query workload. +//! "Summary" here is deliberately broader than the classic streaming +//! sketches [`SummaryKind`] enumerates today (Kll/DDSketch/Hll/Cms/…): +//! [`CostModel::rank_candidates`] ranks whatever [`SummaryKind`] variants +//! [`boundary::summary_candidates`] offers for an intent, so it already +//! extends to non-sketch realizations — sampling-based summaries, +//! wavelet-transform summaries, OMP/compressive-sensing summaries, … — +//! the day a variant for one lands in [`asap_sketch`]; nothing in this +//! trait or [`boundary`]'s dispatch is sketch-specific. +//! +//! Every entry point that doesn't take an explicit `&dyn CostModel` +//! ([`realize`](crate::boundary::realize), [`bind`](crate::bind::bind), +//! [`bind_in`](crate::bind::bind_in)) runs against [`DefaultCostModel`], so +//! a deployment that never plugs in its own cost model keeps today's +//! static-preference-order behavior exactly, byte for byte. + +use asap_ir::intent_algebra::agg_intent::AggIntent; +use asap_sketch::SummaryKind; + +/// Ranks the candidate summary families for one [`AggIntent`], best choice +/// first. +/// +/// [`boundary::summary_candidates`] returns every family that *can* answer an +/// intent, in an arbitrary static preference order (issue #98's "one home" +/// for the candidate set). A `CostModel` re-orders that list under real, +/// deployment-specific cost knowledge this crate has no way to know about — +/// [`boundary::bind_summary_with`] binds whichever candidate ends up first after +/// ranking. +pub trait CostModel { + /// Rank `candidates` (as returned by + /// [`summary_candidates`](crate::boundary::summary_candidates)) for + /// `intent`, best choice first. + /// + /// Implementations MAY reorder freely and MAY drop entries that aren't + /// available in their deployment, but MUST NOT invent a candidate that + /// wasn't in the input — an unknown [`SummaryKind`] has no + /// [`SummaryParams`](asap_sketch::SummaryParams) sizing logic in + /// [`boundary::bind_summary_with`] and binding it will panic. Returning an + /// empty `Vec` means "no candidate is acceptable"; `bind_summary_with` treats + /// that the same as `candidates` having been empty to begin with. + fn rank_candidates(&self, intent: &AggIntent, candidates: &[SummaryKind]) -> Vec; +} + +/// The default cost model: preserves [`summary_candidates`]'s built-in static +/// order unchanged. +/// +/// [`summary_candidates`]: crate::boundary::summary_candidates +pub struct DefaultCostModel; + +impl CostModel for DefaultCostModel { + fn rank_candidates(&self, _intent: &AggIntent, candidates: &[SummaryKind]) -> Vec { + candidates.to_vec() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::boundary::summary_candidates; + use asap_ir::intent_algebra::agg_intent::default_cardinality; + + #[test] + fn default_cost_model_preserves_static_order() { + let intent = default_cardinality(); + let candidates = summary_candidates(&intent); + assert_eq!( + DefaultCostModel.rank_candidates(&intent, candidates), + candidates.to_vec() + ); + } + + struct AlwaysPreferLast; + + impl CostModel for AlwaysPreferLast { + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SummaryKind], + ) -> Vec { + let mut v = candidates.to_vec(); + v.reverse(); + v + } + } + + #[test] + fn custom_cost_model_can_reorder_candidates() { + let intent = default_cardinality(); + let candidates = summary_candidates(&intent); + let ranked = AlwaysPreferLast.rank_candidates(&intent, candidates); + assert_eq!(ranked.first(), candidates.last()); + } +} diff --git a/crates/plan/src/lib.rs b/crates/plan/src/lib.rs index c66ef6bb..c69ff114 100644 --- a/crates/plan/src/lib.rs +++ b/crates/plan/src/lib.rs @@ -24,8 +24,10 @@ //! - [`bind`] — the L3→L4 binding pass: walks a `QueryExpr` tree, fires the //! [`boundary`] decision per node, and emits the sketch-bound //! [`SummaryExpr`](asap_sketch::SummaryExpr) DAG (issue #98). -//! - [`cost_model`] — stub: cost traits + the model CSE credits a shared -//! producer against (issues #6, #33). +//! - [`cost_model`] — the [`CostModel`](cost_model::CostModel) trait every +//! deployment's cost-based sketch selection plugs into (issues #6, #33). +//! `asap-plan` itself only ships [`DefaultCostModel`](cost_model::DefaultCostModel), +//! which preserves [`boundary`]'s built-in static preference order. pub mod cse; @@ -33,5 +35,6 @@ pub mod bind; pub mod boundary; pub mod cost_model; -pub use bind::{bind, bind_in, BindError}; -pub use boundary::{realize, sketch_candidates, Realization}; +pub use bind::{bind, bind_in, bind_in_with, bind_with, BindError}; +pub use boundary::{realize, realize_with, summary_candidates, Realization}; +pub use cost_model::{CostModel, DefaultCostModel};