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 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/// Cardinality estimate for a query result
22#[derive(Debug, Clone, Default)]
23pub struct CardinalityEstimate {
24    /// Estimated row/record count
25    pub rows: f64,
26    /// Selectivity factor (0.0 - 1.0)
27    pub selectivity: f64,
28    /// Confidence in the estimate (0.0 - 1.0)
29    pub confidence: f64,
30}
31
32impl CardinalityEstimate {
33    /// Create a new cardinality estimate
34    pub fn new(rows: f64, selectivity: f64) -> Self {
35        Self {
36            rows,
37            selectivity,
38            confidence: 1.0,
39        }
40    }
41
42    /// Full table scan estimate
43    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    /// Apply a filter to reduce cardinality
52    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; // Reduce confidence with each estimate
56        self
57    }
58}
59
60/// Cost of executing a query plan.
61///
62/// Mirrors PostgreSQL's `Cost` split: `startup_cost` is the work needed
63/// before the **first** row can be produced, `total` is the work to
64/// produce the **last** row. Both are reported so plan selection can
65/// pick a low-startup plan when a small `LIMIT` is in scope, even if
66/// total work is higher.
67///
68/// See `src/storage/query/planner/README.md` § Invariant 1.
69#[derive(Debug, Clone, Default)]
70pub struct PlanCost {
71    /// CPU computation cost
72    pub cpu: f64,
73    /// IO access cost
74    pub io: f64,
75    /// Network transfer cost (for distributed)
76    pub network: f64,
77    /// Memory requirement
78    pub memory: f64,
79    /// Cost to produce the **first** row.
80    ///
81    /// Zero for streaming operators (full scan, index scan, filter over
82    /// scan). Equal to `total` for blocking operators (sort, hash join
83    /// build side, materialize).
84    pub startup_cost: f64,
85    /// Cost to produce the **last** row.
86    pub total: f64,
87}
88
89impl PlanCost {
90    /// Create a new cost estimate with `startup_cost = 0` (streaming).
91    pub fn new(cpu: f64, io: f64, memory: f64) -> Self {
92        let total = cpu + io * 10.0 + memory * 0.1; // IO is expensive
93        Self {
94            cpu,
95            io,
96            network: 0.0,
97            memory,
98            startup_cost: 0.0,
99            total,
100        }
101    }
102
103    /// Create a cost with an explicit `startup_cost`. Use for blocking
104    /// operators (sort, hash build) and for index point lookups whose
105    /// first-row cost is non-zero.
106    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    /// Compose two costs in a **pipelined** fashion: the second operator
119    /// consumes the first as a stream.
120    ///
121    /// Both `startup_cost` and `total` add together. Use for filter
122    /// over scan, projection over filter, etc.
123    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    /// Compose two costs where `self` must be **fully consumed** before
135    /// `blocker` can produce its first row.
136    ///
137    /// `self.total` flows into `blocker.startup_cost`. Use for sort,
138    /// hash build, materialise — anything that has to drain its input
139    /// before emitting.
140    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    /// Backwards-compatible alias for [`combine_pipelined`].
152    ///
153    /// New code should prefer `combine_pipelined` / `combine_blocking`
154    /// explicitly. This is kept so existing callers compile unchanged.
155    pub fn combine(&self, other: &PlanCost) -> PlanCost {
156        self.combine_pipelined(other)
157    }
158
159    /// Scale cost by a factor (cardinality multiplier, etc.).
160    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,             // Memory doesn't scale linearly
166            startup_cost: self.startup_cost, // startup is per-plan, not per-row
167            total: self.total * factor,
168        }
169    }
170
171    /// Plan-comparison helper. Picks `Less` when `self` should be
172    /// preferred over `other`.
173    ///
174    /// When `limit` is `Some(k)` and `k < 0.1 * cardinality`, the
175    /// comparison switches from `total` to `startup_cost` — the client
176    /// will only consume a small slice of the result, so we want the
177    /// plan that produces the first rows fastest even if the full scan
178    /// would be more expensive.
179    ///
180    /// This mirrors PostgreSQL's `compare_path_costs_fuzzily` logic for
181    /// `STARTUP` vs `TOTAL` cost ordering.
182    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/// Statistics about a table or graph
199#[derive(Debug, Clone, Default)]
200pub struct TableStats {
201    /// Total row count
202    pub row_count: u64,
203    /// Average row size in bytes
204    pub avg_row_size: u32,
205    /// Number of pages
206    pub page_count: u64,
207    /// Column statistics
208    pub columns: Vec<ColumnStats>,
209}
210
211/// Statistics about a column
212#[derive(Debug, Clone, Default)]
213pub struct ColumnStats {
214    /// Column name
215    pub name: String,
216    /// Number of distinct values
217    pub distinct_count: u64,
218    /// Null count
219    pub null_count: u64,
220    /// Minimum value (if orderable)
221    pub min_value: Option<String>,
222    /// Maximum value (if orderable)
223    pub max_value: Option<String>,
224    /// Has index
225    pub has_index: bool,
226}
227
228/// Cost estimator for query plans
229pub struct CostEstimator {
230    /// Default table row count estimate
231    default_row_count: f64,
232    /// Cost per row scan
233    row_scan_cost: f64,
234    /// Cost per index lookup
235    index_lookup_cost: f64,
236    /// Cost per hash join probe
237    hash_probe_cost: f64,
238    /// Cost per nested loop iteration
239    nested_loop_cost: f64,
240    /// Cost per graph edge traversal
241    edge_traversal_cost: f64,
242    /// Optional stats provider. When present, `estimate_table_cardinality`
243    /// and the selectivity computation use real per-table / per-column
244    /// statistics instead of the heuristic constants. `None` preserves the
245    /// legacy behaviour so callers can adopt stats incrementally.
246    stats: Arc<dyn StatsProvider>,
247}
248
249impl CostEstimator {
250    /// Create a new cost estimator with default parameters and a
251    /// [`NullProvider`] — no real stats, pure heuristic mode.
252    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    /// Create a cost estimator that consults `provider` for real table /
265    /// column / index statistics. Any lookups the provider cannot satisfy
266    /// fall back to the heuristic path automatically.
267    pub fn with_stats(provider: Arc<dyn StatsProvider>) -> Self {
268        Self {
269            stats: provider,
270            ..Self::new()
271        }
272    }
273
274    /// Swap the stats provider on an existing estimator. Useful for tests
275    /// and for planners that build one `CostEstimator` and repoint it at
276    /// per-query snapshots.
277    pub fn set_stats(&mut self, provider: Arc<dyn StatsProvider>) {
278        self.stats = provider;
279    }
280
281    /// Estimate cost of a query expression
282    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            // DML/DDL statements have minimal query cost
292            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    /// Estimate cardinality of a query result
386    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            // DML/DDL/Command statements return affected-row count or nothing
396            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    // =========================================================================
490    // Table Query Estimation
491    // =========================================================================
492
493    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        // I/O cost: use Mackert-Lohman when we have index stats and a filter
499        // column with a known index; otherwise fall back to the naive heuristic.
500        let io = self.estimate_table_io(query, cardinality.rows);
501
502        let memory = cardinality.rows * 100.0; // 100 bytes per row estimate
503
504        PlanCost::new(cpu, io, memory)
505    }
506
507    /// Compute the I/O page cost for a table scan.
508    ///
509    /// When the query has a simple equality or range filter on an indexed
510    /// column, use `IndexStats::correlated_io_cost` (Mackert-Lohman) which
511    /// accounts for `index_correlation` (0.0 = random I/O, 1.0 = sequential).
512    /// Falls back to the naive `rows / 100` heuristic otherwise.
513    fn estimate_table_io(&self, query: &TableQuery, result_rows: f64) -> f64 {
514        const ROWS_PER_PAGE: f64 = 100.0;
515
516        // Look up total heap pages from table stats if available
517        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 the filter is a simple comparison on an indexed column, use
523        // the Mackert-Lohman formula with correlation from IndexStats.
524        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        // Heuristic fallback: assume sequential pages = rows / 100
533        (result_rows / ROWS_PER_PAGE).ceil()
534    }
535
536    fn estimate_table_cardinality(&self, query: &TableQuery) -> CardinalityEstimate {
537        // Prefer real row counts from the stats provider; fall back to the
538        // heuristic `default_row_count` when no stats are registered.
539        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        // Apply filter selectivity (stats-aware when provider has index
548        // stats on the compared column).
549        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        // Apply limit
555        if let Some(limit) = query.limit {
556            estimate.rows = estimate.rows.min(limit as f64);
557        }
558
559        estimate
560    }
561
562    /// Stats-aware selectivity computation.
563    ///
564    /// Resolution order (best → worst):
565    ///   1. `column_mcv` for equality on a known frequent value
566    ///   2. `column_histogram` for ranges and BETWEEN
567    ///   3. `index_stats.point_selectivity()` for indexed columns
568    ///   4. Hardcoded heuristic constants as final fallback
569    ///
570    /// Mirrors postgres `var_eq_const` / `histogram_selectivity` in
571    /// `src/backend/utils/adt/selfuncs.c`. Histogram + MCV data
572    /// structures already live in `super::histogram`; this method is
573    /// where we finally consume them on the hot planner path.
574    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 we have an MCV list, sum the per-value frequencies
598                // for values that are actually in the list, plus the
599                // residual estimate for the rest.
600                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                // Column-to-column predicates lack histogram leverage
646                // — assume moderate selectivity. Histogram/MCV hooks
647                // only help literal-valued filters.
648                0.1
649            }
650            AstFilter::CompareExpr { .. } => {
651                // Expression-shaped predicates: conservative 0.1 until
652                // the planner learns to walk Expr trees. Matches the
653                // CompareFields default.
654                0.1
655            }
656        }
657    }
658
659    // =========================================================================
660    // Graph Query Estimation
661    // =========================================================================
662
663    fn estimate_graph(&self, query: &GraphQuery) -> PlanCost {
664        let cardinality = self.estimate_graph_cardinality(query);
665
666        // Graph queries are more expensive due to pointer chasing
667        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; // More random IO
672        let memory = cardinality.rows * 200.0; // Larger due to paths
673
674        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        // Each edge reduces cardinality
682        let base_rows = self.default_row_count;
683        let edge_factor = 0.1_f64.powf(edges); // Each edge is highly selective
684
685        let mut estimate = CardinalityEstimate::new(base_rows * nodes * edge_factor, edge_factor);
686        estimate.confidence = 0.5; // Graph estimates are less accurate
687
688        // Apply filter
689        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    // =========================================================================
698    // Join Query Estimation
699    // =========================================================================
700
701    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        // Hash join cost model.
709        //
710        // Build side (left) is **blocking** — we must drain the entire
711        // left input and populate the hash table before any probe can
712        // produce its first output row. Probe side (right) is then
713        // streamed pipelined.
714        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; // hash table footprint
717
718        // The build operator: zero work upstream, blocking on left input.
719        let build_op = PlanCost::with_startup(build_cpu, 0.0, join_memory, build_cpu);
720        // The probe operator: pipelined over right input.
721        let probe_op = PlanCost::new(probe_cpu, 0.0, 0.0);
722
723        // Compose: left → block on build → pipelined probe with right.
724        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        // Join selectivity based on join type
735        let selectivity = match query.join_type {
736            JoinType::Inner => 0.1,      // Inner join is selective
737            JoinType::LeftOuter => 1.0,  // Left join preserves left side
738            JoinType::RightOuter => 1.0, // Right join preserves right side
739            JoinType::FullOuter => 1.0,  // Full outer preserves both sides entirely
740            JoinType::Cross => 1.0,      // Cartesian product — every pair matches
741        };
742
743        CardinalityEstimate::new(
744            left.rows * right.rows * selectivity,
745            left.selectivity * right.selectivity * selectivity,
746        )
747    }
748
749    // =========================================================================
750    // Path Query Estimation
751    // =========================================================================
752
753    fn estimate_path(&self, query: &PathQuery) -> PlanCost {
754        let cardinality = self.estimate_path_cardinality(query);
755
756        // BFS/DFS cost
757        let max_hops = query.max_length;
758        let branching_factor: f64 = 5.0; // Average edges per node
759
760        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; // Visited set
764
765        PlanCost::new(cpu, io, memory)
766    }
767
768    fn estimate_path_cardinality(&self, query: &PathQuery) -> CardinalityEstimate {
769        // Path queries typically return few results
770        let max_paths = 10.0;
771        CardinalityEstimate::new(max_paths, 0.001)
772    }
773
774    // =========================================================================
775    // Vector Query Estimation
776    // =========================================================================
777
778    fn estimate_vector(&self, query: &VectorQuery) -> PlanCost {
779        // HNSW search is O(log n) with relatively low constant
780        // Typical search visits ~100-500 nodes for 1M vectors
781        let k = query.k as f64;
782
783        // Base cost from HNSW traversal — must descend the layer graph
784        // before *any* candidate can be returned. This is the operator's
785        // intrinsic startup cost.
786        let hnsw_cost = 100.0 * (1.0 + k.ln()); // ~100-300 node visits
787
788        // Metadata filtering adds cost if present
789        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; // HNSW layers are cached
798        let memory = k * 32.0 + 1000.0; // k results + working set
799
800        // Vector search is *partly* blocking: HNSW must traverse the
801        // entry layers before the first neighbour is known, so the
802        // first-row cost is roughly the descent cost. Subsequent rows
803        // come essentially free until `k`.
804        PlanCost::with_startup(cpu, io, memory, hnsw_cost * 0.5)
805    }
806
807    fn estimate_vector_cardinality(&self, query: &VectorQuery) -> CardinalityEstimate {
808        // Vector search returns exactly k results (or fewer if not enough vectors)
809        let k = query.k as f64;
810        CardinalityEstimate::new(k, 0.1)
811    }
812
813    // =========================================================================
814    // Hybrid Query Estimation
815    // =========================================================================
816
817    fn estimate_hybrid(&self, query: &HybridQuery) -> PlanCost {
818        // Hybrid cost = structured + vector + fusion overhead
819        let structured_cost = self.estimate(&query.structured);
820        let vector_cost = self.estimate_vector(&query.vector);
821
822        // Fusion overhead depends on strategy
823        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        // Result size depends on fusion strategy
844        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, // Rerank and filter strategies return vector k
852        };
853
854        CardinalityEstimate::new(rows, 0.2)
855    }
856
857    // =========================================================================
858    // Filter Selectivity
859    // =========================================================================
860
861    fn estimate_filter_selectivity(filter: &AstFilter) -> f64 {
862        match filter {
863            AstFilter::Compare { op, .. } => {
864                match op {
865                    CompareOp::Eq => 0.01, // Equality is very selective
866                    CompareOp::Ne => 0.99, // Inequality is not selective
867                    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                // Each value adds 1% selectivity
874                (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) // Inclusion-exclusion
889            }
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    /// Equality selectivity for `column = value`.
899    ///
900    /// Resolution order:
901    /// 1. MCV list — exact frequency for tracked values, residual
902    ///    formula for untracked values.
903    /// 2. `index_stats.point_selectivity()` — `1 / distinct_keys`.
904    /// 3. Heuristic constant `0.01`.
905    fn eq_selectivity(&self, table: &str, column: Option<&str>, value: &Value) -> f64 {
906        if let Some(col) = column {
907            // 1. Most-common-values lookup.
908            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                    // Untracked value: residual / non_mcv_distinct.
914                    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            // 2. Index stats fallback.
921            if let Some(s) = self.stats.index_stats(table, col) {
922                return s.point_selectivity();
923            }
924        }
925        // 3. Heuristic.
926        0.01
927    }
928
929    /// Range selectivity for `lo <= column <= hi`. Either bound may
930    /// be `None` to express an open side. Used by `<`, `<=`, `>`,
931    /// `>=`, and `BETWEEN`.
932    ///
933    /// Resolution order:
934    /// 1. Histogram — `Histogram::range_selectivity` with bounds
935    ///    converted via `column_value_from`.
936    /// 2. `index_stats.point_selectivity() * (distinct_keys / 2)`
937    ///    capped at the legacy heuristic.
938    /// 3. Heuristic `0.3` for one-sided, `0.25` for two-sided.
939    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            // 1. Histogram bucket arithmetic.
948            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            // 2. Index stats fallback.
954            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        // 3. Heuristic.
964        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
978/// Convert a query AST `Value` into a histogram-comparable
979/// [`super::histogram::ColumnValue`]. Returns `None` for value types
980/// that histograms don't support (Bool, Null, Bytes, etc.) — callers
981/// fall through to the heuristic path.
982fn 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        // Other variants (Null, Blob, Boolean, IpAddr, MacAddr,
1010        // Vector, Json, Uuid, NodeRef, EdgeRef, vector ref...) are
1011        // not orderable in a histogram-meaningful way; the planner
1012        // falls through to the heuristic for these.
1013        _ => None,
1014    }
1015}
1016
1017/// Resolve a `FieldRef` to a bare column name when it refers to `table`.
1018/// Returns `None` when the field targets another relation — in that case
1019/// Extract the first plain column name from a filter for index-stat lookup.
1020/// Walks AND nodes; stops at OR/NOT (too complex for simple correlation lookup).
1021fn 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
1032/// the legacy heuristic still applies.
1033fn 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        // Node / edge property refs don't map to table-level stats.
1039        _ => 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        // Default would be 1000; provider says 50_000.
1099        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        // 1M rows × (1 / 1M distinct) ≈ 1 row
1132        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        // Heuristic 0.01 on 1M rows = 10_000
1150        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        // Default 1000 rows × 0.01 eq selectivity = 10
1159        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        // 100_000 × (1/10) × (1/1000) = 10
1208        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); // AND should be very selective
1281    }
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    // ---------------------------------------------------------------
1315    // Target 2: startup_cost vs total_cost split
1316    // ---------------------------------------------------------------
1317
1318    #[test]
1319    fn startup_zero_for_full_scan() {
1320        // estimate_table is implemented as a streaming sequential scan
1321        // (no startup cost — the first row is producible as soon as the
1322        // first page is read).
1323        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        // combine_blocking models a sort or hash build: the input must
1333        // be fully consumed before the blocker can emit its first row.
1334        let input = PlanCost::new(100.0, 10.0, 50.0); // cost = 100 + 100 + 5 = 205
1335        let blocker = PlanCost::new(20.0, 0.0, 10.0); // cost = 20 + 0 + 1 = 21
1336        let composed = input.combine_blocking(&blocker);
1337        // Blocking startup absorbs all of input.total
1338        assert_eq!(composed.startup_cost, input.total);
1339        // Total is input.total + blocker.total
1340        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        // Two plans with the same total but different startup. With a
1356        // small LIMIT, the planner must pick the low-startup plan.
1357        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        // Cardinality 10_000, LIMIT 10 → 10 < 0.1 * 10_000 = 1000 → use startup.
1374        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        // Same two plans, no LIMIT — total wins.
1383        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        // LIMIT 5000 vs cardinality 10_000 → 5000 > 1000 → use total.
1408        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        // Direct combine_blocking semantics: a hash join must drain the
1433        // left input and build the hash table before producing the first
1434        // probe result.
1435        let left = PlanCost::new(80.0, 8.0, 100.0); // table scan
1436        let build = PlanCost::with_startup(50.0, 0.0, 200.0, 50.0); // build op
1437        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        // estimate_vector now uses with_startup so HNSW descent shows
1450        // up as startup_cost > 0 (and < total — subsequent neighbours
1451        // are essentially free).
1452        let estimator = CostEstimator::new();
1453        // We can't easily build a VectorQuery without the AST helpers,
1454        // so test the direct cost surface with_startup uses.
1455        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; // suppress unused
1459    }
1460
1461    #[test]
1462    fn with_startup_clamps_total_below_startup() {
1463        // If a caller asks for total < startup, with_startup raises total.
1464        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    // ---------------------------------------------------------------
1476    // Perf 1.3: histogram + MCV plug-in into filter_selectivity
1477    // ---------------------------------------------------------------
1478
1479    use super::super::histogram::{ColumnValue, Histogram, MostCommonValues};
1480
1481    fn provider_with_skew() -> Arc<StaticProvider> {
1482        // Build a histogram where 80 of 100 values fall in [0, 9]
1483        // and the rest spread sparsely up to 1000. range_selectivity
1484        // for `<= 9` should be ~0.8, vastly beating the heuristic 0.3.
1485        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        // MCV says "boss" is 50% of the table → selectivity 0.5,
1525        // not the 0.01 heuristic.
1526        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        // 1 - 0.55 (mcv totals) = 0.45 spread across (distinct - 2)
1543        // distinct values. We don't have an exact distinct count, so
1544        // the planner uses the default 100 → 0.45 / 98 ≈ 0.0046.
1545        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        // 1 - 0.5 == 0.5
1560        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        // Histogram says ~80% of values are in [0, 9], heuristic
1573        // would have said 0.3.
1574        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        // Provider has no histogram on `unknown_col` — must fall
1597        // through to the 0.3 heuristic without panicking.
1598        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}