issundb_core/graph/stats.rs
1//! High-order cardinality statistics for the query optimizer.
2//!
3//! The optimizer estimates the cost of an `Expand` from the average fan-out per
4//! input row: the number of edges of the expanded type divided by the number of
5//! candidate source nodes. The simplest model divides the global typed edge
6//! count by the total node count, which assumes every node type expands at the
7//! same rate. Real schemas are skewed: a `Person` may have dozens of `KNOWS`
8//! edges while a `City` has none, yet both inflate the global denominator.
9//!
10//! This module precomputes the per-source-label typed out-degree (and the
11//! symmetric per-destination-label typed in-degree): for each `(label, type)`
12//! pair, the count of edges of that type incident to a node carrying that label
13//! in the given direction. Dividing by the label's node count yields the
14//! per-label expand ratio, the "expand ratio" of high-order statistics. The
15//! table is a schema-level aggregate (bounded by distinct labels times distinct
16//! types), recomputed by one full scan and cached against the committed-write
17//! generation, so it is refreshed only when writes advance past the cached
18//! value. Estimates only drive plan weights, so a stale table never affects
19//! correctness.
20
21use ahash::AHashMap;
22
23use crate::{
24 error::Error,
25 schema::{EdgeRecord, LabelId, NodeId, NodeRecord, TypeId},
26 storage::{
27 ids::{get_label, get_type},
28 lmdb::Storage,
29 props,
30 },
31};
32
33use super::Graph;
34
35/// The data graph schema as edge frequencies, tagged with the committed-write
36/// generation the table reflects.
37///
38/// `out_by_src_label` and `in_by_dst_label` are the per-source-label and
39/// per-destination-label typed edge counts (the marginals) that back the
40/// expand-ratio cardinality estimate. `triples` is the realized schema graph:
41/// for each directed `(src_label, type, dst_label)` actually present in the
42/// data, the count of edges matching it. The set of `triples` keys is the
43/// schema connectivity that drives type inference; the counts refine the
44/// cardinality estimate when both endpoint labels are known.
45pub(crate) struct EdgeFanout {
46 /// The `csr_cache` write generation this table reflects.
47 generation: u64,
48 /// Count of edges of a type whose source node carries a label.
49 out_by_src_label: AHashMap<(LabelId, TypeId), u64>,
50 /// Count of edges of a type whose target node carries a label.
51 in_by_dst_label: AHashMap<(LabelId, TypeId), u64>,
52 /// Count of edges matching a realized `(src_label, type, dst_label)` schema
53 /// triple. A multi-label endpoint contributes one triple per label it
54 /// carries, so an edge between an `m`-label source and an `n`-label target
55 /// contributes to `m * n` triples.
56 triples: AHashMap<(LabelId, TypeId, LabelId), u64>,
57}
58
59impl EdgeFanout {
60 /// Build the frequency table from one pass over the node labels and one over
61 /// the edges. A node with multiple labels contributes to each of its labels,
62 /// matching the label-index semantics where such a node appears in every
63 /// matching label scan.
64 fn build(storage: &Storage, generation: u64) -> Result<Self, Error> {
65 let rtxn = storage.env.read_txn()?;
66
67 let mut node_labels: AHashMap<NodeId, Vec<LabelId>> = AHashMap::new();
68 for result in storage.nodes.iter(&rtxn)? {
69 let (id, bytes) = result?;
70 let rec: NodeRecord = props::decode(bytes)?;
71 if !rec.labels.is_empty() {
72 node_labels.insert(id, rec.labels);
73 }
74 }
75
76 let mut out_by_src_label: AHashMap<(LabelId, TypeId), u64> = AHashMap::new();
77 let mut in_by_dst_label: AHashMap<(LabelId, TypeId), u64> = AHashMap::new();
78 let mut triples: AHashMap<(LabelId, TypeId, LabelId), u64> = AHashMap::new();
79 for result in storage.edges.iter(&rtxn)? {
80 let (_edge_id, bytes) = result?;
81 let rec: EdgeRecord = props::decode(bytes)?;
82 let src_labels = node_labels.get(&rec.src);
83 let dst_labels = node_labels.get(&rec.dst);
84 if let Some(labels) = src_labels {
85 for &label in labels {
86 *out_by_src_label.entry((label, rec.edge_type)).or_insert(0) += 1;
87 }
88 }
89 if let Some(labels) = dst_labels {
90 for &label in labels {
91 *in_by_dst_label.entry((label, rec.edge_type)).or_insert(0) += 1;
92 }
93 }
94 if let (Some(srcs), Some(dsts)) = (src_labels, dst_labels) {
95 for &s in srcs {
96 for &d in dsts {
97 *triples.entry((s, rec.edge_type, d)).or_insert(0) += 1;
98 }
99 }
100 }
101 }
102
103 Ok(Self {
104 generation,
105 out_by_src_label,
106 in_by_dst_label,
107 triples,
108 })
109 }
110}
111
112impl Graph {
113 /// Resolve label and type names to their ids, returning `None` when either
114 /// is unknown to the registry (the caller then cannot decide on the schema).
115 fn resolve_label_type(
116 &self,
117 label: &str,
118 rel_type: &str,
119 ) -> Result<Option<(LabelId, TypeId)>, Error> {
120 let rtxn = self.storage.env.read_txn()?;
121 let label_id = match get_label(&self.storage, &rtxn, label)? {
122 Some(id) => id,
123 None => return Ok(None),
124 };
125 let type_id = match get_type(&self.storage, &rtxn, rel_type)? {
126 Some(id) => id,
127 None => return Ok(None),
128 };
129 Ok(Some((label_id, type_id)))
130 }
131
132 /// Run `f` against the cached schema table, rebuilding it first when
133 /// committed writes have advanced past the cached generation.
134 fn with_fanout<T>(&self, f: impl FnOnce(&EdgeFanout) -> T) -> Result<T, Error> {
135 let generation = self.csr_cache.current_gen();
136 let mut guard = self.edge_fanout.lock();
137 let fresh = guard.as_ref().is_some_and(|t| t.generation == generation);
138 if fresh {
139 if let Some(table) = guard.as_ref() {
140 return Ok(f(table));
141 }
142 }
143 // Stale or absent: rebuild, run the closure against the new table, then
144 // cache it. Computing the result before storing keeps the helper
145 // panic-free (no `expect` on the just-populated guard).
146 let table = EdgeFanout::build(&self.storage, generation)?;
147 let result = f(&table);
148 *guard = Some(table);
149 Ok(result)
150 }
151
152 /// Estimated average fan-out for expanding edges of `rel_type` from a node
153 /// carrying `src_label`: the per-source-label typed out-degree, or the typed
154 /// in-degree when `incoming` is true.
155 ///
156 /// Returns the count of qualifying edges divided by the count of
157 /// `src_label` nodes. Returns `None` when the label or type is unknown, the
158 /// label has no nodes, or no such edges exist, so the caller can fall back
159 /// to the global average fan-out. The underlying frequency table is
160 /// recomputed lazily when committed writes advance past the cached
161 /// generation; because the result only weights plan choices, a stale or
162 /// absent estimate never affects query correctness.
163 pub fn estimate_expand_fanout(
164 &self,
165 src_label: &str,
166 rel_type: &str,
167 incoming: bool,
168 ) -> Result<Option<f64>, Error> {
169 let (label_id, type_id) = match self.resolve_label_type(src_label, rel_type)? {
170 Some(ids) => ids,
171 None => return Ok(None),
172 };
173 let node_count = self.node_count_by_label(src_label)?;
174 if node_count == 0 {
175 return Ok(None);
176 }
177 self.with_fanout(|table| {
178 let map = if incoming {
179 &table.in_by_dst_label
180 } else {
181 &table.out_by_src_label
182 };
183 match map.get(&(label_id, type_id)).copied() {
184 Some(edges) if edges > 0 => Some(edges as f64 / node_count as f64),
185 _ => None,
186 }
187 })
188 }
189
190 /// Destination-label-aware fan-out: the average number of `dst_label`
191 /// neighbors reached by expanding edges of `rel_type` from a node carrying
192 /// `src_label` (or the symmetric in-direction when `incoming`).
193 ///
194 /// This sharpens [`Graph::estimate_expand_fanout`] when the expansion target
195 /// also carries a label, dividing the realized `(src_label, type, dst_label)`
196 /// triple count by the `src_label` node count instead of the type marginal.
197 /// Returns `None` (fall back to the marginal or the global average) when a
198 /// label or type is unknown, the source label has no nodes, or no such
199 /// triple exists.
200 pub fn estimate_expand_fanout_to(
201 &self,
202 src_label: &str,
203 rel_type: &str,
204 dst_label: &str,
205 incoming: bool,
206 ) -> Result<Option<f64>, Error> {
207 let (src_id, type_id) = match self.resolve_label_type(src_label, rel_type)? {
208 Some(ids) => ids,
209 None => return Ok(None),
210 };
211 let dst_id = {
212 let rtxn = self.storage.env.read_txn()?;
213 match get_label(&self.storage, &rtxn, dst_label)? {
214 Some(id) => id,
215 None => return Ok(None),
216 }
217 };
218 let node_count = self.node_count_by_label(src_label)?;
219 if node_count == 0 {
220 return Ok(None);
221 }
222 // An outgoing expand traverses `src --type--> dst`; an incoming expand
223 // from a `src_label` node reaches a `dst_label` node along the reversed
224 // edge `dst --type--> src`, so the triple key swaps its endpoints.
225 let key = if incoming {
226 (dst_id, type_id, src_id)
227 } else {
228 (src_id, type_id, dst_id)
229 };
230 self.with_fanout(|table| match table.triples.get(&key).copied() {
231 Some(edges) if edges > 0 => Some(edges as f64 / node_count as f64),
232 _ => None,
233 })
234 }
235
236 /// Whether the data schema contains any directed edge `src_label --rel_type-->
237 /// dst_label`. Returns `Some(false)` when the labels and type are all known
238 /// but no such edge exists (the directed pattern is unsatisfiable), and
239 /// `None` when any of the three names is unknown to the registry, so the
240 /// caller cannot decide.
241 ///
242 /// The underlying schema table reflects all committed writes (it is rebuilt
243 /// when the write generation advances), so a `Some(false)` is authoritative
244 /// for committed state. Callers that prune work on this answer must guard
245 /// against uncommitted same-statement writes, which the table cannot see.
246 pub fn schema_has_edge(
247 &self,
248 src_label: &str,
249 rel_type: &str,
250 dst_label: &str,
251 ) -> Result<Option<bool>, Error> {
252 let (src_id, type_id) = match self.resolve_label_type(src_label, rel_type)? {
253 Some(ids) => ids,
254 None => return Ok(None),
255 };
256 let dst_id = {
257 let rtxn = self.storage.env.read_txn()?;
258 match get_label(&self.storage, &rtxn, dst_label)? {
259 Some(id) => id,
260 None => return Ok(None),
261 }
262 };
263 self.with_fanout(|table| Some(table.triples.contains_key(&(src_id, type_id, dst_id))))
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use serde_json::json;
271 use tempfile::TempDir;
272
273 fn open_graph() -> (TempDir, Graph) {
274 let dir = TempDir::new().unwrap();
275 let graph = Graph::open(dir.path(), 1).unwrap();
276 (dir, graph)
277 }
278
279 #[test]
280 fn expand_fanout_is_per_source_label() {
281 let (_dir, graph) = open_graph();
282
283 // Three Person nodes and one City node. The global average fan-out would
284 // divide by all four nodes; the per-label ratio divides only by the
285 // Person count, so the two models disagree.
286 let p0 = graph.add_node("Person", &json!({})).unwrap();
287 let p1 = graph.add_node("Person", &json!({})).unwrap();
288 let p2 = graph.add_node("Person", &json!({})).unwrap();
289 let c0 = graph.add_node("City", &json!({})).unwrap();
290
291 // Two KNOWS edges, both leaving p0; one VISITED edge from p1 to c0.
292 graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
293 graph.add_edge(p0, p2, "KNOWS", &json!({})).unwrap();
294 graph.add_edge(p1, c0, "VISITED", &json!({})).unwrap();
295
296 // KNOWS out of Person: 2 edges / 3 Person nodes.
297 let knows = graph
298 .estimate_expand_fanout("Person", "KNOWS", false)
299 .unwrap();
300 assert_eq!(knows, Some(2.0 / 3.0));
301
302 // VISITED out of Person: 1 edge / 3 Person nodes.
303 let visited = graph
304 .estimate_expand_fanout("Person", "VISITED", false)
305 .unwrap();
306 assert_eq!(visited, Some(1.0 / 3.0));
307
308 // VISITED into City: 1 incoming edge / 1 City node.
309 let visited_in = graph
310 .estimate_expand_fanout("City", "VISITED", true)
311 .unwrap();
312 assert_eq!(visited_in, Some(1.0));
313
314 // A City has no outgoing KNOWS, so the caller falls back to the global
315 // model rather than treating the fan-out as zero.
316 let city_knows = graph
317 .estimate_expand_fanout("City", "KNOWS", false)
318 .unwrap();
319 assert_eq!(city_knows, None);
320
321 // Unknown label and unknown type both fall back.
322 assert_eq!(
323 graph
324 .estimate_expand_fanout("Ghost", "KNOWS", false)
325 .unwrap(),
326 None
327 );
328 assert_eq!(
329 graph
330 .estimate_expand_fanout("Person", "GHOST", false)
331 .unwrap(),
332 None
333 );
334 }
335
336 #[test]
337 fn expand_fanout_refreshes_after_writes() {
338 let (_dir, graph) = open_graph();
339 let p0 = graph.add_node("Person", &json!({})).unwrap();
340 let p1 = graph.add_node("Person", &json!({})).unwrap();
341 graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
342
343 // One KNOWS edge over two Person nodes.
344 assert_eq!(
345 graph
346 .estimate_expand_fanout("Person", "KNOWS", false)
347 .unwrap(),
348 Some(0.5)
349 );
350
351 // Adding another KNOWS edge advances the write generation, so the cached
352 // table is rebuilt on the next query.
353 graph.add_edge(p1, p0, "KNOWS", &json!({})).unwrap();
354 assert_eq!(
355 graph
356 .estimate_expand_fanout("Person", "KNOWS", false)
357 .unwrap(),
358 Some(1.0)
359 );
360 }
361
362 #[test]
363 fn schema_has_edge_reflects_realized_triples() {
364 let (_dir, graph) = open_graph();
365 let p0 = graph.add_node("Person", &json!({})).unwrap();
366 let p1 = graph.add_node("Person", &json!({})).unwrap();
367 let c0 = graph.add_node("City", &json!({})).unwrap();
368
369 // Person KNOWS Person, and Person LIVES_IN City. No City ever has an
370 // outgoing KNOWS, and no Person LIVES_IN a Person.
371 graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
372 graph.add_edge(p0, c0, "LIVES_IN", &json!({})).unwrap();
373
374 assert_eq!(
375 graph.schema_has_edge("Person", "KNOWS", "Person").unwrap(),
376 Some(true)
377 );
378 assert_eq!(
379 graph.schema_has_edge("Person", "LIVES_IN", "City").unwrap(),
380 Some(true)
381 );
382 // Realized in neither the data nor the schema: a provably empty pattern.
383 assert_eq!(
384 graph.schema_has_edge("City", "KNOWS", "Person").unwrap(),
385 Some(false)
386 );
387 assert_eq!(
388 graph
389 .schema_has_edge("Person", "LIVES_IN", "Person")
390 .unwrap(),
391 Some(false)
392 );
393 // Unknown label or type yields an undecidable answer, never a false prune.
394 assert_eq!(
395 graph.schema_has_edge("Ghost", "KNOWS", "Person").unwrap(),
396 None
397 );
398 assert_eq!(
399 graph.schema_has_edge("Person", "GHOST", "Person").unwrap(),
400 None
401 );
402 }
403
404 #[test]
405 fn expand_fanout_to_uses_destination_label() {
406 let (_dir, graph) = open_graph();
407 // p0 KNOWS one Person and two Cities. The marginal KNOWS fan-out mixes
408 // both targets; the destination-aware fan-out separates them.
409 let p0 = graph.add_node("Person", &json!({})).unwrap();
410 let p1 = graph.add_node("Person", &json!({})).unwrap();
411 let c0 = graph.add_node("City", &json!({})).unwrap();
412 let c1 = graph.add_node("City", &json!({})).unwrap();
413 graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
414 graph.add_edge(p0, c0, "KNOWS", &json!({})).unwrap();
415 graph.add_edge(p0, c1, "KNOWS", &json!({})).unwrap();
416
417 // Two Person nodes (p0, p1); the marginal KNOWS fan-out is 3 edges / 2.
418 assert_eq!(
419 graph
420 .estimate_expand_fanout("Person", "KNOWS", false)
421 .unwrap(),
422 Some(1.5)
423 );
424 // Of those edges, one targets a Person and two target a City, each over
425 // the same two Person sources.
426 assert_eq!(
427 graph
428 .estimate_expand_fanout_to("Person", "KNOWS", "Person", false)
429 .unwrap(),
430 Some(0.5)
431 );
432 assert_eq!(
433 graph
434 .estimate_expand_fanout_to("Person", "KNOWS", "City", false)
435 .unwrap(),
436 Some(1.0)
437 );
438 // A schema-absent destination falls back rather than reporting zero.
439 let p2 = graph.add_node("Robot", &json!({})).unwrap();
440 let _ = p2;
441 assert_eq!(
442 graph
443 .estimate_expand_fanout_to("Person", "KNOWS", "Robot", false)
444 .unwrap(),
445 None
446 );
447 }
448}