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