1use 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
15pub 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
42fn 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
82fn 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 if base >= 10 {
98 base = 1;
99 exponent += 1;
100 }
101 format!("{sign}{base}e{exponent}")
102}
103
104#[derive(Debug)]
108pub struct SelectivityEstimator {
109 stats_manager: Arc<StatisticsManager>,
110 feedback: Option<Arc<SelectivityFeedbackManager>>,
115}
116
117pub mod defaults {
119 pub const EQUALITY: f64 = 0.1;
121 pub const RANGE: f64 = 0.333;
123 pub const COMPARISON: f64 = 0.333;
125 pub const NOT_EQUAL: f64 = 0.9;
127 pub const IS_NULL: f64 = 0.05;
129 pub const IS_NOT_NULL: f64 = 0.95;
131 pub const IN_LIST: f64 = 0.3;
133 pub const EXISTS: f64 = 0.5;
135 pub const AND_CORRELATION: f64 = 0.9;
137 pub const OR_CORRELATION: f64 = 0.9;
139}
140
141impl SelectivityEstimator {
142 pub fn new(stats_manager: Arc<StatisticsManager>) -> Self {
144 Self {
145 stats_manager,
146 feedback: None,
147 }
148 }
149
150 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 pub fn feedback_manager(&self) -> Option<&Arc<SelectivityFeedbackManager>> {
167 self.feedback.as_ref()
168 }
169
170 pub fn snapshot(&self) -> SelectivitySnapshot {
173 self.feedback
174 .as_ref()
175 .map(|feedback| feedback.snapshot())
176 .unwrap_or_default()
177 }
178
179 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 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 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 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 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 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 if s.should_use_histogram() {
264 if let Some(ref histogram) = s.histogram {
265 return histogram.estimate_range_selectivity(range);
266 }
267 }
268
269 defaults::RANGE
271 }
272 _ => defaults::RANGE,
273 }
274 }
275
276 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 selectivity.min(0.8)
292 }
293
294 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 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 pub fn estimate_less_than_selectivity(&self, value: Option<f64>) -> f64 {
358 match value {
361 Some(v) if v < 0.0 => 0.1, Some(0.0) => 0.05, _ => defaults::COMPARISON,
364 }
365 }
366
367 pub fn estimate_greater_than_selectivity(&self, value: Option<f64>) -> f64 {
369 match value {
370 Some(v) if v < 0.0 => 0.9, Some(0.0) => 0.95, _ => defaults::COMPARISON,
373 }
374 }
375
376 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 0.5_f64.min(0.1 + middle_wildcards as f64 * 0.1)
392 }
393 (false, true) => {
394 0.1_f64.min(0.05 + middle_wildcards as f64 * 0.02)
396 }
397 (true, false) => {
398 0.2_f64.min(0.1 + middle_wildcards as f64 * 0.05)
400 }
401 (false, false) => {
402 0.05
404 }
405 }
406 }
407
408 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 pub fn estimate_is_null_selectivity(&self) -> f64 {
418 defaults::IS_NULL
419 }
420
421 pub fn estimate_is_not_null_selectivity(&self) -> f64 {
423 defaults::IS_NOT_NULL
424 }
425
426 pub fn estimate_not_selectivity(&self, inner_selectivity: f64) -> f64 {
430 (1.0 - inner_selectivity).clamp(0.01, 0.99)
431 }
432
433 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 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 0.1
475 }
476 Expression::Property { .. } => {
477 0.5
480 }
481 _ => defaults::EQUALITY,
482 };
483
484 match snapshot {
485 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 Some(snapshot) => snapshot.corrected(&key, estimated).unwrap_or(estimated),
501 }
502 }
503
504 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 let property_name = self
518 .extract_property_name(left)
519 .or_else(|| self.extract_property_name(right));
520
521 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 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 (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 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 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 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 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 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 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 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 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 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 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 fn extract_value(&self, expr: &Expression) -> Option<Value> {
760 match expr {
761 Expression::Literal(value) => Some(value.clone()),
762 _ => None,
763 }
764 }
765
766 fn estimate_list_size(&self, expr: &Expression) -> usize {
768 match expr {
769 Expression::List(items) => items.len(),
770 _ => 3, }
772 }
773}
774
775fn 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 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 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 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 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 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 let near_a = condition_key(Some("s"), <_expr(800));
1000 let near_b = condition_key(Some("s"), <_expr(801));
1001 let far = condition_key(Some("s"), <_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}