Skip to main content

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}