Skip to main content

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