1use std::sync::Arc;
13
14use super::stats_provider::{NullProvider, StatsProvider};
15use crate::storage::query::ast::{
16 CompareOp, FieldRef, Filter as AstFilter, GraphQuery, HybridQuery, JoinQuery, JoinType,
17 PathQuery, QueryExpr, TableQuery, VectorQuery,
18};
19use crate::storage::schema::Value;
20
21#[derive(Debug, Clone, Default)]
23pub struct CardinalityEstimate {
24 pub rows: f64,
26 pub selectivity: f64,
28 pub confidence: f64,
30}
31
32impl CardinalityEstimate {
33 pub fn new(rows: f64, selectivity: f64) -> Self {
35 Self {
36 rows,
37 selectivity,
38 confidence: 1.0,
39 }
40 }
41
42 pub fn full_scan(table_size: f64) -> Self {
44 Self {
45 rows: table_size,
46 selectivity: 1.0,
47 confidence: 1.0,
48 }
49 }
50
51 pub fn with_filter(mut self, filter_selectivity: f64) -> Self {
53 self.rows *= filter_selectivity;
54 self.selectivity *= filter_selectivity;
55 self.confidence *= 0.9; self
57 }
58}
59
60#[derive(Debug, Clone, Default)]
70pub struct PlanCost {
71 pub cpu: f64,
73 pub io: f64,
75 pub network: f64,
77 pub memory: f64,
79 pub startup_cost: f64,
85 pub total: f64,
87}
88
89impl PlanCost {
90 pub fn new(cpu: f64, io: f64, memory: f64) -> Self {
92 let total = cpu + io * 10.0 + memory * 0.1; Self {
94 cpu,
95 io,
96 network: 0.0,
97 memory,
98 startup_cost: 0.0,
99 total,
100 }
101 }
102
103 pub fn with_startup(cpu: f64, io: f64, memory: f64, startup_cost: f64) -> Self {
107 let total = cpu + io * 10.0 + memory * 0.1;
108 Self {
109 cpu,
110 io,
111 network: 0.0,
112 memory,
113 startup_cost: startup_cost.max(0.0),
114 total: total.max(startup_cost),
115 }
116 }
117
118 pub fn combine_pipelined(&self, other: &PlanCost) -> PlanCost {
124 PlanCost {
125 cpu: self.cpu + other.cpu,
126 io: self.io + other.io,
127 network: self.network + other.network,
128 memory: self.memory.max(other.memory),
129 startup_cost: self.startup_cost + other.startup_cost,
130 total: self.total + other.total,
131 }
132 }
133
134 pub fn combine_blocking(&self, blocker: &PlanCost) -> PlanCost {
141 PlanCost {
142 cpu: self.cpu + blocker.cpu,
143 io: self.io + blocker.io,
144 network: self.network + blocker.network,
145 memory: self.memory.max(blocker.memory),
146 startup_cost: self.total + blocker.startup_cost,
147 total: self.total + blocker.total,
148 }
149 }
150
151 pub fn combine(&self, other: &PlanCost) -> PlanCost {
156 self.combine_pipelined(other)
157 }
158
159 pub fn scale(&self, factor: f64) -> PlanCost {
161 PlanCost {
162 cpu: self.cpu * factor,
163 io: self.io * factor,
164 network: self.network * factor,
165 memory: self.memory, startup_cost: self.startup_cost, total: self.total * factor,
168 }
169 }
170
171 pub fn prefer_over(
183 &self,
184 other: &PlanCost,
185 limit: Option<u64>,
186 cardinality: f64,
187 ) -> std::cmp::Ordering {
188 let use_startup = matches!(limit, Some(k) if (k as f64) < 0.1 * cardinality.max(1.0));
189 let (lhs, rhs) = if use_startup {
190 (self.startup_cost, other.startup_cost)
191 } else {
192 (self.total, other.total)
193 };
194 lhs.partial_cmp(&rhs).unwrap_or(std::cmp::Ordering::Equal)
195 }
196}
197
198#[derive(Debug, Clone, Default)]
200pub struct TableStats {
201 pub row_count: u64,
203 pub avg_row_size: u32,
205 pub page_count: u64,
207 pub columns: Vec<ColumnStats>,
209}
210
211#[derive(Debug, Clone, Default)]
213pub struct ColumnStats {
214 pub name: String,
216 pub distinct_count: u64,
218 pub null_count: u64,
220 pub min_value: Option<String>,
222 pub max_value: Option<String>,
224 pub has_index: bool,
226}
227
228pub struct CostEstimator {
230 default_row_count: f64,
232 row_scan_cost: f64,
234 index_lookup_cost: f64,
236 hash_probe_cost: f64,
238 nested_loop_cost: f64,
240 edge_traversal_cost: f64,
242 stats: Arc<dyn StatsProvider>,
247}
248
249impl CostEstimator {
250 pub fn new() -> Self {
253 Self {
254 default_row_count: 1000.0,
255 row_scan_cost: 1.0,
256 index_lookup_cost: 0.1,
257 hash_probe_cost: 0.5,
258 nested_loop_cost: 2.0,
259 edge_traversal_cost: 1.5,
260 stats: Arc::new(NullProvider),
261 }
262 }
263
264 pub fn with_stats(provider: Arc<dyn StatsProvider>) -> Self {
268 Self {
269 stats: provider,
270 ..Self::new()
271 }
272 }
273
274 pub fn set_stats(&mut self, provider: Arc<dyn StatsProvider>) {
278 self.stats = provider;
279 }
280
281 pub fn estimate(&self, query: &QueryExpr) -> PlanCost {
283 match query {
284 QueryExpr::Table(tq) => self.estimate_table(tq),
285 QueryExpr::Graph(gq) => self.estimate_graph(gq),
286 QueryExpr::Join(jq) => self.estimate_join(jq),
287 QueryExpr::Path(pq) => self.estimate_path(pq),
288 QueryExpr::Vector(vq) => self.estimate_vector(vq),
289 QueryExpr::Hybrid(hq) => self.estimate_hybrid(hq),
290 QueryExpr::Insert(_)
292 | QueryExpr::Update(_)
293 | QueryExpr::Delete(_)
294 | QueryExpr::CreateTable(_)
295 | QueryExpr::CreateCollection(_)
296 | QueryExpr::CreateVector(_)
297 | QueryExpr::DropTable(_)
298 | QueryExpr::DropGraph(_)
299 | QueryExpr::DropVector(_)
300 | QueryExpr::DropDocument(_)
301 | QueryExpr::DropKv(_)
302 | QueryExpr::DropCollection(_)
303 | QueryExpr::Truncate(_)
304 | QueryExpr::AlterTable(_)
305 | QueryExpr::CreateVcsRef(_)
306 | QueryExpr::DropVcsRef(_)
307 | QueryExpr::VcsCommand(_)
308 | QueryExpr::GraphCommand(_)
309 | QueryExpr::SearchCommand(_)
310 | QueryExpr::CreateIndex(_)
311 | QueryExpr::DropIndex(_)
312 | QueryExpr::ProbabilisticCommand(_)
313 | QueryExpr::Ask(_)
314 | QueryExpr::SetConfig { .. }
315 | QueryExpr::ShowConfig { .. }
316 | QueryExpr::SetSecret { .. }
317 | QueryExpr::DeleteSecret { .. }
318 | QueryExpr::SetKv { .. }
319 | QueryExpr::DeleteKv { .. }
320 | QueryExpr::ShowSecrets { .. }
321 | QueryExpr::SetTenant(_)
322 | QueryExpr::ShowTenant
323 | QueryExpr::CreateTimeSeries(_)
324 | QueryExpr::CreateMetric(_)
325 | QueryExpr::AlterMetric(_)
326 | QueryExpr::CreateSlo(_)
327 | QueryExpr::DropTimeSeries(_)
328 | QueryExpr::CreateQueue(_)
329 | QueryExpr::AlterQueue(_)
330 | QueryExpr::DropQueue(_)
331 | QueryExpr::QueueSelect(_)
332 | QueryExpr::QueueCommand(_)
333 | QueryExpr::KvCommand(_)
334 | QueryExpr::ConfigCommand(_)
335 | QueryExpr::CreateTree(_)
336 | QueryExpr::DropTree(_)
337 | QueryExpr::TreeCommand(_)
338 | QueryExpr::ExplainAlter(_)
339 | QueryExpr::TransactionControl(_)
340 | QueryExpr::MaintenanceCommand(_)
341 | QueryExpr::CreateSchema(_)
342 | QueryExpr::DropSchema(_)
343 | QueryExpr::CreateSequence(_)
344 | QueryExpr::DropSequence(_)
345 | QueryExpr::CopyFrom(_)
346 | QueryExpr::CreateView(_)
347 | QueryExpr::DropView(_)
348 | QueryExpr::RefreshMaterializedView(_)
349 | QueryExpr::CreatePolicy(_)
350 | QueryExpr::DropPolicy(_)
351 | QueryExpr::CreateServer(_)
352 | QueryExpr::DropServer(_)
353 | QueryExpr::CreateForeignTable(_)
354 | QueryExpr::DropForeignTable(_)
355 | QueryExpr::Grant(_)
356 | QueryExpr::Revoke(_)
357 | QueryExpr::AlterUser(_)
358 | QueryExpr::CreateUser(_)
359 | QueryExpr::CreateIamPolicy { .. }
360 | QueryExpr::DropIamPolicy { .. }
361 | QueryExpr::AttachPolicy { .. }
362 | QueryExpr::DetachPolicy { .. }
363 | QueryExpr::ShowPolicies { .. }
364 | QueryExpr::ShowEffectivePermissions { .. }
365 | QueryExpr::RankOf(_)
366 | QueryExpr::ApproxRankOf(_)
367 | QueryExpr::RankRange(_)
368 | QueryExpr::SimulatePolicy { .. }
369 | QueryExpr::LintPolicy { .. }
370 | QueryExpr::MigratePolicyMode { .. }
371 | QueryExpr::CreateMigration(_)
372 | QueryExpr::ApplyMigration(_)
373 | QueryExpr::RollbackMigration(_)
374 | QueryExpr::ExplainMigration(_)
375 | QueryExpr::EventsBackfill(_)
376 | QueryExpr::EventsBackfillStatus { .. } => PlanCost::new(1.0, 1.0, 0.0),
377 }
378 }
379
380 pub fn estimate_cardinality(&self, query: &QueryExpr) -> CardinalityEstimate {
382 match query {
383 QueryExpr::Table(tq) => self.estimate_table_cardinality(tq),
384 QueryExpr::Graph(gq) => self.estimate_graph_cardinality(gq),
385 QueryExpr::Join(jq) => self.estimate_join_cardinality(jq),
386 QueryExpr::Path(pq) => self.estimate_path_cardinality(pq),
387 QueryExpr::Vector(vq) => self.estimate_vector_cardinality(vq),
388 QueryExpr::Hybrid(hq) => self.estimate_hybrid_cardinality(hq),
389 QueryExpr::Insert(_)
391 | QueryExpr::Update(_)
392 | QueryExpr::Delete(_)
393 | QueryExpr::CreateTable(_)
394 | QueryExpr::CreateCollection(_)
395 | QueryExpr::CreateVector(_)
396 | QueryExpr::DropTable(_)
397 | QueryExpr::DropGraph(_)
398 | QueryExpr::DropVector(_)
399 | QueryExpr::DropDocument(_)
400 | QueryExpr::DropKv(_)
401 | QueryExpr::DropCollection(_)
402 | QueryExpr::Truncate(_)
403 | QueryExpr::AlterTable(_)
404 | QueryExpr::CreateVcsRef(_)
405 | QueryExpr::DropVcsRef(_)
406 | QueryExpr::VcsCommand(_)
407 | QueryExpr::GraphCommand(_)
408 | QueryExpr::SearchCommand(_)
409 | QueryExpr::CreateIndex(_)
410 | QueryExpr::DropIndex(_)
411 | QueryExpr::ProbabilisticCommand(_)
412 | QueryExpr::Ask(_)
413 | QueryExpr::SetConfig { .. }
414 | QueryExpr::ShowConfig { .. }
415 | QueryExpr::SetSecret { .. }
416 | QueryExpr::DeleteSecret { .. }
417 | QueryExpr::SetKv { .. }
418 | QueryExpr::DeleteKv { .. }
419 | QueryExpr::ShowSecrets { .. }
420 | QueryExpr::SetTenant(_)
421 | QueryExpr::ShowTenant
422 | QueryExpr::CreateTimeSeries(_)
423 | QueryExpr::CreateMetric(_)
424 | QueryExpr::AlterMetric(_)
425 | QueryExpr::CreateSlo(_)
426 | QueryExpr::DropTimeSeries(_)
427 | QueryExpr::CreateQueue(_)
428 | QueryExpr::AlterQueue(_)
429 | QueryExpr::DropQueue(_)
430 | QueryExpr::QueueSelect(_)
431 | QueryExpr::QueueCommand(_)
432 | QueryExpr::KvCommand(_)
433 | QueryExpr::ConfigCommand(_)
434 | QueryExpr::CreateTree(_)
435 | QueryExpr::DropTree(_)
436 | QueryExpr::TreeCommand(_)
437 | QueryExpr::ExplainAlter(_)
438 | QueryExpr::TransactionControl(_)
439 | QueryExpr::MaintenanceCommand(_)
440 | QueryExpr::CreateSchema(_)
441 | QueryExpr::DropSchema(_)
442 | QueryExpr::CreateSequence(_)
443 | QueryExpr::DropSequence(_)
444 | QueryExpr::CopyFrom(_)
445 | QueryExpr::CreateView(_)
446 | QueryExpr::DropView(_)
447 | QueryExpr::RefreshMaterializedView(_)
448 | QueryExpr::CreatePolicy(_)
449 | QueryExpr::DropPolicy(_)
450 | QueryExpr::CreateServer(_)
451 | QueryExpr::DropServer(_)
452 | QueryExpr::CreateForeignTable(_)
453 | QueryExpr::DropForeignTable(_)
454 | QueryExpr::Grant(_)
455 | QueryExpr::Revoke(_)
456 | QueryExpr::AlterUser(_)
457 | QueryExpr::CreateUser(_)
458 | QueryExpr::CreateIamPolicy { .. }
459 | QueryExpr::DropIamPolicy { .. }
460 | QueryExpr::AttachPolicy { .. }
461 | QueryExpr::DetachPolicy { .. }
462 | QueryExpr::ShowPolicies { .. }
463 | QueryExpr::ShowEffectivePermissions { .. }
464 | QueryExpr::RankOf(_)
465 | QueryExpr::ApproxRankOf(_)
466 | QueryExpr::RankRange(_)
467 | QueryExpr::SimulatePolicy { .. }
468 | QueryExpr::LintPolicy { .. }
469 | QueryExpr::MigratePolicyMode { .. }
470 | QueryExpr::CreateMigration(_)
471 | QueryExpr::ApplyMigration(_)
472 | QueryExpr::RollbackMigration(_)
473 | QueryExpr::ExplainMigration(_)
474 | QueryExpr::EventsBackfill(_)
475 | QueryExpr::EventsBackfillStatus { .. } => CardinalityEstimate::new(1.0, 1.0),
476 }
477 }
478
479 fn estimate_table(&self, query: &TableQuery) -> PlanCost {
484 let cardinality = self.estimate_table_cardinality(query);
485
486 let cpu = cardinality.rows * self.row_scan_cost;
487
488 let io = self.estimate_table_io(query, cardinality.rows);
491
492 let memory = cardinality.rows * 100.0; PlanCost::new(cpu, io, memory)
495 }
496
497 fn estimate_table_io(&self, query: &TableQuery, result_rows: f64) -> f64 {
504 const ROWS_PER_PAGE: f64 = 100.0;
505
506 let table_stats = self.stats.table_stats(&query.table);
508 let heap_pages = table_stats
509 .map(|s| s.page_count as f64)
510 .unwrap_or_else(|| (result_rows / ROWS_PER_PAGE).max(1.0));
511
512 if let Some(filter) = crate::storage::query::sql_lowering::effective_table_filter(query) {
515 if let Some(col) = first_filter_column(&filter, &query.table) {
516 if let Some(idx) = self.stats.index_stats(&query.table, col) {
517 return idx.correlated_io_cost(result_rows, heap_pages);
518 }
519 }
520 }
521
522 (result_rows / ROWS_PER_PAGE).ceil()
524 }
525
526 fn estimate_table_cardinality(&self, query: &TableQuery) -> CardinalityEstimate {
527 let base_rows = self
530 .stats
531 .table_stats(&query.table)
532 .map(|s| s.row_count as f64)
533 .unwrap_or(self.default_row_count);
534
535 let mut estimate = CardinalityEstimate::full_scan(base_rows);
536
537 if let Some(filter) = crate::storage::query::sql_lowering::effective_table_filter(query) {
540 let selectivity = self.filter_selectivity(&filter, &query.table);
541 estimate = estimate.with_filter(selectivity);
542 }
543
544 if let Some(limit) = query.limit {
546 estimate.rows = estimate.rows.min(limit as f64);
547 }
548
549 estimate
550 }
551
552 fn filter_selectivity(&self, filter: &AstFilter, table: &str) -> f64 {
565 match filter {
566 AstFilter::Compare { field, op, value } => {
567 let column = column_name_for_table(field, table);
568 match op {
569 CompareOp::Eq => self.eq_selectivity(table, column, value),
570 CompareOp::Ne => 1.0 - self.eq_selectivity(table, column, value),
571 CompareOp::Lt | CompareOp::Le => {
572 self.range_selectivity(table, column, None, Some(value))
573 }
574 CompareOp::Gt | CompareOp::Ge => {
575 self.range_selectivity(table, column, Some(value), None)
576 }
577 }
578 }
579 AstFilter::Between {
580 field, low, high, ..
581 } => {
582 let column = column_name_for_table(field, table);
583 self.range_selectivity(table, column, Some(low), Some(high))
584 }
585 AstFilter::In { field, values, .. } => {
586 let column = column_name_for_table(field, table);
587 if let Some(c) = column {
591 if let Some(mcv) = self.stats.column_mcv(table, c) {
592 let mut hits: f64 = 0.0;
593 let mut residual_count = 0usize;
594 for v in values {
595 if let Some(cv) = column_value_from(v) {
596 if let Some(freq) = mcv.frequency_of(&cv) {
597 hits += freq;
598 } else {
599 residual_count += 1;
600 }
601 } else {
602 residual_count += 1;
603 }
604 }
605 let total = mcv.total_frequency();
606 let distinct = self.stats.distinct_values(table, c).unwrap_or(100);
607 let non_mcv_distinct =
608 distinct.saturating_sub(mcv.values.len() as u64).max(1);
609 let per_residual = (1.0 - total) / non_mcv_distinct as f64;
610 let estimate = hits + (residual_count as f64) * per_residual;
611 return estimate.clamp(0.0, 1.0).min(0.5);
612 }
613 if let Some(s) = self.stats.index_stats(table, c) {
614 return (s.point_selectivity() * values.len() as f64).min(0.5);
615 }
616 }
617 (values.len() as f64 * 0.01).min(0.5)
618 }
619 AstFilter::Like { .. } => 0.1,
620 AstFilter::StartsWith { .. } => 0.15,
621 AstFilter::EndsWith { .. } => 0.15,
622 AstFilter::Contains { .. } => 0.1,
623 AstFilter::IsNull { .. } => 0.01,
624 AstFilter::IsNotNull { .. } => 0.99,
625 AstFilter::And(left, right) => {
626 self.filter_selectivity(left, table) * self.filter_selectivity(right, table)
627 }
628 AstFilter::Or(left, right) => {
629 let s1 = self.filter_selectivity(left, table);
630 let s2 = self.filter_selectivity(right, table);
631 s1 + s2 - (s1 * s2)
632 }
633 AstFilter::Not(inner) => 1.0 - self.filter_selectivity(inner, table),
634 AstFilter::CompareFields { .. } => {
635 0.1
639 }
640 AstFilter::CompareExpr { .. } => {
641 0.1
645 }
646 }
647 }
648
649 fn estimate_graph(&self, query: &GraphQuery) -> PlanCost {
654 let cardinality = self.estimate_graph_cardinality(query);
655
656 let nodes = query.pattern.nodes.len() as f64;
658 let edges = query.pattern.edges.len() as f64;
659
660 let cpu = cardinality.rows * self.edge_traversal_cost * (nodes + edges);
661 let io = cardinality.rows * 0.1; let memory = cardinality.rows * 200.0; PlanCost::new(cpu, io, memory)
665 }
666
667 fn estimate_graph_cardinality(&self, query: &GraphQuery) -> CardinalityEstimate {
668 let nodes = query.pattern.nodes.len() as f64;
669 let edges = query.pattern.edges.len() as f64;
670
671 let base_rows = self.default_row_count;
673 let edge_factor = 0.1_f64.powf(edges); let mut estimate = CardinalityEstimate::new(base_rows * nodes * edge_factor, edge_factor);
676 estimate.confidence = 0.5; if let Some(ref filter) = query.filter {
680 let selectivity = Self::estimate_filter_selectivity(filter);
681 estimate = estimate.with_filter(selectivity);
682 }
683
684 estimate
685 }
686
687 fn estimate_join(&self, query: &JoinQuery) -> PlanCost {
692 let left_cost = self.estimate(&query.left);
693 let right_cost = self.estimate(&query.right);
694
695 let left_card = self.estimate_cardinality(&query.left);
696 let right_card = self.estimate_cardinality(&query.right);
697
698 let build_cpu = left_card.rows * self.hash_probe_cost;
705 let probe_cpu = right_card.rows * self.hash_probe_cost;
706 let join_memory = left_card.rows * 100.0; let build_op = PlanCost::with_startup(build_cpu, 0.0, join_memory, build_cpu);
710 let probe_op = PlanCost::new(probe_cpu, 0.0, 0.0);
712
713 let after_build = left_cost.combine_blocking(&build_op);
715 after_build
716 .combine_pipelined(&right_cost)
717 .combine_pipelined(&probe_op)
718 }
719
720 fn estimate_join_cardinality(&self, query: &JoinQuery) -> CardinalityEstimate {
721 let left = self.estimate_cardinality(&query.left);
722 let right = self.estimate_cardinality(&query.right);
723
724 let selectivity = match query.join_type {
726 JoinType::Inner => 0.1, JoinType::LeftOuter => 1.0, JoinType::RightOuter => 1.0, JoinType::FullOuter => 1.0, JoinType::Cross => 1.0, };
732
733 CardinalityEstimate::new(
734 left.rows * right.rows * selectivity,
735 left.selectivity * right.selectivity * selectivity,
736 )
737 }
738
739 fn estimate_path(&self, query: &PathQuery) -> PlanCost {
744 let cardinality = self.estimate_path_cardinality(query);
745
746 let max_hops = query.max_length;
748 let branching_factor: f64 = 5.0; let nodes_visited = branching_factor.powf(max_hops as f64).min(10000.0);
751 let cpu = nodes_visited * self.edge_traversal_cost;
752 let io = nodes_visited * 0.1;
753 let memory = nodes_visited * 50.0; PlanCost::new(cpu, io, memory)
756 }
757
758 fn estimate_path_cardinality(&self, query: &PathQuery) -> CardinalityEstimate {
759 let max_paths = 10.0;
761 CardinalityEstimate::new(max_paths, 0.001)
762 }
763
764 fn estimate_vector(&self, query: &VectorQuery) -> PlanCost {
769 let k = query.k as f64;
772
773 let hnsw_cost = 100.0 * (1.0 + k.ln()); let filter_cost =
780 if crate::storage::query::sql_lowering::effective_vector_filter(query).is_some() {
781 50.0
782 } else {
783 0.0
784 };
785
786 let cpu = hnsw_cost + filter_cost;
787 let io = 20.0; let memory = k * 32.0 + 1000.0; PlanCost::with_startup(cpu, io, memory, hnsw_cost * 0.5)
795 }
796
797 fn estimate_vector_cardinality(&self, query: &VectorQuery) -> CardinalityEstimate {
798 let k = query.k as f64;
800 CardinalityEstimate::new(k, 0.1)
801 }
802
803 fn estimate_hybrid(&self, query: &HybridQuery) -> PlanCost {
808 let structured_cost = self.estimate(&query.structured);
810 let vector_cost = self.estimate_vector(&query.vector);
811
812 let fusion_overhead = match &query.fusion {
814 crate::storage::query::ast::FusionStrategy::Rerank { .. } => 50.0,
815 crate::storage::query::ast::FusionStrategy::FilterThenSearch => 10.0,
816 crate::storage::query::ast::FusionStrategy::SearchThenFilter => 10.0,
817 crate::storage::query::ast::FusionStrategy::RRF { .. } => 30.0,
818 crate::storage::query::ast::FusionStrategy::Intersection => 20.0,
819 crate::storage::query::ast::FusionStrategy::Union { .. } => 40.0,
820 };
821
822 PlanCost::new(
823 structured_cost.cpu + vector_cost.cpu + fusion_overhead,
824 structured_cost.io + vector_cost.io,
825 structured_cost.memory + vector_cost.memory,
826 )
827 }
828
829 fn estimate_hybrid_cardinality(&self, query: &HybridQuery) -> CardinalityEstimate {
830 let structured_card = self.estimate_cardinality(&query.structured);
831 let vector_card = self.estimate_vector_cardinality(&query.vector);
832
833 let rows = match &query.fusion {
835 crate::storage::query::ast::FusionStrategy::Intersection => {
836 structured_card.rows.min(vector_card.rows)
837 }
838 crate::storage::query::ast::FusionStrategy::Union { .. } => {
839 structured_card.rows + vector_card.rows
840 }
841 _ => vector_card.rows, };
843
844 CardinalityEstimate::new(rows, 0.2)
845 }
846
847 fn estimate_filter_selectivity(filter: &AstFilter) -> f64 {
852 match filter {
853 AstFilter::Compare { op, .. } => {
854 match op {
855 CompareOp::Eq => 0.01, CompareOp::Ne => 0.99, CompareOp::Lt | CompareOp::Le => 0.3,
858 CompareOp::Gt | CompareOp::Ge => 0.3,
859 }
860 }
861 AstFilter::Between { .. } => 0.25,
862 AstFilter::In { values, .. } => {
863 (values.len() as f64 * 0.01).min(0.5)
865 }
866 AstFilter::Like { .. } => 0.1,
867 AstFilter::StartsWith { .. } => 0.15,
868 AstFilter::EndsWith { .. } => 0.15,
869 AstFilter::Contains { .. } => 0.1,
870 AstFilter::IsNull { .. } => 0.01,
871 AstFilter::IsNotNull { .. } => 0.99,
872 AstFilter::And(left, right) => {
873 Self::estimate_filter_selectivity(left) * Self::estimate_filter_selectivity(right)
874 }
875 AstFilter::Or(left, right) => {
876 let s1 = Self::estimate_filter_selectivity(left);
877 let s2 = Self::estimate_filter_selectivity(right);
878 s1 + s2 - (s1 * s2) }
880 AstFilter::Not(inner) => 1.0 - Self::estimate_filter_selectivity(inner),
881 AstFilter::CompareFields { .. } => 0.1,
882 AstFilter::CompareExpr { .. } => 0.1,
883 }
884 }
885}
886
887impl CostEstimator {
888 fn eq_selectivity(&self, table: &str, column: Option<&str>, value: &Value) -> f64 {
896 if let Some(col) = column {
897 if let Some(mcv) = self.stats.column_mcv(table, col) {
899 if let Some(cv) = column_value_from(value) {
900 if let Some(freq) = mcv.frequency_of(&cv) {
901 return freq;
902 }
903 let total = mcv.total_frequency();
905 let distinct = self.stats.distinct_values(table, col).unwrap_or(100);
906 let non_mcv_distinct = distinct.saturating_sub(mcv.values.len() as u64).max(1);
907 return ((1.0 - total) / non_mcv_distinct as f64).clamp(0.0, 1.0);
908 }
909 }
910 if let Some(s) = self.stats.index_stats(table, col) {
912 return s.point_selectivity();
913 }
914 }
915 0.01
917 }
918
919 fn range_selectivity(
930 &self,
931 table: &str,
932 column: Option<&str>,
933 lo: Option<&Value>,
934 hi: Option<&Value>,
935 ) -> f64 {
936 if let Some(col) = column {
937 if let Some(h) = self.stats.column_histogram(table, col) {
939 let lo_cv = lo.and_then(column_value_from);
940 let hi_cv = hi.and_then(column_value_from);
941 return h.range_selectivity(lo_cv.as_ref(), hi_cv.as_ref());
942 }
943 if let Some(s) = self.stats.index_stats(table, col) {
945 let cap = if lo.is_some() && hi.is_some() {
946 0.25
947 } else {
948 0.3
949 };
950 return (s.point_selectivity() * (s.distinct_keys as f64 / 2.0)).min(cap);
951 }
952 }
953 if lo.is_some() && hi.is_some() {
955 0.25
956 } else {
957 0.3
958 }
959 }
960}
961
962impl Default for CostEstimator {
963 fn default() -> Self {
964 Self::new()
965 }
966}
967
968fn column_value_from(v: &crate::storage::schema::Value) -> Option<super::histogram::ColumnValue> {
973 use super::histogram::ColumnValue;
974 use crate::storage::schema::Value;
975 match v {
976 Value::Integer(i) | Value::BigInt(i) => Some(ColumnValue::Int(*i)),
977 Value::UnsignedInteger(u) => Some(ColumnValue::Int(*u as i64)),
978 Value::Float(f) if f.is_finite() => Some(ColumnValue::Float(*f)),
979 Value::Text(s) => Some(ColumnValue::Text(s.to_string())),
980 Value::Email(s)
981 | Value::Url(s)
982 | Value::NodeRef(s)
983 | Value::EdgeRef(s)
984 | Value::TableRef(s)
985 | Value::Password(s) => Some(ColumnValue::Text(s.clone())),
986 Value::Timestamp(t) => Some(ColumnValue::Int(*t)),
987 Value::Duration(d) => Some(ColumnValue::Int(*d)),
988 Value::TimestampMs(t) => Some(ColumnValue::Int(*t)),
989 Value::Decimal(d) => Some(ColumnValue::Int(*d)),
990 Value::Date(d) => Some(ColumnValue::Int(i64::from(*d))),
991 Value::Time(t) => Some(ColumnValue::Int(i64::from(*t))),
992 Value::Phone(p) => Some(ColumnValue::Int(*p as i64)),
993 Value::Semver(v) => Some(ColumnValue::Int(i64::from(*v))),
994 Value::Port(v) => Some(ColumnValue::Int(i64::from(*v))),
995 Value::PageRef(v) => Some(ColumnValue::Int(i64::from(*v))),
996 Value::EnumValue(v) => Some(ColumnValue::Int(i64::from(*v))),
997 Value::Latitude(v) => Some(ColumnValue::Int(i64::from(*v))),
998 Value::Longitude(v) => Some(ColumnValue::Int(i64::from(*v))),
999 _ => None,
1004 }
1005}
1006
1007fn first_filter_column<'a>(filter: &'a AstFilter, table: &str) -> Option<&'a str> {
1012 match filter {
1013 AstFilter::Compare { field, .. } => column_name_for_table(field, table),
1014 AstFilter::Between { field, .. } => column_name_for_table(field, table),
1015 AstFilter::And(l, r) => {
1016 first_filter_column(l, table).or_else(|| first_filter_column(r, table))
1017 }
1018 _ => None,
1019 }
1020}
1021
1022fn column_name_for_table<'a>(field: &'a FieldRef, table: &str) -> Option<&'a str> {
1024 match field {
1025 FieldRef::TableColumn { table: t, column } if t == table || t.is_empty() => {
1026 Some(column.as_str())
1027 }
1028 _ => None,
1030 }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035 use super::super::stats_provider::StaticProvider;
1036 use super::*;
1037 use crate::storage::index::{IndexKind, IndexStats};
1038 use crate::storage::query::ast::{FieldRef, Projection};
1039 use crate::storage::schema::Value;
1040
1041 fn eq_filter(table: &str, column: &str, value: i64) -> AstFilter {
1042 AstFilter::Compare {
1043 field: FieldRef::column(table, column),
1044 op: CompareOp::Eq,
1045 value: Value::Integer(value),
1046 }
1047 }
1048
1049 fn table_query(name: &str, filter: Option<AstFilter>) -> TableQuery {
1050 TableQuery {
1051 table: name.to_string(),
1052 source: None,
1053 alias: None,
1054 select_items: Vec::new(),
1055 columns: vec![Projection::All],
1056 where_expr: None,
1057 filter,
1058 group_by_exprs: Vec::new(),
1059 group_by: Vec::new(),
1060 having_expr: None,
1061 having: None,
1062 order_by: vec![],
1063 limit: None,
1064 limit_param: None,
1065 offset: None,
1066 offset_param: None,
1067 expand: None,
1068 as_of: None,
1069 sessionize: None,
1070 distinct: false,
1071 }
1072 }
1073
1074 #[test]
1075 fn injected_row_count_overrides_default() {
1076 let provider = Arc::new(StaticProvider::new().with_table(
1077 "users",
1078 TableStats {
1079 row_count: 50_000,
1080 avg_row_size: 256,
1081 page_count: 500,
1082 columns: vec![],
1083 },
1084 ));
1085 let estimator = CostEstimator::with_stats(provider);
1086 let q = table_query("users", None);
1087 let card = estimator.estimate_table_cardinality(&q);
1088 assert_eq!(card.rows, 50_000.0);
1090 }
1091
1092 #[test]
1093 fn stats_aware_eq_selectivity_beats_default() {
1094 let provider = Arc::new(
1095 StaticProvider::new()
1096 .with_table(
1097 "users",
1098 TableStats {
1099 row_count: 1_000_000,
1100 avg_row_size: 256,
1101 page_count: 10_000,
1102 columns: vec![],
1103 },
1104 )
1105 .with_index(
1106 "users",
1107 "email",
1108 IndexStats {
1109 entries: 1_000_000,
1110 distinct_keys: 1_000_000,
1111 approx_bytes: 0,
1112 kind: IndexKind::Hash,
1113 has_bloom: true,
1114 index_correlation: 0.0,
1115 },
1116 ),
1117 );
1118 let estimator = CostEstimator::with_stats(provider);
1119 let q = table_query("users", Some(eq_filter("users", "email", 0)));
1120 let card = estimator.estimate_table_cardinality(&q);
1121 assert!(card.rows < 2.0, "expected ~1 row, got {}", card.rows);
1123 }
1124
1125 #[test]
1126 fn fallback_when_no_index_stats() {
1127 let provider = Arc::new(StaticProvider::new().with_table(
1128 "users",
1129 TableStats {
1130 row_count: 1_000_000,
1131 avg_row_size: 256,
1132 page_count: 10_000,
1133 columns: vec![],
1134 },
1135 ));
1136 let estimator = CostEstimator::with_stats(provider);
1137 let q = table_query("users", Some(eq_filter("users", "email", 0)));
1138 let card = estimator.estimate_table_cardinality(&q);
1139 assert!((card.rows - 10_000.0).abs() < 1.0);
1141 }
1142
1143 #[test]
1144 fn null_provider_keeps_legacy_behaviour() {
1145 let estimator = CostEstimator::new();
1146 let q = table_query("whatever", Some(eq_filter("whatever", "id", 1)));
1147 let card = estimator.estimate_table_cardinality(&q);
1148 assert!((card.rows - 10.0).abs() < 1.0);
1150 }
1151
1152 #[test]
1153 fn and_combines_stats_selectivities() {
1154 let provider = Arc::new(
1155 StaticProvider::new()
1156 .with_table(
1157 "t",
1158 TableStats {
1159 row_count: 100_000,
1160 avg_row_size: 64,
1161 page_count: 100,
1162 columns: vec![],
1163 },
1164 )
1165 .with_index(
1166 "t",
1167 "a",
1168 IndexStats {
1169 entries: 100_000,
1170 distinct_keys: 10,
1171 approx_bytes: 0,
1172 kind: IndexKind::BTree,
1173 has_bloom: false,
1174 index_correlation: 0.0,
1175 },
1176 )
1177 .with_index(
1178 "t",
1179 "b",
1180 IndexStats {
1181 entries: 100_000,
1182 distinct_keys: 1000,
1183 approx_bytes: 0,
1184 kind: IndexKind::BTree,
1185 has_bloom: false,
1186 index_correlation: 0.0,
1187 },
1188 ),
1189 );
1190 let estimator = CostEstimator::with_stats(provider);
1191 let filter = AstFilter::And(
1192 Box::new(eq_filter("t", "a", 1)),
1193 Box::new(eq_filter("t", "b", 1)),
1194 );
1195 let q = table_query("t", Some(filter));
1196 let card = estimator.estimate_table_cardinality(&q);
1197 assert!(card.rows < 15.0, "got {}", card.rows);
1199 }
1200
1201 #[test]
1202 fn test_table_cost_estimation() {
1203 let estimator = CostEstimator::new();
1204
1205 let query = QueryExpr::Table(TableQuery {
1206 table: "hosts".to_string(),
1207 source: None,
1208 alias: None,
1209 select_items: Vec::new(),
1210 columns: vec![Projection::All],
1211 where_expr: None,
1212 filter: None,
1213 group_by_exprs: Vec::new(),
1214 group_by: Vec::new(),
1215 having_expr: None,
1216 having: None,
1217 order_by: vec![],
1218 limit: None,
1219 limit_param: None,
1220 offset: None,
1221 offset_param: None,
1222 expand: None,
1223 as_of: None,
1224 sessionize: None,
1225 distinct: false,
1226 });
1227
1228 let cost = estimator.estimate(&query);
1229 assert!(cost.cpu > 0.0);
1230 assert!(cost.total > 0.0);
1231 }
1232
1233 #[test]
1234 fn test_filter_selectivity() {
1235 let estimator = CostEstimator::new();
1236
1237 let eq_filter = AstFilter::Compare {
1238 field: FieldRef::column("hosts", "id"),
1239 op: CompareOp::Eq,
1240 value: Value::Integer(1),
1241 };
1242 assert!(CostEstimator::estimate_filter_selectivity(&eq_filter) < 0.1);
1243
1244 let ne_filter = AstFilter::Compare {
1245 field: FieldRef::column("hosts", "id"),
1246 op: CompareOp::Ne,
1247 value: Value::Integer(1),
1248 };
1249 assert!(CostEstimator::estimate_filter_selectivity(&ne_filter) > 0.9);
1250 }
1251
1252 #[test]
1253 fn test_and_selectivity() {
1254 let estimator = CostEstimator::new();
1255
1256 let and_filter = AstFilter::And(
1257 Box::new(AstFilter::Compare {
1258 field: FieldRef::column("hosts", "a"),
1259 op: CompareOp::Eq,
1260 value: Value::Integer(1),
1261 }),
1262 Box::new(AstFilter::Compare {
1263 field: FieldRef::column("hosts", "b"),
1264 op: CompareOp::Eq,
1265 value: Value::Integer(2),
1266 }),
1267 );
1268
1269 let selectivity = CostEstimator::estimate_filter_selectivity(&and_filter);
1270 assert!(selectivity < 0.01); }
1272
1273 #[test]
1274 fn test_cardinality_with_limit() {
1275 let estimator = CostEstimator::new();
1276
1277 let query = TableQuery {
1278 table: "hosts".to_string(),
1279 source: None,
1280 alias: None,
1281 select_items: Vec::new(),
1282 columns: vec![Projection::All],
1283 where_expr: None,
1284 filter: None,
1285 group_by_exprs: Vec::new(),
1286 group_by: Vec::new(),
1287 having_expr: None,
1288 having: None,
1289 order_by: vec![],
1290 limit: Some(10),
1291 limit_param: None,
1292 offset: None,
1293 offset_param: None,
1294 expand: None,
1295 as_of: None,
1296 sessionize: None,
1297 distinct: false,
1298 };
1299
1300 let card = estimator.estimate_table_cardinality(&query);
1301 assert!(card.rows <= 10.0);
1302 }
1303
1304 #[test]
1309 fn startup_zero_for_full_scan() {
1310 let estimator = CostEstimator::new();
1314 let q = table_query("any_table", None);
1315 let cost = estimator.estimate(&QueryExpr::Table(q));
1316 assert_eq!(cost.startup_cost, 0.0, "full scan must have zero startup");
1317 assert!(cost.total > 0.0);
1318 }
1319
1320 #[test]
1321 fn startup_nonzero_for_blocking_combine() {
1322 let input = PlanCost::new(100.0, 10.0, 50.0); let blocker = PlanCost::new(20.0, 0.0, 10.0); let composed = input.combine_blocking(&blocker);
1327 assert_eq!(composed.startup_cost, input.total);
1329 assert_eq!(composed.total, input.total + blocker.total);
1331 assert!(composed.startup_cost > 0.0);
1332 }
1333
1334 #[test]
1335 fn pipelined_combine_adds_startup_directly() {
1336 let upstream = PlanCost::with_startup(50.0, 5.0, 10.0, 30.0);
1337 let downstream = PlanCost::with_startup(20.0, 0.0, 0.0, 5.0);
1338 let composed = upstream.combine_pipelined(&downstream);
1339 assert_eq!(composed.startup_cost, 30.0 + 5.0);
1340 assert_eq!(composed.total, upstream.total + downstream.total);
1341 }
1342
1343 #[test]
1344 fn cost_prefers_low_startup_when_limit_small() {
1345 let fast_first = PlanCost {
1348 cpu: 100.0,
1349 io: 10.0,
1350 network: 0.0,
1351 memory: 50.0,
1352 startup_cost: 5.0,
1353 total: 200.0,
1354 };
1355 let slow_first = PlanCost {
1356 cpu: 100.0,
1357 io: 10.0,
1358 network: 0.0,
1359 memory: 50.0,
1360 startup_cost: 150.0,
1361 total: 200.0,
1362 };
1363 assert_eq!(
1365 fast_first.prefer_over(&slow_first, Some(10), 10_000.0),
1366 std::cmp::Ordering::Less
1367 );
1368 }
1369
1370 #[test]
1371 fn cost_prefers_low_total_when_no_limit() {
1372 let low_total = PlanCost {
1374 cpu: 50.0,
1375 io: 5.0,
1376 network: 0.0,
1377 memory: 0.0,
1378 startup_cost: 30.0,
1379 total: 100.0,
1380 };
1381 let high_total = PlanCost {
1382 cpu: 100.0,
1383 io: 10.0,
1384 network: 0.0,
1385 memory: 0.0,
1386 startup_cost: 5.0,
1387 total: 200.0,
1388 };
1389 assert_eq!(
1390 low_total.prefer_over(&high_total, None, 10_000.0),
1391 std::cmp::Ordering::Less
1392 );
1393 }
1394
1395 #[test]
1396 fn limit_threshold_falls_back_to_total_when_limit_large() {
1397 let low_total = PlanCost {
1399 cpu: 50.0,
1400 io: 5.0,
1401 network: 0.0,
1402 memory: 0.0,
1403 startup_cost: 30.0,
1404 total: 100.0,
1405 };
1406 let low_startup = PlanCost {
1407 cpu: 100.0,
1408 io: 10.0,
1409 network: 0.0,
1410 memory: 0.0,
1411 startup_cost: 5.0,
1412 total: 200.0,
1413 };
1414 assert_eq!(
1415 low_total.prefer_over(&low_startup, Some(5000), 10_000.0),
1416 std::cmp::Ordering::Less
1417 );
1418 }
1419
1420 #[test]
1421 fn hash_join_startup_includes_build_cost() {
1422 let left = PlanCost::new(80.0, 8.0, 100.0); let build = PlanCost::with_startup(50.0, 0.0, 200.0, 50.0); let after_build = left.combine_blocking(&build);
1428 assert!(
1429 after_build.startup_cost >= left.total,
1430 "after-build startup ({}) must absorb left.total ({})",
1431 after_build.startup_cost,
1432 left.total
1433 );
1434 assert!(after_build.total >= after_build.startup_cost);
1435 }
1436
1437 #[test]
1438 fn vector_search_reports_nonzero_startup() {
1439 let estimator = CostEstimator::new();
1443 let v = PlanCost::with_startup(150.0, 20.0, 1320.0, 50.0);
1446 assert!(v.startup_cost > 0.0);
1447 assert!(v.startup_cost < v.total);
1448 let _ = estimator; }
1450
1451 #[test]
1452 fn with_startup_clamps_total_below_startup() {
1453 let cost = PlanCost::with_startup(1.0, 0.0, 0.0, 100.0);
1455 assert!(cost.total >= cost.startup_cost);
1456 }
1457
1458 #[test]
1459 fn default_plancost_has_zero_startup() {
1460 let c = PlanCost::default();
1461 assert_eq!(c.startup_cost, 0.0);
1462 assert_eq!(c.total, 0.0);
1463 }
1464
1465 use super::super::histogram::{ColumnValue, Histogram, MostCommonValues};
1470
1471 fn provider_with_skew() -> Arc<StaticProvider> {
1472 let mut sample: Vec<ColumnValue> = Vec::new();
1476 for i in 0..80 {
1477 sample.push(ColumnValue::Int(i % 10));
1478 }
1479 for i in 0..20 {
1480 sample.push(ColumnValue::Int(10 + i * 50));
1481 }
1482 let h = Histogram::equi_depth_from_sample(sample, 10);
1483
1484 let mcv = MostCommonValues::new(vec![
1485 (ColumnValue::Text("boss".to_string()), 0.5),
1486 (ColumnValue::Text("intern".to_string()), 0.05),
1487 ]);
1488
1489 Arc::new(
1490 StaticProvider::new()
1491 .with_table(
1492 "people",
1493 TableStats {
1494 row_count: 100_000,
1495 avg_row_size: 64,
1496 page_count: 100,
1497 columns: vec![],
1498 },
1499 )
1500 .with_histogram("people", "score", h)
1501 .with_mcv("people", "role", mcv),
1502 )
1503 }
1504
1505 #[test]
1506 fn eq_uses_mcv_when_value_is_tracked() {
1507 let provider = provider_with_skew();
1508 let estimator = CostEstimator::with_stats(provider);
1509 let filter = AstFilter::Compare {
1510 field: FieldRef::column("people", "role"),
1511 op: CompareOp::Eq,
1512 value: Value::text("boss".to_string()),
1513 };
1514 let s = estimator.filter_selectivity(&filter, "people");
1517 assert!(
1518 (s - 0.5).abs() < 1e-9,
1519 "MCV-tracked equality should report exact frequency, got {s}"
1520 );
1521 }
1522
1523 #[test]
1524 fn eq_uses_residual_for_non_mcv_value() {
1525 let provider = provider_with_skew();
1526 let estimator = CostEstimator::with_stats(provider);
1527 let filter = AstFilter::Compare {
1528 field: FieldRef::column("people", "role"),
1529 op: CompareOp::Eq,
1530 value: Value::text("staff".to_string()),
1531 };
1532 let s = estimator.filter_selectivity(&filter, "people");
1536 assert!(s > 0.0 && s < 0.01, "residual eq should be tiny, got {s}");
1537 }
1538
1539 #[test]
1540 fn ne_is_one_minus_eq_under_mcv() {
1541 let provider = provider_with_skew();
1542 let estimator = CostEstimator::with_stats(provider);
1543 let filter = AstFilter::Compare {
1544 field: FieldRef::column("people", "role"),
1545 op: CompareOp::Ne,
1546 value: Value::text("boss".to_string()),
1547 };
1548 let s = estimator.filter_selectivity(&filter, "people");
1549 assert!((s - 0.5).abs() < 1e-9, "Ne selectivity = 0.5, got {s}");
1551 }
1552
1553 #[test]
1554 fn range_uses_histogram_when_present() {
1555 let provider = provider_with_skew();
1556 let estimator = CostEstimator::with_stats(provider);
1557 let filter = AstFilter::Compare {
1558 field: FieldRef::column("people", "score"),
1559 op: CompareOp::Le,
1560 value: Value::Integer(9),
1561 };
1562 let s = estimator.filter_selectivity(&filter, "people");
1565 assert!(
1566 s > 0.5,
1567 "histogram-based range selectivity should beat 0.3, got {s}"
1568 );
1569 }
1570
1571 #[test]
1572 fn between_uses_histogram() {
1573 let provider = provider_with_skew();
1574 let estimator = CostEstimator::with_stats(provider);
1575 let filter = AstFilter::Between {
1576 field: FieldRef::column("people", "score"),
1577 low: Value::Integer(0),
1578 high: Value::Integer(9),
1579 };
1580 let s = estimator.filter_selectivity(&filter, "people");
1581 assert!(s > 0.5, "BETWEEN should use histogram too, got {s}");
1582 }
1583
1584 #[test]
1585 fn graceful_fallback_when_histogram_absent() {
1586 let provider = Arc::new(StaticProvider::new().with_table(
1589 "people",
1590 TableStats {
1591 row_count: 1000,
1592 avg_row_size: 64,
1593 page_count: 10,
1594 columns: vec![],
1595 },
1596 ));
1597 let estimator = CostEstimator::with_stats(provider);
1598 let filter = AstFilter::Compare {
1599 field: FieldRef::column("people", "unknown_col"),
1600 op: CompareOp::Lt,
1601 value: Value::Integer(50),
1602 };
1603 let s = estimator.filter_selectivity(&filter, "people");
1604 assert!((s - 0.3).abs() < 1e-9, "fallback heuristic 0.3, got {s}");
1605 }
1606}