Skip to main content

linkrs_query/optimizer/cost/
selectivity.rs

1//! Selective Estimator Module
2//!
3//! Used to estimate the selectivity of the query conditions
4
5use std::sync::Arc;
6
7use crate::optimizer::stats::feedback::selectivity::{
8    SelectivityFeedbackManager, SelectivitySnapshot,
9};
10use crate::optimizer::stats::{RangeCondition, StatisticsManager};
11use linkrs_core::types::BinaryOperator;
12use linkrs_core::types::Expression;
13use linkrs_core::value::Value;
14
15/// Build the normalized condition key for a predicate expression.
16///
17/// The key is used by the statistics feedback loop to attach
18/// estimated-vs-actual row ratios to a specific predicate. Literals and
19/// numbers are replaced with placeholders so that structurally identical
20/// predicates with different constants aggregate to the same key. When a
21/// space is present the key is prefixed with `"{space}:"` so corrections are
22/// scoped to a space and can be invalidated per space.
23///
24/// Ordered range comparisons additionally carry a coarse bound bucket
25/// (`~8e2`): selectivity is a function of the bound, so `value < 800` must
26/// not share corrections with `value < 10`, while nearby bounds
27/// (`value < 801`) still aggregate. Equality and other shapes keep the
28/// purely structural key.
29pub fn condition_key(space: Option<&str>, expr: &Expression) -> String {
30    let normalized =
31        crate::optimizer::stats::feedback::fingerprint::normalize_query(&format!("{:?}", expr));
32    let key = match space {
33        Some(space) => format!("{}:{}", space, normalized),
34        None => normalized,
35    };
36    match range_bound_bucket(expr) {
37        Some(bucket) => format!("{key}~{bucket}"),
38        None => key,
39    }
40}
41
42/// Coarse bound bucket for ordered property-vs-literal comparisons.
43///
44/// Quantizes the literal to one significant digit so structurally identical
45/// range predicates with very different bounds learn separately while
46/// nearby bounds still share. Returns `None` for non-ordered shapes
47/// (equality keeps the structural key) and non-numeric bounds.
48fn range_bound_bucket(expr: &Expression) -> Option<String> {
49    let Expression::Binary { op, left, right } = expr else {
50        return None;
51    };
52    if !matches!(
53        op,
54        BinaryOperator::LessThan
55            | BinaryOperator::LessThanOrEqual
56            | BinaryOperator::GreaterThan
57            | BinaryOperator::GreaterThanOrEqual
58    ) {
59        return None;
60    }
61    let bound = [left.as_ref(), right.as_ref()]
62        .into_iter()
63        .find_map(|side| match side {
64            Expression::Literal(value) => numeric_as_f64(value),
65            _ => None,
66        })?;
67    let other_is_property = [left.as_ref(), right.as_ref()].iter().any(|side| {
68        matches!(
69            side,
70            Expression::Property { .. }
71                | Expression::TagProperty { .. }
72                | Expression::EdgeProperty { .. }
73                | Expression::LabelTagProperty { .. }
74        )
75    });
76    if !other_is_property {
77        return None;
78    }
79    Some(format!("bound{}", quantize_bound(bound)))
80}
81
82/// Quantize a bound to one significant digit (`800` and `801` share
83/// `8e2`; `10` maps to `1e1`).
84fn quantize_bound(bound: f64) -> String {
85    if bound == 0.0 {
86        return "0".to_string();
87    }
88    if !bound.is_finite() {
89        return "nonfinite".to_string();
90    }
91    let sign = if bound < 0.0 { "-" } else { "" };
92    let abs = bound.abs();
93    let mut exponent = abs.log10().floor() as i32;
94    let mut base = (abs / 10f64.powi(exponent)).floor() as i32;
95    // Floating-point edge at exact powers of ten (e.g. 1000.0 may
96    // quantize as base 10): renormalize instead of emitting `10eN`.
97    if base >= 10 {
98        base = 1;
99        exponent += 1;
100    }
101    format!("{sign}{base}e{exponent}")
102}
103
104/// Selective Estimator
105///
106/// Provide selective estimates based on statistical information and heuristic rules.
107#[derive(Debug)]
108pub struct SelectivityEstimator {
109    stats_manager: Arc<StatisticsManager>,
110    /// Optional feedback-driven correction source (stats
111    /// feedback loop). When present, `estimate_from_expression` prefers the
112    /// corrected selectivity for a known condition key over the histogram /
113    /// heuristic estimate and registers newly estimated conditions.
114    feedback: Option<Arc<SelectivityFeedbackManager>>,
115}
116
117/// Default selective constants
118pub mod defaults {
119    /// Default selectivity for equivalent queries (assuming 10 different values)
120    pub const EQUALITY: f64 = 0.1;
121    /// The default selectivity for range queries is such that approximately one-third of the data is selected.
122    pub const RANGE: f64 = 0.333;
123    /// The default selectivity for the “less than/greater than” query
124    pub const COMPARISON: f64 = 0.333;
125    /// The default selectivity of inequality queries
126    pub const NOT_EQUAL: f64 = 0.9;
127    /// The selectivity of IS NULL queries (which usually rarely return a value of NULL)
128    pub const IS_NULL: f64 = 0.05;
129    /// The selectivity of the IS NOT NULL query
130    pub const IS_NOT_NULL: f64 = 0.95;
131    /// The default selectivity of an IN query (assuming 3 values)
132    pub const IN_LIST: f64 = 0.3;
133    /// The SELECTIVE nature of the EXISTS query
134    pub const EXISTS: f64 = 0.5;
135    /// The selective penalty of the Boolean AND operation
136    pub const AND_CORRELATION: f64 = 0.9;
137    /// The selective penalty for the Boolean OR operation
138    pub const OR_CORRELATION: f64 = 0.9;
139}
140
141impl SelectivityEstimator {
142    /// Create a new selective estimator.
143    pub fn new(stats_manager: Arc<StatisticsManager>) -> Self {
144        Self {
145            stats_manager,
146            feedback: None,
147        }
148    }
149
150    /// Create a selective estimator wired to a feedback correction manager.
151    ///
152    /// When feedback is enabled, `estimate_from_expression` consults the
153    /// corrected selectivity for the normalized condition key first and
154    /// registers newly estimated conditions for later correction.
155    pub fn with_feedback(
156        stats_manager: Arc<StatisticsManager>,
157        feedback: Arc<SelectivityFeedbackManager>,
158    ) -> Self {
159        Self {
160            stats_manager,
161            feedback: Some(feedback),
162        }
163    }
164
165    /// Return the underlying feedback correction manager, if any.
166    pub fn feedback_manager(&self) -> Option<&Arc<SelectivityFeedbackManager>> {
167        self.feedback.as_ref()
168    }
169
170    /// Capture the learned corrections for one planning decision to read
171    /// through. Empty when no feedback manager is attached.
172    pub fn snapshot(&self) -> SelectivitySnapshot {
173        self.feedback
174            .as_ref()
175            .map(|feedback| feedback.snapshot())
176            .unwrap_or_default()
177    }
178
179    /// Estimate a predicate without touching shared feedback state.
180    ///
181    /// Unlike [`estimate_from_expression`](Self::estimate_from_expression),
182    /// this neither registers the condition nor refreshes its baseline: the
183    /// snapshotted correction (if any) applies to the fresh estimate, so
184    /// repeated calls stay identical until execution feedback lands.
185    pub fn estimate_readonly(
186        &self,
187        space: Option<&str>,
188        expr: &Expression,
189        tag_name: Option<&str>,
190        snapshot: &SelectivitySnapshot,
191    ) -> f64 {
192        self.estimate_inner(space, expr, tag_name, Some(snapshot))
193    }
194
195    /// Estimated Equivalence Condition Selectivity
196    ///
197    /// If there is histogram statistical information, use the histogram for an accurate estimation.
198    /// Otherwise, if basic statistical information is available, use 1 divided by the number of distinct values.
199    /// Otherwise the default value of 0.1 is used
200    pub fn estimate_equality_selectivity(
201        &self,
202        space: Option<&str>,
203        tag_name: Option<&str>,
204        property_name: &str,
205        value: Option<&Value>,
206    ) -> f64 {
207        let stats = space.and_then(|s| {
208            self.stats_manager
209                .get_property_stats(s, tag_name, property_name)
210        });
211
212        match stats {
213            Some(s) => {
214                // Give priority to using histograms for accurate estimates.
215                if s.should_use_histogram() {
216                    if let Some(ref histogram) = s.histogram {
217                        if let Some(v) = value {
218                            return histogram.estimate_equality_selectivity(v);
219                        }
220                    }
221                }
222
223                // Return to basic statistical information
224                if s.distinct_values > 0 {
225                    (1.0 / s.distinct_values as f64).min(1.0)
226                } else {
227                    defaults::EQUALITY
228                }
229            }
230            _ => defaults::EQUALITY,
231        }
232    }
233
234    /// Estimated Equivalence Condition Selectivity (simplified version, without values)
235    pub fn estimate_equality_selectivity_simple(
236        &self,
237        space: Option<&str>,
238        tag_name: Option<&str>,
239        property_name: &str,
240    ) -> f64 {
241        self.estimate_equality_selectivity(space, tag_name, property_name, None)
242    }
243
244    /// Conditional selectivity of the estimation range
245    ///
246    /// If there is histogram statistical information, the calculations are based on that histogram.
247    /// Otherwise, use the default value of 1/3.
248    pub fn estimate_range_selectivity(
249        &self,
250        space: Option<&str>,
251        tag_name: Option<&str>,
252        property_name: &str,
253        range: &RangeCondition,
254    ) -> f64 {
255        let stats = space.and_then(|s| {
256            self.stats_manager
257                .get_property_stats(s, tag_name, property_name)
258        });
259
260        match stats {
261            Some(s) => {
262                // Give priority to using histograms for accurate estimations.
263                if s.should_use_histogram() {
264                    if let Some(ref histogram) = s.histogram {
265                        return histogram.estimate_range_selectivity(range);
266                    }
267                }
268
269                // Revert to the default values.
270                defaults::RANGE
271            }
272            _ => defaults::RANGE,
273        }
274    }
275
276    /// Conditional Selectivity of Estimation Ranges (with Boundary Values)
277    ///
278    /// Adjust the selectivity based on the size of the scope.
279    pub fn estimate_range_selectivity_with_bounds(
280        &self,
281        min_val: f64,
282        max_val: f64,
283        range_size: f64,
284    ) -> f64 {
285        if max_val <= min_val {
286            return defaults::RANGE;
287        }
288        let total_range = max_val - min_val;
289        let selectivity = (range_size / total_range).clamp(0.001, 1.0);
290        // Range queries usually do not retrieve a large amount of data; adding an upper limit helps to control the amount of data retrieved.
291        selectivity.min(0.8)
292    }
293
294    /// Range-comparison selectivity from the sampled min/max envelope.
295    ///
296    /// Linear interpolation over `[min, max]` assuming uniform distribution:
297    /// `prop > bound` covers `(max - bound) / (max - min)` of the range.
298    /// Falls back to the fixed heuristics when no envelope is available.
299    pub fn estimate_ordered_comparison(
300        &self,
301        space: Option<&str>,
302        tag_name: Option<&str>,
303        property_name: &str,
304        bound: Option<f64>,
305        greater_than: bool,
306    ) -> f64 {
307        let Some(bound) = bound else {
308            return if greater_than {
309                self.estimate_greater_than_selectivity(None)
310            } else {
311                self.estimate_less_than_selectivity(None)
312            };
313        };
314        let Some(stats) = space.and_then(|s| {
315            self.stats_manager
316                .get_property_stats(s, tag_name, property_name)
317        }) else {
318            return if greater_than {
319                self.estimate_greater_than_selectivity(Some(bound))
320            } else {
321                self.estimate_less_than_selectivity(Some(bound))
322            };
323        };
324        let (Some(min), Some(max)) = (
325            stats.min_value.as_ref().and_then(numeric_as_f64),
326            stats.max_value.as_ref().and_then(numeric_as_f64),
327        ) else {
328            return if greater_than {
329                self.estimate_greater_than_selectivity(Some(bound))
330            } else {
331                self.estimate_less_than_selectivity(Some(bound))
332            };
333        };
334        let span = max - min;
335        if !span.is_finite() || span <= 0.0 {
336            // Single-valued column: the comparison either keeps everything or
337            // almost nothing.
338            let keeps_all = if greater_than {
339                bound < min
340            } else {
341                bound > max
342            };
343            return if keeps_all { 0.95 } else { 0.01 };
344        }
345        let fraction = if greater_than {
346            (max - bound) / span
347        } else {
348            (bound - min) / span
349        };
350        fraction.clamp(0.001, 0.999).min(0.8)
351    }
352
353    /// Estimated to be less than conditional selectivity
354    ///
355    /// If there is statistical information available, the calculations are based on histograms.
356    /// Otherwise, assume that the data is evenly distributed, and return 1/3.
357    pub fn estimate_less_than_selectivity(&self, value: Option<f64>) -> f64 {
358        // If there are specific values, you can try to adjust the settings based on the distribution of those values.
359        // A simple heuristic is used here: assume that the data is evenly distributed.
360        match value {
361            Some(v) if v < 0.0 => 0.1, // Negative values are generally less common.
362            Some(0.0) => 0.05,         // Zero values are usually very rare.
363            _ => defaults::COMPARISON,
364        }
365    }
366
367    /// The estimate is greater than the condition selectivity.
368    pub fn estimate_greater_than_selectivity(&self, value: Option<f64>) -> f64 {
369        match value {
370            Some(v) if v < 0.0 => 0.9, // When comparing values, it is generally preferable to choose the option that represents the majority of the data. In this case, since “greater than a negative value” indicates a positive value, that option would correspond to the majority of the data points in the dataset.
371            Some(0.0) => 0.95, // When the value is greater than zero, it usually indicates that the majority of the data falls into that category.
372            _ => defaults::COMPARISON,
373        }
374    }
375
376    /// Estimating the selectivity of LIKE conditions
377    ///
378    /// Adjust the selectivity based on the prefix and suffix wildcards of the pattern:
379    /// - prefix%: high selectivity (about 0.1)
380    /// - %suffix: medium selectivity (about 0.2)
381    /// - %substring%: less selective (about 0.5)
382    /// - No wildcard: exact match (about 0.05)
383    pub fn estimate_like_selectivity(&self, pattern: &str) -> f64 {
384        let has_prefix = pattern.starts_with('%');
385        let has_suffix = pattern.ends_with('%');
386        let middle_wildcards = pattern.matches('%').count() + pattern.matches('_').count();
387
388        match (has_prefix, has_suffix) {
389            (true, true) => {
390                // The selectivity of the %xxx% mode is very low.
391                0.5_f64.min(0.1 + middle_wildcards as f64 * 0.1)
392            }
393            (false, true) => {
394                // The prefix matching with a percentage of xxx% has a relatively high degree of selectivity.
395                0.1_f64.min(0.05 + middle_wildcards as f64 * 0.02)
396            }
397            (true, false) => {
398                // The matching accuracy for suffixes with the “%xxx” pattern is moderately high.
399                0.2_f64.min(0.1 + middle_wildcards as f64 * 0.05)
400            }
401            (false, false) => {
402                // No wildcards; the match is nearly exact.
403                0.05
404            }
405        }
406    }
407
408    /// Estimating the selectivity of the IN list
409    ///
410    /// Assuming that the selectivity of each value is the same, the total selectivity = number of values * selectivity of a single value.
411    pub fn estimate_in_selectivity(&self, list_size: usize) -> f64 {
412        let single_selectivity = defaults::EQUALITY;
413        (list_size as f64 * single_selectivity).min(0.9)
414    }
415
416    /// Estimated IS NULL selectivity
417    pub fn estimate_is_null_selectivity(&self) -> f64 {
418        defaults::IS_NULL
419    }
420
421    /// The “IS NOT NULL” condition is used for selection purposes.
422    pub fn estimate_is_not_null_selectivity(&self) -> f64 {
423        defaults::IS_NOT_NULL
424    }
425
426    /// Estimated NOT conditional selectivity
427    ///
428    /// The selectivity of the NOT condition = 1 – the selectivity of the original condition
429    pub fn estimate_not_selectivity(&self, inner_selectivity: f64) -> f64 {
430        (1.0 - inner_selectivity).clamp(0.01, 0.99)
431    }
432
433    /// Estimating selectivity from expressions
434    ///
435    /// This is the main entry method, which distributes the data to the specific estimation methods based on the type of the expression.
436    ///
437    /// When a feedback correction manager is attached, the normalized
438    /// condition key is looked up first: a registered correction overrides
439    /// the histogram / heuristic estimate. Otherwise the estimate is
440    /// computed and registered so later executions can correct it.
441    pub fn estimate_from_expression(
442        &self,
443        space: Option<&str>,
444        expr: &Expression,
445        tag_name: Option<&str>,
446    ) -> f64 {
447        self.estimate_inner(space, expr, tag_name, None)
448    }
449
450    /// Shared estimate body. `None` snapshot is the recording path used by
451    /// the row estimators (the feedback loop's write path); `Some` is the
452    /// read-only path used by planning decisions.
453    fn estimate_inner(
454        &self,
455        space: Option<&str>,
456        expr: &Expression,
457        tag_name: Option<&str>,
458        snapshot: Option<&SelectivitySnapshot>,
459    ) -> f64 {
460        let key = condition_key(space, expr);
461        let estimated = match expr {
462            Expression::Binary { op, left, right } => {
463                self.estimate_binary_expression(space, op, left, right, tag_name, snapshot)
464            }
465            Expression::Unary { op, operand } => {
466                self.estimate_unary_expression(space, op, operand, tag_name, snapshot)
467            }
468            Expression::Function { name, args } => {
469                let exprs: Vec<Expression> = args.iter().map(|a| a.as_expr().clone()).collect();
470                self.estimate_function_expression(space, tag_name, name, &exprs)
471            }
472            Expression::Literal(_) => {
473                // The selectivity of literal value conditions depends on the specific values; such conditions are generally considered to be highly selective.
474                0.1
475            }
476            Expression::Property { .. } => {
477                // attribute itself as a condition (e.g. WHERE n.active)
478                // Assume that approximately half of the boolean attributes have the value “true”.
479                0.5
480            }
481            _ => defaults::EQUALITY,
482        };
483
484        match snapshot {
485            // Recording path: register the estimate as the feedback baseline
486            // on first sight and keep tracking fresh statistics afterwards; a
487            // learned correction overrides the fresh estimate when present.
488            None => {
489                if let Some(feedback) = &self.feedback {
490                    feedback.register_condition(key.clone(), estimated);
491                    feedback.refresh_estimated(&key, estimated);
492                    if let Some(corrected) = feedback.get_corrected_selectivity(&key) {
493                        return corrected;
494                    }
495                }
496                estimated
497            }
498            // Read-only path: apply the snapshotted correction without
499            // registering or refreshing anything.
500            Some(snapshot) => snapshot.corrected(&key, estimated).unwrap_or(estimated),
501        }
502    }
503
504    /// Estimating the selectivity of binary expressions
505    fn estimate_binary_expression(
506        &self,
507        space: Option<&str>,
508        op: &BinaryOperator,
509        left: &Expression,
510        right: &Expression,
511        tag_name: Option<&str>,
512        snapshot: Option<&SelectivitySnapshot>,
513    ) -> f64 {
514        match op {
515            BinaryOperator::Equal => {
516                // Try to extract the attribute names and values from the expression.
517                let property_name = self
518                    .extract_property_name(left)
519                    .or_else(|| self.extract_property_name(right));
520
521                // Try to extract the value.
522                let value = self
523                    .extract_value(right)
524                    .or_else(|| self.extract_value(left));
525
526                if let Some(prop) = property_name {
527                    self.estimate_equality_selectivity(space, tag_name, &prop, value.as_ref())
528                } else {
529                    defaults::EQUALITY
530                }
531            }
532            BinaryOperator::NotEqual => {
533                // Inequality queries usually select the majority of the data.
534                defaults::NOT_EQUAL
535            }
536            BinaryOperator::LessThan => {
537                let value = self.extract_numeric_value(right);
538                if let Some(prop) = self.extract_property_name(left) {
539                    self.estimate_ordered_comparison(space, tag_name, &prop, value, false)
540                } else {
541                    self.estimate_less_than_selectivity(value)
542                }
543            }
544            BinaryOperator::LessThanOrEqual => {
545                let value = self.extract_numeric_value(right);
546                if let Some(prop) = self.extract_property_name(left) {
547                    self.estimate_ordered_comparison(space, tag_name, &prop, value, false)
548                        .clamp(0.01, 0.9)
549                } else {
550                    self.estimate_less_than_selectivity(value).clamp(0.01, 0.9)
551                }
552            }
553            BinaryOperator::GreaterThan => {
554                let value = self.extract_numeric_value(right);
555                if let Some(prop) = self.extract_property_name(left) {
556                    self.estimate_ordered_comparison(space, tag_name, &prop, value, true)
557                } else {
558                    self.estimate_greater_than_selectivity(value)
559                }
560            }
561            BinaryOperator::GreaterThanOrEqual => {
562                let value = self.extract_numeric_value(right);
563                if let Some(prop) = self.extract_property_name(left) {
564                    self.estimate_ordered_comparison(space, tag_name, &prop, value, true)
565                        .clamp(0.01, 0.9)
566                } else {
567                    self.estimate_greater_than_selectivity(value)
568                        .clamp(0.01, 0.9)
569                }
570            }
571            BinaryOperator::And => {
572                let left_sel = self.estimate_inner(space, left, tag_name, snapshot);
573                let right_sel = self.estimate_inner(space, right, tag_name, snapshot);
574                // The selectivity of the “AND” operator is usually slightly higher than that of the multiplication operator (because there may be correlations between the conditions).
575                (left_sel * right_sel / defaults::AND_CORRELATION).min(1.0)
576            }
577            BinaryOperator::Or => {
578                let left_sel = self.estimate_inner(space, left, tag_name, snapshot);
579                let right_sel = self.estimate_inner(space, right, tag_name, snapshot);
580                // OR selectivity: P(A or B) = P(A) + P(B) - P(A and B)
581                let combined =
582                    left_sel + right_sel - left_sel * right_sel * defaults::OR_CORRELATION;
583                combined.clamp(0.01, 0.99)
584            }
585            BinaryOperator::In => {
586                // Estimating the size of the IN list, narrowed by the
587                // property NDV when column statistics are available.
588                let list_size = self.estimate_list_size(right);
589                if let Some(prop) = self
590                    .extract_property_name(left)
591                    .or_else(|| self.extract_property_name(right))
592                {
593                    return self
594                        .estimate_in_selectivity_for_property(space, tag_name, &prop, list_size);
595                }
596                self.estimate_in_selectivity(list_size)
597            }
598            _ => defaults::EQUALITY,
599        }
600    }
601
602    /// Estimating the selectivity of a unary expression
603    fn estimate_unary_expression(
604        &self,
605        space: Option<&str>,
606        op: &linkrs_core::types::UnaryOperator,
607        expr: &Expression,
608        tag_name: Option<&str>,
609        snapshot: Option<&SelectivitySnapshot>,
610    ) -> f64 {
611        use linkrs_core::types::UnaryOperator;
612
613        match op {
614            UnaryOperator::Not => {
615                let inner = self.estimate_inner(space, expr, tag_name, snapshot);
616                self.estimate_not_selectivity(inner)
617            }
618            UnaryOperator::IsNull => self
619                .null_fraction_for(space, tag_name, expr)
620                .unwrap_or(defaults::IS_NULL),
621            UnaryOperator::IsNotNull => self
622                .null_fraction_for(space, tag_name, expr)
623                .map(|f| 1.0 - f)
624                .unwrap_or(defaults::IS_NOT_NULL),
625            _ => defaults::EQUALITY,
626        }
627    }
628
629    /// Null fraction for the column referenced by a unary operand, if tracked.
630    fn null_fraction_for(
631        &self,
632        space: Option<&str>,
633        tag_name: Option<&str>,
634        expr: &Expression,
635    ) -> Option<f64> {
636        let space = space?;
637        let property = self.extract_property_name(expr)?;
638        self.stats_manager
639            .get_property_stats(space, tag_name, &property)
640            .and_then(|s| s.null_fraction())
641    }
642
643    /// Equality selectivity floor for a property from collected NDV.
644    ///
645    /// Returns `0.0` when no usable statistics exist so callers can keep
646    /// their pattern heuristic untouched. Otherwise `1 / NDV`: a fuzzy or
647    /// prefix match cannot be more selective than the equality on the same
648    /// column, so the heuristic is floored (never shrunk) by this value.
649    /// This only corrects underestimation on low-cardinality columns; on
650    /// high-cardinality columns the pattern heuristic stays as the
651    /// conservative upper bound.
652    fn ndv_equality_floor(
653        &self,
654        space: Option<&str>,
655        tag_name: Option<&str>,
656        property: Option<&str>,
657    ) -> f64 {
658        let Some(prop) = property else {
659            return 0.0;
660        };
661        let Some(space) = space else {
662            return 0.0;
663        };
664        self.stats_manager
665            .get_property_stats(space, tag_name, prop)
666            .map(|s| s.distinct_values)
667            .filter(|ndv| *ndv > 0)
668            .map(|ndv| 1.0 / ndv as f64)
669            .unwrap_or(0.0)
670    }
671
672    /// NDV-aware IN-list selectivity for one property column.
673    ///
674    /// `list_size / NDV` capped at 0.9 when statistics exist; otherwise the
675    /// fixed per-value heuristic.
676    fn estimate_in_selectivity_for_property(
677        &self,
678        space: Option<&str>,
679        tag_name: Option<&str>,
680        property_name: &str,
681        list_size: usize,
682    ) -> f64 {
683        if let Some(space) = space {
684            if let Some(stats) =
685                self.stats_manager
686                    .get_property_stats(space, tag_name, property_name)
687            {
688                if stats.distinct_values > 0 {
689                    return (list_size as f64 / stats.distinct_values as f64).clamp(0.001, 0.9);
690                }
691            }
692        }
693        self.estimate_in_selectivity(list_size)
694    }
695
696    /// Estimating the selectivity of function expressions
697    fn estimate_function_expression(
698        &self,
699        space: Option<&str>,
700        tag_name: Option<&str>,
701        name: &str,
702        args: &[Expression],
703    ) -> f64 {
704        let name_lower = name.to_lowercase();
705        let first_property = args.first().and_then(|a| self.extract_property_name(a));
706
707        match name_lower.as_str() {
708            "like" | "ilike" if args.len() >= 2 => {
709                // Try to extract the LIKE pattern.
710                if let Expression::Literal(linkrs_core::value::Value::String(pattern)) = &args[1] {
711                    let heuristic = self.estimate_like_selectivity(pattern);
712                    let floor = self.ndv_equality_floor(space, tag_name, first_property.as_deref());
713                    return heuristic.max(floor);
714                }
715                defaults::EQUALITY
716            }
717            "exists" => defaults::EXISTS,
718            "contains" | "has" => {
719                let floor = self.ndv_equality_floor(space, tag_name, first_property.as_deref());
720                0.2_f64.max(floor)
721            }
722            "starts_with" => {
723                let floor = self.ndv_equality_floor(space, tag_name, first_property.as_deref());
724                0.1_f64.max(floor)
725            }
726            "ends_with" => {
727                let floor = self.ndv_equality_floor(space, tag_name, first_property.as_deref());
728                0.2_f64.max(floor)
729            }
730            "in" => {
731                let list_size = args.len().saturating_sub(1);
732                if let Some(prop) = first_property {
733                    return self
734                        .estimate_in_selectivity_for_property(space, tag_name, &prop, list_size);
735                }
736                self.estimate_in_selectivity(list_size)
737            }
738            _ => defaults::EQUALITY,
739        }
740    }
741
742    /// Extract the attribute names from the expression.
743    fn extract_property_name(&self, expr: &Expression) -> Option<String> {
744        match expr {
745            Expression::Property { property, .. } => Some(property.clone()),
746            _ => None,
747        }
748    }
749
750    /// Extract the numerical values from the expression.
751    fn extract_numeric_value(&self, expr: &Expression) -> Option<f64> {
752        match expr {
753            Expression::Literal(value) => numeric_as_f64(value),
754            _ => None,
755        }
756    }
757
758    /// Extract values from the expression.
759    fn extract_value(&self, expr: &Expression) -> Option<Value> {
760        match expr {
761            Expression::Literal(value) => Some(value.clone()),
762            _ => None,
763        }
764    }
765
766    /// Estimate the list size
767    fn estimate_list_size(&self, expr: &Expression) -> usize {
768        match expr {
769            Expression::List(items) => items.len(),
770            _ => 3, // The default assumption is that there are 3 elements.
771        }
772    }
773}
774
775/// Numeric projection shared by expression literals and stored min/max
776/// envelope values.
777fn numeric_as_f64(value: &Value) -> Option<f64> {
778    match value {
779        Value::SmallInt(i) => Some(*i as f64),
780        Value::Int(i) => Some(*i as f64),
781        Value::BigInt(i) => Some(*i as f64),
782        Value::Float(f) => Some((*f).into()),
783        Value::Double(f) => Some(*f),
784        _ => None,
785    }
786}
787
788impl Clone for SelectivityEstimator {
789    fn clone(&self) -> Self {
790        Self {
791            stats_manager: self.stats_manager.clone(),
792            feedback: self.feedback.clone(),
793        }
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use crate::optimizer::stats::PropertyStatistics;
801
802    fn estimator_with_envelope(
803        space: &str,
804        tag: &str,
805        prop: &str,
806        min: i32,
807        max: i32,
808    ) -> SelectivityEstimator {
809        let manager = Arc::new(StatisticsManager::new());
810        let mut stat = PropertyStatistics::new(prop.to_string(), Some(tag.to_string()));
811        stat.observe_value(&Value::Int(min));
812        stat.observe_value(&Value::Int(max));
813        manager.update_property_stats(space, stat);
814        SelectivityEstimator::new(manager)
815    }
816
817    fn comparison_expr(prop: &str, bound: i64, op: BinaryOperator) -> Expression {
818        Expression::Binary {
819            op,
820            left: Box::new(Expression::Property {
821                object: Box::new(Expression::Variable("n".to_string())),
822                property: prop.to_string(),
823            }),
824            right: Box::new(Expression::Literal(Value::BigInt(bound))),
825        }
826    }
827
828    #[test]
829    fn greater_than_interpolates_over_envelope() {
830        let est = estimator_with_envelope("s", "person", "age", 0, 100);
831        // age > 80 covers 20% of [0, 100].
832        let sel = est.estimate_from_expression(
833            Some("s"),
834            &comparison_expr("age", 80, BinaryOperator::GreaterThan),
835            Some("person"),
836        );
837        assert!((sel - 0.2).abs() < 0.01, "sel={sel}");
838    }
839
840    #[test]
841    fn less_than_beyond_max_is_nearly_total() {
842        let est = estimator_with_envelope("s", "person", "age", 0, 100);
843        let sel = est.estimate_from_expression(
844            Some("s"),
845            &comparison_expr("age", 500, BinaryOperator::LessThan),
846            Some("person"),
847        );
848        assert!(sel > 0.7, "sel={sel}");
849    }
850
851    #[test]
852    fn missing_envelope_falls_back_to_heuristic() {
853        let manager = Arc::new(StatisticsManager::new());
854        let est = SelectivityEstimator::new(manager.clone());
855        let sel = est.estimate_from_expression(
856            Some("s"),
857            &comparison_expr("unknown", 10, BinaryOperator::GreaterThan),
858            Some("person"),
859        );
860        assert!((sel - defaults::COMPARISON).abs() < 1e-9, "sel={sel}");
861    }
862
863    fn estimator_with_ndv(
864        space: &str,
865        tag: &str,
866        prop: &str,
867        distinct: u64,
868    ) -> SelectivityEstimator {
869        let manager = Arc::new(StatisticsManager::new());
870        let mut stat = PropertyStatistics::new(prop.to_string(), Some(tag.to_string()));
871        stat.distinct_values = distinct;
872        manager.update_property_stats(space, stat);
873        SelectivityEstimator::new(manager)
874    }
875
876    fn property_expr(prop: &str) -> Expression {
877        Expression::Property {
878            object: Box::new(Expression::Variable("n".to_string())),
879            property: prop.to_string(),
880        }
881    }
882
883    #[test]
884    fn in_list_uses_ndv_when_available() {
885        let est = estimator_with_ndv("s", "person", "age", 100);
886        let expr = Expression::Binary {
887            op: BinaryOperator::In,
888            left: Box::new(property_expr("age")),
889            right: Box::new(Expression::List(vec![
890                Expression::Literal(Value::Int(1)),
891                Expression::Literal(Value::Int(2)),
892                Expression::Literal(Value::Int(3)),
893            ])),
894        };
895        let sel = est.estimate_from_expression(Some("s"), &expr, Some("person"));
896        // 3 / 100 = 0.03, far below the fixed 0.3 heuristic.
897        assert!((sel - 0.03).abs() < 1e-9, "sel={sel}");
898    }
899
900    #[test]
901    fn in_list_without_stats_keeps_heuristic() {
902        let manager = Arc::new(StatisticsManager::new());
903        let est = SelectivityEstimator::new(manager);
904        let expr = Expression::Binary {
905            op: BinaryOperator::In,
906            left: Box::new(property_expr("age")),
907            right: Box::new(Expression::List(vec![
908                Expression::Literal(Value::Int(1)),
909                Expression::Literal(Value::Int(2)),
910                Expression::Literal(Value::Int(3)),
911            ])),
912        };
913        let sel = est.estimate_from_expression(Some("s"), &expr, Some("person"));
914        assert!((sel - 0.3).abs() < 1e-9, "sel={sel}");
915    }
916
917    #[test]
918    fn readonly_estimate_neither_registers_nor_refreshes() {
919        use std::sync::Arc;
920
921        let stats = Arc::new(StatisticsManager::new());
922        let feedback = Arc::new(SelectivityFeedbackManager::new());
923        let estimator =
924            SelectivityEstimator::with_feedback(Arc::clone(&stats), Arc::clone(&feedback));
925        let expr = Expression::Binary {
926            op: BinaryOperator::Equal,
927            left: Box::new(property_expr("age")),
928            right: Box::new(Expression::Literal(Value::Int(30))),
929        };
930
931        let pristine = estimator.snapshot();
932        assert!(pristine.is_empty());
933        let first = estimator.estimate_readonly(None, &expr, None, &pristine);
934        assert!((first - defaults::EQUALITY).abs() < 1e-9, "sel={first}");
935        assert!(
936            estimator.snapshot().is_empty(),
937            "read-only estimates must not register conditions"
938        );
939
940        let recorded = estimator.estimate_from_expression(None, &expr, None);
941        assert!((recorded - first).abs() < 1e-9, "recorded={recorded}");
942        assert_eq!(estimator.snapshot().len(), 1);
943
944        // Learned feedback applies through later snapshots only, and only
945        // after enough samples: a single update stays below the stability
946        // bar and keeps pricing the fresh estimate.
947        let key = condition_key(None, &expr);
948        feedback.update_feedback_ratio(&key, 2.0);
949        let unstable = estimator.snapshot();
950        assert_eq!(
951            estimator.estimate_readonly(None, &expr, None, &unstable),
952            first,
953            "single-sample corrections must not move planning reads"
954        );
955        feedback.update_feedback_ratio(&key, 2.0);
956        feedback.update_feedback_ratio(&key, 2.0);
957        let learned = estimator.snapshot();
958        let corrected = estimator.estimate_readonly(None, &expr, None, &learned);
959        // EWMA with alpha 0.3 over three 2.0 ratios: 1.0 -> 1.3 -> 1.51 -> 1.657.
960        let expected = (defaults::EQUALITY * 1.657).clamp(0.1 * defaults::EQUALITY, 0.99);
961        assert!(
962            (corrected - expected).abs() < 1e-9,
963            "corrected={corrected} expected={expected}"
964        );
965        assert_eq!(
966            estimator.estimate_readonly(None, &expr, None, &pristine),
967            first
968        );
969    }
970
971    #[test]
972    fn fuzzy_match_floored_by_ndv_on_low_cardinality() {
973        // Prefix heuristic is 0.1, but with only 2 distinct values the match
974        // cannot be more selective than the 0.5 equality floor.
975        let est = estimator_with_ndv("s", "person", "name", 2);
976        let expr = Expression::Function {
977            name: "starts_with".to_string(),
978            args: vec![linkrs_core::types::expr::FunctionArg::positional(
979                property_expr("name"),
980            )],
981        };
982        let sel = est.estimate_from_expression(Some("s"), &expr, Some("person"));
983        assert!((sel - 0.5).abs() < 1e-9, "sel={sel}");
984    }
985
986    fn lt_expr(bound: i32) -> Expression {
987        Expression::Binary {
988            op: BinaryOperator::LessThan,
989            left: Box::new(property_expr("value")),
990            right: Box::new(Expression::Literal(Value::Int(bound))),
991        }
992    }
993
994    #[test]
995    fn range_bound_bucket_separates_distant_bounds() {
996        // `value < 800` and `value < 801` share one bucket; `value < 10`
997        // learns separately so one bound's correction cannot leak into
998        // a selectivity regime it never measured.
999        let near_a = condition_key(Some("s"), &lt_expr(800));
1000        let near_b = condition_key(Some("s"), &lt_expr(801));
1001        let far = condition_key(Some("s"), &lt_expr(10));
1002        assert_eq!(near_a, near_b, "near_a={near_a} near_b={near_b}");
1003        assert_ne!(near_a, far, "near={near_a} far={far}");
1004        assert!(
1005            near_a.contains("~bound8e2"),
1006            "range key must carry its bucket: {near_a}"
1007        );
1008    }
1009
1010    #[test]
1011    fn equality_keeps_structural_key_without_bound_bucket() {
1012        let expr = Expression::Binary {
1013            op: BinaryOperator::Equal,
1014            left: Box::new(property_expr("age")),
1015            right: Box::new(Expression::Literal(Value::Int(30))),
1016        };
1017        let key = condition_key(Some("s"), &expr);
1018        assert!(
1019            !key.contains("~bound"),
1020            "equality must not gain a bound bucket: {key}"
1021        );
1022    }
1023}