Skip to main content

velesdb_core/collection/query_cost/
mod.rs

1//! Query cost estimation module.
2//!
3//! This module provides cost estimation for vector queries before execution,
4//! allowing rejection of expensive queries or parameter adjustment.
5//!
6//! # Features
7//!
8//! - **Cost estimation**: Predict query cost based on dataset size, ef_search, etc.
9//! - **Latency estimation**: Convert cost to estimated milliseconds
10//! - **Cost limits**: Reject queries exceeding max_cost threshold
11//! - **EXPLAIN support**: Provide cost breakdown for VelesQL queries
12//!
13//! # Example
14//!
15//! ```ignore
16//! use velesdb_core::collection::query_cost::{QueryCostEstimator, QueryParams};
17//!
18//! let estimator = QueryCostEstimator::default();
19//! let params = QueryParams {
20//!     dataset_size: 100_000,
21//!     ef_search: 128,
22//!     top_k: 10,
23//!     filter_selectivity: Some(0.1),
24//! };
25//!
26//! let estimate = estimator.estimate(&params);
27//! println!("Estimated cost: {}", estimate.total_cost);
28//! println!("Estimated latency: {}ms", estimate.estimated_latency_ms);
29//! ```
30
31// Reason: Numeric casts in query cost estimation are intentional:
32// - All casts are for cost modeling and latency estimation
33// - f64/usize conversions for computing operation costs
34// - Values bounded by dataset size and hardware limits
35// - Precision loss acceptable for cost estimates (approximate by design)
36#![allow(clippy::cast_precision_loss)]
37#![allow(clippy::cast_possible_truncation)]
38#![allow(clippy::cast_sign_loss)]
39
40use std::fmt;
41
42pub mod calibration;
43pub mod cost_factors;
44pub mod cost_model;
45pub(crate) mod feedback;
46pub mod plan_generator;
47pub mod query_executor;
48
49#[cfg(test)]
50mod plan_generator_tests;
51#[cfg(test)]
52mod tests;
53
54pub use cost_model::{CostEstimator, OperationCost, OperationCostFactors};
55pub(crate) use feedback::CboFeedbackLoop;
56pub use plan_generator::{CandidatePlan, PlanGenerator, QueryCharacteristics};
57pub use query_executor::{ExecutionContext, PlanCache, QueryOptimizer};
58
59/// Parameters for cost estimation
60#[derive(Debug, Clone)]
61pub struct QueryParams {
62    /// Number of vectors in the dataset
63    pub dataset_size: usize,
64    /// ef_search parameter for HNSW
65    pub ef_search: usize,
66    /// Number of results requested
67    pub top_k: usize,
68    /// Filter selectivity (0.0-1.0, fraction of vectors passing filter)
69    /// None means no filter (selectivity = 1.0)
70    pub filter_selectivity: Option<f64>,
71}
72
73impl Default for QueryParams {
74    fn default() -> Self {
75        Self {
76            dataset_size: 10_000,
77            ef_search: 128,
78            top_k: 10,
79            filter_selectivity: None,
80        }
81    }
82}
83
84impl QueryParams {
85    /// Creates new query params
86    #[must_use]
87    pub fn new(dataset_size: usize, ef_search: usize, top_k: usize) -> Self {
88        Self {
89            dataset_size,
90            ef_search,
91            top_k,
92            filter_selectivity: None,
93        }
94    }
95
96    /// Sets filter selectivity
97    #[must_use]
98    pub fn with_filter_selectivity(mut self, selectivity: f64) -> Self {
99        self.filter_selectivity = Some(selectivity.clamp(0.001, 1.0));
100        self
101    }
102}
103
104/// Breakdown of cost factors
105#[derive(Debug, Clone)]
106pub struct CostFactors {
107    /// Cost from dataset size (O(log n) for HNSW)
108    pub dataset_size_factor: f64,
109    /// Cost from ef_search parameter
110    pub ef_search_factor: f64,
111    /// Cost reduction from filter selectivity
112    pub filter_selectivity_factor: f64,
113    /// Cost from top_k (sub-linear)
114    pub top_k_factor: f64,
115}
116
117impl Default for CostFactors {
118    fn default() -> Self {
119        Self {
120            dataset_size_factor: 1.0,
121            ef_search_factor: 1.0,
122            filter_selectivity_factor: 1.0,
123            top_k_factor: 1.0,
124        }
125    }
126}
127
128/// Estimated cost of a query
129#[derive(Debug, Clone)]
130pub struct QueryCostEstimate {
131    /// Total estimated cost (abstract units)
132    pub total_cost: f64,
133    /// Estimated latency in milliseconds
134    pub estimated_latency_ms: f64,
135    /// Breakdown of cost factors
136    pub factors: CostFactors,
137}
138
139impl QueryCostEstimate {
140    /// Creates a new estimate
141    #[must_use]
142    pub fn new(total_cost: f64, estimated_latency_ms: f64, factors: CostFactors) -> Self {
143        Self {
144            total_cost,
145            estimated_latency_ms,
146            factors,
147        }
148    }
149}
150
151/// Calibration constants for cost estimation
152#[derive(Debug, Clone)]
153pub struct CostCalibration {
154    /// Base cost unit (normalized to 1.0)
155    pub base_cost: f64,
156    /// Reference ef_search for normalization (default 100)
157    pub reference_ef_search: f64,
158    /// Reference top_k for normalization (default 10)
159    pub reference_top_k: f64,
160    /// Milliseconds per cost unit (calibrated via benchmarks)
161    pub ms_per_cost_unit: f64,
162    /// Exponent for filter selectivity impact (0.3 = mild impact)
163    pub filter_exponent: f64,
164}
165
166impl Default for CostCalibration {
167    fn default() -> Self {
168        Self {
169            base_cost: 1.0,
170            reference_ef_search: 100.0,
171            reference_top_k: 10.0,
172            ms_per_cost_unit: 0.1,
173            filter_exponent: 0.3,
174        }
175    }
176}
177
178impl CostCalibration {
179    /// Creates calibration for fast systems (lower latency per cost)
180    #[must_use]
181    pub fn fast_system() -> Self {
182        Self {
183            ms_per_cost_unit: 0.05,
184            ..Default::default()
185        }
186    }
187
188    /// Creates calibration for slow systems (higher latency per cost)
189    #[must_use]
190    pub fn slow_system() -> Self {
191        Self {
192            ms_per_cost_unit: 0.2,
193            ..Default::default()
194        }
195    }
196}
197
198/// Error when query cost exceeds limit
199#[derive(Debug, Clone)]
200pub struct QueryCostExceeded {
201    /// Estimated cost
202    pub estimated: f64,
203    /// Maximum allowed cost
204    pub max_allowed: f64,
205}
206
207impl fmt::Display for QueryCostExceeded {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        write!(
210            f,
211            "Query cost {:.1} exceeds limit {:.1}",
212            self.estimated, self.max_allowed
213        )
214    }
215}
216
217impl std::error::Error for QueryCostExceeded {}
218
219/// Query cost estimator
220#[derive(Debug, Clone)]
221pub struct QueryCostEstimator {
222    /// Calibration constants
223    calibration: CostCalibration,
224    /// Optional maximum cost limit for the collection
225    max_cost: Option<f64>,
226}
227
228impl Default for QueryCostEstimator {
229    fn default() -> Self {
230        Self::new(CostCalibration::default())
231    }
232}
233
234impl QueryCostEstimator {
235    /// Creates a new estimator with the given calibration
236    #[must_use]
237    pub fn new(calibration: CostCalibration) -> Self {
238        Self {
239            calibration,
240            max_cost: None,
241        }
242    }
243
244    /// Creates an estimator with a cost limit
245    #[must_use]
246    pub fn with_max_cost(mut self, max_cost: f64) -> Self {
247        self.max_cost = Some(max_cost);
248        self
249    }
250
251    /// Sets the maximum allowed cost
252    pub fn set_max_cost(&mut self, max_cost: Option<f64>) {
253        self.max_cost = max_cost;
254    }
255
256    /// Gets the current max cost limit
257    #[must_use]
258    pub fn max_cost(&self) -> Option<f64> {
259        self.max_cost
260    }
261
262    /// Applies a runtime-observed `ms_per_cost_unit` from the CBO feedback
263    /// loop (issue #469 Phase 2).
264    ///
265    /// Overrides the static `calibration.ms_per_cost_unit` so that cost-guard
266    /// checks and EXPLAIN latency estimates reflect actual observed performance
267    /// rather than the compile-time default (`0.1 ms/unit`).
268    ///
269    /// Only call this when `CboFeedbackLoop::adjusted_ms_per_cost_unit`
270    /// returns `Some`, i.e., after ≥ `MIN_SAMPLES` observations.
271    #[must_use]
272    pub fn with_feedback(mut self, ms_per_cost_unit: f64) -> Self {
273        self.calibration.ms_per_cost_unit = ms_per_cost_unit;
274        self
275    }
276
277    /// Estimates the cost of a query
278    ///
279    /// # Cost Formula
280    ///
281    /// ```text
282    /// cost = base_cost
283    ///      * log2(dataset_size + 1)      // O(log n) HNSW traversal
284    ///      * (ef_search / 100)           // Linear with ef_search
285    ///      * sqrt(top_k / 10)            // Sub-linear with k
286    ///      * (1.0 / selectivity)^0.3     // Filter overhead
287    /// ```
288    #[must_use]
289    pub fn estimate(&self, params: &QueryParams) -> QueryCostEstimate {
290        let cal = &self.calibration;
291
292        // Dataset size factor: O(log n) for HNSW
293        let dataset_size_factor = if params.dataset_size > 0 {
294            (params.dataset_size as f64 + 1.0).log2()
295        } else {
296            1.0
297        };
298
299        // ef_search factor: linear scaling
300        let ef_search_factor = params.ef_search as f64 / cal.reference_ef_search;
301
302        // top_k factor: sub-linear (sqrt)
303        let top_k_factor = (params.top_k as f64 / cal.reference_top_k).sqrt();
304
305        // Filter selectivity factor: inverse relationship with exponent
306        let selectivity = params.filter_selectivity.unwrap_or(1.0).max(0.001);
307        let filter_selectivity_factor = (1.0 / selectivity).powf(cal.filter_exponent);
308
309        // Total cost
310        let total_cost = cal.base_cost
311            * dataset_size_factor
312            * ef_search_factor
313            * top_k_factor
314            * filter_selectivity_factor;
315
316        // Estimated latency
317        let estimated_latency_ms = total_cost * cal.ms_per_cost_unit;
318
319        let factors = CostFactors {
320            dataset_size_factor,
321            ef_search_factor,
322            filter_selectivity_factor,
323            top_k_factor,
324        };
325
326        QueryCostEstimate::new(total_cost, estimated_latency_ms, factors)
327    }
328
329    /// Checks if query exceeds max cost
330    ///
331    /// # Errors
332    ///
333    /// Returns `QueryCostExceeded` if the estimated cost exceeds `max_cost`.
334    pub fn check_cost_limit(
335        &self,
336        params: &QueryParams,
337        max_cost: f64,
338    ) -> Result<QueryCostEstimate, QueryCostExceeded> {
339        let estimate = self.estimate(params);
340
341        if estimate.total_cost > max_cost {
342            Err(QueryCostExceeded {
343                estimated: estimate.total_cost,
344                max_allowed: max_cost,
345            })
346        } else {
347            Ok(estimate)
348        }
349    }
350
351    /// Checks if query exceeds the collection's max cost (if set)
352    ///
353    /// # Errors
354    ///
355    /// Returns `QueryCostExceeded` if max_cost is set and exceeded.
356    pub fn check_collection_limit(
357        &self,
358        params: &QueryParams,
359    ) -> Result<QueryCostEstimate, QueryCostExceeded> {
360        let estimate = self.estimate(params);
361
362        if let Some(max) = self.max_cost {
363            if estimate.total_cost > max {
364                return Err(QueryCostExceeded {
365                    estimated: estimate.total_cost,
366                    max_allowed: max,
367                });
368            }
369        }
370
371        Ok(estimate)
372    }
373
374    /// Generates an EXPLAIN-style breakdown
375    #[must_use]
376    pub fn explain(&self, params: &QueryParams) -> String {
377        let estimate = self.estimate(params);
378
379        format!(
380            "Query Cost Estimate\n\
381             ===================\n\
382             Total Cost: {:.2}\n\
383             Estimated Latency: {:.2}ms\n\n\
384             Cost Breakdown:\n\
385             - Dataset Size Factor (log2({})): {:.2}\n\
386             - ef_search Factor ({}/{}): {:.2}\n\
387             - top_k Factor (sqrt({}/10)): {:.2}\n\
388             - Filter Selectivity Factor: {:.2}\n",
389            estimate.total_cost,
390            estimate.estimated_latency_ms,
391            params.dataset_size,
392            estimate.factors.dataset_size_factor,
393            params.ef_search,
394            self.calibration.reference_ef_search as usize,
395            estimate.factors.ef_search_factor,
396            params.top_k,
397            estimate.factors.top_k_factor,
398            estimate.factors.filter_selectivity_factor,
399        )
400    }
401}
402
403/// Builder for convenient query param construction
404#[derive(Debug, Default)]
405pub struct QueryParamsBuilder {
406    params: QueryParams,
407}
408
409impl QueryParamsBuilder {
410    /// Creates a new builder
411    #[must_use]
412    pub fn new() -> Self {
413        Self::default()
414    }
415
416    /// Sets dataset size
417    #[must_use]
418    pub fn dataset_size(mut self, size: usize) -> Self {
419        self.params.dataset_size = size;
420        self
421    }
422
423    /// Sets ef_search
424    #[must_use]
425    pub fn ef_search(mut self, ef: usize) -> Self {
426        self.params.ef_search = ef;
427        self
428    }
429
430    /// Sets top_k
431    #[must_use]
432    pub fn top_k(mut self, k: usize) -> Self {
433        self.params.top_k = k;
434        self
435    }
436
437    /// Sets filter selectivity
438    #[must_use]
439    pub fn filter_selectivity(mut self, selectivity: f64) -> Self {
440        self.params.filter_selectivity = Some(selectivity.clamp(0.001, 1.0));
441        self
442    }
443
444    /// Builds the QueryParams
445    #[must_use]
446    pub fn build(self) -> QueryParams {
447        self.params
448    }
449}