Skip to main content

reddb_server/storage/query/planner/
cost.rs

1//! Cost Estimation
2//!
3//! Cost-based query plan selection with cardinality estimation.
4//!
5//! # Cost Model
6//!
7//! - **CPU cost**: Computation overhead
8//! - **IO cost**: Disk/memory access
9//! - **Network cost**: For distributed queries
10//! - **Memory cost**: Working memory required
11
12use 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/// Cost of executing a query plan.
24///
25/// Mirrors PostgreSQL's `Cost` split: `startup_cost` is the work needed
26/// before the **first** row can be produced, `total` is the work to
27/// produce the **last** row. Both are reported so plan selection can
28/// pick a low-startup plan when a small `LIMIT` is in scope, even if
29/// total work is higher.
30///
31/// See `src/storage/query/planner/README.md` § Invariant 1.
32#[derive(Debug, Clone, Default)]
33pub struct PlanCost {
34    /// CPU computation cost
35    pub cpu: f64,
36    /// IO access cost
37    pub io: f64,
38    /// Network transfer cost (for distributed)
39    pub network: f64,
40    /// Memory requirement
41    pub memory: f64,
42    /// Cost to produce the **first** row.
43    ///
44    /// Zero for streaming operators (full scan, index scan, filter over
45    /// scan). Equal to `total` for blocking operators (sort, hash join
46    /// build side, materialize).
47    pub startup_cost: f64,
48    /// Cost to produce the **last** row.
49    pub total: f64,
50}
51
52impl PlanCost {
53    /// Create a new cost estimate with `startup_cost = 0` (streaming).
54    pub fn new(cpu: f64, io: f64, memory: f64) -> Self {
55        let total = cpu + io * 10.0 + memory * 0.1; // IO is expensive
56        Self {
57            cpu,
58            io,
59            network: 0.0,
60            memory,
61            startup_cost: 0.0,
62            total,
63        }
64    }
65
66    /// Create a cost with an explicit `startup_cost`. Use for blocking
67    /// operators (sort, hash build) and for index point lookups whose
68    /// first-row cost is non-zero.
69    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    /// Compose two costs in a **pipelined** fashion: the second operator
82    /// consumes the first as a stream.
83    ///
84    /// Both `startup_cost` and `total` add together. Use for filter
85    /// over scan, projection over filter, etc.
86    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    /// Compose two costs where `self` must be **fully consumed** before
98    /// `blocker` can produce its first row.
99    ///
100    /// `self.total` flows into `blocker.startup_cost`. Use for sort,
101    /// hash build, materialise — anything that has to drain its input
102    /// before emitting.
103    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    /// Backwards-compatible alias for [`combine_pipelined`].
115    ///
116    /// New code should prefer `combine_pipelined` / `combine_blocking`
117    /// explicitly. This is kept so existing callers compile unchanged.
118    pub fn combine(&self, other: &PlanCost) -> PlanCost {
119        self.combine_pipelined(other)
120    }
121
122    /// Scale cost by a factor (cardinality multiplier, etc.).
123    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,             // Memory doesn't scale linearly
129            startup_cost: self.startup_cost, // startup is per-plan, not per-row
130            total: self.total * factor,
131        }
132    }
133
134    /// Plan-comparison helper. Picks `Less` when `self` should be
135    /// preferred over `other`.
136    ///
137    /// When `limit` is `Some(k)` and `k < 0.1 * cardinality`, the
138    /// comparison switches from `total` to `startup_cost` — the client
139    /// will only consume a small slice of the result, so we want the
140    /// plan that produces the first rows fastest even if the full scan
141    /// would be more expensive.
142    ///
143    /// This mirrors PostgreSQL's `compare_path_costs_fuzzily` logic for
144    /// `STARTUP` vs `TOTAL` cost ordering.
145    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/// Statistics about a table or graph
162#[derive(Debug, Clone, Default)]
163pub struct TableStats {
164    /// Total row count
165    pub row_count: u64,
166    /// Average row size in bytes
167    pub avg_row_size: u32,
168    /// Number of pages
169    pub page_count: u64,
170    /// Column statistics
171    pub columns: Vec<ColumnStats>,
172}
173
174/// Statistics about a column
175#[derive(Debug, Clone, Default)]
176pub struct ColumnStats {
177    /// Column name
178    pub name: String,
179    /// Number of distinct values
180    pub distinct_count: u64,
181    /// Null count
182    pub null_count: u64,
183    /// Minimum value (if orderable)
184    pub min_value: Option<String>,
185    /// Maximum value (if orderable)
186    pub max_value: Option<String>,
187    /// Has index
188    pub has_index: bool,
189}
190
191/// Cost estimator for query plans
192pub struct CostEstimator {
193    /// Default table row count estimate
194    default_row_count: f64,
195    /// Cost per row scan
196    row_scan_cost: f64,
197    /// Cost per index lookup
198    index_lookup_cost: f64,
199    /// Cost per hash join probe
200    hash_probe_cost: f64,
201    /// Cost per nested loop iteration
202    nested_loop_cost: f64,
203    /// Cost per graph edge traversal
204    edge_traversal_cost: f64,
205    /// Optional stats provider. When present, `estimate_table_cardinality`
206    /// and the selectivity computation use real per-table / per-column
207    /// statistics instead of the heuristic constants. `None` preserves the
208    /// legacy behaviour so callers can adopt stats incrementally.
209    stats: Arc<dyn StatsProvider>,
210}
211
212impl CostEstimator {
213    /// Create a new cost estimator with default parameters and a
214    /// [`NullProvider`] — no real stats, pure heuristic mode.
215    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    /// Create a cost estimator that consults `provider` for real table /
228    /// column / index statistics. Any lookups the provider cannot satisfy
229    /// fall back to the heuristic path automatically.
230    pub fn with_stats(provider: Arc<dyn StatsProvider>) -> Self {
231        Self {
232            stats: provider,
233            ..Self::new()
234        }
235    }
236
237    /// Swap the stats provider on an existing estimator. Useful for tests
238    /// and for planners that build one `CostEstimator` and repoint it at
239    /// per-query snapshots.
240    pub fn set_stats(&mut self, provider: Arc<dyn StatsProvider>) {
241        self.stats = provider;
242    }
243
244    /// Estimate cost of a query expression
245    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            // DML/DDL statements have minimal query cost
255            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    /// Estimate cardinality of a query result
350    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            // DML/DDL/Command statements return affected-row count or nothing
360            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    // =========================================================================
455    // Table Query Estimation
456    // =========================================================================
457
458    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        // I/O cost: use Mackert-Lohman when we have index stats and a filter
464        // column with a known index; otherwise fall back to the naive heuristic.
465        let io = self.estimate_table_io(query, cardinality.rows);
466
467        let memory = cardinality.rows * 100.0; // 100 bytes per row estimate
468
469        PlanCost::new(cpu, io, memory)
470    }
471
472    /// Compute the I/O page cost for a table scan.
473    ///
474    /// When the query has a simple equality or range filter on an indexed
475    /// column, use `IndexStats::correlated_io_cost` (Mackert-Lohman) which
476    /// accounts for `index_correlation` (0.0 = random I/O, 1.0 = sequential).
477    /// Falls back to the naive `rows / 100` heuristic otherwise.
478    fn estimate_table_io(&self, query: &TableQuery, result_rows: f64) -> f64 {
479        const ROWS_PER_PAGE: f64 = 100.0;
480
481        // Look up total heap pages from table stats if available
482        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 the filter is a simple comparison on an indexed column, use
488        // the Mackert-Lohman formula with correlation from IndexStats.
489        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        // Heuristic fallback: assume sequential pages = rows / 100
498        (result_rows / ROWS_PER_PAGE).ceil()
499    }
500
501    fn estimate_table_cardinality(&self, query: &TableQuery) -> CardinalityEstimate {
502        // Prefer real row counts from the stats provider; fall back to the
503        // heuristic `default_row_count` when no stats are registered.
504        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        // Apply filter selectivity (stats-aware when provider has index
513        // stats on the compared column).
514        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        // Apply limit
520        if let Some(limit) = query.limit {
521            estimate.rows = estimate.rows.min(limit as f64);
522        }
523
524        estimate
525    }
526
527    /// Stats-aware selectivity computation.
528    ///
529    /// Resolution order (best → worst):
530    ///   1. `column_mcv` for equality on a known frequent value
531    ///   2. `column_histogram` for ranges and BETWEEN
532    ///   3. `index_stats.point_selectivity()` for indexed columns
533    ///   4. Hardcoded heuristic constants as final fallback
534    ///
535    /// Mirrors postgres `var_eq_const` / `histogram_selectivity` in
536    /// `src/backend/utils/adt/selfuncs.c`. Histogram + MCV data
537    /// structures already live in `super::histogram`; this method is
538    /// where we finally consume them on the hot planner path.
539    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 we have an MCV list, sum the per-value frequencies
563                // for values that are actually in the list, plus the
564                // residual estimate for the rest.
565                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                // Column-to-column predicates lack histogram leverage
611                // — assume moderate selectivity. Histogram/MCV hooks
612                // only help literal-valued filters.
613                0.1
614            }
615            AstFilter::CompareExpr { .. } => {
616                // Expression-shaped predicates: conservative 0.1 until
617                // the planner learns to walk Expr trees. Matches the
618                // CompareFields default.
619                0.1
620            }
621        }
622    }
623
624    // =========================================================================
625    // Graph Query Estimation
626    // =========================================================================
627
628    fn estimate_graph(&self, query: &GraphQuery) -> PlanCost {
629        let cardinality = self.estimate_graph_cardinality(query);
630
631        // Graph queries are more expensive due to pointer chasing
632        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; // More random IO
637        let memory = cardinality.rows * 200.0; // Larger due to paths
638
639        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        // Each edge reduces cardinality
647        let base_rows = self.default_row_count;
648        let edge_factor = 0.1_f64.powf(edges); // Each edge is highly selective
649
650        let mut estimate = CardinalityEstimate::new(base_rows * nodes * edge_factor, edge_factor);
651        estimate.confidence = 0.5; // Graph estimates are less accurate
652
653        // Apply filter
654        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    // =========================================================================
663    // Join Query Estimation
664    // =========================================================================
665
666    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        // Hash join cost model.
674        //
675        // Build side (left) is **blocking** — we must drain the entire
676        // left input and populate the hash table before any probe can
677        // produce its first output row. Probe side (right) is then
678        // streamed pipelined.
679        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; // hash table footprint
682
683        // The build operator: zero work upstream, blocking on left input.
684        let build_op = PlanCost::with_startup(build_cpu, 0.0, join_memory, build_cpu);
685        // The probe operator: pipelined over right input.
686        let probe_op = PlanCost::new(probe_cpu, 0.0, 0.0);
687
688        // Compose: left → block on build → pipelined probe with right.
689        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        // Join selectivity based on join type
700        let selectivity = match query.join_type {
701            JoinType::Inner => 0.1,      // Inner join is selective
702            JoinType::LeftOuter => 1.0,  // Left join preserves left side
703            JoinType::RightOuter => 1.0, // Right join preserves right side
704            JoinType::FullOuter => 1.0,  // Full outer preserves both sides entirely
705            JoinType::Cross => 1.0,      // Cartesian product — every pair matches
706        };
707
708        CardinalityEstimate::new(
709            left.rows * right.rows * selectivity,
710            left.selectivity * right.selectivity * selectivity,
711        )
712    }
713
714    // =========================================================================
715    // Path Query Estimation
716    // =========================================================================
717
718    fn estimate_path(&self, query: &PathQuery) -> PlanCost {
719        let cardinality = self.estimate_path_cardinality(query);
720
721        // BFS/DFS cost
722        let max_hops = query.max_length;
723        let branching_factor: f64 = 5.0; // Average edges per node
724
725        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; // Visited set
729
730        PlanCost::new(cpu, io, memory)
731    }
732
733    fn estimate_path_cardinality(&self, query: &PathQuery) -> CardinalityEstimate {
734        // Path queries typically return few results
735        let max_paths = 10.0;
736        CardinalityEstimate::new(max_paths, 0.001)
737    }
738
739    // =========================================================================
740    // Vector Query Estimation
741    // =========================================================================
742
743    fn estimate_vector(&self, query: &VectorQuery) -> PlanCost {
744        // HNSW search is O(log n) with relatively low constant
745        // Typical search visits ~100-500 nodes for 1M vectors
746        let k = query.k as f64;
747
748        // Base cost from HNSW traversal — must descend the layer graph
749        // before *any* candidate can be returned. This is the operator's
750        // intrinsic startup cost.
751        let hnsw_cost = 100.0 * (1.0 + k.ln()); // ~100-300 node visits
752
753        // Metadata filtering adds cost if present
754        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; // HNSW layers are cached
762        let memory = k * 32.0 + 1000.0; // k results + working set
763
764        // Vector search is *partly* blocking: HNSW must traverse the
765        // entry layers before the first neighbour is known, so the
766        // first-row cost is roughly the descent cost. Subsequent rows
767        // come essentially free until `k`.
768        PlanCost::with_startup(cpu, io, memory, hnsw_cost * 0.5)
769    }
770
771    fn estimate_vector_cardinality(&self, query: &VectorQuery) -> CardinalityEstimate {
772        // Vector search returns exactly k results (or fewer if not enough vectors)
773        let k = query.k as f64;
774        CardinalityEstimate::new(k, 0.1)
775    }
776
777    // =========================================================================
778    // Hybrid Query Estimation
779    // =========================================================================
780
781    fn estimate_hybrid(&self, query: &HybridQuery) -> PlanCost {
782        // Hybrid cost = structured + vector + fusion overhead
783        let structured_cost = self.estimate(&query.structured);
784        let vector_cost = self.estimate_vector(&query.vector);
785
786        // Fusion overhead depends on strategy
787        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        // Result size depends on fusion strategy
808        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, // Rerank and filter strategies return vector k
814        };
815
816        CardinalityEstimate::new(rows, 0.2)
817    }
818
819    // =========================================================================
820    // Filter Selectivity
821    // =========================================================================
822
823    fn estimate_filter_selectivity(filter: &AstFilter) -> f64 {
824        match filter {
825            AstFilter::Compare { op, .. } => {
826                match op {
827                    CompareOp::Eq => 0.01, // Equality is very selective
828                    CompareOp::Ne => 0.99, // Inequality is not selective
829                    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                // Each value adds 1% selectivity
836                (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) // Inclusion-exclusion
851            }
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    /// Equality selectivity for `column = value`.
861    ///
862    /// Resolution order:
863    /// 1. MCV list — exact frequency for tracked values, residual
864    ///    formula for untracked values.
865    /// 2. `index_stats.point_selectivity()` — `1 / distinct_keys`.
866    /// 3. Heuristic constant `0.01`.
867    fn eq_selectivity(&self, table: &str, column: Option<&str>, value: &Value) -> f64 {
868        if let Some(col) = column {
869            // 1. Most-common-values lookup.
870            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                    // Untracked value: residual / non_mcv_distinct.
876                    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            // 2. Index stats fallback.
883            if let Some(s) = self.stats.index_stats(table, col) {
884                return s.point_selectivity();
885            }
886        }
887        // 3. Heuristic.
888        0.01
889    }
890
891    /// Range selectivity for `lo <= column <= hi`. Either bound may
892    /// be `None` to express an open side. Used by `<`, `<=`, `>`,
893    /// `>=`, and `BETWEEN`.
894    ///
895    /// Resolution order:
896    /// 1. Histogram — `Histogram::range_selectivity` with bounds
897    ///    converted via `column_value_from`.
898    /// 2. `index_stats.point_selectivity() * (distinct_keys / 2)`
899    ///    capped at the legacy heuristic.
900    /// 3. Heuristic `0.3` for one-sided, `0.25` for two-sided.
901    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            // 1. Histogram bucket arithmetic.
910            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            // 2. Index stats fallback.
916            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        // 3. Heuristic.
926        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
940/// Convert a query AST `Value` into a histogram-comparable
941/// [`super::histogram::ColumnValue`]. Returns `None` for value types
942/// that histograms don't support (Bool, Null, Bytes, etc.) — callers
943/// fall through to the heuristic path.
944fn 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        // Other variants (Null, Blob, Boolean, IpAddr, MacAddr,
972        // Vector, Json, Uuid, NodeRef, EdgeRef, vector ref...) are
973        // not orderable in a histogram-meaningful way; the planner
974        // falls through to the heuristic for these.
975        _ => None,
976    }
977}
978
979/// Resolve a `FieldRef` to a bare column name when it refers to `table`.
980/// Returns `None` when the field targets another relation — in that case
981/// Extract the first plain column name from a filter for index-stat lookup.
982/// Walks AND nodes; stops at OR/NOT (too complex for simple correlation lookup).
983fn 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
994/// the legacy heuristic still applies.
995fn 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        // Node / edge property refs don't map to table-level stats.
1001        _ => 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        // Default would be 1000; provider says 50_000.
1061        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        // 1M rows × (1 / 1M distinct) ≈ 1 row
1094        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        // Heuristic 0.01 on 1M rows = 10_000
1112        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        // Default 1000 rows × 0.01 eq selectivity = 10
1121        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        // 100_000 × (1/10) × (1/1000) = 10
1170        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); // AND should be very selective
1243    }
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    // ---------------------------------------------------------------
1277    // Target 2: startup_cost vs total_cost split
1278    // ---------------------------------------------------------------
1279
1280    #[test]
1281    fn startup_zero_for_full_scan() {
1282        // estimate_table is implemented as a streaming sequential scan
1283        // (no startup cost — the first row is producible as soon as the
1284        // first page is read).
1285        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        // combine_blocking models a sort or hash build: the input must
1295        // be fully consumed before the blocker can emit its first row.
1296        let input = PlanCost::new(100.0, 10.0, 50.0); // cost = 100 + 100 + 5 = 205
1297        let blocker = PlanCost::new(20.0, 0.0, 10.0); // cost = 20 + 0 + 1 = 21
1298        let composed = input.combine_blocking(&blocker);
1299        // Blocking startup absorbs all of input.total
1300        assert_eq!(composed.startup_cost, input.total);
1301        // Total is input.total + blocker.total
1302        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        // Two plans with the same total but different startup. With a
1318        // small LIMIT, the planner must pick the low-startup plan.
1319        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        // Cardinality 10_000, LIMIT 10 → 10 < 0.1 * 10_000 = 1000 → use startup.
1336        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        // Same two plans, no LIMIT — total wins.
1345        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        // LIMIT 5000 vs cardinality 10_000 → 5000 > 1000 → use total.
1370        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        // Direct combine_blocking semantics: a hash join must drain the
1395        // left input and build the hash table before producing the first
1396        // probe result.
1397        let left = PlanCost::new(80.0, 8.0, 100.0); // table scan
1398        let build = PlanCost::with_startup(50.0, 0.0, 200.0, 50.0); // build op
1399        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        // estimate_vector now uses with_startup so HNSW descent shows
1412        // up as startup_cost > 0 (and < total — subsequent neighbours
1413        // are essentially free).
1414        let estimator = CostEstimator::new();
1415        // We can't easily build a VectorQuery without the AST helpers,
1416        // so test the direct cost surface with_startup uses.
1417        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; // suppress unused
1421    }
1422
1423    #[test]
1424    fn with_startup_clamps_total_below_startup() {
1425        // If a caller asks for total < startup, with_startup raises total.
1426        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    // ---------------------------------------------------------------
1438    // Perf 1.3: histogram + MCV plug-in into filter_selectivity
1439    // ---------------------------------------------------------------
1440
1441    use super::super::histogram::{ColumnValue, Histogram, MostCommonValues};
1442
1443    fn provider_with_skew() -> Arc<StaticProvider> {
1444        // Build a histogram where 80 of 100 values fall in [0, 9]
1445        // and the rest spread sparsely up to 1000. range_selectivity
1446        // for `<= 9` should be ~0.8, vastly beating the heuristic 0.3.
1447        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        // MCV says "boss" is 50% of the table → selectivity 0.5,
1487        // not the 0.01 heuristic.
1488        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        // 1 - 0.55 (mcv totals) = 0.45 spread across (distinct - 2)
1505        // distinct values. We don't have an exact distinct count, so
1506        // the planner uses the default 100 → 0.45 / 98 ≈ 0.0046.
1507        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        // 1 - 0.5 == 0.5
1522        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        // Histogram says ~80% of values are in [0, 9], heuristic
1535        // would have said 0.3.
1536        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        // Provider has no histogram on `unknown_col` — must fall
1559        // through to the 0.3 heuristic without panicking.
1560        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}