Skip to main content

velesdb_core/velesql/
planner.rs

1//! Query Planner for hybrid MATCH + NEAR queries.
2//!
3//! This module provides intelligent query planning for hybrid graph-vector queries,
4//! choosing the optimal execution strategy based on estimated selectivity.
5//!
6//! # Cost-based strategy selection (Issue #467)
7//!
8//! `choose_strategy_with_cbo()` now uses calibrated [`OperationCostFactors`]
9//! from [`CollectionStats::calibrated_cost_factors`] instead of the former
10//! hard-coded weights `0.2` (I/O) and `0.8` (CPU). The calibrated factors
11//! are derived during `analyze()` from collection statistics and histograms.
12//! When no calibrated factors are available (collection never analyzed), the
13//! planner falls back to `OperationCostFactors::default()`, which reproduces
14//! the exact same costs as the old constants.
15//!
16//! # Future improvements
17//!
18//! - Collect runtime statistics for actual selectivity estimation
19//! - Implement cost model based on index cardinality
20//! - Add adaptive query execution with plan switching
21
22// Reason: Numeric casts across this file are intentional and bounded:
23// - u64/usize → f64: cardinalities used in planning heuristics; ±1 ULP is operationally irrelevant.
24// - f64 → usize: over-fetch factor clamped to [1.0, 64.0] before cast; no truncation possible.
25#![allow(
26    clippy::cast_precision_loss,
27    clippy::cast_possible_truncation,
28    clippy::cast_sign_loss
29)]
30
31use crate::collection::query_cost::cost_model::OperationCostFactors;
32use crate::collection::stats::CollectionStats;
33use crate::velesql::ast::Condition;
34pub use crate::velesql::cost_estimator::{Cost, CostEstimator, SelectivityMethod};
35pub use crate::velesql::query_stats::QueryStats;
36
37/// Execution strategy for hybrid queries.
38///
39/// This is the planner's *intent*; the executor realizes it only partially:
40/// on the SELECT path (`execution_paths.rs`), `GraphFirst` selects a
41/// full-scan-then-score realization for metadata filters and every other
42/// variant (including `Parallel`) is executed as `VectorFirst`. Graph
43/// predicates in SELECT WHERE that are AND-required take the GraphFirst
44/// anchored fetch (`graph_prefilter.rs`) independently of this strategy —
45/// anchor sets are evaluated first and retrieval is exhaustive within them;
46/// only OR/NOT-wrapped predicates remain post-filters over the fetch
47/// window. On the top-level MATCH path (`match_dispatch.rs`),
48/// `Parallel` runs `GraphFirst` and `VectorFirst` **sequentially** and merges
49/// the result sets (true parallelism is a future optimization).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum ExecutionStrategy {
53    /// Execute vector search first, then filter by graph pattern.
54    /// Best when graph filter is not very selective (>10% of data).
55    VectorFirst,
56    /// Intended: execute graph pattern first, then vector search on
57    /// candidates (selective filters, <1% of data). Realized on the SELECT
58    /// path as a full scan scored by vector similarity for metadata filters;
59    /// not realized for SELECT graph predicates (post-filter applies).
60    GraphFirst,
61    /// Intended: execute both sides in parallel and merge (medium
62    /// selectivity, 1-10%). Realized as `VectorFirst` on the SELECT path and
63    /// as sequential GraphFirst + VectorFirst with a merge on the MATCH path.
64    Parallel,
65}
66
67const VECTOR_FIRST_FILTER_PENALTY: f64 = 1.5;
68const PARALLEL_MERGE_OVERHEAD: f64 = 25.0;
69const GRAPH_TO_VECTOR_SCALING: f64 = 100.0;
70
71/// Query planner for hybrid MATCH + NEAR queries.
72#[derive(Debug, Default)]
73pub struct QueryPlanner {
74    /// Runtime statistics for adaptive planning.
75    stats: QueryStats,
76    /// CBO calibration feedback loop (issue #469).
77    cbo_feedback: crate::collection::query_cost::CboFeedbackLoop,
78    /// Selectivity threshold for GraphFirst strategy.
79    graph_first_threshold: f64,
80    /// Selectivity threshold for VectorFirst strategy.
81    vector_first_threshold: f64,
82}
83
84impl QueryPlanner {
85    /// Creates a new query planner with default thresholds.
86    #[must_use]
87    pub fn new() -> Self {
88        Self {
89            stats: QueryStats::new(),
90            cbo_feedback: crate::collection::query_cost::CboFeedbackLoop::new(),
91            graph_first_threshold: 0.01,  // <1% → GraphFirst
92            vector_first_threshold: 0.50, // >50% → VectorFirst
93        }
94    }
95
96    /// Creates a planner with custom selectivity thresholds.
97    #[must_use]
98    pub fn with_thresholds(graph_first: f64, vector_first: f64) -> Self {
99        Self {
100            stats: QueryStats::new(),
101            cbo_feedback: crate::collection::query_cost::CboFeedbackLoop::new(),
102            graph_first_threshold: graph_first,
103            vector_first_threshold: vector_first,
104        }
105    }
106
107    /// Chooses the optimal execution strategy based on estimated selectivity.
108    #[must_use]
109    pub fn choose_strategy(&self, estimated_selectivity: Option<f64>) -> ExecutionStrategy {
110        let selectivity = estimated_selectivity.unwrap_or_else(|| self.stats.graph_selectivity());
111
112        if selectivity < self.graph_first_threshold {
113            ExecutionStrategy::GraphFirst
114        } else if selectivity > self.vector_first_threshold {
115            ExecutionStrategy::VectorFirst
116        } else {
117            ExecutionStrategy::Parallel
118        }
119    }
120
121    /// Chooses strategy using CBO with collection statistics and optional filter.
122    ///
123    /// Uses calibrated [`OperationCostFactors`] from `stats.calibrated_cost_factors`
124    /// (when available) to derive I/O and CPU weights for the candidate filter cost.
125    /// Falls back to `OperationCostFactors::default()` when no calibrated factors
126    /// exist, reproducing the historical `0.2` / `0.8` I/O/CPU split.
127    #[must_use]
128    pub fn choose_strategy_with_cbo(
129        &self,
130        stats: &CollectionStats,
131        filter: Option<&Condition>,
132        k: usize,
133    ) -> ExecutionStrategy {
134        // Without metadata/graph filter, vector-first is the only meaningful strategy.
135        if filter.is_none() {
136            return ExecutionStrategy::VectorFirst;
137        }
138
139        let estimator = CostEstimator::new(stats);
140        let filter_selectivity = estimate_filter_selectivity(stats, filter);
141        let filter_cost = filter.map_or(Cost::new(0.0, 0.0), |f| estimator.estimate_filter_cost(f));
142        let vector_cost = estimator.estimate_hnsw_search_cost(k.max(1));
143        let total_rows = stats.total_points.max(stats.row_count).max(1) as f64; // usize→f64: planning heuristic
144
145        // Vector-first evaluates metadata predicates on over-fetched ANN candidates.
146        // Required over-fetch scales inversely with filter selectivity.
147        let over_fetch = (1.0 / filter_selectivity).clamp(1.0, 64.0);
148        let candidate_rows = ((k.max(1) as f64) * over_fetch).min(total_rows); // usize→f64: planning heuristic
149                                                                               // Derive I/O and CPU weights from calibrated factors using the same
150                                                                               // backward-compatible pattern as CostEstimator: multiply the historical
151                                                                               // ratios (0.2 / 0.8) by (calibrated / default) so that default factors
152                                                                               // produce identical costs to the old hard-coded constants.
153        let defaults = OperationCostFactors::default();
154        let factors = stats.calibrated_cost_factors.as_ref().unwrap_or(&defaults);
155        let io_ratio = factors.seq_page_cost / defaults.seq_page_cost;
156        let cpu_ratio = factors.cpu_tuple_cost / defaults.cpu_tuple_cost;
157        let candidate_filter_cost = Cost::new(
158            candidate_rows * 0.2 * io_ratio,
159            candidate_rows * 0.8 * cpu_ratio,
160        );
161        let vector_first =
162            vector_cost.total() + (candidate_filter_cost.total() * VECTOR_FIRST_FILTER_PENALTY);
163        let graph_first = filter_cost.total()
164            + (vector_cost.total() * filter_cost.io_cost.max(1.0) / GRAPH_TO_VECTOR_SCALING);
165        let parallel = vector_cost.total().max(filter_cost.total()) + PARALLEL_MERGE_OVERHEAD;
166
167        let candidates = [
168            (ExecutionStrategy::VectorFirst, vector_first),
169            (ExecutionStrategy::GraphFirst, graph_first),
170            (ExecutionStrategy::Parallel, parallel),
171        ];
172
173        candidates
174            .into_iter()
175            .min_by(|a, b| a.1.total_cmp(&b.1))
176            .map_or(ExecutionStrategy::Parallel, |(strategy, _)| strategy)
177    }
178
179    /// Like `choose_strategy_with_cbo` but also returns the CBO-computed over-fetch factor.
180    ///
181    /// Returns `(strategy, over_fetch)` where `over_fetch` is a multiplier for `k` when
182    /// doing vector-first search with a selective metadata filter:
183    /// - `VectorFirst` with filter: `(1 / selectivity).clamp(1, 64)`
184    /// - `VectorFirst` without filter: `1`
185    /// - `GraphFirst`: `1` (graph pre-filters, no over-fetch needed)
186    /// - `Parallel`: `2` (merge overhead)
187    #[must_use]
188    pub fn choose_strategy_with_cbo_and_overfetch(
189        &self,
190        stats: &CollectionStats,
191        filter: Option<&Condition>,
192        k: usize,
193    ) -> (ExecutionStrategy, usize) {
194        if filter.is_none() {
195            return (ExecutionStrategy::VectorFirst, 1);
196        }
197
198        let filter_selectivity = estimate_filter_selectivity(stats, filter);
199        let strategy = self.choose_strategy_with_cbo(stats, filter, k);
200
201        // Derive over-fetch from the CBO selectivity estimate.
202        // GraphFirst/Parallel: fixed factors. VectorFirst: scale inversely with selectivity.
203        let over_fetch = match strategy {
204            ExecutionStrategy::VectorFirst => {
205                // (1 / selectivity) rounded up, clamped to [1, 64].
206                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
207                // Reason: over_fetch_f is in [1.0, 64.0]; casting to usize is safe.
208                let over_fetch_f = (1.0 / filter_selectivity).clamp(1.0, 64.0);
209                over_fetch_f.ceil() as usize
210            }
211            ExecutionStrategy::GraphFirst => 1,
212            ExecutionStrategy::Parallel => 2,
213        };
214
215        (strategy, over_fetch)
216    }
217
218    /// Returns a reference to the query statistics.
219    #[must_use]
220    pub fn stats(&self) -> &QueryStats {
221        &self.stats
222    }
223
224    /// Records a CBO feedback observation (issue #469).
225    ///
226    /// Called after each vector query with the dataset size, ef_search used,
227    /// and the actual wall-clock duration. The feedback loop adjusts
228    /// `ms_per_cost_unit` via EMA with α=0.05 after `MIN_SAMPLES` observations.
229    pub fn record_cbo_feedback(&self, dataset_size: usize, ef_search: usize, actual_ms: f64) {
230        self.cbo_feedback.record(dataset_size, ef_search, actual_ms);
231    }
232
233    /// Returns the feedback-adjusted `ms_per_cost_unit`, if available.
234    ///
235    /// Returns `None` until the feedback loop has seen enough observations.
236    /// The planner falls back to the static calibration default in that case.
237    #[must_use]
238    pub fn adjusted_ms_per_cost_unit(&self) -> Option<f64> {
239        self.cbo_feedback.adjusted_ms_per_cost_unit()
240    }
241
242    /// Returns the total number of CBO feedback samples recorded.
243    ///
244    /// Used by EXPLAIN ANALYZE to surface confidence of the calibration.
245    #[must_use]
246    pub fn cbo_sample_count(&self) -> u64 {
247        self.cbo_feedback.sample_count()
248    }
249
250    /// Estimates selectivity based on label and relationship type counts.
251    ///
252    /// This is a heuristic based on the principle that:
253    /// - Rare labels/types → low selectivity → GraphFirst
254    /// - Common labels/types → high selectivity → VectorFirst
255    #[must_use]
256    pub fn estimate_selectivity(
257        &self,
258        label_count: u64,
259        total_nodes: u64,
260        rel_type_count: u64,
261        total_edges: u64,
262    ) -> f64 {
263        if total_nodes == 0 {
264            return 1.0; // No data → assume all match
265        }
266
267        let label_sel = if total_nodes > 0 {
268            label_count as f64 / total_nodes as f64 // u64→f64: selectivity ratio heuristic
269        } else {
270            1.0
271        };
272
273        let rel_sel = if total_edges == 0 {
274            // No edges in graph → relationship predicate is vacuously true
275            1.0
276        } else if rel_type_count == 0 {
277            // Edges exist but none match requested type → nothing matches
278            0.0
279        } else {
280            rel_type_count as f64 / total_edges as f64 // u64→f64: selectivity ratio heuristic
281        };
282
283        // Combined selectivity (multiplicative for AND)
284        label_sel * rel_sel
285    }
286
287    /// Choose optimal strategy for hybrid queries with ORDER BY similarity().
288    ///
289    /// When ORDER BY similarity() is present, we optimize for:
290    /// 1. Always execute vector search first (it naturally orders by similarity)
291    /// 2. Apply filters as post-processing to preserve ordering
292    /// 3. Use early termination when LIMIT is specified
293    ///
294    /// # Arguments
295    /// * `has_order_by_similarity` - True if ORDER BY similarity() is in query
296    /// * `has_filter` - True if WHERE clause with non-vector conditions
297    /// * `limit` - Optional LIMIT value for early termination optimization
298    /// * `estimated_selectivity` - Optional estimated filter selectivity
299    #[must_use]
300    pub fn choose_hybrid_strategy(
301        &self,
302        has_order_by_similarity: bool,
303        has_filter: bool,
304        limit: Option<u64>,
305        estimated_selectivity: Option<f64>,
306    ) -> HybridExecutionPlan {
307        if has_order_by_similarity {
308            // ORDER BY similarity() always benefits from VectorFirst
309            // because HNSW naturally returns results in similarity order
310            let over_fetch_factor = if has_filter {
311                // Over-fetch based on selectivity to ensure LIMIT results after filtering
312                let sel = estimated_selectivity.unwrap_or(0.5);
313                if sel > 0.0 {
314                    (1.0 / sel).clamp(2.0, 10.0)
315                } else {
316                    10.0
317                }
318            } else {
319                1.0
320            };
321
322            HybridExecutionPlan {
323                strategy: ExecutionStrategy::VectorFirst,
324                over_fetch_factor,
325                use_early_termination: limit.is_some(),
326                recompute_scores: false,
327            }
328        } else if has_filter {
329            // No ORDER BY similarity - use standard planning
330            let selectivity =
331                estimated_selectivity.unwrap_or_else(|| self.stats.graph_selectivity());
332            let strategy = self.choose_strategy(Some(selectivity));
333
334            HybridExecutionPlan {
335                strategy,
336                over_fetch_factor: if matches!(strategy, ExecutionStrategy::VectorFirst) {
337                    2.0
338                } else {
339                    1.0
340                },
341                use_early_termination: limit.is_some(),
342                recompute_scores: true,
343            }
344        } else {
345            // No filter, no ORDER BY - simple vector search
346            HybridExecutionPlan {
347                strategy: ExecutionStrategy::VectorFirst,
348                over_fetch_factor: 1.0,
349                use_early_termination: true,
350                recompute_scores: false,
351            }
352        }
353    }
354
355    /// Estimate cost in microseconds for a given execution plan.
356    ///
357    /// Uses runtime statistics to estimate total query cost.
358    #[must_use]
359    pub fn estimate_cost(&self, plan: &HybridExecutionPlan, candidate_count: u64) -> u64 {
360        let vector_cost = self.stats.avg_vector_latency_us();
361        let graph_cost = self.stats.avg_graph_latency_us();
362
363        match plan.strategy {
364            ExecutionStrategy::VectorFirst => {
365                // Vector search + optional filter pass
366                vector_cost + candidate_count // 1µs per filter check
367            }
368            ExecutionStrategy::GraphFirst => {
369                // Graph traversal + vector search on candidates
370                graph_cost + (candidate_count * vector_cost / 1000).max(1)
371            }
372            ExecutionStrategy::Parallel => {
373                // Max of both (parallel execution)
374                vector_cost.max(graph_cost) + 10 // 10µs merge overhead
375            }
376        }
377    }
378}
379
380/// Execution plan for hybrid queries (US-009).
381#[derive(Debug, Clone, PartialEq)]
382pub struct HybridExecutionPlan {
383    /// Primary execution strategy.
384    pub strategy: ExecutionStrategy,
385    /// Factor to multiply LIMIT for over-fetching when filtering.
386    pub over_fetch_factor: f64,
387    /// Whether to use early termination optimization.
388    pub use_early_termination: bool,
389    /// Whether scores need to be recomputed after filtering.
390    pub recompute_scores: bool,
391}
392
393impl Default for HybridExecutionPlan {
394    fn default() -> Self {
395        Self {
396            strategy: ExecutionStrategy::VectorFirst,
397            over_fetch_factor: 1.0,
398            use_early_termination: true,
399            recompute_scores: false,
400        }
401    }
402}
403
404/// Estimates filter selectivity from collection statistics and an optional condition.
405///
406/// Returns a value clamped to `[0.001, 1.0]`, defaulting to `1.0` when no filter is present.
407fn estimate_filter_selectivity(stats: &CollectionStats, filter: Option<&Condition>) -> f64 {
408    let estimator = CostEstimator::new(stats);
409    filter.map_or(1.0, |f| {
410        estimator
411            .estimate_condition_selectivity(f)
412            .clamp(0.001, 1.0)
413    })
414}
415
416// Tests moved to planner_tests.rs per project rules