1use std::sync::Arc;
13
14use super::stats_provider::{NullProvider, StatsProvider};
15use reddb_rql::ast::{
16 CompareOp, FieldRef, Filter as AstFilter, GraphQuery, HybridQuery, JoinQuery, JoinType,
17 PathQuery, QueryExpr, TableQuery, VectorQuery,
18};
19use reddb_types::Value;
20
21pub use reddb_engine::query::logical_plan::CardinalityEstimate;
22
23#[derive(Debug, Clone, Default)]
33pub struct PlanCost {
34 pub cpu: f64,
36 pub io: f64,
38 pub network: f64,
40 pub memory: f64,
42 pub startup_cost: f64,
48 pub total: f64,
50}
51
52impl PlanCost {
53 pub fn new(cpu: f64, io: f64, memory: f64) -> Self {
55 let total = cpu + io * 10.0 + memory * 0.1; Self {
57 cpu,
58 io,
59 network: 0.0,
60 memory,
61 startup_cost: 0.0,
62 total,
63 }
64 }
65
66 pub fn with_startup(cpu: f64, io: f64, memory: f64, startup_cost: f64) -> Self {
70 let total = cpu + io * 10.0 + memory * 0.1;
71 Self {
72 cpu,
73 io,
74 network: 0.0,
75 memory,
76 startup_cost: startup_cost.max(0.0),
77 total: total.max(startup_cost),
78 }
79 }
80
81 pub fn combine_pipelined(&self, other: &PlanCost) -> PlanCost {
87 PlanCost {
88 cpu: self.cpu + other.cpu,
89 io: self.io + other.io,
90 network: self.network + other.network,
91 memory: self.memory.max(other.memory),
92 startup_cost: self.startup_cost + other.startup_cost,
93 total: self.total + other.total,
94 }
95 }
96
97 pub fn combine_blocking(&self, blocker: &PlanCost) -> PlanCost {
104 PlanCost {
105 cpu: self.cpu + blocker.cpu,
106 io: self.io + blocker.io,
107 network: self.network + blocker.network,
108 memory: self.memory.max(blocker.memory),
109 startup_cost: self.total + blocker.startup_cost,
110 total: self.total + blocker.total,
111 }
112 }
113
114 pub fn combine(&self, other: &PlanCost) -> PlanCost {
119 self.combine_pipelined(other)
120 }
121
122 pub fn scale(&self, factor: f64) -> PlanCost {
124 PlanCost {
125 cpu: self.cpu * factor,
126 io: self.io * factor,
127 network: self.network * factor,
128 memory: self.memory, startup_cost: self.startup_cost, total: self.total * factor,
131 }
132 }
133
134 pub fn prefer_over(
146 &self,
147 other: &PlanCost,
148 limit: Option<u64>,
149 cardinality: f64,
150 ) -> std::cmp::Ordering {
151 let use_startup = matches!(limit, Some(k) if (k as f64) < 0.1 * cardinality.max(1.0));
152 let (lhs, rhs) = if use_startup {
153 (self.startup_cost, other.startup_cost)
154 } else {
155 (self.total, other.total)
156 };
157 lhs.partial_cmp(&rhs).unwrap_or(std::cmp::Ordering::Equal)
158 }
159}
160
161#[derive(Debug, Clone, Default)]
163pub struct TableStats {
164 pub row_count: u64,
166 pub avg_row_size: u32,
168 pub page_count: u64,
170 pub columns: Vec<ColumnStats>,
172}
173
174#[derive(Debug, Clone, Default)]
176pub struct ColumnStats {
177 pub name: String,
179 pub distinct_count: u64,
181 pub null_count: u64,
183 pub min_value: Option<String>,
185 pub max_value: Option<String>,
187 pub has_index: bool,
189}
190
191pub struct CostEstimator {
193 default_row_count: f64,
195 row_scan_cost: f64,
197 index_lookup_cost: f64,
199 hash_probe_cost: f64,
201 nested_loop_cost: f64,
203 edge_traversal_cost: f64,
205 stats: Arc<dyn StatsProvider>,
210}
211
212impl CostEstimator {
213 pub fn new() -> Self {
216 Self {
217 default_row_count: 1000.0,
218 row_scan_cost: 1.0,
219 index_lookup_cost: 0.1,
220 hash_probe_cost: 0.5,
221 nested_loop_cost: 2.0,
222 edge_traversal_cost: 1.5,
223 stats: Arc::new(NullProvider),
224 }
225 }
226
227 pub fn with_stats(provider: Arc<dyn StatsProvider>) -> Self {
231 Self {
232 stats: provider,
233 ..Self::new()
234 }
235 }
236
237 pub fn set_stats(&mut self, provider: Arc<dyn StatsProvider>) {
241 self.stats = provider;
242 }
243
244 pub fn estimate(&self, query: &QueryExpr) -> PlanCost {
246 match query {
247 QueryExpr::Table(tq) => self.estimate_table(tq),
248 QueryExpr::Graph(gq) => self.estimate_graph(gq),
249 QueryExpr::Join(jq) => self.estimate_join(jq),
250 QueryExpr::Path(pq) => self.estimate_path(pq),
251 QueryExpr::Vector(vq) => self.estimate_vector(vq),
252 QueryExpr::Hybrid(hq) => self.estimate_hybrid(hq),
253 QueryExpr::Explain(explain) => self.estimate(&explain.inner),
254 QueryExpr::Insert(_)
256 | QueryExpr::Update(_)
257 | QueryExpr::Delete(_)
258 | QueryExpr::Function(_)
259 | QueryExpr::CreateTable(_)
260 | QueryExpr::CreateCollection(_)
261 | QueryExpr::CreateVector(_)
262 | QueryExpr::DropTable(_)
263 | QueryExpr::DropGraph(_)
264 | QueryExpr::DropVector(_)
265 | QueryExpr::DropDocument(_)
266 | QueryExpr::DropKv(_)
267 | QueryExpr::DropCollection(_)
268 | QueryExpr::Truncate(_)
269 | QueryExpr::AlterTable(_)
270 | QueryExpr::CreateVcsRef(_)
271 | QueryExpr::DropVcsRef(_)
272 | QueryExpr::ForkStore(_)
273 | QueryExpr::PromoteFork(_)
274 | QueryExpr::DropFork(_)
275 | QueryExpr::VcsCommand(_)
276 | QueryExpr::GraphCommand(_)
277 | QueryExpr::SearchCommand(_)
278 | QueryExpr::CreateIndex(_)
279 | QueryExpr::DropIndex(_)
280 | QueryExpr::ProbabilisticCommand(_)
281 | QueryExpr::Ask(_)
282 | QueryExpr::SetConfig { .. }
283 | QueryExpr::ShowConfig { .. }
284 | QueryExpr::Scrub { .. }
285 | QueryExpr::SetSecret { .. }
286 | QueryExpr::DeleteSecret { .. }
287 | QueryExpr::SetKv { .. }
288 | QueryExpr::DeleteKv { .. }
289 | QueryExpr::ShowSecrets { .. }
290 | QueryExpr::SetTenant(_)
291 | QueryExpr::ShowTenant
292 | QueryExpr::CreateTimeSeries(_)
293 | QueryExpr::CreateMetric(_)
294 | QueryExpr::AlterMetric(_)
295 | QueryExpr::CreateSlo(_)
296 | QueryExpr::DropTimeSeries(_)
297 | QueryExpr::CreateQueue(_)
298 | QueryExpr::AlterQueue(_)
299 | QueryExpr::DropQueue(_)
300 | QueryExpr::QueueSelect(_)
301 | QueryExpr::QueueCommand(_)
302 | QueryExpr::KvCommand(_)
303 | QueryExpr::ConfigCommand(_)
304 | QueryExpr::CreateTree(_)
305 | QueryExpr::DropTree(_)
306 | QueryExpr::TreeCommand(_)
307 | QueryExpr::ExplainAlter(_)
308 | QueryExpr::TransactionControl(_)
309 | QueryExpr::MaintenanceCommand(_)
310 | QueryExpr::CreateSchema(_)
311 | QueryExpr::DropSchema(_)
312 | QueryExpr::CreateSequence(_)
313 | QueryExpr::DropSequence(_)
314 | QueryExpr::CopyFrom(_)
315 | QueryExpr::CreateView(_)
316 | QueryExpr::DropView(_)
317 | QueryExpr::RefreshMaterializedView(_)
318 | QueryExpr::CreatePolicy(_)
319 | QueryExpr::DropPolicy(_)
320 | QueryExpr::CreateServer(_)
321 | QueryExpr::DropServer(_)
322 | QueryExpr::CreateForeignTable(_)
323 | QueryExpr::DropForeignTable(_)
324 | QueryExpr::Grant(_)
325 | QueryExpr::Revoke(_)
326 | QueryExpr::AlterUser(_)
327 | QueryExpr::CreateUser(_)
328 | QueryExpr::CreateIamPolicy { .. }
329 | QueryExpr::DropIamPolicy { .. }
330 | QueryExpr::AttachPolicy { .. }
331 | QueryExpr::DetachPolicy { .. }
332 | QueryExpr::ShowPolicies { .. }
333 | QueryExpr::ShowEffectivePermissions { .. }
334 | QueryExpr::RankOf(_)
335 | QueryExpr::ApproxRankOf(_)
336 | QueryExpr::RankRange(_)
337 | QueryExpr::SimulatePolicy { .. }
338 | QueryExpr::LintPolicy { .. }
339 | QueryExpr::MigratePolicyMode { .. }
340 | QueryExpr::CreateMigration(_)
341 | QueryExpr::ApplyMigration(_)
342 | QueryExpr::RollbackMigration(_)
343 | QueryExpr::ExplainMigration(_)
344 | QueryExpr::EventsBackfill(_)
345 | QueryExpr::EventsBackfillStatus { .. } => PlanCost::new(1.0, 1.0, 0.0),
346 }
347 }
348
349 pub fn estimate_cardinality(&self, query: &QueryExpr) -> CardinalityEstimate {
351 match query {
352 QueryExpr::Table(tq) => self.estimate_table_cardinality(tq),
353 QueryExpr::Graph(gq) => self.estimate_graph_cardinality(gq),
354 QueryExpr::Join(jq) => self.estimate_join_cardinality(jq),
355 QueryExpr::Path(pq) => self.estimate_path_cardinality(pq),
356 QueryExpr::Vector(vq) => self.estimate_vector_cardinality(vq),
357 QueryExpr::Hybrid(hq) => self.estimate_hybrid_cardinality(hq),
358 QueryExpr::Explain(explain) => self.estimate_cardinality(&explain.inner),
359 QueryExpr::Insert(_)
361 | QueryExpr::Update(_)
362 | QueryExpr::Delete(_)
363 | QueryExpr::Function(_)
364 | QueryExpr::CreateTable(_)
365 | QueryExpr::CreateCollection(_)
366 | QueryExpr::CreateVector(_)
367 | QueryExpr::DropTable(_)
368 | QueryExpr::DropGraph(_)
369 | QueryExpr::DropVector(_)
370 | QueryExpr::DropDocument(_)
371 | QueryExpr::DropKv(_)
372 | QueryExpr::DropCollection(_)
373 | QueryExpr::Truncate(_)
374 | QueryExpr::AlterTable(_)
375 | QueryExpr::CreateVcsRef(_)
376 | QueryExpr::DropVcsRef(_)
377 | QueryExpr::ForkStore(_)
378 | QueryExpr::PromoteFork(_)
379 | QueryExpr::DropFork(_)
380 | QueryExpr::VcsCommand(_)
381 | QueryExpr::GraphCommand(_)
382 | QueryExpr::SearchCommand(_)
383 | QueryExpr::CreateIndex(_)
384 | QueryExpr::DropIndex(_)
385 | QueryExpr::ProbabilisticCommand(_)
386 | QueryExpr::Ask(_)
387 | QueryExpr::SetConfig { .. }
388 | QueryExpr::ShowConfig { .. }
389 | QueryExpr::Scrub { .. }
390 | QueryExpr::SetSecret { .. }
391 | QueryExpr::DeleteSecret { .. }
392 | QueryExpr::SetKv { .. }
393 | QueryExpr::DeleteKv { .. }
394 | QueryExpr::ShowSecrets { .. }
395 | QueryExpr::SetTenant(_)
396 | QueryExpr::ShowTenant
397 | QueryExpr::CreateTimeSeries(_)
398 | QueryExpr::CreateMetric(_)
399 | QueryExpr::AlterMetric(_)
400 | QueryExpr::CreateSlo(_)
401 | QueryExpr::DropTimeSeries(_)
402 | QueryExpr::CreateQueue(_)
403 | QueryExpr::AlterQueue(_)
404 | QueryExpr::DropQueue(_)
405 | QueryExpr::QueueSelect(_)
406 | QueryExpr::QueueCommand(_)
407 | QueryExpr::KvCommand(_)
408 | QueryExpr::ConfigCommand(_)
409 | QueryExpr::CreateTree(_)
410 | QueryExpr::DropTree(_)
411 | QueryExpr::TreeCommand(_)
412 | QueryExpr::ExplainAlter(_)
413 | QueryExpr::TransactionControl(_)
414 | QueryExpr::MaintenanceCommand(_)
415 | QueryExpr::CreateSchema(_)
416 | QueryExpr::DropSchema(_)
417 | QueryExpr::CreateSequence(_)
418 | QueryExpr::DropSequence(_)
419 | QueryExpr::CopyFrom(_)
420 | QueryExpr::CreateView(_)
421 | QueryExpr::DropView(_)
422 | QueryExpr::RefreshMaterializedView(_)
423 | QueryExpr::CreatePolicy(_)
424 | QueryExpr::DropPolicy(_)
425 | QueryExpr::CreateServer(_)
426 | QueryExpr::DropServer(_)
427 | QueryExpr::CreateForeignTable(_)
428 | QueryExpr::DropForeignTable(_)
429 | QueryExpr::Grant(_)
430 | QueryExpr::Revoke(_)
431 | QueryExpr::AlterUser(_)
432 | QueryExpr::CreateUser(_)
433 | QueryExpr::CreateIamPolicy { .. }
434 | QueryExpr::DropIamPolicy { .. }
435 | QueryExpr::AttachPolicy { .. }
436 | QueryExpr::DetachPolicy { .. }
437 | QueryExpr::ShowPolicies { .. }
438 | QueryExpr::ShowEffectivePermissions { .. }
439 | QueryExpr::RankOf(_)
440 | QueryExpr::ApproxRankOf(_)
441 | QueryExpr::RankRange(_)
442 | QueryExpr::SimulatePolicy { .. }
443 | QueryExpr::LintPolicy { .. }
444 | QueryExpr::MigratePolicyMode { .. }
445 | QueryExpr::CreateMigration(_)
446 | QueryExpr::ApplyMigration(_)
447 | QueryExpr::RollbackMigration(_)
448 | QueryExpr::ExplainMigration(_)
449 | QueryExpr::EventsBackfill(_)
450 | QueryExpr::EventsBackfillStatus { .. } => CardinalityEstimate::new(1.0, 1.0),
451 }
452 }
453
454 fn estimate_table(&self, query: &TableQuery) -> PlanCost {
459 let cardinality = self.estimate_table_cardinality(query);
460
461 let cpu = cardinality.rows * self.row_scan_cost;
462
463 let io = self.estimate_table_io(query, cardinality.rows);
466
467 let memory = cardinality.rows * 100.0; PlanCost::new(cpu, io, memory)
470 }
471
472 fn estimate_table_io(&self, query: &TableQuery, result_rows: f64) -> f64 {
479 const ROWS_PER_PAGE: f64 = 100.0;
480
481 let table_stats = self.stats.table_stats(&query.table);
483 let heap_pages = table_stats
484 .map(|s| s.page_count as f64)
485 .unwrap_or_else(|| (result_rows / ROWS_PER_PAGE).max(1.0));
486
487 if let Some(filter) = reddb_rql::sql_lowering::effective_table_filter(query) {
490 if let Some(col) = first_filter_column(&filter, &query.table) {
491 if let Some(idx) = self.stats.index_stats(&query.table, col) {
492 return idx.correlated_io_cost(result_rows, heap_pages);
493 }
494 }
495 }
496
497 (result_rows / ROWS_PER_PAGE).ceil()
499 }
500
501 fn estimate_table_cardinality(&self, query: &TableQuery) -> CardinalityEstimate {
502 let base_rows = self
505 .stats
506 .table_stats(&query.table)
507 .map(|s| s.row_count as f64)
508 .unwrap_or(self.default_row_count);
509
510 let mut estimate = CardinalityEstimate::full_scan(base_rows);
511
512 if let Some(filter) = reddb_rql::sql_lowering::effective_table_filter(query) {
515 let selectivity = self.filter_selectivity(&filter, &query.table);
516 estimate = estimate.with_filter(selectivity);
517 }
518
519 if let Some(limit) = query.limit {
521 estimate.rows = estimate.rows.min(limit as f64);
522 }
523
524 estimate
525 }
526
527 fn filter_selectivity(&self, filter: &AstFilter, table: &str) -> f64 {
540 match filter {
541 AstFilter::Compare { field, op, value } => {
542 let column = column_name_for_table(field, table);
543 match op {
544 CompareOp::Eq => self.eq_selectivity(table, column, value),
545 CompareOp::Ne => 1.0 - self.eq_selectivity(table, column, value),
546 CompareOp::Lt | CompareOp::Le => {
547 self.range_selectivity(table, column, None, Some(value))
548 }
549 CompareOp::Gt | CompareOp::Ge => {
550 self.range_selectivity(table, column, Some(value), None)
551 }
552 }
553 }
554 AstFilter::Between {
555 field, low, high, ..
556 } => {
557 let column = column_name_for_table(field, table);
558 self.range_selectivity(table, column, Some(low), Some(high))
559 }
560 AstFilter::In { field, values, .. } => {
561 let column = column_name_for_table(field, table);
562 if let Some(c) = column {
566 if let Some(mcv) = self.stats.column_mcv(table, c) {
567 let mut hits: f64 = 0.0;
568 let mut residual_count = 0usize;
569 for v in values {
570 if let Some(cv) = column_value_from(v) {
571 if let Some(freq) = mcv.frequency_of(&cv) {
572 hits += freq;
573 } else {
574 residual_count += 1;
575 }
576 } else {
577 residual_count += 1;
578 }
579 }
580 let total = mcv.total_frequency();
581 let distinct = self.stats.distinct_values(table, c).unwrap_or(100);
582 let non_mcv_distinct =
583 distinct.saturating_sub(mcv.values.len() as u64).max(1);
584 let per_residual = (1.0 - total) / non_mcv_distinct as f64;
585 let estimate = hits + (residual_count as f64) * per_residual;
586 return estimate.clamp(0.0, 1.0).min(0.5);
587 }
588 if let Some(s) = self.stats.index_stats(table, c) {
589 return (s.point_selectivity() * values.len() as f64).min(0.5);
590 }
591 }
592 (values.len() as f64 * 0.01).min(0.5)
593 }
594 AstFilter::Like { .. } => 0.1,
595 AstFilter::StartsWith { .. } => 0.15,
596 AstFilter::EndsWith { .. } => 0.15,
597 AstFilter::Contains { .. } => 0.1,
598 AstFilter::IsNull { .. } => 0.01,
599 AstFilter::IsNotNull { .. } => 0.99,
600 AstFilter::And(left, right) => {
601 self.filter_selectivity(left, table) * self.filter_selectivity(right, table)
602 }
603 AstFilter::Or(left, right) => {
604 let s1 = self.filter_selectivity(left, table);
605 let s2 = self.filter_selectivity(right, table);
606 s1 + s2 - (s1 * s2)
607 }
608 AstFilter::Not(inner) => 1.0 - self.filter_selectivity(inner, table),
609 AstFilter::CompareFields { .. } => {
610 0.1
614 }
615 AstFilter::CompareExpr { .. } => {
616 0.1
620 }
621 }
622 }
623
624 fn estimate_graph(&self, query: &GraphQuery) -> PlanCost {
629 let cardinality = self.estimate_graph_cardinality(query);
630
631 let nodes = query.pattern.nodes.len() as f64;
633 let edges = query.pattern.edges.len() as f64;
634
635 let cpu = cardinality.rows * self.edge_traversal_cost * (nodes + edges);
636 let io = cardinality.rows * 0.1; let memory = cardinality.rows * 200.0; PlanCost::new(cpu, io, memory)
640 }
641
642 fn estimate_graph_cardinality(&self, query: &GraphQuery) -> CardinalityEstimate {
643 let nodes = query.pattern.nodes.len() as f64;
644 let edges = query.pattern.edges.len() as f64;
645
646 let base_rows = self.default_row_count;
648 let edge_factor = 0.1_f64.powf(edges); let mut estimate = CardinalityEstimate::new(base_rows * nodes * edge_factor, edge_factor);
651 estimate.confidence = 0.5; if let Some(ref filter) = query.filter {
655 let selectivity = Self::estimate_filter_selectivity(filter);
656 estimate = estimate.with_filter(selectivity);
657 }
658
659 estimate
660 }
661
662 fn estimate_join(&self, query: &JoinQuery) -> PlanCost {
667 let left_cost = self.estimate(&query.left);
668 let right_cost = self.estimate(&query.right);
669
670 let left_card = self.estimate_cardinality(&query.left);
671 let right_card = self.estimate_cardinality(&query.right);
672
673 let build_cpu = left_card.rows * self.hash_probe_cost;
680 let probe_cpu = right_card.rows * self.hash_probe_cost;
681 let join_memory = left_card.rows * 100.0; let build_op = PlanCost::with_startup(build_cpu, 0.0, join_memory, build_cpu);
685 let probe_op = PlanCost::new(probe_cpu, 0.0, 0.0);
687
688 let after_build = left_cost.combine_blocking(&build_op);
690 after_build
691 .combine_pipelined(&right_cost)
692 .combine_pipelined(&probe_op)
693 }
694
695 fn estimate_join_cardinality(&self, query: &JoinQuery) -> CardinalityEstimate {
696 let left = self.estimate_cardinality(&query.left);
697 let right = self.estimate_cardinality(&query.right);
698
699 let selectivity = match query.join_type {
701 JoinType::Inner => 0.1, JoinType::LeftOuter => 1.0, JoinType::RightOuter => 1.0, JoinType::FullOuter => 1.0, JoinType::Cross => 1.0, };
707
708 CardinalityEstimate::new(
709 left.rows * right.rows * selectivity,
710 left.selectivity * right.selectivity * selectivity,
711 )
712 }
713
714 fn estimate_path(&self, query: &PathQuery) -> PlanCost {
719 let cardinality = self.estimate_path_cardinality(query);
720
721 let max_hops = query.max_length;
723 let branching_factor: f64 = 5.0; let nodes_visited = branching_factor.powf(max_hops as f64).min(10000.0);
726 let cpu = nodes_visited * self.edge_traversal_cost;
727 let io = nodes_visited * 0.1;
728 let memory = nodes_visited * 50.0; PlanCost::new(cpu, io, memory)
731 }
732
733 fn estimate_path_cardinality(&self, query: &PathQuery) -> CardinalityEstimate {
734 let max_paths = 10.0;
736 CardinalityEstimate::new(max_paths, 0.001)
737 }
738
739 fn estimate_vector(&self, query: &VectorQuery) -> PlanCost {
744 let k = query.k as f64;
747
748 let hnsw_cost = 100.0 * (1.0 + k.ln()); let filter_cost = if reddb_rql::sql_lowering::effective_vector_filter(query).is_some() {
755 50.0
756 } else {
757 0.0
758 };
759
760 let cpu = hnsw_cost + filter_cost;
761 let io = 20.0; let memory = k * 32.0 + 1000.0; PlanCost::with_startup(cpu, io, memory, hnsw_cost * 0.5)
769 }
770
771 fn estimate_vector_cardinality(&self, query: &VectorQuery) -> CardinalityEstimate {
772 let k = query.k as f64;
774 CardinalityEstimate::new(k, 0.1)
775 }
776
777 fn estimate_hybrid(&self, query: &HybridQuery) -> PlanCost {
782 let structured_cost = self.estimate(&query.structured);
784 let vector_cost = self.estimate_vector(&query.vector);
785
786 let fusion_overhead = match &query.fusion {
788 reddb_rql::ast::FusionStrategy::Rerank { .. } => 50.0,
789 reddb_rql::ast::FusionStrategy::FilterThenSearch => 10.0,
790 reddb_rql::ast::FusionStrategy::SearchThenFilter => 10.0,
791 reddb_rql::ast::FusionStrategy::RRF { .. } => 30.0,
792 reddb_rql::ast::FusionStrategy::Intersection => 20.0,
793 reddb_rql::ast::FusionStrategy::Union { .. } => 40.0,
794 };
795
796 PlanCost::new(
797 structured_cost.cpu + vector_cost.cpu + fusion_overhead,
798 structured_cost.io + vector_cost.io,
799 structured_cost.memory + vector_cost.memory,
800 )
801 }
802
803 fn estimate_hybrid_cardinality(&self, query: &HybridQuery) -> CardinalityEstimate {
804 let structured_card = self.estimate_cardinality(&query.structured);
805 let vector_card = self.estimate_vector_cardinality(&query.vector);
806
807 let rows = match &query.fusion {
809 reddb_rql::ast::FusionStrategy::Intersection => {
810 structured_card.rows.min(vector_card.rows)
811 }
812 reddb_rql::ast::FusionStrategy::Union { .. } => structured_card.rows + vector_card.rows,
813 _ => vector_card.rows, };
815
816 CardinalityEstimate::new(rows, 0.2)
817 }
818
819 fn estimate_filter_selectivity(filter: &AstFilter) -> f64 {
824 match filter {
825 AstFilter::Compare { op, .. } => {
826 match op {
827 CompareOp::Eq => 0.01, CompareOp::Ne => 0.99, CompareOp::Lt | CompareOp::Le => 0.3,
830 CompareOp::Gt | CompareOp::Ge => 0.3,
831 }
832 }
833 AstFilter::Between { .. } => 0.25,
834 AstFilter::In { values, .. } => {
835 (values.len() as f64 * 0.01).min(0.5)
837 }
838 AstFilter::Like { .. } => 0.1,
839 AstFilter::StartsWith { .. } => 0.15,
840 AstFilter::EndsWith { .. } => 0.15,
841 AstFilter::Contains { .. } => 0.1,
842 AstFilter::IsNull { .. } => 0.01,
843 AstFilter::IsNotNull { .. } => 0.99,
844 AstFilter::And(left, right) => {
845 Self::estimate_filter_selectivity(left) * Self::estimate_filter_selectivity(right)
846 }
847 AstFilter::Or(left, right) => {
848 let s1 = Self::estimate_filter_selectivity(left);
849 let s2 = Self::estimate_filter_selectivity(right);
850 s1 + s2 - (s1 * s2) }
852 AstFilter::Not(inner) => 1.0 - Self::estimate_filter_selectivity(inner),
853 AstFilter::CompareFields { .. } => 0.1,
854 AstFilter::CompareExpr { .. } => 0.1,
855 }
856 }
857}
858
859impl CostEstimator {
860 fn eq_selectivity(&self, table: &str, column: Option<&str>, value: &Value) -> f64 {
868 if let Some(col) = column {
869 if let Some(mcv) = self.stats.column_mcv(table, col) {
871 if let Some(cv) = column_value_from(value) {
872 if let Some(freq) = mcv.frequency_of(&cv) {
873 return freq;
874 }
875 let total = mcv.total_frequency();
877 let distinct = self.stats.distinct_values(table, col).unwrap_or(100);
878 let non_mcv_distinct = distinct.saturating_sub(mcv.values.len() as u64).max(1);
879 return ((1.0 - total) / non_mcv_distinct as f64).clamp(0.0, 1.0);
880 }
881 }
882 if let Some(s) = self.stats.index_stats(table, col) {
884 return s.point_selectivity();
885 }
886 }
887 0.01
889 }
890
891 fn range_selectivity(
902 &self,
903 table: &str,
904 column: Option<&str>,
905 lo: Option<&Value>,
906 hi: Option<&Value>,
907 ) -> f64 {
908 if let Some(col) = column {
909 if let Some(h) = self.stats.column_histogram(table, col) {
911 let lo_cv = lo.and_then(column_value_from);
912 let hi_cv = hi.and_then(column_value_from);
913 return h.range_selectivity(lo_cv.as_ref(), hi_cv.as_ref());
914 }
915 if let Some(s) = self.stats.index_stats(table, col) {
917 let cap = if lo.is_some() && hi.is_some() {
918 0.25
919 } else {
920 0.3
921 };
922 return (s.point_selectivity() * (s.distinct_keys as f64 / 2.0)).min(cap);
923 }
924 }
925 if lo.is_some() && hi.is_some() {
927 0.25
928 } else {
929 0.3
930 }
931 }
932}
933
934impl Default for CostEstimator {
935 fn default() -> Self {
936 Self::new()
937 }
938}
939
940fn column_value_from(v: &reddb_types::Value) -> Option<super::histogram::ColumnValue> {
945 use super::histogram::ColumnValue;
946 use reddb_types::Value;
947 match v {
948 Value::Integer(i) | Value::BigInt(i) => Some(ColumnValue::Int(*i)),
949 Value::UnsignedInteger(u) => Some(ColumnValue::Int(*u as i64)),
950 Value::Float(f) if f.is_finite() => Some(ColumnValue::Float(*f)),
951 Value::Text(s) => Some(ColumnValue::Text(s.to_string())),
952 Value::Email(s)
953 | Value::Url(s)
954 | Value::NodeRef(s)
955 | Value::EdgeRef(s)
956 | Value::TableRef(s)
957 | Value::Password(s) => Some(ColumnValue::Text(s.clone())),
958 Value::Timestamp(t) => Some(ColumnValue::Int(*t)),
959 Value::Duration(d) => Some(ColumnValue::Int(*d)),
960 Value::TimestampMs(t) => Some(ColumnValue::Int(*t)),
961 Value::Decimal(d) => Some(ColumnValue::Int(*d)),
962 Value::Date(d) => Some(ColumnValue::Int(i64::from(*d))),
963 Value::Time(t) => Some(ColumnValue::Int(i64::from(*t))),
964 Value::Phone(p) => Some(ColumnValue::Int(*p as i64)),
965 Value::Semver(v) => Some(ColumnValue::Int(i64::from(*v))),
966 Value::Port(v) => Some(ColumnValue::Int(i64::from(*v))),
967 Value::PageRef(v) => Some(ColumnValue::Int(i64::from(*v))),
968 Value::EnumValue(v) => Some(ColumnValue::Int(i64::from(*v))),
969 Value::Latitude(v) => Some(ColumnValue::Int(i64::from(*v))),
970 Value::Longitude(v) => Some(ColumnValue::Int(i64::from(*v))),
971 _ => None,
976 }
977}
978
979fn first_filter_column<'a>(filter: &'a AstFilter, table: &str) -> Option<&'a str> {
984 match filter {
985 AstFilter::Compare { field, .. } => column_name_for_table(field, table),
986 AstFilter::Between { field, .. } => column_name_for_table(field, table),
987 AstFilter::And(l, r) => {
988 first_filter_column(l, table).or_else(|| first_filter_column(r, table))
989 }
990 _ => None,
991 }
992}
993
994fn column_name_for_table<'a>(field: &'a FieldRef, table: &str) -> Option<&'a str> {
996 match field {
997 FieldRef::TableColumn { table: t, column } if t == table || t.is_empty() => {
998 Some(column.as_str())
999 }
1000 _ => None,
1002 }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::super::stats_provider::StaticProvider;
1008 use super::*;
1009 use crate::storage::index::{IndexKind, IndexStats};
1010 use reddb_rql::ast::{FieldRef, Projection};
1011 use reddb_types::Value;
1012
1013 fn eq_filter(table: &str, column: &str, value: i64) -> AstFilter {
1014 AstFilter::Compare {
1015 field: FieldRef::column(table, column),
1016 op: CompareOp::Eq,
1017 value: Value::Integer(value),
1018 }
1019 }
1020
1021 fn table_query(name: &str, filter: Option<AstFilter>) -> TableQuery {
1022 TableQuery {
1023 table: name.to_string(),
1024 source: None,
1025 alias: None,
1026 select_items: Vec::new(),
1027 columns: vec![Projection::All],
1028 where_expr: None,
1029 filter,
1030 group_by_exprs: Vec::new(),
1031 group_by: Vec::new(),
1032 having_expr: None,
1033 having: None,
1034 order_by: vec![],
1035 limit: None,
1036 limit_param: None,
1037 offset: None,
1038 offset_param: None,
1039 expand: None,
1040 as_of: None,
1041 sessionize: None,
1042 distinct: false,
1043 }
1044 }
1045
1046 #[test]
1047 fn injected_row_count_overrides_default() {
1048 let provider = Arc::new(StaticProvider::new().with_table(
1049 "users",
1050 TableStats {
1051 row_count: 50_000,
1052 avg_row_size: 256,
1053 page_count: 500,
1054 columns: vec![],
1055 },
1056 ));
1057 let estimator = CostEstimator::with_stats(provider);
1058 let q = table_query("users", None);
1059 let card = estimator.estimate_table_cardinality(&q);
1060 assert_eq!(card.rows, 50_000.0);
1062 }
1063
1064 #[test]
1065 fn stats_aware_eq_selectivity_beats_default() {
1066 let provider = Arc::new(
1067 StaticProvider::new()
1068 .with_table(
1069 "users",
1070 TableStats {
1071 row_count: 1_000_000,
1072 avg_row_size: 256,
1073 page_count: 10_000,
1074 columns: vec![],
1075 },
1076 )
1077 .with_index(
1078 "users",
1079 "email",
1080 IndexStats {
1081 entries: 1_000_000,
1082 distinct_keys: 1_000_000,
1083 approx_bytes: 0,
1084 kind: IndexKind::Hash,
1085 has_bloom: true,
1086 index_correlation: 0.0,
1087 },
1088 ),
1089 );
1090 let estimator = CostEstimator::with_stats(provider);
1091 let q = table_query("users", Some(eq_filter("users", "email", 0)));
1092 let card = estimator.estimate_table_cardinality(&q);
1093 assert!(card.rows < 2.0, "expected ~1 row, got {}", card.rows);
1095 }
1096
1097 #[test]
1098 fn fallback_when_no_index_stats() {
1099 let provider = Arc::new(StaticProvider::new().with_table(
1100 "users",
1101 TableStats {
1102 row_count: 1_000_000,
1103 avg_row_size: 256,
1104 page_count: 10_000,
1105 columns: vec![],
1106 },
1107 ));
1108 let estimator = CostEstimator::with_stats(provider);
1109 let q = table_query("users", Some(eq_filter("users", "email", 0)));
1110 let card = estimator.estimate_table_cardinality(&q);
1111 assert!((card.rows - 10_000.0).abs() < 1.0);
1113 }
1114
1115 #[test]
1116 fn null_provider_keeps_legacy_behaviour() {
1117 let estimator = CostEstimator::new();
1118 let q = table_query("whatever", Some(eq_filter("whatever", "id", 1)));
1119 let card = estimator.estimate_table_cardinality(&q);
1120 assert!((card.rows - 10.0).abs() < 1.0);
1122 }
1123
1124 #[test]
1125 fn and_combines_stats_selectivities() {
1126 let provider = Arc::new(
1127 StaticProvider::new()
1128 .with_table(
1129 "t",
1130 TableStats {
1131 row_count: 100_000,
1132 avg_row_size: 64,
1133 page_count: 100,
1134 columns: vec![],
1135 },
1136 )
1137 .with_index(
1138 "t",
1139 "a",
1140 IndexStats {
1141 entries: 100_000,
1142 distinct_keys: 10,
1143 approx_bytes: 0,
1144 kind: IndexKind::BTree,
1145 has_bloom: false,
1146 index_correlation: 0.0,
1147 },
1148 )
1149 .with_index(
1150 "t",
1151 "b",
1152 IndexStats {
1153 entries: 100_000,
1154 distinct_keys: 1000,
1155 approx_bytes: 0,
1156 kind: IndexKind::BTree,
1157 has_bloom: false,
1158 index_correlation: 0.0,
1159 },
1160 ),
1161 );
1162 let estimator = CostEstimator::with_stats(provider);
1163 let filter = AstFilter::And(
1164 Box::new(eq_filter("t", "a", 1)),
1165 Box::new(eq_filter("t", "b", 1)),
1166 );
1167 let q = table_query("t", Some(filter));
1168 let card = estimator.estimate_table_cardinality(&q);
1169 assert!(card.rows < 15.0, "got {}", card.rows);
1171 }
1172
1173 #[test]
1174 fn test_table_cost_estimation() {
1175 let estimator = CostEstimator::new();
1176
1177 let query = QueryExpr::Table(TableQuery {
1178 table: "hosts".to_string(),
1179 source: None,
1180 alias: None,
1181 select_items: Vec::new(),
1182 columns: vec![Projection::All],
1183 where_expr: None,
1184 filter: None,
1185 group_by_exprs: Vec::new(),
1186 group_by: Vec::new(),
1187 having_expr: None,
1188 having: None,
1189 order_by: vec![],
1190 limit: None,
1191 limit_param: None,
1192 offset: None,
1193 offset_param: None,
1194 expand: None,
1195 as_of: None,
1196 sessionize: None,
1197 distinct: false,
1198 });
1199
1200 let cost = estimator.estimate(&query);
1201 assert!(cost.cpu > 0.0);
1202 assert!(cost.total > 0.0);
1203 }
1204
1205 #[test]
1206 fn test_filter_selectivity() {
1207 let estimator = CostEstimator::new();
1208
1209 let eq_filter = AstFilter::Compare {
1210 field: FieldRef::column("hosts", "id"),
1211 op: CompareOp::Eq,
1212 value: Value::Integer(1),
1213 };
1214 assert!(CostEstimator::estimate_filter_selectivity(&eq_filter) < 0.1);
1215
1216 let ne_filter = AstFilter::Compare {
1217 field: FieldRef::column("hosts", "id"),
1218 op: CompareOp::Ne,
1219 value: Value::Integer(1),
1220 };
1221 assert!(CostEstimator::estimate_filter_selectivity(&ne_filter) > 0.9);
1222 }
1223
1224 #[test]
1225 fn test_and_selectivity() {
1226 let estimator = CostEstimator::new();
1227
1228 let and_filter = AstFilter::And(
1229 Box::new(AstFilter::Compare {
1230 field: FieldRef::column("hosts", "a"),
1231 op: CompareOp::Eq,
1232 value: Value::Integer(1),
1233 }),
1234 Box::new(AstFilter::Compare {
1235 field: FieldRef::column("hosts", "b"),
1236 op: CompareOp::Eq,
1237 value: Value::Integer(2),
1238 }),
1239 );
1240
1241 let selectivity = CostEstimator::estimate_filter_selectivity(&and_filter);
1242 assert!(selectivity < 0.01); }
1244
1245 #[test]
1246 fn test_cardinality_with_limit() {
1247 let estimator = CostEstimator::new();
1248
1249 let query = TableQuery {
1250 table: "hosts".to_string(),
1251 source: None,
1252 alias: None,
1253 select_items: Vec::new(),
1254 columns: vec![Projection::All],
1255 where_expr: None,
1256 filter: None,
1257 group_by_exprs: Vec::new(),
1258 group_by: Vec::new(),
1259 having_expr: None,
1260 having: None,
1261 order_by: vec![],
1262 limit: Some(10),
1263 limit_param: None,
1264 offset: None,
1265 offset_param: None,
1266 expand: None,
1267 as_of: None,
1268 sessionize: None,
1269 distinct: false,
1270 };
1271
1272 let card = estimator.estimate_table_cardinality(&query);
1273 assert!(card.rows <= 10.0);
1274 }
1275
1276 #[test]
1281 fn startup_zero_for_full_scan() {
1282 let estimator = CostEstimator::new();
1286 let q = table_query("any_table", None);
1287 let cost = estimator.estimate(&QueryExpr::Table(q));
1288 assert_eq!(cost.startup_cost, 0.0, "full scan must have zero startup");
1289 assert!(cost.total > 0.0);
1290 }
1291
1292 #[test]
1293 fn startup_nonzero_for_blocking_combine() {
1294 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);
1299 assert_eq!(composed.startup_cost, input.total);
1301 assert_eq!(composed.total, input.total + blocker.total);
1303 assert!(composed.startup_cost > 0.0);
1304 }
1305
1306 #[test]
1307 fn pipelined_combine_adds_startup_directly() {
1308 let upstream = PlanCost::with_startup(50.0, 5.0, 10.0, 30.0);
1309 let downstream = PlanCost::with_startup(20.0, 0.0, 0.0, 5.0);
1310 let composed = upstream.combine_pipelined(&downstream);
1311 assert_eq!(composed.startup_cost, 30.0 + 5.0);
1312 assert_eq!(composed.total, upstream.total + downstream.total);
1313 }
1314
1315 #[test]
1316 fn cost_prefers_low_startup_when_limit_small() {
1317 let fast_first = PlanCost {
1320 cpu: 100.0,
1321 io: 10.0,
1322 network: 0.0,
1323 memory: 50.0,
1324 startup_cost: 5.0,
1325 total: 200.0,
1326 };
1327 let slow_first = PlanCost {
1328 cpu: 100.0,
1329 io: 10.0,
1330 network: 0.0,
1331 memory: 50.0,
1332 startup_cost: 150.0,
1333 total: 200.0,
1334 };
1335 assert_eq!(
1337 fast_first.prefer_over(&slow_first, Some(10), 10_000.0),
1338 std::cmp::Ordering::Less
1339 );
1340 }
1341
1342 #[test]
1343 fn cost_prefers_low_total_when_no_limit() {
1344 let low_total = PlanCost {
1346 cpu: 50.0,
1347 io: 5.0,
1348 network: 0.0,
1349 memory: 0.0,
1350 startup_cost: 30.0,
1351 total: 100.0,
1352 };
1353 let high_total = PlanCost {
1354 cpu: 100.0,
1355 io: 10.0,
1356 network: 0.0,
1357 memory: 0.0,
1358 startup_cost: 5.0,
1359 total: 200.0,
1360 };
1361 assert_eq!(
1362 low_total.prefer_over(&high_total, None, 10_000.0),
1363 std::cmp::Ordering::Less
1364 );
1365 }
1366
1367 #[test]
1368 fn limit_threshold_falls_back_to_total_when_limit_large() {
1369 let low_total = PlanCost {
1371 cpu: 50.0,
1372 io: 5.0,
1373 network: 0.0,
1374 memory: 0.0,
1375 startup_cost: 30.0,
1376 total: 100.0,
1377 };
1378 let low_startup = PlanCost {
1379 cpu: 100.0,
1380 io: 10.0,
1381 network: 0.0,
1382 memory: 0.0,
1383 startup_cost: 5.0,
1384 total: 200.0,
1385 };
1386 assert_eq!(
1387 low_total.prefer_over(&low_startup, Some(5000), 10_000.0),
1388 std::cmp::Ordering::Less
1389 );
1390 }
1391
1392 #[test]
1393 fn hash_join_startup_includes_build_cost() {
1394 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);
1400 assert!(
1401 after_build.startup_cost >= left.total,
1402 "after-build startup ({}) must absorb left.total ({})",
1403 after_build.startup_cost,
1404 left.total
1405 );
1406 assert!(after_build.total >= after_build.startup_cost);
1407 }
1408
1409 #[test]
1410 fn vector_search_reports_nonzero_startup() {
1411 let estimator = CostEstimator::new();
1415 let v = PlanCost::with_startup(150.0, 20.0, 1320.0, 50.0);
1418 assert!(v.startup_cost > 0.0);
1419 assert!(v.startup_cost < v.total);
1420 let _ = estimator; }
1422
1423 #[test]
1424 fn with_startup_clamps_total_below_startup() {
1425 let cost = PlanCost::with_startup(1.0, 0.0, 0.0, 100.0);
1427 assert!(cost.total >= cost.startup_cost);
1428 }
1429
1430 #[test]
1431 fn default_plancost_has_zero_startup() {
1432 let c = PlanCost::default();
1433 assert_eq!(c.startup_cost, 0.0);
1434 assert_eq!(c.total, 0.0);
1435 }
1436
1437 use super::super::histogram::{ColumnValue, Histogram, MostCommonValues};
1442
1443 fn provider_with_skew() -> Arc<StaticProvider> {
1444 let mut sample: Vec<ColumnValue> = Vec::new();
1448 for i in 0..80 {
1449 sample.push(ColumnValue::Int(i % 10));
1450 }
1451 for i in 0..20 {
1452 sample.push(ColumnValue::Int(10 + i * 50));
1453 }
1454 let h = Histogram::equi_depth_from_sample(sample, 10);
1455
1456 let mcv = MostCommonValues::new(vec![
1457 (ColumnValue::Text("boss".to_string()), 0.5),
1458 (ColumnValue::Text("intern".to_string()), 0.05),
1459 ]);
1460
1461 Arc::new(
1462 StaticProvider::new()
1463 .with_table(
1464 "people",
1465 TableStats {
1466 row_count: 100_000,
1467 avg_row_size: 64,
1468 page_count: 100,
1469 columns: vec![],
1470 },
1471 )
1472 .with_histogram("people", "score", h)
1473 .with_mcv("people", "role", mcv),
1474 )
1475 }
1476
1477 #[test]
1478 fn eq_uses_mcv_when_value_is_tracked() {
1479 let provider = provider_with_skew();
1480 let estimator = CostEstimator::with_stats(provider);
1481 let filter = AstFilter::Compare {
1482 field: FieldRef::column("people", "role"),
1483 op: CompareOp::Eq,
1484 value: Value::text("boss".to_string()),
1485 };
1486 let s = estimator.filter_selectivity(&filter, "people");
1489 assert!(
1490 (s - 0.5).abs() < 1e-9,
1491 "MCV-tracked equality should report exact frequency, got {s}"
1492 );
1493 }
1494
1495 #[test]
1496 fn eq_uses_residual_for_non_mcv_value() {
1497 let provider = provider_with_skew();
1498 let estimator = CostEstimator::with_stats(provider);
1499 let filter = AstFilter::Compare {
1500 field: FieldRef::column("people", "role"),
1501 op: CompareOp::Eq,
1502 value: Value::text("staff".to_string()),
1503 };
1504 let s = estimator.filter_selectivity(&filter, "people");
1508 assert!(s > 0.0 && s < 0.01, "residual eq should be tiny, got {s}");
1509 }
1510
1511 #[test]
1512 fn ne_is_one_minus_eq_under_mcv() {
1513 let provider = provider_with_skew();
1514 let estimator = CostEstimator::with_stats(provider);
1515 let filter = AstFilter::Compare {
1516 field: FieldRef::column("people", "role"),
1517 op: CompareOp::Ne,
1518 value: Value::text("boss".to_string()),
1519 };
1520 let s = estimator.filter_selectivity(&filter, "people");
1521 assert!((s - 0.5).abs() < 1e-9, "Ne selectivity = 0.5, got {s}");
1523 }
1524
1525 #[test]
1526 fn range_uses_histogram_when_present() {
1527 let provider = provider_with_skew();
1528 let estimator = CostEstimator::with_stats(provider);
1529 let filter = AstFilter::Compare {
1530 field: FieldRef::column("people", "score"),
1531 op: CompareOp::Le,
1532 value: Value::Integer(9),
1533 };
1534 let s = estimator.filter_selectivity(&filter, "people");
1537 assert!(
1538 s > 0.5,
1539 "histogram-based range selectivity should beat 0.3, got {s}"
1540 );
1541 }
1542
1543 #[test]
1544 fn between_uses_histogram() {
1545 let provider = provider_with_skew();
1546 let estimator = CostEstimator::with_stats(provider);
1547 let filter = AstFilter::Between {
1548 field: FieldRef::column("people", "score"),
1549 low: Value::Integer(0),
1550 high: Value::Integer(9),
1551 };
1552 let s = estimator.filter_selectivity(&filter, "people");
1553 assert!(s > 0.5, "BETWEEN should use histogram too, got {s}");
1554 }
1555
1556 #[test]
1557 fn graceful_fallback_when_histogram_absent() {
1558 let provider = Arc::new(StaticProvider::new().with_table(
1561 "people",
1562 TableStats {
1563 row_count: 1000,
1564 avg_row_size: 64,
1565 page_count: 10,
1566 columns: vec![],
1567 },
1568 ));
1569 let estimator = CostEstimator::with_stats(provider);
1570 let filter = AstFilter::Compare {
1571 field: FieldRef::column("people", "unknown_col"),
1572 op: CompareOp::Lt,
1573 value: Value::Integer(50),
1574 };
1575 let s = estimator.filter_selectivity(&filter, "people");
1576 assert!((s - 0.3).abs() < 1e-9, "fallback heuristic 0.3, got {s}");
1577 }
1578}