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::Explain(explain) => self.estimate(&explain.inner),
291 QueryExpr::Insert(_)
293 | QueryExpr::Update(_)
294 | QueryExpr::Delete(_)
295 | QueryExpr::CreateTable(_)
296 | QueryExpr::CreateCollection(_)
297 | QueryExpr::CreateVector(_)
298 | QueryExpr::DropTable(_)
299 | QueryExpr::DropGraph(_)
300 | QueryExpr::DropVector(_)
301 | QueryExpr::DropDocument(_)
302 | QueryExpr::DropKv(_)
303 | QueryExpr::DropCollection(_)
304 | QueryExpr::Truncate(_)
305 | QueryExpr::AlterTable(_)
306 | QueryExpr::CreateVcsRef(_)
307 | QueryExpr::DropVcsRef(_)
308 | QueryExpr::ForkStore(_)
309 | QueryExpr::PromoteFork(_)
310 | QueryExpr::DropFork(_)
311 | QueryExpr::VcsCommand(_)
312 | QueryExpr::GraphCommand(_)
313 | QueryExpr::SearchCommand(_)
314 | QueryExpr::CreateIndex(_)
315 | QueryExpr::DropIndex(_)
316 | QueryExpr::ProbabilisticCommand(_)
317 | QueryExpr::Ask(_)
318 | QueryExpr::SetConfig { .. }
319 | QueryExpr::ShowConfig { .. }
320 | QueryExpr::Scrub { .. }
321 | QueryExpr::SetSecret { .. }
322 | QueryExpr::DeleteSecret { .. }
323 | QueryExpr::SetKv { .. }
324 | QueryExpr::DeleteKv { .. }
325 | QueryExpr::ShowSecrets { .. }
326 | QueryExpr::SetTenant(_)
327 | QueryExpr::ShowTenant
328 | QueryExpr::CreateTimeSeries(_)
329 | QueryExpr::CreateMetric(_)
330 | QueryExpr::AlterMetric(_)
331 | QueryExpr::CreateSlo(_)
332 | QueryExpr::DropTimeSeries(_)
333 | QueryExpr::CreateQueue(_)
334 | QueryExpr::AlterQueue(_)
335 | QueryExpr::DropQueue(_)
336 | QueryExpr::QueueSelect(_)
337 | QueryExpr::QueueCommand(_)
338 | QueryExpr::KvCommand(_)
339 | QueryExpr::ConfigCommand(_)
340 | QueryExpr::CreateTree(_)
341 | QueryExpr::DropTree(_)
342 | QueryExpr::TreeCommand(_)
343 | QueryExpr::ExplainAlter(_)
344 | QueryExpr::TransactionControl(_)
345 | QueryExpr::MaintenanceCommand(_)
346 | QueryExpr::CreateSchema(_)
347 | QueryExpr::DropSchema(_)
348 | QueryExpr::CreateSequence(_)
349 | QueryExpr::DropSequence(_)
350 | QueryExpr::CopyFrom(_)
351 | QueryExpr::CreateView(_)
352 | QueryExpr::DropView(_)
353 | QueryExpr::RefreshMaterializedView(_)
354 | QueryExpr::CreatePolicy(_)
355 | QueryExpr::DropPolicy(_)
356 | QueryExpr::CreateServer(_)
357 | QueryExpr::DropServer(_)
358 | QueryExpr::CreateForeignTable(_)
359 | QueryExpr::DropForeignTable(_)
360 | QueryExpr::Grant(_)
361 | QueryExpr::Revoke(_)
362 | QueryExpr::AlterUser(_)
363 | QueryExpr::CreateUser(_)
364 | QueryExpr::CreateIamPolicy { .. }
365 | QueryExpr::DropIamPolicy { .. }
366 | QueryExpr::AttachPolicy { .. }
367 | QueryExpr::DetachPolicy { .. }
368 | QueryExpr::ShowPolicies { .. }
369 | QueryExpr::ShowEffectivePermissions { .. }
370 | QueryExpr::RankOf(_)
371 | QueryExpr::ApproxRankOf(_)
372 | QueryExpr::RankRange(_)
373 | QueryExpr::SimulatePolicy { .. }
374 | QueryExpr::LintPolicy { .. }
375 | QueryExpr::MigratePolicyMode { .. }
376 | QueryExpr::CreateMigration(_)
377 | QueryExpr::ApplyMigration(_)
378 | QueryExpr::RollbackMigration(_)
379 | QueryExpr::ExplainMigration(_)
380 | QueryExpr::EventsBackfill(_)
381 | QueryExpr::EventsBackfillStatus { .. } => PlanCost::new(1.0, 1.0, 0.0),
382 }
383 }
384
385 pub fn estimate_cardinality(&self, query: &QueryExpr) -> CardinalityEstimate {
387 match query {
388 QueryExpr::Table(tq) => self.estimate_table_cardinality(tq),
389 QueryExpr::Graph(gq) => self.estimate_graph_cardinality(gq),
390 QueryExpr::Join(jq) => self.estimate_join_cardinality(jq),
391 QueryExpr::Path(pq) => self.estimate_path_cardinality(pq),
392 QueryExpr::Vector(vq) => self.estimate_vector_cardinality(vq),
393 QueryExpr::Hybrid(hq) => self.estimate_hybrid_cardinality(hq),
394 QueryExpr::Explain(explain) => self.estimate_cardinality(&explain.inner),
395 QueryExpr::Insert(_)
397 | QueryExpr::Update(_)
398 | QueryExpr::Delete(_)
399 | QueryExpr::CreateTable(_)
400 | QueryExpr::CreateCollection(_)
401 | QueryExpr::CreateVector(_)
402 | QueryExpr::DropTable(_)
403 | QueryExpr::DropGraph(_)
404 | QueryExpr::DropVector(_)
405 | QueryExpr::DropDocument(_)
406 | QueryExpr::DropKv(_)
407 | QueryExpr::DropCollection(_)
408 | QueryExpr::Truncate(_)
409 | QueryExpr::AlterTable(_)
410 | QueryExpr::CreateVcsRef(_)
411 | QueryExpr::DropVcsRef(_)
412 | QueryExpr::ForkStore(_)
413 | QueryExpr::PromoteFork(_)
414 | QueryExpr::DropFork(_)
415 | QueryExpr::VcsCommand(_)
416 | QueryExpr::GraphCommand(_)
417 | QueryExpr::SearchCommand(_)
418 | QueryExpr::CreateIndex(_)
419 | QueryExpr::DropIndex(_)
420 | QueryExpr::ProbabilisticCommand(_)
421 | QueryExpr::Ask(_)
422 | QueryExpr::SetConfig { .. }
423 | QueryExpr::ShowConfig { .. }
424 | QueryExpr::Scrub { .. }
425 | QueryExpr::SetSecret { .. }
426 | QueryExpr::DeleteSecret { .. }
427 | QueryExpr::SetKv { .. }
428 | QueryExpr::DeleteKv { .. }
429 | QueryExpr::ShowSecrets { .. }
430 | QueryExpr::SetTenant(_)
431 | QueryExpr::ShowTenant
432 | QueryExpr::CreateTimeSeries(_)
433 | QueryExpr::CreateMetric(_)
434 | QueryExpr::AlterMetric(_)
435 | QueryExpr::CreateSlo(_)
436 | QueryExpr::DropTimeSeries(_)
437 | QueryExpr::CreateQueue(_)
438 | QueryExpr::AlterQueue(_)
439 | QueryExpr::DropQueue(_)
440 | QueryExpr::QueueSelect(_)
441 | QueryExpr::QueueCommand(_)
442 | QueryExpr::KvCommand(_)
443 | QueryExpr::ConfigCommand(_)
444 | QueryExpr::CreateTree(_)
445 | QueryExpr::DropTree(_)
446 | QueryExpr::TreeCommand(_)
447 | QueryExpr::ExplainAlter(_)
448 | QueryExpr::TransactionControl(_)
449 | QueryExpr::MaintenanceCommand(_)
450 | QueryExpr::CreateSchema(_)
451 | QueryExpr::DropSchema(_)
452 | QueryExpr::CreateSequence(_)
453 | QueryExpr::DropSequence(_)
454 | QueryExpr::CopyFrom(_)
455 | QueryExpr::CreateView(_)
456 | QueryExpr::DropView(_)
457 | QueryExpr::RefreshMaterializedView(_)
458 | QueryExpr::CreatePolicy(_)
459 | QueryExpr::DropPolicy(_)
460 | QueryExpr::CreateServer(_)
461 | QueryExpr::DropServer(_)
462 | QueryExpr::CreateForeignTable(_)
463 | QueryExpr::DropForeignTable(_)
464 | QueryExpr::Grant(_)
465 | QueryExpr::Revoke(_)
466 | QueryExpr::AlterUser(_)
467 | QueryExpr::CreateUser(_)
468 | QueryExpr::CreateIamPolicy { .. }
469 | QueryExpr::DropIamPolicy { .. }
470 | QueryExpr::AttachPolicy { .. }
471 | QueryExpr::DetachPolicy { .. }
472 | QueryExpr::ShowPolicies { .. }
473 | QueryExpr::ShowEffectivePermissions { .. }
474 | QueryExpr::RankOf(_)
475 | QueryExpr::ApproxRankOf(_)
476 | QueryExpr::RankRange(_)
477 | QueryExpr::SimulatePolicy { .. }
478 | QueryExpr::LintPolicy { .. }
479 | QueryExpr::MigratePolicyMode { .. }
480 | QueryExpr::CreateMigration(_)
481 | QueryExpr::ApplyMigration(_)
482 | QueryExpr::RollbackMigration(_)
483 | QueryExpr::ExplainMigration(_)
484 | QueryExpr::EventsBackfill(_)
485 | QueryExpr::EventsBackfillStatus { .. } => CardinalityEstimate::new(1.0, 1.0),
486 }
487 }
488
489 fn estimate_table(&self, query: &TableQuery) -> PlanCost {
494 let cardinality = self.estimate_table_cardinality(query);
495
496 let cpu = cardinality.rows * self.row_scan_cost;
497
498 let io = self.estimate_table_io(query, cardinality.rows);
501
502 let memory = cardinality.rows * 100.0; PlanCost::new(cpu, io, memory)
505 }
506
507 fn estimate_table_io(&self, query: &TableQuery, result_rows: f64) -> f64 {
514 const ROWS_PER_PAGE: f64 = 100.0;
515
516 let table_stats = self.stats.table_stats(&query.table);
518 let heap_pages = table_stats
519 .map(|s| s.page_count as f64)
520 .unwrap_or_else(|| (result_rows / ROWS_PER_PAGE).max(1.0));
521
522 if let Some(filter) = crate::storage::query::sql_lowering::effective_table_filter(query) {
525 if let Some(col) = first_filter_column(&filter, &query.table) {
526 if let Some(idx) = self.stats.index_stats(&query.table, col) {
527 return idx.correlated_io_cost(result_rows, heap_pages);
528 }
529 }
530 }
531
532 (result_rows / ROWS_PER_PAGE).ceil()
534 }
535
536 fn estimate_table_cardinality(&self, query: &TableQuery) -> CardinalityEstimate {
537 let base_rows = self
540 .stats
541 .table_stats(&query.table)
542 .map(|s| s.row_count as f64)
543 .unwrap_or(self.default_row_count);
544
545 let mut estimate = CardinalityEstimate::full_scan(base_rows);
546
547 if let Some(filter) = crate::storage::query::sql_lowering::effective_table_filter(query) {
550 let selectivity = self.filter_selectivity(&filter, &query.table);
551 estimate = estimate.with_filter(selectivity);
552 }
553
554 if let Some(limit) = query.limit {
556 estimate.rows = estimate.rows.min(limit as f64);
557 }
558
559 estimate
560 }
561
562 fn filter_selectivity(&self, filter: &AstFilter, table: &str) -> f64 {
575 match filter {
576 AstFilter::Compare { field, op, value } => {
577 let column = column_name_for_table(field, table);
578 match op {
579 CompareOp::Eq => self.eq_selectivity(table, column, value),
580 CompareOp::Ne => 1.0 - self.eq_selectivity(table, column, value),
581 CompareOp::Lt | CompareOp::Le => {
582 self.range_selectivity(table, column, None, Some(value))
583 }
584 CompareOp::Gt | CompareOp::Ge => {
585 self.range_selectivity(table, column, Some(value), None)
586 }
587 }
588 }
589 AstFilter::Between {
590 field, low, high, ..
591 } => {
592 let column = column_name_for_table(field, table);
593 self.range_selectivity(table, column, Some(low), Some(high))
594 }
595 AstFilter::In { field, values, .. } => {
596 let column = column_name_for_table(field, table);
597 if let Some(c) = column {
601 if let Some(mcv) = self.stats.column_mcv(table, c) {
602 let mut hits: f64 = 0.0;
603 let mut residual_count = 0usize;
604 for v in values {
605 if let Some(cv) = column_value_from(v) {
606 if let Some(freq) = mcv.frequency_of(&cv) {
607 hits += freq;
608 } else {
609 residual_count += 1;
610 }
611 } else {
612 residual_count += 1;
613 }
614 }
615 let total = mcv.total_frequency();
616 let distinct = self.stats.distinct_values(table, c).unwrap_or(100);
617 let non_mcv_distinct =
618 distinct.saturating_sub(mcv.values.len() as u64).max(1);
619 let per_residual = (1.0 - total) / non_mcv_distinct as f64;
620 let estimate = hits + (residual_count as f64) * per_residual;
621 return estimate.clamp(0.0, 1.0).min(0.5);
622 }
623 if let Some(s) = self.stats.index_stats(table, c) {
624 return (s.point_selectivity() * values.len() as f64).min(0.5);
625 }
626 }
627 (values.len() as f64 * 0.01).min(0.5)
628 }
629 AstFilter::Like { .. } => 0.1,
630 AstFilter::StartsWith { .. } => 0.15,
631 AstFilter::EndsWith { .. } => 0.15,
632 AstFilter::Contains { .. } => 0.1,
633 AstFilter::IsNull { .. } => 0.01,
634 AstFilter::IsNotNull { .. } => 0.99,
635 AstFilter::And(left, right) => {
636 self.filter_selectivity(left, table) * self.filter_selectivity(right, table)
637 }
638 AstFilter::Or(left, right) => {
639 let s1 = self.filter_selectivity(left, table);
640 let s2 = self.filter_selectivity(right, table);
641 s1 + s2 - (s1 * s2)
642 }
643 AstFilter::Not(inner) => 1.0 - self.filter_selectivity(inner, table),
644 AstFilter::CompareFields { .. } => {
645 0.1
649 }
650 AstFilter::CompareExpr { .. } => {
651 0.1
655 }
656 }
657 }
658
659 fn estimate_graph(&self, query: &GraphQuery) -> PlanCost {
664 let cardinality = self.estimate_graph_cardinality(query);
665
666 let nodes = query.pattern.nodes.len() as f64;
668 let edges = query.pattern.edges.len() as f64;
669
670 let cpu = cardinality.rows * self.edge_traversal_cost * (nodes + edges);
671 let io = cardinality.rows * 0.1; let memory = cardinality.rows * 200.0; PlanCost::new(cpu, io, memory)
675 }
676
677 fn estimate_graph_cardinality(&self, query: &GraphQuery) -> CardinalityEstimate {
678 let nodes = query.pattern.nodes.len() as f64;
679 let edges = query.pattern.edges.len() as f64;
680
681 let base_rows = self.default_row_count;
683 let edge_factor = 0.1_f64.powf(edges); let mut estimate = CardinalityEstimate::new(base_rows * nodes * edge_factor, edge_factor);
686 estimate.confidence = 0.5; if let Some(ref filter) = query.filter {
690 let selectivity = Self::estimate_filter_selectivity(filter);
691 estimate = estimate.with_filter(selectivity);
692 }
693
694 estimate
695 }
696
697 fn estimate_join(&self, query: &JoinQuery) -> PlanCost {
702 let left_cost = self.estimate(&query.left);
703 let right_cost = self.estimate(&query.right);
704
705 let left_card = self.estimate_cardinality(&query.left);
706 let right_card = self.estimate_cardinality(&query.right);
707
708 let build_cpu = left_card.rows * self.hash_probe_cost;
715 let probe_cpu = right_card.rows * self.hash_probe_cost;
716 let join_memory = left_card.rows * 100.0; let build_op = PlanCost::with_startup(build_cpu, 0.0, join_memory, build_cpu);
720 let probe_op = PlanCost::new(probe_cpu, 0.0, 0.0);
722
723 let after_build = left_cost.combine_blocking(&build_op);
725 after_build
726 .combine_pipelined(&right_cost)
727 .combine_pipelined(&probe_op)
728 }
729
730 fn estimate_join_cardinality(&self, query: &JoinQuery) -> CardinalityEstimate {
731 let left = self.estimate_cardinality(&query.left);
732 let right = self.estimate_cardinality(&query.right);
733
734 let selectivity = match query.join_type {
736 JoinType::Inner => 0.1, JoinType::LeftOuter => 1.0, JoinType::RightOuter => 1.0, JoinType::FullOuter => 1.0, JoinType::Cross => 1.0, };
742
743 CardinalityEstimate::new(
744 left.rows * right.rows * selectivity,
745 left.selectivity * right.selectivity * selectivity,
746 )
747 }
748
749 fn estimate_path(&self, query: &PathQuery) -> PlanCost {
754 let cardinality = self.estimate_path_cardinality(query);
755
756 let max_hops = query.max_length;
758 let branching_factor: f64 = 5.0; let nodes_visited = branching_factor.powf(max_hops as f64).min(10000.0);
761 let cpu = nodes_visited * self.edge_traversal_cost;
762 let io = nodes_visited * 0.1;
763 let memory = nodes_visited * 50.0; PlanCost::new(cpu, io, memory)
766 }
767
768 fn estimate_path_cardinality(&self, query: &PathQuery) -> CardinalityEstimate {
769 let max_paths = 10.0;
771 CardinalityEstimate::new(max_paths, 0.001)
772 }
773
774 fn estimate_vector(&self, query: &VectorQuery) -> PlanCost {
779 let k = query.k as f64;
782
783 let hnsw_cost = 100.0 * (1.0 + k.ln()); let filter_cost =
790 if crate::storage::query::sql_lowering::effective_vector_filter(query).is_some() {
791 50.0
792 } else {
793 0.0
794 };
795
796 let cpu = hnsw_cost + filter_cost;
797 let io = 20.0; let memory = k * 32.0 + 1000.0; PlanCost::with_startup(cpu, io, memory, hnsw_cost * 0.5)
805 }
806
807 fn estimate_vector_cardinality(&self, query: &VectorQuery) -> CardinalityEstimate {
808 let k = query.k as f64;
810 CardinalityEstimate::new(k, 0.1)
811 }
812
813 fn estimate_hybrid(&self, query: &HybridQuery) -> PlanCost {
818 let structured_cost = self.estimate(&query.structured);
820 let vector_cost = self.estimate_vector(&query.vector);
821
822 let fusion_overhead = match &query.fusion {
824 crate::storage::query::ast::FusionStrategy::Rerank { .. } => 50.0,
825 crate::storage::query::ast::FusionStrategy::FilterThenSearch => 10.0,
826 crate::storage::query::ast::FusionStrategy::SearchThenFilter => 10.0,
827 crate::storage::query::ast::FusionStrategy::RRF { .. } => 30.0,
828 crate::storage::query::ast::FusionStrategy::Intersection => 20.0,
829 crate::storage::query::ast::FusionStrategy::Union { .. } => 40.0,
830 };
831
832 PlanCost::new(
833 structured_cost.cpu + vector_cost.cpu + fusion_overhead,
834 structured_cost.io + vector_cost.io,
835 structured_cost.memory + vector_cost.memory,
836 )
837 }
838
839 fn estimate_hybrid_cardinality(&self, query: &HybridQuery) -> CardinalityEstimate {
840 let structured_card = self.estimate_cardinality(&query.structured);
841 let vector_card = self.estimate_vector_cardinality(&query.vector);
842
843 let rows = match &query.fusion {
845 crate::storage::query::ast::FusionStrategy::Intersection => {
846 structured_card.rows.min(vector_card.rows)
847 }
848 crate::storage::query::ast::FusionStrategy::Union { .. } => {
849 structured_card.rows + vector_card.rows
850 }
851 _ => vector_card.rows, };
853
854 CardinalityEstimate::new(rows, 0.2)
855 }
856
857 fn estimate_filter_selectivity(filter: &AstFilter) -> f64 {
862 match filter {
863 AstFilter::Compare { op, .. } => {
864 match op {
865 CompareOp::Eq => 0.01, CompareOp::Ne => 0.99, CompareOp::Lt | CompareOp::Le => 0.3,
868 CompareOp::Gt | CompareOp::Ge => 0.3,
869 }
870 }
871 AstFilter::Between { .. } => 0.25,
872 AstFilter::In { values, .. } => {
873 (values.len() as f64 * 0.01).min(0.5)
875 }
876 AstFilter::Like { .. } => 0.1,
877 AstFilter::StartsWith { .. } => 0.15,
878 AstFilter::EndsWith { .. } => 0.15,
879 AstFilter::Contains { .. } => 0.1,
880 AstFilter::IsNull { .. } => 0.01,
881 AstFilter::IsNotNull { .. } => 0.99,
882 AstFilter::And(left, right) => {
883 Self::estimate_filter_selectivity(left) * Self::estimate_filter_selectivity(right)
884 }
885 AstFilter::Or(left, right) => {
886 let s1 = Self::estimate_filter_selectivity(left);
887 let s2 = Self::estimate_filter_selectivity(right);
888 s1 + s2 - (s1 * s2) }
890 AstFilter::Not(inner) => 1.0 - Self::estimate_filter_selectivity(inner),
891 AstFilter::CompareFields { .. } => 0.1,
892 AstFilter::CompareExpr { .. } => 0.1,
893 }
894 }
895}
896
897impl CostEstimator {
898 fn eq_selectivity(&self, table: &str, column: Option<&str>, value: &Value) -> f64 {
906 if let Some(col) = column {
907 if let Some(mcv) = self.stats.column_mcv(table, col) {
909 if let Some(cv) = column_value_from(value) {
910 if let Some(freq) = mcv.frequency_of(&cv) {
911 return freq;
912 }
913 let total = mcv.total_frequency();
915 let distinct = self.stats.distinct_values(table, col).unwrap_or(100);
916 let non_mcv_distinct = distinct.saturating_sub(mcv.values.len() as u64).max(1);
917 return ((1.0 - total) / non_mcv_distinct as f64).clamp(0.0, 1.0);
918 }
919 }
920 if let Some(s) = self.stats.index_stats(table, col) {
922 return s.point_selectivity();
923 }
924 }
925 0.01
927 }
928
929 fn range_selectivity(
940 &self,
941 table: &str,
942 column: Option<&str>,
943 lo: Option<&Value>,
944 hi: Option<&Value>,
945 ) -> f64 {
946 if let Some(col) = column {
947 if let Some(h) = self.stats.column_histogram(table, col) {
949 let lo_cv = lo.and_then(column_value_from);
950 let hi_cv = hi.and_then(column_value_from);
951 return h.range_selectivity(lo_cv.as_ref(), hi_cv.as_ref());
952 }
953 if let Some(s) = self.stats.index_stats(table, col) {
955 let cap = if lo.is_some() && hi.is_some() {
956 0.25
957 } else {
958 0.3
959 };
960 return (s.point_selectivity() * (s.distinct_keys as f64 / 2.0)).min(cap);
961 }
962 }
963 if lo.is_some() && hi.is_some() {
965 0.25
966 } else {
967 0.3
968 }
969 }
970}
971
972impl Default for CostEstimator {
973 fn default() -> Self {
974 Self::new()
975 }
976}
977
978fn column_value_from(v: &crate::storage::schema::Value) -> Option<super::histogram::ColumnValue> {
983 use super::histogram::ColumnValue;
984 use crate::storage::schema::Value;
985 match v {
986 Value::Integer(i) | Value::BigInt(i) => Some(ColumnValue::Int(*i)),
987 Value::UnsignedInteger(u) => Some(ColumnValue::Int(*u as i64)),
988 Value::Float(f) if f.is_finite() => Some(ColumnValue::Float(*f)),
989 Value::Text(s) => Some(ColumnValue::Text(s.to_string())),
990 Value::Email(s)
991 | Value::Url(s)
992 | Value::NodeRef(s)
993 | Value::EdgeRef(s)
994 | Value::TableRef(s)
995 | Value::Password(s) => Some(ColumnValue::Text(s.clone())),
996 Value::Timestamp(t) => Some(ColumnValue::Int(*t)),
997 Value::Duration(d) => Some(ColumnValue::Int(*d)),
998 Value::TimestampMs(t) => Some(ColumnValue::Int(*t)),
999 Value::Decimal(d) => Some(ColumnValue::Int(*d)),
1000 Value::Date(d) => Some(ColumnValue::Int(i64::from(*d))),
1001 Value::Time(t) => Some(ColumnValue::Int(i64::from(*t))),
1002 Value::Phone(p) => Some(ColumnValue::Int(*p as i64)),
1003 Value::Semver(v) => Some(ColumnValue::Int(i64::from(*v))),
1004 Value::Port(v) => Some(ColumnValue::Int(i64::from(*v))),
1005 Value::PageRef(v) => Some(ColumnValue::Int(i64::from(*v))),
1006 Value::EnumValue(v) => Some(ColumnValue::Int(i64::from(*v))),
1007 Value::Latitude(v) => Some(ColumnValue::Int(i64::from(*v))),
1008 Value::Longitude(v) => Some(ColumnValue::Int(i64::from(*v))),
1009 _ => None,
1014 }
1015}
1016
1017fn first_filter_column<'a>(filter: &'a AstFilter, table: &str) -> Option<&'a str> {
1022 match filter {
1023 AstFilter::Compare { field, .. } => column_name_for_table(field, table),
1024 AstFilter::Between { field, .. } => column_name_for_table(field, table),
1025 AstFilter::And(l, r) => {
1026 first_filter_column(l, table).or_else(|| first_filter_column(r, table))
1027 }
1028 _ => None,
1029 }
1030}
1031
1032fn column_name_for_table<'a>(field: &'a FieldRef, table: &str) -> Option<&'a str> {
1034 match field {
1035 FieldRef::TableColumn { table: t, column } if t == table || t.is_empty() => {
1036 Some(column.as_str())
1037 }
1038 _ => None,
1040 }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::super::stats_provider::StaticProvider;
1046 use super::*;
1047 use crate::storage::index::{IndexKind, IndexStats};
1048 use crate::storage::query::ast::{FieldRef, Projection};
1049 use crate::storage::schema::Value;
1050
1051 fn eq_filter(table: &str, column: &str, value: i64) -> AstFilter {
1052 AstFilter::Compare {
1053 field: FieldRef::column(table, column),
1054 op: CompareOp::Eq,
1055 value: Value::Integer(value),
1056 }
1057 }
1058
1059 fn table_query(name: &str, filter: Option<AstFilter>) -> TableQuery {
1060 TableQuery {
1061 table: name.to_string(),
1062 source: None,
1063 alias: None,
1064 select_items: Vec::new(),
1065 columns: vec![Projection::All],
1066 where_expr: None,
1067 filter,
1068 group_by_exprs: Vec::new(),
1069 group_by: Vec::new(),
1070 having_expr: None,
1071 having: None,
1072 order_by: vec![],
1073 limit: None,
1074 limit_param: None,
1075 offset: None,
1076 offset_param: None,
1077 expand: None,
1078 as_of: None,
1079 sessionize: None,
1080 distinct: false,
1081 }
1082 }
1083
1084 #[test]
1085 fn injected_row_count_overrides_default() {
1086 let provider = Arc::new(StaticProvider::new().with_table(
1087 "users",
1088 TableStats {
1089 row_count: 50_000,
1090 avg_row_size: 256,
1091 page_count: 500,
1092 columns: vec![],
1093 },
1094 ));
1095 let estimator = CostEstimator::with_stats(provider);
1096 let q = table_query("users", None);
1097 let card = estimator.estimate_table_cardinality(&q);
1098 assert_eq!(card.rows, 50_000.0);
1100 }
1101
1102 #[test]
1103 fn stats_aware_eq_selectivity_beats_default() {
1104 let provider = Arc::new(
1105 StaticProvider::new()
1106 .with_table(
1107 "users",
1108 TableStats {
1109 row_count: 1_000_000,
1110 avg_row_size: 256,
1111 page_count: 10_000,
1112 columns: vec![],
1113 },
1114 )
1115 .with_index(
1116 "users",
1117 "email",
1118 IndexStats {
1119 entries: 1_000_000,
1120 distinct_keys: 1_000_000,
1121 approx_bytes: 0,
1122 kind: IndexKind::Hash,
1123 has_bloom: true,
1124 index_correlation: 0.0,
1125 },
1126 ),
1127 );
1128 let estimator = CostEstimator::with_stats(provider);
1129 let q = table_query("users", Some(eq_filter("users", "email", 0)));
1130 let card = estimator.estimate_table_cardinality(&q);
1131 assert!(card.rows < 2.0, "expected ~1 row, got {}", card.rows);
1133 }
1134
1135 #[test]
1136 fn fallback_when_no_index_stats() {
1137 let provider = Arc::new(StaticProvider::new().with_table(
1138 "users",
1139 TableStats {
1140 row_count: 1_000_000,
1141 avg_row_size: 256,
1142 page_count: 10_000,
1143 columns: vec![],
1144 },
1145 ));
1146 let estimator = CostEstimator::with_stats(provider);
1147 let q = table_query("users", Some(eq_filter("users", "email", 0)));
1148 let card = estimator.estimate_table_cardinality(&q);
1149 assert!((card.rows - 10_000.0).abs() < 1.0);
1151 }
1152
1153 #[test]
1154 fn null_provider_keeps_legacy_behaviour() {
1155 let estimator = CostEstimator::new();
1156 let q = table_query("whatever", Some(eq_filter("whatever", "id", 1)));
1157 let card = estimator.estimate_table_cardinality(&q);
1158 assert!((card.rows - 10.0).abs() < 1.0);
1160 }
1161
1162 #[test]
1163 fn and_combines_stats_selectivities() {
1164 let provider = Arc::new(
1165 StaticProvider::new()
1166 .with_table(
1167 "t",
1168 TableStats {
1169 row_count: 100_000,
1170 avg_row_size: 64,
1171 page_count: 100,
1172 columns: vec![],
1173 },
1174 )
1175 .with_index(
1176 "t",
1177 "a",
1178 IndexStats {
1179 entries: 100_000,
1180 distinct_keys: 10,
1181 approx_bytes: 0,
1182 kind: IndexKind::BTree,
1183 has_bloom: false,
1184 index_correlation: 0.0,
1185 },
1186 )
1187 .with_index(
1188 "t",
1189 "b",
1190 IndexStats {
1191 entries: 100_000,
1192 distinct_keys: 1000,
1193 approx_bytes: 0,
1194 kind: IndexKind::BTree,
1195 has_bloom: false,
1196 index_correlation: 0.0,
1197 },
1198 ),
1199 );
1200 let estimator = CostEstimator::with_stats(provider);
1201 let filter = AstFilter::And(
1202 Box::new(eq_filter("t", "a", 1)),
1203 Box::new(eq_filter("t", "b", 1)),
1204 );
1205 let q = table_query("t", Some(filter));
1206 let card = estimator.estimate_table_cardinality(&q);
1207 assert!(card.rows < 15.0, "got {}", card.rows);
1209 }
1210
1211 #[test]
1212 fn test_table_cost_estimation() {
1213 let estimator = CostEstimator::new();
1214
1215 let query = QueryExpr::Table(TableQuery {
1216 table: "hosts".to_string(),
1217 source: None,
1218 alias: None,
1219 select_items: Vec::new(),
1220 columns: vec![Projection::All],
1221 where_expr: None,
1222 filter: None,
1223 group_by_exprs: Vec::new(),
1224 group_by: Vec::new(),
1225 having_expr: None,
1226 having: None,
1227 order_by: vec![],
1228 limit: None,
1229 limit_param: None,
1230 offset: None,
1231 offset_param: None,
1232 expand: None,
1233 as_of: None,
1234 sessionize: None,
1235 distinct: false,
1236 });
1237
1238 let cost = estimator.estimate(&query);
1239 assert!(cost.cpu > 0.0);
1240 assert!(cost.total > 0.0);
1241 }
1242
1243 #[test]
1244 fn test_filter_selectivity() {
1245 let estimator = CostEstimator::new();
1246
1247 let eq_filter = AstFilter::Compare {
1248 field: FieldRef::column("hosts", "id"),
1249 op: CompareOp::Eq,
1250 value: Value::Integer(1),
1251 };
1252 assert!(CostEstimator::estimate_filter_selectivity(&eq_filter) < 0.1);
1253
1254 let ne_filter = AstFilter::Compare {
1255 field: FieldRef::column("hosts", "id"),
1256 op: CompareOp::Ne,
1257 value: Value::Integer(1),
1258 };
1259 assert!(CostEstimator::estimate_filter_selectivity(&ne_filter) > 0.9);
1260 }
1261
1262 #[test]
1263 fn test_and_selectivity() {
1264 let estimator = CostEstimator::new();
1265
1266 let and_filter = AstFilter::And(
1267 Box::new(AstFilter::Compare {
1268 field: FieldRef::column("hosts", "a"),
1269 op: CompareOp::Eq,
1270 value: Value::Integer(1),
1271 }),
1272 Box::new(AstFilter::Compare {
1273 field: FieldRef::column("hosts", "b"),
1274 op: CompareOp::Eq,
1275 value: Value::Integer(2),
1276 }),
1277 );
1278
1279 let selectivity = CostEstimator::estimate_filter_selectivity(&and_filter);
1280 assert!(selectivity < 0.01); }
1282
1283 #[test]
1284 fn test_cardinality_with_limit() {
1285 let estimator = CostEstimator::new();
1286
1287 let query = TableQuery {
1288 table: "hosts".to_string(),
1289 source: None,
1290 alias: None,
1291 select_items: Vec::new(),
1292 columns: vec![Projection::All],
1293 where_expr: None,
1294 filter: None,
1295 group_by_exprs: Vec::new(),
1296 group_by: Vec::new(),
1297 having_expr: None,
1298 having: None,
1299 order_by: vec![],
1300 limit: Some(10),
1301 limit_param: None,
1302 offset: None,
1303 offset_param: None,
1304 expand: None,
1305 as_of: None,
1306 sessionize: None,
1307 distinct: false,
1308 };
1309
1310 let card = estimator.estimate_table_cardinality(&query);
1311 assert!(card.rows <= 10.0);
1312 }
1313
1314 #[test]
1319 fn startup_zero_for_full_scan() {
1320 let estimator = CostEstimator::new();
1324 let q = table_query("any_table", None);
1325 let cost = estimator.estimate(&QueryExpr::Table(q));
1326 assert_eq!(cost.startup_cost, 0.0, "full scan must have zero startup");
1327 assert!(cost.total > 0.0);
1328 }
1329
1330 #[test]
1331 fn startup_nonzero_for_blocking_combine() {
1332 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);
1337 assert_eq!(composed.startup_cost, input.total);
1339 assert_eq!(composed.total, input.total + blocker.total);
1341 assert!(composed.startup_cost > 0.0);
1342 }
1343
1344 #[test]
1345 fn pipelined_combine_adds_startup_directly() {
1346 let upstream = PlanCost::with_startup(50.0, 5.0, 10.0, 30.0);
1347 let downstream = PlanCost::with_startup(20.0, 0.0, 0.0, 5.0);
1348 let composed = upstream.combine_pipelined(&downstream);
1349 assert_eq!(composed.startup_cost, 30.0 + 5.0);
1350 assert_eq!(composed.total, upstream.total + downstream.total);
1351 }
1352
1353 #[test]
1354 fn cost_prefers_low_startup_when_limit_small() {
1355 let fast_first = PlanCost {
1358 cpu: 100.0,
1359 io: 10.0,
1360 network: 0.0,
1361 memory: 50.0,
1362 startup_cost: 5.0,
1363 total: 200.0,
1364 };
1365 let slow_first = PlanCost {
1366 cpu: 100.0,
1367 io: 10.0,
1368 network: 0.0,
1369 memory: 50.0,
1370 startup_cost: 150.0,
1371 total: 200.0,
1372 };
1373 assert_eq!(
1375 fast_first.prefer_over(&slow_first, Some(10), 10_000.0),
1376 std::cmp::Ordering::Less
1377 );
1378 }
1379
1380 #[test]
1381 fn cost_prefers_low_total_when_no_limit() {
1382 let low_total = PlanCost {
1384 cpu: 50.0,
1385 io: 5.0,
1386 network: 0.0,
1387 memory: 0.0,
1388 startup_cost: 30.0,
1389 total: 100.0,
1390 };
1391 let high_total = PlanCost {
1392 cpu: 100.0,
1393 io: 10.0,
1394 network: 0.0,
1395 memory: 0.0,
1396 startup_cost: 5.0,
1397 total: 200.0,
1398 };
1399 assert_eq!(
1400 low_total.prefer_over(&high_total, None, 10_000.0),
1401 std::cmp::Ordering::Less
1402 );
1403 }
1404
1405 #[test]
1406 fn limit_threshold_falls_back_to_total_when_limit_large() {
1407 let low_total = PlanCost {
1409 cpu: 50.0,
1410 io: 5.0,
1411 network: 0.0,
1412 memory: 0.0,
1413 startup_cost: 30.0,
1414 total: 100.0,
1415 };
1416 let low_startup = PlanCost {
1417 cpu: 100.0,
1418 io: 10.0,
1419 network: 0.0,
1420 memory: 0.0,
1421 startup_cost: 5.0,
1422 total: 200.0,
1423 };
1424 assert_eq!(
1425 low_total.prefer_over(&low_startup, Some(5000), 10_000.0),
1426 std::cmp::Ordering::Less
1427 );
1428 }
1429
1430 #[test]
1431 fn hash_join_startup_includes_build_cost() {
1432 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);
1438 assert!(
1439 after_build.startup_cost >= left.total,
1440 "after-build startup ({}) must absorb left.total ({})",
1441 after_build.startup_cost,
1442 left.total
1443 );
1444 assert!(after_build.total >= after_build.startup_cost);
1445 }
1446
1447 #[test]
1448 fn vector_search_reports_nonzero_startup() {
1449 let estimator = CostEstimator::new();
1453 let v = PlanCost::with_startup(150.0, 20.0, 1320.0, 50.0);
1456 assert!(v.startup_cost > 0.0);
1457 assert!(v.startup_cost < v.total);
1458 let _ = estimator; }
1460
1461 #[test]
1462 fn with_startup_clamps_total_below_startup() {
1463 let cost = PlanCost::with_startup(1.0, 0.0, 0.0, 100.0);
1465 assert!(cost.total >= cost.startup_cost);
1466 }
1467
1468 #[test]
1469 fn default_plancost_has_zero_startup() {
1470 let c = PlanCost::default();
1471 assert_eq!(c.startup_cost, 0.0);
1472 assert_eq!(c.total, 0.0);
1473 }
1474
1475 use super::super::histogram::{ColumnValue, Histogram, MostCommonValues};
1480
1481 fn provider_with_skew() -> Arc<StaticProvider> {
1482 let mut sample: Vec<ColumnValue> = Vec::new();
1486 for i in 0..80 {
1487 sample.push(ColumnValue::Int(i % 10));
1488 }
1489 for i in 0..20 {
1490 sample.push(ColumnValue::Int(10 + i * 50));
1491 }
1492 let h = Histogram::equi_depth_from_sample(sample, 10);
1493
1494 let mcv = MostCommonValues::new(vec![
1495 (ColumnValue::Text("boss".to_string()), 0.5),
1496 (ColumnValue::Text("intern".to_string()), 0.05),
1497 ]);
1498
1499 Arc::new(
1500 StaticProvider::new()
1501 .with_table(
1502 "people",
1503 TableStats {
1504 row_count: 100_000,
1505 avg_row_size: 64,
1506 page_count: 100,
1507 columns: vec![],
1508 },
1509 )
1510 .with_histogram("people", "score", h)
1511 .with_mcv("people", "role", mcv),
1512 )
1513 }
1514
1515 #[test]
1516 fn eq_uses_mcv_when_value_is_tracked() {
1517 let provider = provider_with_skew();
1518 let estimator = CostEstimator::with_stats(provider);
1519 let filter = AstFilter::Compare {
1520 field: FieldRef::column("people", "role"),
1521 op: CompareOp::Eq,
1522 value: Value::text("boss".to_string()),
1523 };
1524 let s = estimator.filter_selectivity(&filter, "people");
1527 assert!(
1528 (s - 0.5).abs() < 1e-9,
1529 "MCV-tracked equality should report exact frequency, got {s}"
1530 );
1531 }
1532
1533 #[test]
1534 fn eq_uses_residual_for_non_mcv_value() {
1535 let provider = provider_with_skew();
1536 let estimator = CostEstimator::with_stats(provider);
1537 let filter = AstFilter::Compare {
1538 field: FieldRef::column("people", "role"),
1539 op: CompareOp::Eq,
1540 value: Value::text("staff".to_string()),
1541 };
1542 let s = estimator.filter_selectivity(&filter, "people");
1546 assert!(s > 0.0 && s < 0.01, "residual eq should be tiny, got {s}");
1547 }
1548
1549 #[test]
1550 fn ne_is_one_minus_eq_under_mcv() {
1551 let provider = provider_with_skew();
1552 let estimator = CostEstimator::with_stats(provider);
1553 let filter = AstFilter::Compare {
1554 field: FieldRef::column("people", "role"),
1555 op: CompareOp::Ne,
1556 value: Value::text("boss".to_string()),
1557 };
1558 let s = estimator.filter_selectivity(&filter, "people");
1559 assert!((s - 0.5).abs() < 1e-9, "Ne selectivity = 0.5, got {s}");
1561 }
1562
1563 #[test]
1564 fn range_uses_histogram_when_present() {
1565 let provider = provider_with_skew();
1566 let estimator = CostEstimator::with_stats(provider);
1567 let filter = AstFilter::Compare {
1568 field: FieldRef::column("people", "score"),
1569 op: CompareOp::Le,
1570 value: Value::Integer(9),
1571 };
1572 let s = estimator.filter_selectivity(&filter, "people");
1575 assert!(
1576 s > 0.5,
1577 "histogram-based range selectivity should beat 0.3, got {s}"
1578 );
1579 }
1580
1581 #[test]
1582 fn between_uses_histogram() {
1583 let provider = provider_with_skew();
1584 let estimator = CostEstimator::with_stats(provider);
1585 let filter = AstFilter::Between {
1586 field: FieldRef::column("people", "score"),
1587 low: Value::Integer(0),
1588 high: Value::Integer(9),
1589 };
1590 let s = estimator.filter_selectivity(&filter, "people");
1591 assert!(s > 0.5, "BETWEEN should use histogram too, got {s}");
1592 }
1593
1594 #[test]
1595 fn graceful_fallback_when_histogram_absent() {
1596 let provider = Arc::new(StaticProvider::new().with_table(
1599 "people",
1600 TableStats {
1601 row_count: 1000,
1602 avg_row_size: 64,
1603 page_count: 10,
1604 columns: vec![],
1605 },
1606 ));
1607 let estimator = CostEstimator::with_stats(provider);
1608 let filter = AstFilter::Compare {
1609 field: FieldRef::column("people", "unknown_col"),
1610 op: CompareOp::Lt,
1611 value: Value::Integer(50),
1612 };
1613 let s = estimator.filter_selectivity(&filter, "people");
1614 assert!((s - 0.3).abs() < 1e-9, "fallback heuristic 0.3, got {s}");
1615 }
1616}