Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 76 additions & 6 deletions crates/plan/src/bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -50,14 +51,32 @@ 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<Rc<L4Node>, BindError> {
bind_in(expr, &BindingScope::default())
}

/// 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<Rc<L4Node>, 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<Rc<L4Node>, 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<Rc<L4Node>, BindError> {
if let QueryExpr::Aggregate {
by,
aggs,
Expand All @@ -69,12 +88,16 @@ pub fn bind_in(expr: &QueryExpr, scope: &BindingScope) -> Result<Rc<L4Node>, 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 => {}
}
Expand All @@ -95,6 +118,7 @@ fn bind_summary_agg(
params: SummaryParams,
scope: &BindingScope,
estimate: bool,
cost_model: &dyn CostModel,
) -> Result<Rc<L4Node>, BindError> {
let child_schema = child.output_schema_in(scope)?;
// The single canonical L3 derivation (per-series vs cross-series, name
Expand All @@ -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,
Expand Down Expand Up @@ -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<SummaryKind> {
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"]));
Expand Down
49 changes: 34 additions & 15 deletions crates/plan/src/boundary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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],
Expand All @@ -73,16 +75,25 @@ 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, .. }
| AggIntent::Cardinality { accuracy, .. }
| 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 ─────────────────────────────────────
Expand Down Expand Up @@ -169,19 +180,27 @@ 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.
AccuracyTarget::Exact => (f64::MIN_POSITIVE, DEFAULT_DELTA),
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 {
Expand Down Expand Up @@ -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]
Expand Down
108 changes: 103 additions & 5 deletions crates/plan/src/cost_model.rs
Original file line number Diff line number Diff line change
@@ -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<SummaryKind>;
}

/// 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<SummaryKind> {
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<SummaryKind> {
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());
}
}
11 changes: 7 additions & 4 deletions crates/plan/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@
//! - [`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;

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};
Loading