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}
62
63/// Builds a [`GraphStats::plan_signature`]: a hash of every input the
64/// planner reads from the stats, with cardinalities coarsened to
65/// power-of-two buckets. Backends feed it the same items `GraphStats`
66/// holds, in a deterministic order, without building the maps.
67pub struct PlanSignature {
68    hasher: DefaultHasher,
69}
70
71impl Default for PlanSignature {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl PlanSignature {
78    pub fn new() -> Self {
79        Self {
80            hasher: DefaultHasher::new(),
81        }
82    }
83
84    /// Bucket of a cardinality: 0 for 0, else its bit length (1, 2-3,
85    /// 4-7, ...). Cost estimates within one bucket are within a factor
86    /// of two of each other.
87    pub fn bucket(count: usize) -> u32 {
88        usize::BITS - count.leading_zeros()
89    }
90
91    pub fn totals(&mut self, nodes: usize, relationships: usize) {
92        (0u8, Self::bucket(nodes), Self::bucket(relationships)).hash(&mut self.hasher);
93    }
94
95    /// A label present in the stats (even with no nodes left: an absent
96    /// label and an empty one estimate differently).
97    pub fn label(&mut self, label: &str, nodes: usize) {
98        (1u8, label, Self::bucket(nodes)).hash(&mut self.hasher);
99    }
100
101    pub fn relationship_type(&mut self, rel_type: &str, relationships: usize) {
102        (2u8, rel_type, Self::bucket(relationships)).hash(&mut self.hasher);
103    }
104
105    pub fn node_distinct(&mut self, label: &str, property: &str, distinct: usize) {
106        (3u8, label, property, Self::bucket(distinct)).hash(&mut self.hasher);
107    }
108
109    pub fn relationship_distinct(&mut self, rel_type: &str, property: &str, distinct: usize) {
110        (4u8, rel_type, property, Self::bucket(distinct)).hash(&mut self.hasher);
111    }
112
113    /// An online range / text / point / vector index scope, as the
114    /// `*_indexes` sets of [`GraphStats`] hold them.
115    pub fn index(
116        &mut self,
117        entity: StoredIndexEntity,
118        kind: StoredIndexKind,
119        label: &str,
120        property: &str,
121    ) {
122        (5u8, entity, kind.as_str(), label, property).hash(&mut self.hasher);
123    }
124
125    pub fn finish(&self) -> u64 {
126        self.hasher.finish()
127    }
128}
129
130impl GraphStats {
131    /// Selectivity of an equality predicate `label:prop = value`.
132    /// Returns `Some(rows)` when we have enough info to answer; `None`
133    /// when the optimizer should fall back to its conservative default.
134    pub fn estimate_node_property_equality(&self, label: &str, property: &str) -> Option<u64> {
135        let total = self.nodes_by_label.get(label).copied()? as u64;
136        let distinct = self
137            .node_distinct_values
138            .get(&(label.to_string(), property.to_string()))
139            .copied()
140            .unwrap_or(1)
141            .max(1) as u64;
142        // Uniform-distribution heuristic: each value owns
143        // ⌈total / distinct⌉ rows.
144        Some(total.div_ceil(distinct))
145    }
146
147    pub fn label_count(&self, label: &str) -> Option<u64> {
148        self.nodes_by_label.get(label).copied().map(|c| c as u64)
149    }
150
151    pub fn relationship_type_count(&self, rel_type: &str) -> Option<u64> {
152        self.relationships_by_type
153            .get(rel_type)
154            .copied()
155            .map(|c| c as u64)
156    }
157
158    pub fn fingerprint(&self) -> u64 {
159        let mut hasher = DefaultHasher::new();
160        self.node_count.hash(&mut hasher);
161        self.relationship_count.hash(&mut hasher);
162        self.nodes_by_label.hash(&mut hasher);
163        self.relationships_by_type.hash(&mut hasher);
164        self.node_distinct_values.hash(&mut hasher);
165        self.relationship_distinct_values.hash(&mut hasher);
166        self.node_range_indexes.hash(&mut hasher);
167        self.relationship_range_indexes.hash(&mut hasher);
168        self.node_text_indexes.hash(&mut hasher);
169        self.relationship_text_indexes.hash(&mut hasher);
170        self.node_point_indexes.hash(&mut hasher);
171        self.relationship_point_indexes.hash(&mut hasher);
172        self.node_vector_indexes.hash(&mut hasher);
173        self.relationship_vector_indexes.hash(&mut hasher);
174        hasher.finish()
175    }
176
177    /// Hash of what the planner reads from these stats, with every
178    /// cardinality coarsened to a power-of-two bucket (see
179    /// [`PlanSignature`]). Two stats with the same signature have the
180    /// same labels, types, counted keys and online index scopes, and
181    /// counts within a factor of two: a plan made for one is a sound
182    /// and reasonable plan for the other. Plans never depend on stats
183    /// for correctness, only for cost.
184    pub fn plan_signature(&self) -> u64 {
185        let mut sig = PlanSignature::new();
186        sig.totals(self.node_count, self.relationship_count);
187        for (label, count) in &self.nodes_by_label {
188            sig.label(label, *count);
189        }
190        for (rel_type, count) in &self.relationships_by_type {
191            sig.relationship_type(rel_type, *count);
192        }
193        for ((label, property), distinct) in self.node_distinct_values.iter() {
194            sig.node_distinct(label, property, *distinct);
195        }
196        for ((rel_type, property), distinct) in self.relationship_distinct_values.iter() {
197            sig.relationship_distinct(rel_type, property, *distinct);
198        }
199        let scopes = [
200            (
201                StoredIndexEntity::Node,
202                StoredIndexKind::Range,
203                &self.node_range_indexes,
204            ),
205            (
206                StoredIndexEntity::Node,
207                StoredIndexKind::Text,
208                &self.node_text_indexes,
209            ),
210            (
211                StoredIndexEntity::Node,
212                StoredIndexKind::Point,
213                &self.node_point_indexes,
214            ),
215            (
216                StoredIndexEntity::Node,
217                StoredIndexKind::Vector,
218                &self.node_vector_indexes,
219            ),
220            (
221                StoredIndexEntity::Relationship,
222                StoredIndexKind::Range,
223                &self.relationship_range_indexes,
224            ),
225            (
226                StoredIndexEntity::Relationship,
227                StoredIndexKind::Text,
228                &self.relationship_text_indexes,
229            ),
230            (
231                StoredIndexEntity::Relationship,
232                StoredIndexKind::Point,
233                &self.relationship_point_indexes,
234            ),
235            (
236                StoredIndexEntity::Relationship,
237                StoredIndexKind::Vector,
238                &self.relationship_vector_indexes,
239            ),
240        ];
241        for (entity, kind, set) in scopes {
242            for (label, property) in set {
243                sig.index(entity, kind, label, property);
244            }
245        }
246        sig.finish()
247    }
248
249    pub fn has_node_range_index(&self, label: &str, property: &str) -> bool {
250        self.node_range_indexes
251            .contains(&(label.to_owned(), property.to_owned()))
252    }
253
254    pub fn has_node_text_index(&self, label: &str, property: &str) -> bool {
255        self.node_text_indexes
256            .contains(&(label.to_owned(), property.to_owned()))
257    }
258
259    pub fn has_node_point_index(&self, label: &str, property: &str) -> bool {
260        self.node_point_indexes
261            .contains(&(label.to_owned(), property.to_owned()))
262    }
263
264    pub fn has_relationship_range_index(&self, rel_type: &str, property: &str) -> bool {
265        self.relationship_range_indexes
266            .contains(&(rel_type.to_owned(), property.to_owned()))
267    }
268
269    pub fn has_relationship_text_index(&self, rel_type: &str, property: &str) -> bool {
270        self.relationship_text_indexes
271            .contains(&(rel_type.to_owned(), property.to_owned()))
272    }
273
274    pub fn has_relationship_point_index(&self, rel_type: &str, property: &str) -> bool {
275        self.relationship_point_indexes
276            .contains(&(rel_type.to_owned(), property.to_owned()))
277    }
278
279    pub fn has_node_vector_index(&self, label: &str, property: &str) -> bool {
280        self.node_vector_indexes
281            .contains(&(label.to_owned(), property.to_owned()))
282    }
283
284    pub fn has_relationship_vector_index(&self, rel_type: &str, property: &str) -> bool {
285        self.relationship_vector_indexes
286            .contains(&(rel_type.to_owned(), property.to_owned()))
287    }
288}