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