Skip to main content

lora_store/memory/
stats.rs

1//! Lightweight cardinality stats used by the cost model.
2//!
3//! Label and type counts are exact, derived in O(labels + types) from
4//! the existing `nodes_by_label` / `relationships_by_type` maps. The
5//! per-(label / type, property) distinct counts are estimates from the
6//! counting multi-resolution bitmaps in `memory::distinct_stats`, kept
7//! for every property key on every write (deletes included) and
8//! rebuilt on snapshot load / WAL replay. No separate ANALYZE phase, no
9//! background sampling, and no dependence on which hash property indexes
10//! happen to be active.
11
12use std::collections::{hash_map::DefaultHasher, BTreeMap, BTreeSet};
13use std::hash::{Hash, Hasher};
14use std::sync::Arc;
15
16use super::index_catalog::{StoredIndexEntity, StoredIndexKind};
17
18/// `(label or relationship type, property key) → estimated distinct
19/// values`.
20pub type DistinctValues = BTreeMap<(String, String), usize>;
21
22/// Snapshot of graph cardinality. Populated by the storage backend
23/// (see [`super::InMemoryGraph::stats`]).
24#[derive(Debug, Default, Clone, PartialEq, Eq)]
25pub struct GraphStats {
26    /// Total live node count.
27    pub node_count: usize,
28    /// Total live relationship count.
29    pub relationship_count: usize,
30    /// Per-label node count. `nodes_by_label[label].len()`.
31    pub nodes_by_label: BTreeMap<String, usize>,
32    /// Per-rel-type relationship count. `relationships_by_type[type].len()`.
33    pub relationships_by_type: BTreeMap<String, usize>,
34    /// Per-(label, property) approximate distinct value count, for
35    /// every property key that a live node with that label carries,
36    /// indexed or not. Estimated from a distinct-value sketch kept on
37    /// every write (see `memory::distinct_stats`): a function of the live
38    /// data alone, so a restarted process, the writer and a process with
39    /// a different lookup history get the same numbers. Absent only for
40    /// keys no node of the label has (or whose values the hash index
41    /// can't key: NaN, durations, points, vectors). Shared: the store
42    /// reuses one map while the estimates don't change.
43    pub node_distinct_values: Arc<DistinctValues>,
44    /// Per-(relationship type, property) distinct value count; as above.
45    pub relationship_distinct_values: Arc<DistinctValues>,
46    /// Online catalog-backed range indexes by `(label_or_type, property)`.
47    pub node_range_indexes: BTreeSet<(String, String)>,
48    pub relationship_range_indexes: BTreeSet<(String, String)>,
49    /// Online catalog-backed text indexes by `(label_or_type, property)`.
50    pub node_text_indexes: BTreeSet<(String, String)>,
51    pub relationship_text_indexes: BTreeSet<(String, String)>,
52    /// Online catalog-backed point indexes by `(label_or_type, property)`.
53    pub node_point_indexes: BTreeSet<(String, String)>,
54    pub relationship_point_indexes: BTreeSet<(String, String)>,
55    /// Online catalog-backed vector indexes by `(label_or_type, property)`.
56    /// Tracked alongside the other index scopes so the optimizer / planner
57    /// can see them in stats fingerprints, though kNN currently goes
58    /// through a flat per-query scan rather than a dedicated structure.
59    pub node_vector_indexes: BTreeSet<(String, String)>,
60    pub relationship_vector_indexes: BTreeSet<(String, String)>,
61    /// List elements held by the RANGE index scopes whose property holds
62    /// lists, by `(label_or_type, property)`: what an element seek
63    /// (`$x IN n.list`) is costed from. Exact counts, kept on every write.
64    pub node_list_elements: BTreeMap<(String, String), ListElementStats>,
65    pub relationship_list_elements: BTreeMap<(String, String), ListElementStats>,
66}
67
68/// List elements in one RANGE index scope (see
69/// [`GraphStats::node_list_elements`]).
70#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
71pub struct ListElementStats {
72    /// Distinct elements across every list.
73    pub distinct: usize,
74    /// `(element, entity)` pairs: each list counts each distinct element
75    /// it holds once.
76    pub postings: usize,
77}
78
79impl ListElementStats {
80    /// Entities one element seek finds, on average.
81    pub fn per_element(&self) -> u64 {
82        (self.postings as u64).div_ceil(self.distinct.max(1) as u64)
83    }
84}
85
86/// Builds a [`GraphStats::plan_signature`]: a hash of every input the
87/// planner reads from the stats, with cardinalities coarsened to
88/// power-of-two buckets. Backends feed it the same items `GraphStats`
89/// holds, in a deterministic order, without building the maps.
90pub struct PlanSignature {
91    hasher: DefaultHasher,
92}
93
94impl Default for PlanSignature {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100impl PlanSignature {
101    pub fn new() -> Self {
102        Self {
103            hasher: DefaultHasher::new(),
104        }
105    }
106
107    /// Bucket of a cardinality: 0 for 0, else its bit length (1, 2-3,
108    /// 4-7, ...). Cost estimates within one bucket are within a factor
109    /// of two of each other.
110    pub fn bucket(count: usize) -> u32 {
111        usize::BITS - count.leading_zeros()
112    }
113
114    pub fn totals(&mut self, nodes: usize, relationships: usize) {
115        (0u8, Self::bucket(nodes), Self::bucket(relationships)).hash(&mut self.hasher);
116    }
117
118    /// A label present in the stats (even with no nodes left: an absent
119    /// label and an empty one estimate differently).
120    pub fn label(&mut self, label: &str, nodes: usize) {
121        (1u8, label, Self::bucket(nodes)).hash(&mut self.hasher);
122    }
123
124    pub fn relationship_type(&mut self, rel_type: &str, relationships: usize) {
125        (2u8, rel_type, Self::bucket(relationships)).hash(&mut self.hasher);
126    }
127
128    pub fn node_distinct(&mut self, label: &str, property: &str, distinct: usize) {
129        (3u8, label, property, Self::bucket(distinct)).hash(&mut self.hasher);
130    }
131
132    pub fn relationship_distinct(&mut self, rel_type: &str, property: &str, distinct: usize) {
133        (4u8, rel_type, property, Self::bucket(distinct)).hash(&mut self.hasher);
134    }
135
136    /// An online range / text / point / vector index scope, as the
137    /// `*_indexes` sets of [`GraphStats`] hold them.
138    pub fn index(
139        &mut self,
140        entity: StoredIndexEntity,
141        kind: StoredIndexKind,
142        label: &str,
143        property: &str,
144    ) {
145        (5u8, entity, kind.as_str(), label, property).hash(&mut self.hasher);
146    }
147
148    /// A RANGE index scope holding list elements, by the entities one
149    /// element seek finds on average.
150    pub fn list_elements(
151        &mut self,
152        entity: StoredIndexEntity,
153        label: &str,
154        property: &str,
155        stats: ListElementStats,
156    ) {
157        (
158            6u8,
159            entity,
160            label,
161            property,
162            Self::bucket(stats.per_element() as usize),
163        )
164            .hash(&mut self.hasher);
165    }
166
167    pub fn finish(&self) -> u64 {
168        self.hasher.finish()
169    }
170}
171
172impl GraphStats {
173    /// Selectivity of an equality predicate `label:prop = value`.
174    /// Returns `Some(rows)` when we have enough info to answer; `None`
175    /// when the optimizer should fall back to its conservative default.
176    pub fn estimate_node_property_equality(&self, label: &str, property: &str) -> Option<u64> {
177        let total = self.nodes_by_label.get(label).copied()? as u64;
178        let distinct = self
179            .node_distinct_values
180            .get(&(label.to_string(), property.to_string()))
181            .copied()
182            .unwrap_or(1)
183            .max(1) as u64;
184        // Uniform-distribution heuristic: each value owns
185        // ⌈total / distinct⌉ rows.
186        Some(total.div_ceil(distinct))
187    }
188
189    /// Rows an element seek `$x IN label.property` finds, from the RANGE
190    /// index on the pair: the average entities per element, or 0 when
191    /// the indexed values hold no list element. `None` without the index.
192    pub fn estimate_node_list_element(&self, label: &str, property: &str) -> Option<u64> {
193        let scope = (label.to_owned(), property.to_owned());
194        if !self.node_range_indexes.contains(&scope) {
195            return None;
196        }
197        Some(
198            self.node_list_elements
199                .get(&scope)
200                .map_or(0, ListElementStats::per_element),
201        )
202    }
203
204    /// Mirror of [`Self::estimate_node_list_element`] for relationships.
205    pub fn estimate_relationship_list_element(
206        &self,
207        rel_type: &str,
208        property: &str,
209    ) -> Option<u64> {
210        let scope = (rel_type.to_owned(), property.to_owned());
211        if !self.relationship_range_indexes.contains(&scope) {
212            return None;
213        }
214        Some(
215            self.relationship_list_elements
216                .get(&scope)
217                .map_or(0, ListElementStats::per_element),
218        )
219    }
220
221    pub fn label_count(&self, label: &str) -> Option<u64> {
222        self.nodes_by_label.get(label).copied().map(|c| c as u64)
223    }
224
225    pub fn relationship_type_count(&self, rel_type: &str) -> Option<u64> {
226        self.relationships_by_type
227            .get(rel_type)
228            .copied()
229            .map(|c| c as u64)
230    }
231
232    pub fn fingerprint(&self) -> u64 {
233        let mut hasher = DefaultHasher::new();
234        self.node_count.hash(&mut hasher);
235        self.relationship_count.hash(&mut hasher);
236        self.nodes_by_label.hash(&mut hasher);
237        self.relationships_by_type.hash(&mut hasher);
238        self.node_distinct_values.hash(&mut hasher);
239        self.relationship_distinct_values.hash(&mut hasher);
240        self.node_range_indexes.hash(&mut hasher);
241        self.relationship_range_indexes.hash(&mut hasher);
242        self.node_text_indexes.hash(&mut hasher);
243        self.relationship_text_indexes.hash(&mut hasher);
244        self.node_point_indexes.hash(&mut hasher);
245        self.relationship_point_indexes.hash(&mut hasher);
246        self.node_vector_indexes.hash(&mut hasher);
247        self.relationship_vector_indexes.hash(&mut hasher);
248        self.node_list_elements.hash(&mut hasher);
249        self.relationship_list_elements.hash(&mut hasher);
250        hasher.finish()
251    }
252
253    /// Hash of what the planner reads from these stats, with every
254    /// cardinality coarsened to a power-of-two bucket (see
255    /// [`PlanSignature`]). Two stats with the same signature have the
256    /// same labels, types, counted keys and online index scopes, and
257    /// counts within a factor of two: a plan made for one is a sound
258    /// and reasonable plan for the other. Plans never depend on stats
259    /// for correctness, only for cost.
260    pub fn plan_signature(&self) -> u64 {
261        let mut sig = PlanSignature::new();
262        sig.totals(self.node_count, self.relationship_count);
263        for (label, count) in &self.nodes_by_label {
264            sig.label(label, *count);
265        }
266        for (rel_type, count) in &self.relationships_by_type {
267            sig.relationship_type(rel_type, *count);
268        }
269        for ((label, property), distinct) in self.node_distinct_values.iter() {
270            sig.node_distinct(label, property, *distinct);
271        }
272        for ((rel_type, property), distinct) in self.relationship_distinct_values.iter() {
273            sig.relationship_distinct(rel_type, property, *distinct);
274        }
275        let scopes = [
276            (
277                StoredIndexEntity::Node,
278                StoredIndexKind::Range,
279                &self.node_range_indexes,
280            ),
281            (
282                StoredIndexEntity::Node,
283                StoredIndexKind::Text,
284                &self.node_text_indexes,
285            ),
286            (
287                StoredIndexEntity::Node,
288                StoredIndexKind::Point,
289                &self.node_point_indexes,
290            ),
291            (
292                StoredIndexEntity::Node,
293                StoredIndexKind::Vector,
294                &self.node_vector_indexes,
295            ),
296            (
297                StoredIndexEntity::Relationship,
298                StoredIndexKind::Range,
299                &self.relationship_range_indexes,
300            ),
301            (
302                StoredIndexEntity::Relationship,
303                StoredIndexKind::Text,
304                &self.relationship_text_indexes,
305            ),
306            (
307                StoredIndexEntity::Relationship,
308                StoredIndexKind::Point,
309                &self.relationship_point_indexes,
310            ),
311            (
312                StoredIndexEntity::Relationship,
313                StoredIndexKind::Vector,
314                &self.relationship_vector_indexes,
315            ),
316        ];
317        for (entity, kind, set) in scopes {
318            for (label, property) in set {
319                sig.index(entity, kind, label, property);
320            }
321        }
322        for (entity, elements) in [
323            (StoredIndexEntity::Node, &self.node_list_elements),
324            (
325                StoredIndexEntity::Relationship,
326                &self.relationship_list_elements,
327            ),
328        ] {
329            for ((label, property), stats) in elements {
330                sig.list_elements(entity, label, property, *stats);
331            }
332        }
333        sig.finish()
334    }
335
336    pub fn has_node_range_index(&self, label: &str, property: &str) -> bool {
337        self.node_range_indexes
338            .contains(&(label.to_owned(), property.to_owned()))
339    }
340
341    pub fn has_node_text_index(&self, label: &str, property: &str) -> bool {
342        self.node_text_indexes
343            .contains(&(label.to_owned(), property.to_owned()))
344    }
345
346    pub fn has_node_point_index(&self, label: &str, property: &str) -> bool {
347        self.node_point_indexes
348            .contains(&(label.to_owned(), property.to_owned()))
349    }
350
351    pub fn has_relationship_range_index(&self, rel_type: &str, property: &str) -> bool {
352        self.relationship_range_indexes
353            .contains(&(rel_type.to_owned(), property.to_owned()))
354    }
355
356    pub fn has_relationship_text_index(&self, rel_type: &str, property: &str) -> bool {
357        self.relationship_text_indexes
358            .contains(&(rel_type.to_owned(), property.to_owned()))
359    }
360
361    pub fn has_relationship_point_index(&self, rel_type: &str, property: &str) -> bool {
362        self.relationship_point_indexes
363            .contains(&(rel_type.to_owned(), property.to_owned()))
364    }
365
366    pub fn has_node_vector_index(&self, label: &str, property: &str) -> bool {
367        self.node_vector_indexes
368            .contains(&(label.to_owned(), property.to_owned()))
369    }
370
371    pub fn has_relationship_vector_index(&self, rel_type: &str, property: &str) -> bool {
372        self.relationship_vector_indexes
373            .contains(&(rel_type.to_owned(), property.to_owned()))
374    }
375}