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}