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