Skip to main content

velesdb_core/velesql/cost_estimator/
mod.rs

1//! Cost estimator for hybrid MATCH + NEAR query planning.
2//!
3//! Uses [`OperationCostFactors`] (calibrated or default) to compute I/O and
4//! CPU costs for query plan nodes.
5//!
6//! # Transition from hard-coded constants (Issue #467)
7//!
8//! The former constants `FILTER_SCAN_IO_WEIGHT` (0.2), `FILTER_SCAN_CPU_WEIGHT`
9//! (0.8), `HNSW_IO_WEIGHT` (0.5), and `HNSW_CPU_WEIGHT` (1.0) have been
10//! removed. Cost computation now derives I/O and CPU weights from the fields
11//! of [`OperationCostFactors`], which are calibrated dynamically during
12//! `analyze()` based on collection statistics and histograms.
13//!
14//! Backward-compatible formulas (using `COMPAT_FILTER_IO`, `COMPAT_HNSW_IO`,
15//! etc.) ensure that **default factors produce identical costs** to the old
16//! hard-coded constants. When calibrated factors differ from defaults, costs
17//! scale proportionally via `(calibrated / default)` ratios.
18//!
19//! # Module layout (Devin Finding F on PR #606)
20//!
21//! The estimator was split across three files to respect the 500 NLOC file
22//! limit:
23//!
24//! - `mod.rs` (this file) — public types (`Cost`, `SelectivityMethod`,
25//!   `CostEstimator`), internal `CostFactorsRef`, base selectivity helpers
26//!   (`estimate_condition_selectivity`, comparison/in/between/like),
27//!   filter-cost + HNSW-cost helpers.
28//! - `selectivity_method.rs` — method-aware variants
29//!   (`estimate_condition_selectivity_with_method` + `_with_method` helpers
30//!   per predicate kind) + the `has_cardinality_data` guard.
31//! - `plan_cost.rs` — `estimate_plan_cost` + per-node cost functions
32//!   (vector search, table scan, limit/offset, index lookup, match
33//!   traversal).
34//!
35//! All three files contribute `impl CostEstimator<'_>` blocks — Rust allows
36//! multiple impl blocks across files for the same type, so there is no
37//! public API change and no cross-module trait indirection.
38
39// Reason: usize/u64 → f64 for selectivity ratios and log2 inputs; these are
40// cardinalities where ±1 ULP has no operational impact on query planning.
41#![allow(clippy::cast_precision_loss)]
42
43mod plan_cost;
44mod selectivity_method;
45
46use crate::collection::query_cost::cost_model::OperationCostFactors;
47use crate::collection::stats::next_after;
48use crate::collection::stats::CollectionStats;
49use crate::collection::stats::Histogram;
50use crate::velesql::ast::{CompareOp, Condition, Value};
51
52// ---------------------------------------------------------------------------
53// Backward-compatibility constants
54// ---------------------------------------------------------------------------
55// These reproduce the historical I/O and CPU ratios when factors == default.
56// The formulas multiply these by (factors.field / default.field) so that
57// calibrated factors scale the cost proportionally while default factors
58// yield the exact same costs as the old hard-coded constants.
59
60/// Historical I/O ratio for filter scan cost.
61pub(super) const COMPAT_FILTER_IO: f64 = 0.2;
62/// Historical CPU ratio for filter scan cost.
63pub(super) const COMPAT_FILTER_CPU: f64 = 0.8;
64/// Historical I/O ratio for HNSW search cost.
65pub(super) const COMPAT_HNSW_IO: f64 = 0.5;
66/// Historical CPU ratio for HNSW search cost.
67pub(super) const COMPAT_HNSW_CPU: f64 = 1.0;
68
69/// Composite cost estimate.
70#[derive(Debug, Clone, Copy, Default, PartialEq)]
71pub struct Cost {
72    /// Estimated I/O component (arbitrary units).
73    pub io_cost: f64,
74    /// Estimated CPU component (arbitrary units).
75    pub cpu_cost: f64,
76}
77
78/// Source of a selectivity estimate, used by EXPLAIN to report how a
79/// predicate's selectivity was computed (issue #471, Devin finding 2).
80///
81/// Ordered by increasing noise / decreasing confidence:
82/// 1. `Histogram` — derived from calibrated histogram buckets (most accurate).
83/// 2. `Cardinality` — derived from `distinct_count` only (no distribution).
84/// 3. `Heuristic` — hard-coded constant (e.g. 0.1 for `Match`, 0.05 for
85///    `ContainsText`) because the predicate type has no stats path at all.
86///
87/// For compound predicates (`And`/`Or`/`Not`/`Group`), the reported method is
88/// the **worst case** among children in the order above, so EXPLAIN never
89/// overstates its confidence.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum SelectivityMethod {
93    /// Selectivity computed from histogram bucket data.
94    Histogram,
95    /// Selectivity computed from `distinct_count` cardinality (no histogram).
96    Cardinality,
97    /// Selectivity computed from a heuristic constant.
98    Heuristic,
99}
100
101impl SelectivityMethod {
102    /// Returns the EXPLAIN display label for this method.
103    #[must_use]
104    pub const fn as_str(self) -> &'static str {
105        match self {
106            Self::Histogram => "histogram",
107            Self::Cardinality => "cardinality",
108            Self::Heuristic => "heuristic",
109        }
110    }
111
112    /// Returns the worst (least confident) of two methods.
113    ///
114    /// Ordering: `Heuristic > Cardinality > Histogram`. Used to combine the
115    /// methods of sub-predicates under `And`/`Or`/`Not`/`Group` so the
116    /// reported method reflects the loosest child.
117    #[must_use]
118    pub const fn worst(self, other: Self) -> Self {
119        match (self, other) {
120            (Self::Heuristic, _) | (_, Self::Heuristic) => Self::Heuristic,
121            (Self::Cardinality, _) | (_, Self::Cardinality) => Self::Cardinality,
122            _ => Self::Histogram,
123        }
124    }
125}
126
127impl std::fmt::Display for SelectivityMethod {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.write_str(self.as_str())
130    }
131}
132
133impl Cost {
134    #[must_use]
135    /// Creates a new cost value from I/O and CPU components.
136    pub const fn new(io_cost: f64, cpu_cost: f64) -> Self {
137        Self { io_cost, cpu_cost }
138    }
139
140    #[must_use]
141    /// Returns the total cost (I/O + CPU).
142    pub const fn total(self) -> f64 {
143        self.io_cost + self.cpu_cost
144    }
145}
146
147/// Reference to cost factors — either calibrated from stats, or default.
148///
149/// Zero-allocation on cache-hit path: `Calibrated` borrows from
150/// `CollectionStats`, `Default` is a unit variant resolved inline.
151#[derive(Debug)]
152enum CostFactorsRef<'a> {
153    /// Calibrated factors stored in `CollectionStats` (zero-copy borrow).
154    Calibrated(&'a OperationCostFactors),
155    /// Default factors (no allocation needed).
156    Default,
157}
158
159impl CostFactorsRef<'_> {
160    /// Returns a reference to the effective factors.
161    ///
162    /// For `Calibrated`, returns the borrowed reference directly.
163    /// For `Default`, returns a reference to a lazily-initialized static default.
164    fn get(&self) -> &OperationCostFactors {
165        match self {
166            Self::Calibrated(f) => f,
167            Self::Default => {
168                use std::sync::LazyLock;
169                static DEFAULT_FACTORS: LazyLock<OperationCostFactors> =
170                    LazyLock::new(OperationCostFactors::default);
171                &DEFAULT_FACTORS
172            }
173        }
174    }
175}
176
177/// Cost estimator based on collection statistics.
178///
179/// Uses `OperationCostFactors` (calibrated or default) to compute I/O and
180/// CPU costs. Zero-allocation on cache-hit path via `CostFactorsRef`.
181#[derive(Debug)]
182pub struct CostEstimator<'a> {
183    pub(super) stats: &'a CollectionStats,
184    factors: CostFactorsRef<'a>,
185}
186
187/// Converts a VelesQL `Value` to `f64` for histogram lookup.
188///
189/// Returns `Some(f64)` for Integer, `UnsignedInteger`, Float, and Boolean.
190/// Returns `None` for Parameter, Null, String, Temporal, and Subquery.
191pub(super) fn value_to_f64(value: &Value) -> Option<f64> {
192    match value {
193        Value::Integer(i) => Some(*i as f64),
194        Value::UnsignedInteger(u) => Some(*u as f64),
195        Value::Float(f) => Some(*f),
196        Value::Boolean(b) => Some(if *b { 1.0 } else { 0.0 }),
197        _ => None,
198    }
199}
200
201/// Lazily-initialized default factors for ratio computation.
202pub(super) fn default_factors() -> &'static OperationCostFactors {
203    use std::sync::LazyLock;
204    static DEFAULT: LazyLock<OperationCostFactors> = LazyLock::new(OperationCostFactors::default);
205    &DEFAULT
206}
207
208impl<'a> CostEstimator<'a> {
209    #[must_use]
210    /// Creates a new estimator with calibrated factors from the collection (if available).
211    ///
212    /// If `stats.calibrated_cost_factors` is `Some`, uses the calibrated factors.
213    /// Otherwise, uses `OperationCostFactors::default()`.
214    pub fn new(stats: &'a CollectionStats) -> Self {
215        let factors = match &stats.calibrated_cost_factors {
216            Some(f) => CostFactorsRef::Calibrated(f),
217            None => CostFactorsRef::Default,
218        };
219        Self { stats, factors }
220    }
221
222    /// Creates an estimator with explicit factors (for tests or override).
223    #[must_use]
224    pub fn with_factors(stats: &'a CollectionStats, factors: &'a OperationCostFactors) -> Self {
225        Self {
226            stats,
227            factors: CostFactorsRef::Calibrated(factors),
228        }
229    }
230
231    /// Returns the active `OperationCostFactors` for use in sibling modules.
232    pub(super) fn factors(&self) -> &OperationCostFactors {
233        self.factors.get()
234    }
235
236    /// Returns the histogram for a column, delegating to `CollectionStats`.
237    pub(super) fn get_histogram(&self, column: &str) -> Option<&Histogram> {
238        self.stats.get_column_histogram(column)
239    }
240
241    #[must_use]
242    /// Estimates filter cost using selectivity derived from stats.
243    ///
244    /// Uses backward-compatible formulas:
245    /// - `io_cost  = scan_rows * COMPAT_FILTER_IO  * (factors.seq_page_cost / default.seq_page_cost)`
246    /// - `cpu_cost = scan_rows * COMPAT_FILTER_CPU * (factors.cpu_tuple_cost / default.cpu_tuple_cost)`
247    ///
248    /// With default factors, this produces identical costs to the old constants.
249    pub fn estimate_filter_cost(&self, filter: &Condition) -> Cost {
250        let selectivity = self.estimate_condition_selectivity(filter).clamp(0.0, 1.0);
251        let total = self.stats.total_points.max(self.stats.row_count) as f64;
252        let scan_rows = (total * selectivity).max(1.0);
253
254        let f = self.factors.get();
255        let d = default_factors();
256        let io_ratio = f.seq_page_cost / d.seq_page_cost;
257        let cpu_ratio = f.cpu_tuple_cost / d.cpu_tuple_cost;
258
259        Cost::new(
260            scan_rows * COMPAT_FILTER_IO * io_ratio,
261            scan_rows * COMPAT_FILTER_CPU * cpu_ratio,
262        )
263    }
264
265    #[must_use]
266    /// Estimates HNSW search cost for top-k retrieval.
267    ///
268    /// Uses backward-compatible formulas:
269    /// - `io_cost  = probe * COMPAT_HNSW_IO  * (factors.random_page_cost / default.random_page_cost)`
270    /// - `cpu_cost = probe * COMPAT_HNSW_CPU * (factors.cpu_distance_cost / default.cpu_distance_cost)`
271    ///
272    /// With default factors, this produces identical costs to the old constants.
273    pub fn estimate_hnsw_search_cost(&self, k: usize) -> Cost {
274        let total = self.stats.total_points.max(self.stats.row_count).max(1) as f64;
275        let probe = (k.max(1) as f64) * total.log2().max(1.0);
276        self.hnsw_cost_from_probe(probe)
277    }
278
279    /// Estimates HNSW search cost parametrized by the actual `ef_search`
280    /// (frontier size) and `candidates` (top-k request) — issue #471, Devin
281    /// finding 4.
282    ///
283    /// Uses the same `(ef + k) * log2(total)` probe formula as
284    /// `Self::estimate_vector_search_node_cost` (private), so callers that have
285    /// `ef_search` / `candidates` available (e.g. pre/post-filter strategy
286    /// comparison in `plan_builder`) get a cost that reflects the real query
287    /// instead of a fixed `k = 10`.
288    #[must_use]
289    pub fn estimate_hnsw_search_cost_with_ef(&self, ef_search: u32, candidates: u32) -> Cost {
290        let total = self.stats.total_points.max(self.stats.row_count).max(1);
291        self.estimate_hnsw_search_cost_with_ef_on_size(ef_search, candidates, total)
292    }
293
294    /// Variant of [`Self::estimate_hnsw_search_cost_with_ef`] that takes the
295    /// effective collection size explicitly.
296    ///
297    /// Callers use this when the HNSW pass runs over a **subset** of the
298    /// collection — typically the surviving rows after a pre-filter. Modeling
299    /// the cost as `(ef + k) * log2(collection_size)` preserves the
300    /// logarithmic scaling HNSW actually exhibits, whereas multiplying the
301    /// full-collection cost by the filter selectivity would imply linear
302    /// scaling in the reduced size (Devin finding E on PR #606).
303    #[must_use]
304    pub fn estimate_hnsw_search_cost_with_ef_on_size(
305        &self,
306        ef_search: u32,
307        candidates: u32,
308        collection_size: u64,
309    ) -> Cost {
310        let total = collection_size.max(1) as f64;
311        let ef = f64::from(ef_search.max(1));
312        let k = f64::from(candidates.max(1));
313        let probe = (ef + k) * total.log2().max(1.0);
314        self.hnsw_cost_from_probe(probe)
315    }
316
317    /// Applies calibrated I/O and CPU weights to a raw HNSW probe count.
318    ///
319    /// Single source of truth for the `Cost::new(probe * io_w, probe * cpu_w)`
320    /// formula used by every HNSW cost helper — avoids duplicating the
321    /// factor-ratio resolution in three places.
322    pub(super) fn hnsw_cost_from_probe(&self, probe: f64) -> Cost {
323        let f = self.factors.get();
324        let d = default_factors();
325        let io_ratio = f.random_page_cost / d.random_page_cost;
326        let cpu_ratio = f.cpu_distance_cost / d.cpu_distance_cost;
327        Cost::new(
328            probe * COMPAT_HNSW_IO * io_ratio,
329            probe * COMPAT_HNSW_CPU * cpu_ratio,
330        )
331    }
332
333    #[must_use]
334    /// Estimates predicate selectivity in the `[0.0, 1.0]` range.
335    ///
336    /// Dispatches on every `Condition` variant — no catch-all. Comparison,
337    /// In, Between, and Like use histogram data when available; text/geo
338    /// predicates return explicit heuristic constants; compound predicates
339    /// use product (AND), inclusion-exclusion (OR), and complement (NOT).
340    pub fn estimate_condition_selectivity(&self, condition: &Condition) -> f64 {
341        match condition {
342            Condition::Comparison(cmp) => self.estimate_comparison_selectivity_with_histogram(
343                &cmp.column,
344                cmp.operator,
345                &cmp.value,
346            ),
347            Condition::In(cond) => {
348                self.estimate_in_selectivity(&cond.column, &cond.values, cond.negated)
349            }
350            Condition::Between(cond) => {
351                self.estimate_between_selectivity(&cond.column, &cond.low, &cond.high)
352            }
353            Condition::Like(cond) => self.estimate_like_selectivity(&cond.column, &cond.pattern),
354            Condition::IsNull(cond) => self
355                .stats
356                .field_stats
357                .get(cond.column.as_str())
358                .map_or(0.1, |s| {
359                    s.null_count as f64 / self.stats.total_points.max(1) as f64
360                }),
361            Condition::Match(_) | Condition::Contains(_) | Condition::GeoDistance(_) => 0.1,
362            Condition::ContainsText(_) => 0.05,
363            Condition::GeoBbox(_) => 0.2,
364            Condition::GraphMatch(_) => 0.5,
365            Condition::And(left, right) => {
366                self.estimate_condition_selectivity(left)
367                    * self.estimate_condition_selectivity(right)
368            }
369            Condition::Or(left, right) => {
370                let l = self.estimate_condition_selectivity(left);
371                let r = self.estimate_condition_selectivity(right);
372                (l + r - (l * r)).clamp(0.0, 1.0)
373            }
374            Condition::Not(inner) => 1.0 - self.estimate_condition_selectivity(inner),
375            Condition::Group(inner) => self.estimate_condition_selectivity(inner),
376            Condition::VectorSearch(_)
377            | Condition::VectorFusedSearch(_)
378            | Condition::SparseVectorSearch(_)
379            | Condition::Similarity(_) => 1.0,
380        }
381    }
382
383    /// Estimates selectivity for a `Comparison` condition using histogram data.
384    ///
385    /// Dispatches on `CompareOp`: Eq → histogram equality, NotEq → complement,
386    /// Lt/Lte/Gt/Gte → histogram less-than with appropriate adjustments.
387    /// Falls back to `CollectionStats::estimate_selectivity()` when no histogram
388    /// is available or the value cannot be converted to `f64`.
389    pub(super) fn estimate_comparison_selectivity_with_histogram(
390        &self,
391        column: &str,
392        op: CompareOp,
393        value: &Value,
394    ) -> f64 {
395        // Parameter values are unknown at plan time — use heuristic.
396        if matches!(value, Value::Parameter(_)) {
397            return 0.1;
398        }
399
400        let Some(v) = value_to_f64(value) else {
401            return self.stats.estimate_selectivity(column);
402        };
403
404        let Some(hist) = self.get_histogram(column) else {
405            return self.stats.estimate_selectivity(column);
406        };
407
408        let sel = match op {
409            CompareOp::Eq => hist.estimate_eq_selectivity(v),
410            CompareOp::NotEq => 1.0 - hist.estimate_eq_selectivity(v),
411            CompareOp::Lt => hist.estimate_lt_selectivity(v),
412            CompareOp::Lte => hist.estimate_lt_selectivity(next_after(v)),
413            CompareOp::Gt => 1.0 - hist.estimate_lt_selectivity(next_after(v)),
414            CompareOp::Gte => 1.0 - hist.estimate_lt_selectivity(v),
415        };
416        sel.clamp(0.0, 1.0)
417    }
418
419    /// Estimates selectivity for a `Between` condition using histogram range.
420    ///
421    /// Converts low/high to `f64` and delegates to `Histogram::estimate_range_selectivity`.
422    /// Falls back to `0.3` when no histogram is available or conversion fails.
423    pub(super) fn estimate_between_selectivity(
424        &self,
425        column: &str,
426        low: &Value,
427        high: &Value,
428    ) -> f64 {
429        let (Some(low_f), Some(high_f)) = (value_to_f64(low), value_to_f64(high)) else {
430            return 0.3;
431        };
432
433        match self.get_histogram(column) {
434            // BETWEEN is inclusive on both ends (low <= x <= high).
435            // Use next_after(high_f) so bucket_range_fraction includes values
436            // at the exact upper boundary — consistent with CompareOp::Lte.
437            Some(h) => h.estimate_range_selectivity(low_f, next_after(high_f)),
438            None => 0.3,
439        }
440    }
441
442    /// Estimates selectivity for an `In` condition.
443    ///
444    /// Sums per-value equality selectivities via histogram lookups when available.
445    /// Falls back to `base_selectivity × list_size` without a histogram.
446    /// If negated (NOT IN), returns `1.0 - sel`.
447    pub(super) fn estimate_in_selectivity(
448        &self,
449        column: &str,
450        values: &[Value],
451        negated: bool,
452    ) -> f64 {
453        let sel = if let Some(h) = self.get_histogram(column) {
454            let numeric_sels: Vec<f64> = values
455                .iter()
456                .filter_map(value_to_f64)
457                .map(|v| h.estimate_eq_selectivity(v))
458                .collect();
459            if numeric_sels.is_empty() {
460                // All values are non-numeric (e.g. strings) — fall back to
461                // cardinality-based estimate so we don't silently return 0.0.
462                let base = self.stats.estimate_selectivity(column);
463                (base * values.len() as f64).clamp(0.0, 1.0)
464            } else {
465                let sum: f64 = numeric_sels.into_iter().sum();
466                sum.clamp(0.0, 1.0)
467            }
468        } else {
469            let base = self.stats.estimate_selectivity(column);
470            (base * values.len() as f64).clamp(0.0, 1.0)
471        };
472
473        if negated {
474            1.0 - sel
475        } else {
476            sel
477        }
478    }
479
480    /// Estimates the cost of applying a post-filter predicate to the
481    /// candidate set returned by an HNSW search (issue #609).
482    ///
483    /// Unlike [`Self::estimate_filter_cost_from_selectivity`] (which scales
484    /// with `total × selectivity`), the post-filter runs only on the set of
485    /// candidates HNSW explored, not on the full collection.
486    ///
487    /// # Choice of cardinality
488    ///
489    /// VelesDB's actual post-filter execution (`search_post_filter` +
490    /// `filter_and_hydrate` in `collection/search/vector_filter.rs`)
491    /// evaluates the predicate on an *oversampled* candidate set
492    /// (`compute_oversampled_k(k, filter) = clamp(k/selectivity, [k+10,
493    /// 10_000])`) and truncates to `k` **after** the filter. The predicate
494    /// therefore runs on more than `k` tuples in the common case — a
495    /// `k`-only model (naive "top-k post-filter") under-estimates the
496    /// cost for high-recall HNSW regimes (Devin review on PR #612).
497    ///
498    /// The model below uses `max(k, ef_search)` as the effective
499    /// predicate-evaluation cardinality. This is a conservative upper
500    /// bound on the HNSW-returned set size:
501    /// - when `k ≥ ef_search` (unusual), the predicate runs on `k`;
502    /// - when `ef_search > k` (typical: ef≈160, k≈10), it runs on
503    ///   up to `ef_search` candidates before truncation.
504    ///
505    /// The true oversampled count
506    /// (`clamp(k/selectivity, [k+10, 10_000])`) is not passed into the
507    /// planner — the planner does not know the execution-time selectivity
508    /// estimate that `compute_oversampled_k` uses. `max(k, ef_search)` is
509    /// the closest bound available at plan time.
510    ///
511    /// Returns a zero-I/O cost: the candidates are already in memory
512    /// after the HNSW pass, so no page reads are charged.
513    #[must_use]
514    pub fn estimate_post_filter_topk_cost(&self, k: u32, ef_search: u32) -> Cost {
515        let n = f64::from(k.max(ef_search).max(1));
516        let f = self.factors.get();
517        let d = default_factors();
518        let cpu_ratio = f.cpu_tuple_cost / d.cpu_tuple_cost;
519        // Reason: max(k, ef_search) × default cpu_tuple_cost scaled by
520        // ratio to the calibrated factor — the physical reality of
521        // evaluating a predicate on the HNSW candidate set before top-k
522        // truncation (see `search_post_filter` + `filter_and_hydrate`).
523        Cost::new(0.0, n * d.cpu_tuple_cost * cpu_ratio)
524    }
525
526    /// Estimates filter cost from an already-computed selectivity value.
527    ///
528    /// Useful when the caller has a pre-computed selectivity (e.g. from
529    /// `estimate_condition_selectivity` or a heuristic) and wants to translate
530    /// it into a calibrated cost without building a `Condition` AST.
531    ///
532    /// Uses the same backward-compatible formula as `estimate_filter_cost`.
533    #[must_use]
534    pub fn estimate_filter_cost_from_selectivity(&self, selectivity: f64) -> Cost {
535        let sel = selectivity.clamp(0.0, 1.0);
536        let total = self.stats.total_points.max(self.stats.row_count) as f64;
537        let scan_rows = (total * sel).max(1.0);
538
539        let f = self.factors.get();
540        let d = default_factors();
541        let io_ratio = f.seq_page_cost / d.seq_page_cost;
542        let cpu_ratio = f.cpu_tuple_cost / d.cpu_tuple_cost;
543
544        Cost::new(
545            scan_rows * COMPAT_FILTER_IO * io_ratio,
546            scan_rows * COMPAT_FILTER_CPU * cpu_ratio,
547        )
548    }
549
550    /// Estimates selectivity for a `Like` condition.
551    ///
552    /// Prefix patterns (ending with `%`, not starting with `%`) use histogram
553    /// range estimation on the ordinal prefix range when available.
554    /// Non-prefix patterns return `0.05`.
555    pub(super) fn estimate_like_selectivity(&self, column: &str, pattern: &str) -> f64 {
556        let is_prefix = pattern.ends_with('%') && !pattern.starts_with('%');
557        if !is_prefix {
558            return 0.05;
559        }
560
561        let Some(_hist) = self.get_histogram(column) else {
562            return 0.1;
563        };
564
565        // For string columns the histogram is built on ordinal ranks.
566        // A prefix pattern 'abc%' matches a contiguous range of ordinal
567        // values. Without the full string→rank mapping at plan time we
568        // approximate: the prefix covers roughly 1/distinct_count of the
569        // domain, scaled by the number of buckets that span that range.
570        // This is more accurate than the previous 1/bucket_count heuristic.
571        let distinct = self
572            .stats
573            .column_stats
574            .get(column)
575            .or_else(|| self.stats.field_stats.get(column))
576            .map_or(1, |cs| cs.distinct_count.max(1));
577        (1.0 / distinct as f64).clamp(0.01, 1.0)
578    }
579}