Skip to main content

hawdb_optimizer_graph/graph/
catalog.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use super::value_range::{compare_histogram_value, range_bound_matches, ValueRangeBound};
16use crate::cardinality_defaults::{
17    NULL_SELECTIVITY_DIVISOR_CAP, RANGE_SELECTIVITY_DIVISOR, SAMPLED_HISTOGRAM_MATCH_PSEUDOCOUNT,
18    SAMPLED_HISTOGRAM_TOTAL_PSEUDOCOUNT,
19};
20use hawdb_core::{Catalog, GraphStatistics, IndexKind, Value};
21use hawdb_plan_cypher::ComparisonOp;
22use std::collections::{BTreeMap, BTreeSet};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub(super) struct ExpandEstimate {
26    pub(super) path_count: Option<u64>,
27    pub(super) rel_count: u64,
28    pub(super) source_count: u64,
29    pub(super) average_fanout: u64,
30    pub(super) property_distinct_product: u64,
31    pub(super) estimated_rows: u64,
32    pub(super) hop_estimates: Vec<HopEstimate>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub(super) struct HopEstimate {
37    pub(super) hop: usize,
38    pub(super) rows: u64,
39    pub(super) exact: bool,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct OptimizerIndexStatistics {
44    pub index_size: u64,
45    pub distinct_count: u64,
46}
47
48#[derive(Debug, Default, Clone, PartialEq, Eq)]
49pub struct OptimizerCatalog {
50    pub(super) assume_all_indexes: bool,
51    pub(super) equality_property_indexes: BTreeSet<(String, String)>,
52    pub(super) composite_property_indexes: BTreeSet<(String, Vec<String>)>,
53    pub(super) range_property_indexes: BTreeSet<(String, String)>,
54    pub(super) ordered_range_indexes: BTreeSet<(String, String)>,
55    pub(super) full_text_property_indexes: BTreeSet<(String, String)>,
56    pub(super) label_counts: BTreeMap<String, u64>,
57    pub(super) rel_type_counts: BTreeMap<String, u64>,
58    pub(super) rel_type_source_counts: BTreeMap<String, u64>,
59    pub(super) rel_type_target_counts: BTreeMap<String, u64>,
60    pub(super) path_counts: BTreeMap<(String, String, String), u64>,
61    pub(super) path_source_distinct_counts: BTreeMap<(String, String, String), u64>,
62    pub(super) path_target_distinct_counts: BTreeMap<(String, String, String), u64>,
63    pub(super) bounded_path_counts: BTreeMap<(String, String, String, usize), u64>,
64    pub(super) bounded_path_source_distinct_counts: BTreeMap<(String, String, String, usize), u64>,
65    pub(super) bounded_path_target_distinct_counts: BTreeMap<(String, String, String, usize), u64>,
66    pub(super) property_index_statistics: BTreeMap<(String, String), OptimizerIndexStatistics>,
67    pub(super) composite_index_statistics:
68        BTreeMap<(String, Vec<String>), OptimizerIndexStatistics>,
69    pub(super) property_distinct_counts: BTreeMap<(String, String), u64>,
70    pub(super) rel_property_distinct_counts: BTreeMap<(String, String), u64>,
71    pub(super) property_histograms: BTreeMap<(String, String), Vec<Value>>,
72    pub(super) rel_property_histograms: BTreeMap<(String, String), Vec<Value>>,
73    pub(super) sampled_property_histograms: BTreeMap<(String, String), bool>,
74    pub(super) sampled_rel_property_histograms: BTreeMap<(String, String), bool>,
75}
76
77#[derive(Debug, Default, Clone, PartialEq, Eq)]
78pub struct OptimizerCatalogIndexes {
79    pub(super) equality_property_indexes: BTreeSet<(String, String)>,
80    pub(super) composite_property_indexes: BTreeSet<(String, Vec<String>)>,
81    pub(super) range_property_indexes: BTreeSet<(String, String)>,
82    pub(super) full_text_property_indexes: BTreeSet<(String, String)>,
83}
84
85#[derive(Debug, Default, Clone, PartialEq, Eq)]
86pub struct OptimizerCatalogStatistics {
87    pub(super) label_counts: BTreeMap<String, u64>,
88    pub(super) rel_type_counts: BTreeMap<String, u64>,
89    pub(super) rel_type_source_counts: BTreeMap<String, u64>,
90    pub(super) rel_type_target_counts: BTreeMap<String, u64>,
91    pub(super) path_counts: BTreeMap<(String, String, String), u64>,
92    pub(super) path_source_distinct_counts: BTreeMap<(String, String, String), u64>,
93    pub(super) path_target_distinct_counts: BTreeMap<(String, String, String), u64>,
94    pub(super) bounded_path_counts: BTreeMap<(String, String, String, usize), u64>,
95    pub(super) bounded_path_source_distinct_counts: BTreeMap<(String, String, String, usize), u64>,
96    pub(super) bounded_path_target_distinct_counts: BTreeMap<(String, String, String, usize), u64>,
97    pub(super) property_index_statistics: BTreeMap<(String, String), OptimizerIndexStatistics>,
98    pub(super) composite_index_statistics:
99        BTreeMap<(String, Vec<String>), OptimizerIndexStatistics>,
100    pub(super) property_distinct_counts: BTreeMap<(String, String), u64>,
101    pub(super) rel_property_distinct_counts: BTreeMap<(String, String), u64>,
102    pub(super) property_histograms: BTreeMap<(String, String), Vec<Value>>,
103    pub(super) rel_property_histograms: BTreeMap<(String, String), Vec<Value>>,
104    pub(super) sampled_property_histograms: BTreeMap<(String, String), bool>,
105    pub(super) sampled_rel_property_histograms: BTreeMap<(String, String), bool>,
106}
107
108/// Converts storage-neutral schema and statistics into optimizer cost inputs.
109///
110/// Basic cardinalities remain usable while an advanced statistics refresh is
111/// incomplete; only advanced histograms, path counts, and index samples are
112/// withheld in that state.
113#[doc(hidden)]
114pub fn optimizer_catalog_from_graph_statistics(
115    catalog: &Catalog,
116    statistics: &GraphStatistics,
117) -> OptimizerCatalog {
118    let advanced_statistics = statistics
119        .advanced_statistics_complete
120        .then_some(statistics);
121    let equality_property_indexes = catalog.property_indexes().filter_map(|index| {
122        if index.kind != IndexKind::Equality {
123            return None;
124        }
125        catalog
126            .label_name(index.label_id)
127            .map(|label| (label.to_string(), index.property.clone()))
128    });
129    let composite_property_indexes = catalog.composite_property_indexes().filter_map(|index| {
130        catalog
131            .label_name(index.label_id)
132            .map(|label| (label.to_string(), index.properties.clone()))
133    });
134    let range_property_indexes = catalog.property_indexes().filter_map(|index| {
135        if index.kind != IndexKind::Range {
136            return None;
137        }
138        catalog
139            .label_name(index.label_id)
140            .map(|label| (label.to_string(), index.property.clone()))
141    });
142    let full_text_property_indexes = catalog.property_indexes().filter_map(|index| {
143        if index.kind != IndexKind::FullText {
144            return None;
145        }
146        catalog
147            .label_name(index.label_id)
148            .map(|label| (label.to_string(), index.property.clone()))
149    });
150    let label_counts = statistics
151        .label_counts
152        .iter()
153        .filter_map(|(label_id, count)| {
154            catalog
155                .label_name(*label_id)
156                .map(|label| (label.to_string(), *count))
157        });
158    let rel_type_counts = statistics
159        .rel_type_counts
160        .iter()
161        .filter_map(|(rel_type_id, count)| {
162            catalog
163                .rel_type_name(*rel_type_id)
164                .map(|rel_type| (rel_type.to_string(), *count))
165        });
166    let rel_type_source_counts = advanced_statistics
167        .into_iter()
168        .flat_map(|statistics| statistics.rel_type_source_counts.iter())
169        .filter_map(|(rel_type_id, count)| {
170            catalog
171                .rel_type_name(*rel_type_id)
172                .map(|rel_type| (rel_type.to_string(), *count))
173        });
174    let rel_type_target_counts = advanced_statistics
175        .into_iter()
176        .flat_map(|statistics| statistics.rel_type_target_counts.iter())
177        .filter_map(|(rel_type_id, count)| {
178            catalog
179                .rel_type_name(*rel_type_id)
180                .map(|rel_type| (rel_type.to_string(), *count))
181        });
182    let path_counts = advanced_statistics
183        .into_iter()
184        .flat_map(|statistics| statistics.path_counts.iter())
185        .filter_map(|((source_label_id, rel_type_id, target_label_id), count)| {
186            Some((
187                (
188                    catalog.label_name(*source_label_id)?.to_string(),
189                    catalog.rel_type_name(*rel_type_id)?.to_string(),
190                    catalog.label_name(*target_label_id)?.to_string(),
191                ),
192                *count,
193            ))
194        });
195    let path_source_distinct_counts = advanced_statistics
196        .into_iter()
197        .flat_map(|statistics| statistics.path_source_distinct_counts.iter())
198        .filter_map(|((source_label_id, rel_type_id, target_label_id), count)| {
199            Some((
200                (
201                    catalog.label_name(*source_label_id)?.to_string(),
202                    catalog.rel_type_name(*rel_type_id)?.to_string(),
203                    catalog.label_name(*target_label_id)?.to_string(),
204                ),
205                *count,
206            ))
207        });
208    let path_target_distinct_counts = advanced_statistics
209        .into_iter()
210        .flat_map(|statistics| statistics.path_target_distinct_counts.iter())
211        .filter_map(|((source_label_id, rel_type_id, target_label_id), count)| {
212            Some((
213                (
214                    catalog.label_name(*source_label_id)?.to_string(),
215                    catalog.rel_type_name(*rel_type_id)?.to_string(),
216                    catalog.label_name(*target_label_id)?.to_string(),
217                ),
218                *count,
219            ))
220        });
221    let bounded_path_counts = advanced_statistics
222        .into_iter()
223        .flat_map(|statistics| statistics.bounded_path_counts.iter())
224        .filter_map(
225            |((source_label_id, rel_type_id, target_label_id, hops), count)| {
226                Some((
227                    (
228                        catalog.label_name(*source_label_id)?.to_string(),
229                        catalog.rel_type_name(*rel_type_id)?.to_string(),
230                        catalog.label_name(*target_label_id)?.to_string(),
231                        *hops,
232                    ),
233                    *count,
234                ))
235            },
236        );
237    let bounded_path_source_distinct_counts = advanced_statistics
238        .into_iter()
239        .flat_map(|statistics| statistics.bounded_path_source_distinct_counts.iter())
240        .filter_map(
241            |((source_label_id, rel_type_id, target_label_id, hops), count)| {
242                Some((
243                    (
244                        catalog.label_name(*source_label_id)?.to_string(),
245                        catalog.rel_type_name(*rel_type_id)?.to_string(),
246                        catalog.label_name(*target_label_id)?.to_string(),
247                        *hops,
248                    ),
249                    *count,
250                ))
251            },
252        );
253    let bounded_path_target_distinct_counts = advanced_statistics
254        .into_iter()
255        .flat_map(|statistics| statistics.bounded_path_target_distinct_counts.iter())
256        .filter_map(
257            |((source_label_id, rel_type_id, target_label_id, hops), count)| {
258                Some((
259                    (
260                        catalog.label_name(*source_label_id)?.to_string(),
261                        catalog.rel_type_name(*rel_type_id)?.to_string(),
262                        catalog.label_name(*target_label_id)?.to_string(),
263                        *hops,
264                    ),
265                    *count,
266                ))
267            },
268        );
269    let property_distinct_counts = advanced_statistics
270        .into_iter()
271        .flat_map(|statistics| statistics.property_distinct_counts.iter())
272        .filter_map(|((label_id, property), count)| {
273            catalog
274                .label_name(*label_id)
275                .map(|label| ((label.to_string(), property.clone()), *count))
276        });
277    let property_histograms = advanced_statistics
278        .into_iter()
279        .flat_map(|statistics| statistics.property_histograms.iter())
280        .filter_map(|((label_id, property), values)| {
281            catalog
282                .label_name(*label_id)
283                .map(|label| ((label.to_string(), property.clone()), values.clone()))
284        });
285    let sampled_property_histograms = advanced_statistics
286        .into_iter()
287        .flat_map(|statistics| statistics.sampled_property_histograms.iter())
288        .filter_map(|((label_id, property), sampled)| {
289            catalog
290                .label_name(*label_id)
291                .map(|label| ((label.to_string(), property.clone()), *sampled))
292        });
293    let rel_property_distinct_counts = advanced_statistics
294        .into_iter()
295        .flat_map(|statistics| statistics.rel_property_distinct_counts.iter())
296        .filter_map(|((rel_type_id, property), count)| {
297            catalog
298                .rel_type_name(*rel_type_id)
299                .map(|rel_type| ((rel_type.to_string(), property.clone()), *count))
300        });
301    let rel_property_histograms = advanced_statistics
302        .into_iter()
303        .flat_map(|statistics| statistics.rel_property_histograms.iter())
304        .filter_map(|((rel_type_id, property), values)| {
305            catalog
306                .rel_type_name(*rel_type_id)
307                .map(|rel_type| ((rel_type.to_string(), property.clone()), values.clone()))
308        });
309    let sampled_rel_property_histograms = advanced_statistics
310        .into_iter()
311        .flat_map(|statistics| statistics.sampled_rel_property_histograms.iter())
312        .filter_map(|((rel_type_id, property), sampled)| {
313            catalog
314                .rel_type_name(*rel_type_id)
315                .map(|rel_type| ((rel_type.to_string(), property.clone()), *sampled))
316        });
317    let property_index_statistics = catalog.property_indexes().filter_map(|index| {
318        if index.kind == IndexKind::FullText {
319            return None;
320        }
321        let statistics = advanced_statistics?;
322        let sample = statistics.index_samples.get(&index.id)?;
323        let distinct_count = sample.estimated_unique_values()?;
324        let label = catalog.label_name(index.label_id)?;
325        Some((
326            (label.to_string(), index.property.clone()),
327            OptimizerIndexStatistics {
328                index_size: sample.index_size,
329                distinct_count,
330            },
331        ))
332    });
333    let composite_index_statistics = catalog.composite_property_indexes().filter_map(|index| {
334        let statistics = advanced_statistics?;
335        let sample = statistics.index_samples.get(&index.id)?;
336        let distinct_count = sample.estimated_unique_values()?;
337        let label = catalog.label_name(index.label_id)?;
338        Some((
339            (label.to_string(), index.properties.clone()),
340            OptimizerIndexStatistics {
341                index_size: sample.index_size,
342                distinct_count,
343            },
344        ))
345    });
346    OptimizerCatalog::new(
347        OptimizerCatalogIndexes::new(
348            equality_property_indexes,
349            composite_property_indexes,
350            range_property_indexes,
351            full_text_property_indexes,
352        ),
353        OptimizerCatalogStatistics::new(
354            label_counts,
355            rel_type_counts,
356            rel_type_source_counts,
357            path_counts,
358            bounded_path_counts,
359            property_distinct_counts,
360            property_histograms,
361        )
362        .with_property_index_statistics(property_index_statistics)
363        .with_composite_index_statistics(composite_index_statistics)
364        .with_relationship_type_target_counts(rel_type_target_counts)
365        .with_path_source_distinct_counts(path_source_distinct_counts)
366        .with_path_target_distinct_counts(path_target_distinct_counts)
367        .with_bounded_path_source_distinct_counts(bounded_path_source_distinct_counts)
368        .with_bounded_path_target_distinct_counts(bounded_path_target_distinct_counts)
369        .with_relationship_property_distinct_counts(rel_property_distinct_counts)
370        .with_relationship_property_histograms(rel_property_histograms)
371        .with_sampled_property_histograms(sampled_property_histograms)
372        .with_sampled_relationship_property_histograms(sampled_rel_property_histograms),
373    )
374}
375
376impl OptimizerCatalog {
377    pub fn new(indexes: OptimizerCatalogIndexes, statistics: OptimizerCatalogStatistics) -> Self {
378        Self {
379            assume_all_indexes: false,
380            equality_property_indexes: indexes.equality_property_indexes,
381            composite_property_indexes: indexes.composite_property_indexes,
382            range_property_indexes: indexes.range_property_indexes,
383            ordered_range_indexes: BTreeSet::new(),
384            full_text_property_indexes: indexes.full_text_property_indexes,
385            label_counts: statistics.label_counts,
386            rel_type_counts: statistics.rel_type_counts,
387            rel_type_source_counts: statistics.rel_type_source_counts,
388            rel_type_target_counts: statistics.rel_type_target_counts,
389            path_counts: statistics.path_counts,
390            path_source_distinct_counts: statistics.path_source_distinct_counts,
391            path_target_distinct_counts: statistics.path_target_distinct_counts,
392            bounded_path_counts: statistics.bounded_path_counts,
393            bounded_path_source_distinct_counts: statistics.bounded_path_source_distinct_counts,
394            bounded_path_target_distinct_counts: statistics.bounded_path_target_distinct_counts,
395            property_index_statistics: statistics.property_index_statistics,
396            composite_index_statistics: statistics.composite_index_statistics,
397            property_distinct_counts: statistics.property_distinct_counts,
398            rel_property_distinct_counts: statistics.rel_property_distinct_counts,
399            property_histograms: statistics.property_histograms,
400            rel_property_histograms: statistics.rel_property_histograms,
401            sampled_property_histograms: statistics.sampled_property_histograms,
402            sampled_rel_property_histograms: statistics.sampled_rel_property_histograms,
403        }
404    }
405
406    /// Opts declared range indexes into ascending `Value::cmp` delivery.
407    /// The execution engine must guarantee this for full and projected scans,
408    /// including checkpoint/delta merging. Descriptors do not imply ordering.
409    pub fn with_ordered_range_indexes(
410        mut self,
411        indexes: impl IntoIterator<Item = (String, String)>,
412    ) -> Self {
413        self.ordered_range_indexes = indexes
414            .into_iter()
415            .filter(|index| self.range_property_indexes.contains(index))
416            .collect();
417        self
418    }
419
420    pub(super) fn optimistic() -> Self {
421        Self {
422            assume_all_indexes: true,
423            ..Self::default()
424        }
425    }
426
427    pub(super) fn has_property_index(&self, label: &str, property: &str) -> bool {
428        self.assume_all_indexes
429            || self
430                .equality_property_indexes
431                .contains(&(label.to_string(), property.to_string()))
432    }
433
434    pub(super) fn has_range_property_index(&self, label: &str, property: &str) -> bool {
435        self.assume_all_indexes
436            || self
437                .range_property_indexes
438                .contains(&(label.to_string(), property.to_string()))
439    }
440
441    pub(super) fn has_full_text_property_index(&self, label: &str, property: &str) -> bool {
442        self.assume_all_indexes
443            || self
444                .full_text_property_indexes
445                .contains(&(label.to_string(), property.to_string()))
446    }
447
448    pub(super) fn has_composite_property_index(&self, label: &str, properties: &[String]) -> bool {
449        self.assume_all_indexes
450            || self
451                .composite_property_indexes
452                .contains(&(label.to_string(), properties.to_vec()))
453    }
454
455    pub(super) fn composite_property_indexes_for_label(&self, label: &str) -> Vec<Vec<String>> {
456        if self.assume_all_indexes {
457            return Vec::new();
458        }
459        self.composite_property_indexes
460            .iter()
461            .filter_map(|(candidate_label, properties)| {
462                if candidate_label == label {
463                    Some(properties.clone())
464                } else {
465                    None
466                }
467            })
468            .collect()
469    }
470
471    pub(super) fn label_count(&self, label: &str) -> u64 {
472        self.label_counts.get(label).copied().unwrap_or(1).max(1)
473    }
474
475    pub(super) fn relationship_count(&self, rel_type: &str) -> u64 {
476        self.rel_type_counts
477            .get(rel_type)
478            .copied()
479            .unwrap_or(1)
480            .max(1)
481    }
482
483    pub(super) fn path_source_distinct_count(
484        &self,
485        source_label: &str,
486        rel_type: &str,
487        target_label: &str,
488    ) -> Option<u64> {
489        self.path_source_distinct_counts
490            .get(&(
491                source_label.to_string(),
492                rel_type.to_string(),
493                target_label.to_string(),
494            ))
495            .copied()
496    }
497
498    pub(super) fn path_target_distinct_count(
499        &self,
500        source_label: &str,
501        rel_type: &str,
502        target_label: &str,
503    ) -> Option<u64> {
504        self.path_target_distinct_counts
505            .get(&(
506                source_label.to_string(),
507                rel_type.to_string(),
508                target_label.to_string(),
509            ))
510            .copied()
511    }
512
513    pub(super) fn bounded_path_source_distinct_count(
514        &self,
515        source_label: &str,
516        rel_type: &str,
517        target_label: &str,
518        min_hops: usize,
519        max_hops: usize,
520    ) -> Option<u64> {
521        self.bounded_path_distinct_count(
522            &self.bounded_path_source_distinct_counts,
523            source_label,
524            rel_type,
525            target_label,
526            min_hops,
527            max_hops,
528        )
529    }
530
531    pub(super) fn bounded_path_target_distinct_count(
532        &self,
533        source_label: &str,
534        rel_type: &str,
535        target_label: &str,
536        min_hops: usize,
537        max_hops: usize,
538    ) -> Option<u64> {
539        self.bounded_path_distinct_count(
540            &self.bounded_path_target_distinct_counts,
541            source_label,
542            rel_type,
543            target_label,
544            min_hops,
545            max_hops,
546        )
547    }
548
549    pub(super) fn bounded_path_distinct_count(
550        &self,
551        counts: &BTreeMap<(String, String, String, usize), u64>,
552        source_label: &str,
553        rel_type: &str,
554        target_label: &str,
555        min_hops: usize,
556        max_hops: usize,
557    ) -> Option<u64> {
558        let mut total = 0_u64;
559        let mut found = false;
560        for hop in min_hops..=max_hops.max(min_hops) {
561            if let Some(count) = counts.get(&(
562                source_label.to_string(),
563                rel_type.to_string(),
564                target_label.to_string(),
565                hop,
566            )) {
567                found = true;
568                total = total.saturating_add(*count);
569            }
570        }
571        found.then_some(total.max(1))
572    }
573
574    pub(super) fn distinct_count(&self, label: &str, property: &str) -> u64 {
575        self.property_index_statistics
576            .get(&(label.to_string(), property.to_string()))
577            .map(|statistics| statistics.distinct_count)
578            .or_else(|| {
579                self.property_distinct_counts
580                    .get(&(label.to_string(), property.to_string()))
581                    .copied()
582            })
583            .unwrap_or_else(|| self.label_count(label).max(1))
584    }
585
586    pub(super) fn estimate_property_index_eq_rows(&self, label: &str, property: &str) -> u64 {
587        self.property_index_statistics
588            .get(&(label.to_string(), property.to_string()))
589            .map(|statistics| {
590                statistics
591                    .index_size
592                    .div_ceil(statistics.distinct_count.max(1))
593                    .min(self.label_count(label))
594                    .max(1)
595            })
596            .unwrap_or_else(|| {
597                self.label_count(label)
598                    .div_ceil(self.distinct_count(label, property).max(1))
599                    .max(1)
600            })
601    }
602
603    pub(super) fn estimate_property_index_in_rows(
604        &self,
605        label: &str,
606        property: &str,
607        value_count: u64,
608    ) -> u64 {
609        let upper_bound = self
610            .property_index_statistics
611            .get(&(label.to_string(), property.to_string()))
612            .map_or_else(
613                || self.label_count(label),
614                |statistics| statistics.index_size.min(self.label_count(label)),
615            );
616        self.estimate_property_index_eq_rows(label, property)
617            .saturating_mul(value_count)
618            .min(upper_bound)
619            .max(1)
620    }
621
622    pub(super) fn composite_distinct_count(&self, label: &str, properties: &[String]) -> u64 {
623        self.composite_index_statistics
624            .get(&(label.to_string(), properties.to_vec()))
625            .map(|statistics| statistics.distinct_count)
626            .unwrap_or_else(|| {
627                properties
628                    .iter()
629                    .map(|property| self.distinct_count(label, property).max(1))
630                    .fold(1_u64, |product, distinct| product.saturating_mul(distinct))
631                    .max(1)
632            })
633    }
634
635    pub(super) fn estimate_composite_property_index_rows(
636        &self,
637        label: &str,
638        properties: &[String],
639    ) -> u64 {
640        self.composite_index_statistics
641            .get(&(label.to_string(), properties.to_vec()))
642            .map(|statistics| {
643                statistics
644                    .index_size
645                    .div_ceil(statistics.distinct_count.max(1))
646                    .min(self.label_count(label))
647                    .max(1)
648            })
649            .unwrap_or_else(|| {
650                self.label_count(label)
651                    .div_ceil(self.composite_distinct_count(label, properties))
652                    .max(1)
653            })
654    }
655
656    pub(super) fn estimate_composite_prefix_range_rows(
657        &self,
658        label: &str,
659        index_properties: &[String],
660        equality_properties: &[String],
661        range_property: &str,
662        lower: Option<&ValueRangeBound>,
663        upper: Option<&ValueRangeBound>,
664    ) -> u64 {
665        let label_count = self.label_count(label);
666        let equality_distinct = equality_properties
667            .iter()
668            .map(|property| self.distinct_count(label, property).max(1))
669            .fold(1u64, u64::saturating_mul);
670        let equality_rows = label_count.div_ceil(equality_distinct).max(1);
671        let range_rows = self.estimate_range_bounds_rows(label, range_property, lower, upper);
672        let estimated = equality_rows
673            .saturating_mul(range_rows)
674            .div_ceil(label_count)
675            .max(1);
676        self.composite_index_statistics
677            .get(&(label.to_string(), index_properties.to_vec()))
678            .map_or(estimated, |statistics| {
679                estimated.min(statistics.index_size.max(1))
680            })
681    }
682
683    pub(super) fn known_distinct_count(&self, label: &str, property: &str) -> Option<u64> {
684        self.property_index_statistics
685            .get(&(label.to_string(), property.to_string()))
686            .map(|statistics| statistics.distinct_count)
687            .or_else(|| {
688                self.property_distinct_counts
689                    .get(&(label.to_string(), property.to_string()))
690                    .copied()
691            })
692    }
693
694    pub(super) fn known_rel_property_distinct_count(
695        &self,
696        rel_type: &str,
697        property: &str,
698    ) -> Option<u64> {
699        self.rel_property_distinct_counts
700            .get(&(rel_type.to_string(), property.to_string()))
701            .copied()
702    }
703
704    pub(super) fn rel_property_distinct_count(&self, rel_type: &str, property: &str) -> u64 {
705        self.rel_property_distinct_counts
706            .get(&(rel_type.to_string(), property.to_string()))
707            .copied()
708            .unwrap_or_else(|| {
709                self.rel_type_counts
710                    .get(rel_type)
711                    .copied()
712                    .unwrap_or(1)
713                    .max(1)
714            })
715    }
716
717    pub(super) fn estimate_rel_property_eq_rows(
718        &self,
719        rel_type: &str,
720        property: &str,
721        input_rows: u64,
722    ) -> u64 {
723        input_rows
724            .div_ceil(self.rel_property_distinct_count(rel_type, property).max(1))
725            .max(1)
726    }
727
728    pub(super) fn estimate_rel_property_not_eq_rows(
729        &self,
730        rel_type: &str,
731        property: &str,
732        input_rows: u64,
733    ) -> u64 {
734        input_rows
735            .saturating_sub(self.estimate_rel_property_eq_rows(rel_type, property, input_rows))
736    }
737
738    pub(super) fn estimate_rel_property_in_rows(
739        &self,
740        rel_type: &str,
741        property: &str,
742        values: &[Value],
743        input_rows: u64,
744    ) -> u64 {
745        if values.is_empty() {
746            return 0;
747        }
748        let distinct_count = self.rel_property_distinct_count(rel_type, property).max(1);
749        let value_count = values
750            .iter()
751            .collect::<BTreeSet<_>>()
752            .len()
753            .min(distinct_count as usize) as u64;
754        input_rows
755            .saturating_mul(value_count)
756            .div_ceil(distinct_count)
757            .max(1)
758    }
759
760    pub(super) fn estimate_rel_property_string_match_rows(
761        &self,
762        rel_type: &str,
763        property: &str,
764        input_rows: u64,
765        max_divisor: u64,
766    ) -> u64 {
767        let divisor = self
768            .rel_property_distinct_count(rel_type, property)
769            .max(1)
770            .min(max_divisor)
771            .max(1);
772        input_rows.div_ceil(divisor).max(1)
773    }
774
775    pub(super) fn estimate_rel_property_null_rows(
776        &self,
777        rel_type: &str,
778        property: &str,
779        input_rows: u64,
780    ) -> u64 {
781        let distinct_count = self.rel_property_distinct_count(rel_type, property).max(1);
782        input_rows
783            .div_ceil(distinct_count.min(NULL_SELECTIVITY_DIVISOR_CAP))
784            .max(1)
785    }
786
787    pub(super) fn estimate_rel_property_not_null_rows(
788        &self,
789        rel_type: &str,
790        property: &str,
791        input_rows: u64,
792    ) -> u64 {
793        input_rows
794            .saturating_sub(self.estimate_rel_property_null_rows(rel_type, property, input_rows))
795            .max(1)
796    }
797
798    pub(super) fn estimate_rel_property_range_rows(
799        &self,
800        rel_type: &str,
801        property: &str,
802        op: ComparisonOp,
803        value: &Value,
804        input_rows: u64,
805    ) -> u64 {
806        let key = (rel_type.to_string(), property.to_string());
807        let Some(histogram) = self
808            .rel_property_histograms
809            .get(&key)
810            .filter(|values| !values.is_empty())
811        else {
812            return input_rows.div_ceil(RANGE_SELECTIVITY_DIVISOR).max(1);
813        };
814        let matching_values = histogram
815            .iter()
816            .filter(|candidate| compare_histogram_value(candidate, op, value))
817            .count() as u64;
818        estimate_histogram_rows(
819            input_rows,
820            matching_values,
821            histogram.len() as u64,
822            self.sampled_rel_property_histograms
823                .get(&key)
824                .copied()
825                .unwrap_or(false),
826        )
827    }
828
829    pub(super) fn estimate_range_rows(
830        &self,
831        label: &str,
832        property: &str,
833        op: ComparisonOp,
834        value: &Value,
835    ) -> u64 {
836        let label_count = self.label_count(label);
837        let key = (label.to_string(), property.to_string());
838        let Some(histogram) = self
839            .property_histograms
840            .get(&key)
841            .filter(|values| !values.is_empty())
842        else {
843            return label_count.div_ceil(RANGE_SELECTIVITY_DIVISOR).max(1);
844        };
845        let matching_values = histogram
846            .iter()
847            .filter(|candidate| compare_histogram_value(candidate, op, value))
848            .count() as u64;
849        estimate_histogram_rows(
850            label_count,
851            matching_values,
852            histogram.len() as u64,
853            self.sampled_property_histograms
854                .get(&key)
855                .copied()
856                .unwrap_or(false),
857        )
858    }
859
860    pub(super) fn estimate_property_eq_rows(
861        &self,
862        label: &str,
863        property: &str,
864        input_rows: u64,
865    ) -> u64 {
866        input_rows
867            .div_ceil(self.distinct_count(label, property).max(1))
868            .max(1)
869    }
870
871    pub(super) fn estimate_property_not_eq_rows(
872        &self,
873        label: &str,
874        property: &str,
875        input_rows: u64,
876    ) -> u64 {
877        input_rows.saturating_sub(self.estimate_property_eq_rows(label, property, input_rows))
878    }
879
880    pub(super) fn estimate_property_in_rows(
881        &self,
882        label: &str,
883        property: &str,
884        values: &[Value],
885        input_rows: u64,
886    ) -> u64 {
887        if values.is_empty() {
888            return 0;
889        }
890        let distinct_count = self.distinct_count(label, property).max(1);
891        let value_count = values
892            .iter()
893            .collect::<BTreeSet<_>>()
894            .len()
895            .min(distinct_count as usize) as u64;
896        input_rows
897            .saturating_mul(value_count)
898            .div_ceil(distinct_count)
899            .max(1)
900    }
901
902    pub(super) fn estimate_property_string_match_rows(
903        &self,
904        label: &str,
905        property: &str,
906        input_rows: u64,
907        max_divisor: u64,
908    ) -> u64 {
909        let divisor = self
910            .distinct_count(label, property)
911            .max(1)
912            .min(max_divisor)
913            .max(1);
914        input_rows.div_ceil(divisor).max(1)
915    }
916
917    pub(super) fn estimate_property_null_rows(
918        &self,
919        label: &str,
920        property: &str,
921        input_rows: u64,
922    ) -> u64 {
923        let distinct_count = self.distinct_count(label, property).max(1);
924        input_rows
925            .div_ceil(distinct_count.min(NULL_SELECTIVITY_DIVISOR_CAP))
926            .max(1)
927    }
928
929    pub(super) fn estimate_property_not_null_rows(
930        &self,
931        label: &str,
932        property: &str,
933        input_rows: u64,
934    ) -> u64 {
935        input_rows
936            .saturating_sub(self.estimate_property_null_rows(label, property, input_rows))
937            .max(1)
938    }
939
940    pub(super) fn estimate_property_range_rows(
941        &self,
942        label: &str,
943        property: &str,
944        op: ComparisonOp,
945        value: &Value,
946        input_rows: u64,
947    ) -> u64 {
948        let key = (label.to_string(), property.to_string());
949        let Some(histogram) = self
950            .property_histograms
951            .get(&key)
952            .filter(|values| !values.is_empty())
953        else {
954            return input_rows.div_ceil(RANGE_SELECTIVITY_DIVISOR).max(1);
955        };
956        let matching_values = histogram
957            .iter()
958            .filter(|candidate| compare_histogram_value(candidate, op, value))
959            .count() as u64;
960        estimate_histogram_rows(
961            input_rows,
962            matching_values,
963            histogram.len() as u64,
964            self.sampled_property_histograms
965                .get(&key)
966                .copied()
967                .unwrap_or(false),
968        )
969    }
970
971    pub(super) fn estimate_range_bounds_rows(
972        &self,
973        label: &str,
974        property: &str,
975        lower: Option<&ValueRangeBound>,
976        upper: Option<&ValueRangeBound>,
977    ) -> u64 {
978        let label_count = self.label_count(label);
979        let key = (label.to_string(), property.to_string());
980        let Some(histogram) = self
981            .property_histograms
982            .get(&key)
983            .filter(|values| !values.is_empty())
984        else {
985            return label_count.div_ceil(RANGE_SELECTIVITY_DIVISOR).max(1);
986        };
987        let matching_values = histogram
988            .iter()
989            .filter(|candidate| range_bound_matches(candidate, lower, upper))
990            .count() as u64;
991        estimate_histogram_rows(
992            label_count,
993            matching_values,
994            histogram.len() as u64,
995            self.sampled_property_histograms
996                .get(&key)
997                .copied()
998                .unwrap_or(false),
999        )
1000    }
1001
1002    pub(super) fn estimate_expand_rows(
1003        &self,
1004        source_label: &str,
1005        rel_type: &str,
1006        rel_properties: &BTreeMap<String, Value>,
1007        target_label: &str,
1008        min_hops: usize,
1009        max_hops: usize,
1010    ) -> ExpandEstimate {
1011        let path_count = self
1012            .path_counts
1013            .get(&(
1014                source_label.to_string(),
1015                rel_type.to_string(),
1016                target_label.to_string(),
1017            ))
1018            .copied();
1019        let rel_count = self.rel_type_counts.get(rel_type).copied().unwrap_or(1);
1020        let source_count = self
1021            .rel_type_source_counts
1022            .get(rel_type)
1023            .copied()
1024            .unwrap_or_else(|| self.label_count(source_label).max(1));
1025        let average_fanout = rel_count.div_ceil(source_count.max(1)).max(1);
1026        let one_hop = path_count.unwrap_or_else(|| {
1027            self.label_count(source_label)
1028                .min(self.label_count(target_label))
1029                .max(1)
1030        });
1031        let property_distinct_product = rel_properties
1032            .keys()
1033            .map(|property| self.rel_property_distinct_count(rel_type, property).max(1))
1034            .fold(1_u64, |acc, value| acc.saturating_mul(value))
1035            .max(1);
1036        let mut estimated_rows = 0_u64;
1037        let mut hop_rows = one_hop.max(1);
1038        let mut hop_estimates = Vec::new();
1039        for hop in 1..=max_hops.max(1) {
1040            let exact_hop_rows = self
1041                .bounded_path_counts
1042                .get(&(
1043                    source_label.to_string(),
1044                    rel_type.to_string(),
1045                    target_label.to_string(),
1046                    hop,
1047                ))
1048                .copied();
1049            let current_hop_rows = exact_hop_rows
1050                .unwrap_or(hop_rows)
1051                .div_ceil(property_distinct_product)
1052                .max(1);
1053            hop_estimates.push(HopEstimate {
1054                hop,
1055                rows: current_hop_rows,
1056                exact: exact_hop_rows.is_some(),
1057            });
1058            if hop >= min_hops {
1059                estimated_rows = estimated_rows.saturating_add(current_hop_rows);
1060            }
1061            hop_rows = current_hop_rows.max(1).saturating_mul(average_fanout);
1062        }
1063        ExpandEstimate {
1064            path_count,
1065            rel_count,
1066            source_count,
1067            average_fanout,
1068            property_distinct_product,
1069            estimated_rows: estimated_rows.max(1),
1070            hop_estimates,
1071        }
1072    }
1073}
1074
1075fn estimate_histogram_rows(
1076    input_rows: u64,
1077    matching_values: u64,
1078    histogram_values: u64,
1079    sampled: bool,
1080) -> u64 {
1081    // A sampled CDF is finite evidence rather than an exhaustive rank table. Add-one
1082    // smoothing shrinks it toward the existing 50% fallback by a weight determined by
1083    // the actual sample size, while leaving complete histograms exact.
1084    let (matching_values, histogram_values) = if sampled {
1085        (
1086            matching_values.saturating_add(SAMPLED_HISTOGRAM_MATCH_PSEUDOCOUNT),
1087            histogram_values.saturating_add(SAMPLED_HISTOGRAM_TOTAL_PSEUDOCOUNT),
1088        )
1089    } else {
1090        (matching_values, histogram_values)
1091    };
1092    input_rows
1093        .saturating_mul(matching_values)
1094        .div_ceil(histogram_values.max(1))
1095        .max(1)
1096}
1097
1098impl OptimizerCatalogIndexes {
1099    pub fn new(
1100        equality_property_indexes: impl IntoIterator<Item = (String, String)>,
1101        composite_property_indexes: impl IntoIterator<Item = (String, Vec<String>)>,
1102        range_property_indexes: impl IntoIterator<Item = (String, String)>,
1103        full_text_property_indexes: impl IntoIterator<Item = (String, String)>,
1104    ) -> Self {
1105        Self {
1106            equality_property_indexes: equality_property_indexes.into_iter().collect(),
1107            composite_property_indexes: composite_property_indexes.into_iter().collect(),
1108            range_property_indexes: range_property_indexes.into_iter().collect(),
1109            full_text_property_indexes: full_text_property_indexes.into_iter().collect(),
1110        }
1111    }
1112}
1113
1114impl OptimizerCatalogStatistics {
1115    pub fn new(
1116        label_counts: impl IntoIterator<Item = (String, u64)>,
1117        rel_type_counts: impl IntoIterator<Item = (String, u64)>,
1118        rel_type_source_counts: impl IntoIterator<Item = (String, u64)>,
1119        path_counts: impl IntoIterator<Item = ((String, String, String), u64)>,
1120        bounded_path_counts: impl IntoIterator<Item = ((String, String, String, usize), u64)>,
1121        property_distinct_counts: impl IntoIterator<Item = ((String, String), u64)>,
1122        property_histograms: impl IntoIterator<Item = ((String, String), Vec<Value>)>,
1123    ) -> Self {
1124        Self {
1125            label_counts: label_counts.into_iter().collect(),
1126            rel_type_counts: rel_type_counts.into_iter().collect(),
1127            rel_type_source_counts: rel_type_source_counts.into_iter().collect(),
1128            rel_type_target_counts: BTreeMap::new(),
1129            path_counts: path_counts.into_iter().collect(),
1130            path_source_distinct_counts: BTreeMap::new(),
1131            path_target_distinct_counts: BTreeMap::new(),
1132            bounded_path_counts: bounded_path_counts.into_iter().collect(),
1133            bounded_path_source_distinct_counts: BTreeMap::new(),
1134            bounded_path_target_distinct_counts: BTreeMap::new(),
1135            property_index_statistics: BTreeMap::new(),
1136            composite_index_statistics: BTreeMap::new(),
1137            property_distinct_counts: property_distinct_counts.into_iter().collect(),
1138            rel_property_distinct_counts: BTreeMap::new(),
1139            property_histograms: property_histograms.into_iter().collect(),
1140            rel_property_histograms: BTreeMap::new(),
1141            sampled_property_histograms: BTreeMap::new(),
1142            sampled_rel_property_histograms: BTreeMap::new(),
1143        }
1144    }
1145
1146    pub fn with_property_index_statistics(
1147        mut self,
1148        statistics: impl IntoIterator<Item = ((String, String), OptimizerIndexStatistics)>,
1149    ) -> Self {
1150        self.property_index_statistics = statistics.into_iter().collect();
1151        self
1152    }
1153
1154    pub fn with_composite_index_statistics(
1155        mut self,
1156        statistics: impl IntoIterator<Item = ((String, Vec<String>), OptimizerIndexStatistics)>,
1157    ) -> Self {
1158        self.composite_index_statistics = statistics.into_iter().collect();
1159        self
1160    }
1161
1162    pub fn with_relationship_property_distinct_counts(
1163        mut self,
1164        rel_property_distinct_counts: impl IntoIterator<Item = ((String, String), u64)>,
1165    ) -> Self {
1166        self.rel_property_distinct_counts = rel_property_distinct_counts.into_iter().collect();
1167        self
1168    }
1169
1170    pub fn with_relationship_type_target_counts(
1171        mut self,
1172        rel_type_target_counts: impl IntoIterator<Item = (String, u64)>,
1173    ) -> Self {
1174        self.rel_type_target_counts = rel_type_target_counts.into_iter().collect();
1175        self
1176    }
1177
1178    pub fn with_path_source_distinct_counts(
1179        mut self,
1180        path_source_distinct_counts: impl IntoIterator<Item = ((String, String, String), u64)>,
1181    ) -> Self {
1182        self.path_source_distinct_counts = path_source_distinct_counts.into_iter().collect();
1183        self
1184    }
1185
1186    pub fn with_path_target_distinct_counts(
1187        mut self,
1188        path_target_distinct_counts: impl IntoIterator<Item = ((String, String, String), u64)>,
1189    ) -> Self {
1190        self.path_target_distinct_counts = path_target_distinct_counts.into_iter().collect();
1191        self
1192    }
1193
1194    pub fn with_bounded_path_source_distinct_counts(
1195        mut self,
1196        bounded_path_source_distinct_counts: impl IntoIterator<
1197            Item = ((String, String, String, usize), u64),
1198        >,
1199    ) -> Self {
1200        self.bounded_path_source_distinct_counts =
1201            bounded_path_source_distinct_counts.into_iter().collect();
1202        self
1203    }
1204
1205    pub fn with_bounded_path_target_distinct_counts(
1206        mut self,
1207        bounded_path_target_distinct_counts: impl IntoIterator<
1208            Item = ((String, String, String, usize), u64),
1209        >,
1210    ) -> Self {
1211        self.bounded_path_target_distinct_counts =
1212            bounded_path_target_distinct_counts.into_iter().collect();
1213        self
1214    }
1215
1216    pub fn with_relationship_property_histograms(
1217        mut self,
1218        rel_property_histograms: impl IntoIterator<Item = ((String, String), Vec<Value>)>,
1219    ) -> Self {
1220        self.rel_property_histograms = rel_property_histograms.into_iter().collect();
1221        self
1222    }
1223
1224    pub fn with_sampled_property_histograms(
1225        mut self,
1226        sampled_property_histograms: impl IntoIterator<Item = ((String, String), bool)>,
1227    ) -> Self {
1228        self.sampled_property_histograms = sampled_property_histograms.into_iter().collect();
1229        self
1230    }
1231
1232    pub fn with_sampled_relationship_property_histograms(
1233        mut self,
1234        sampled_rel_property_histograms: impl IntoIterator<Item = ((String, String), bool)>,
1235    ) -> Self {
1236        self.sampled_rel_property_histograms =
1237            sampled_rel_property_histograms.into_iter().collect();
1238        self
1239    }
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244    use super::*;
1245
1246    #[test]
1247    fn graph_statistics_catalog_keeps_basic_counts_when_advanced_stats_are_incomplete() {
1248        let mut source_catalog = Catalog::default();
1249        let memory = source_catalog.get_or_create_label("Memory");
1250        let mut statistics = GraphStatistics {
1251            advanced_statistics_complete: false,
1252            label_counts: BTreeMap::from([(memory, 64)]),
1253            property_distinct_counts: BTreeMap::from([((memory, "kind".to_string()), 4)]),
1254            property_histograms: BTreeMap::from([(
1255                (memory, "kind".to_string()),
1256                vec![Value::String("note".to_string())],
1257            )]),
1258            ..GraphStatistics::default()
1259        };
1260
1261        let incomplete = optimizer_catalog_from_graph_statistics(&source_catalog, &statistics);
1262        assert_eq!(incomplete.label_count("Memory"), 64);
1263        assert_eq!(incomplete.known_distinct_count("Memory", "kind"), None);
1264        assert!(incomplete.property_histograms.is_empty());
1265
1266        statistics.advanced_statistics_complete = true;
1267        let complete = optimizer_catalog_from_graph_statistics(&source_catalog, &statistics);
1268        assert_eq!(complete.label_count("Memory"), 64);
1269        assert_eq!(complete.known_distinct_count("Memory", "kind"), Some(4));
1270        assert_eq!(
1271            complete.property_histograms,
1272            BTreeMap::from([(
1273                ("Memory".to_string(), "kind".to_string()),
1274                vec![Value::String("note".to_string())],
1275            )])
1276        );
1277    }
1278
1279    #[test]
1280    fn range_cardinality_honors_half_open_histogram_bounds() {
1281        let catalog = OptimizerCatalog::new(
1282            OptimizerCatalogIndexes::default(),
1283            OptimizerCatalogStatistics::new(
1284                [("Memory".to_string(), 1_000)],
1285                [],
1286                [],
1287                [],
1288                [],
1289                [],
1290                [(
1291                    ("Memory".to_string(), "created_at".to_string()),
1292                    (0..100).map(Value::Int).collect::<Vec<_>>(),
1293                )],
1294            ),
1295        );
1296
1297        assert_eq!(
1298            catalog.estimate_range_bounds_rows(
1299                "Memory",
1300                "created_at",
1301                Some(&(Value::Int(10), true)),
1302                Some(&(Value::Int(20), false)),
1303            ),
1304            100
1305        );
1306    }
1307
1308    #[test]
1309    fn sampled_histograms_shrink_range_selectivity_toward_default() {
1310        let property_key = ("Memory".to_string(), "score".to_string());
1311        let rel_property_key = ("MENTIONS".to_string(), "score".to_string());
1312        let histogram = (0..100).map(Value::Int).collect::<Vec<_>>();
1313        let statistics = OptimizerCatalogStatistics::new(
1314            [("Memory".to_string(), 1_000)],
1315            [],
1316            [],
1317            [],
1318            [],
1319            [(property_key.clone(), 100)],
1320            [(property_key.clone(), histogram.clone())],
1321        )
1322        .with_relationship_property_histograms([(rel_property_key.clone(), histogram)]);
1323        let exhaustive =
1324            OptimizerCatalog::new(OptimizerCatalogIndexes::default(), statistics.clone());
1325        let sampled = OptimizerCatalog::new(
1326            OptimizerCatalogIndexes::default(),
1327            statistics
1328                .with_sampled_property_histograms([(property_key, true)])
1329                .with_sampled_relationship_property_histograms([(rel_property_key, true)]),
1330        );
1331
1332        assert_eq!(
1333            exhaustive.estimate_range_rows("Memory", "score", ComparisonOp::Lt, &Value::Int(10)),
1334            100
1335        );
1336        assert_eq!(
1337            sampled.estimate_range_rows("Memory", "score", ComparisonOp::Lt, &Value::Int(10)),
1338            108
1339        );
1340        assert_eq!(
1341            sampled.estimate_property_range_rows(
1342                "Memory",
1343                "score",
1344                ComparisonOp::Lt,
1345                &Value::Int(10),
1346                1_000,
1347            ),
1348            108
1349        );
1350        assert_eq!(
1351            sampled.estimate_range_bounds_rows(
1352                "Memory",
1353                "score",
1354                None,
1355                Some(&(Value::Int(10), false)),
1356            ),
1357            108
1358        );
1359        assert_eq!(
1360            exhaustive.estimate_rel_property_range_rows(
1361                "MENTIONS",
1362                "score",
1363                ComparisonOp::Lt,
1364                &Value::Int(10),
1365                1_000,
1366            ),
1367            100
1368        );
1369        assert_eq!(
1370            sampled.estimate_rel_property_range_rows(
1371                "MENTIONS",
1372                "score",
1373                ComparisonOp::Lt,
1374                &Value::Int(10),
1375                1_000,
1376            ),
1377            108
1378        );
1379    }
1380
1381    #[test]
1382    fn index_cardinality_uses_sparse_index_size_and_joint_ndv() {
1383        let statistics = OptimizerCatalogStatistics {
1384            label_counts: BTreeMap::from([("Memory".to_string(), 100)]),
1385            property_index_statistics: BTreeMap::from([(
1386                ("Memory".to_string(), "kind".to_string()),
1387                OptimizerIndexStatistics {
1388                    index_size: 10,
1389                    distinct_count: 2,
1390                },
1391            )]),
1392            composite_index_statistics: BTreeMap::from([(
1393                (
1394                    "Memory".to_string(),
1395                    vec!["kind".to_string(), "source_id".to_string()],
1396                ),
1397                OptimizerIndexStatistics {
1398                    index_size: 8,
1399                    distinct_count: 4,
1400                },
1401            )]),
1402            ..OptimizerCatalogStatistics::default()
1403        };
1404        let catalog = OptimizerCatalog::new(OptimizerCatalogIndexes::default(), statistics);
1405
1406        assert_eq!(catalog.estimate_property_index_eq_rows("Memory", "kind"), 5);
1407        assert_eq!(
1408            catalog.estimate_property_index_in_rows("Memory", "kind", 10),
1409            10
1410        );
1411        assert_eq!(
1412            catalog.estimate_composite_property_index_rows(
1413                "Memory",
1414                &["kind".to_string(), "source_id".to_string()],
1415            ),
1416            2
1417        );
1418    }
1419}