Skip to main content

issundb_core/graph/
algo.rs

1use super::*;
2
3impl Graph {
4    // ------------------------------------------------------------------
5    // Graph algorithms
6    // ------------------------------------------------------------------
7
8    /// Depth-first search outward from `start` up to `hops` levels deep.
9    pub fn dfs(&self, start: NodeId, hops: u8) -> Result<Vec<NodeId>, Error> {
10        self.ensure_csr_fresh()?;
11        let guard = self.matrices.read();
12        let m = guard
13            .as_ref()
14            .ok_or(Error::Corrupt("matrices not initialized"))?;
15        let snap = self.csr_cache.snapshot.load();
16        self.dfs_graphblas(m, &snap, start, hops)
17    }
18
19    /// Counts variable assignments of the directed triangle pattern
20    /// `(a)-[t1]->(b)-[t2]->(c)-[t3]->(a)` under `spec`'s per-hop relationship
21    /// types and per-variable labels.
22    ///
23    /// The count follows Cypher MATCH semantics: each distinct assignment of
24    /// `(a, b, c, e1, e2, e3)` is one match, so a single 3-cycle of distinct
25    /// nodes counts once per rotation of `a` (three when all hops share one
26    /// type), parallel edges multiply, and the three relationships must be
27    /// pairwise distinct (relationship uniqueness), which only constrains
28    /// self-loop assignments where `a == b == c`.
29    pub fn count_triangle_cycles(&self, spec: &TriangleCountSpec) -> Result<u64, Error> {
30        self.ensure_csr_fresh()?;
31        let snap = self.csr_cache.snapshot.load();
32        let n = snap.dense_to_id.len();
33        if n == 0 {
34            return Ok(0);
35        }
36
37        // A named but unregistered relationship type matches nothing.
38        let mut type_ids: [Option<TypeId>; 3] = [None; 3];
39        {
40            let rtxn = self.storage.env.read_txn()?;
41            for (i, name) in spec.rel_types.iter().enumerate() {
42                if let Some(name) = name {
43                    match get_type(&self.storage, &rtxn, name)? {
44                        Some(tid) => type_ids[i] = Some(tid),
45                        None => return Ok(0),
46                    }
47                }
48            }
49        }
50
51        // Dense-index masks for the per-variable labels; `None` means
52        // unconstrained. An unknown label yields an all-false mask, which
53        // counts zero without a special case.
54        let mut masks: [Option<Vec<bool>>; 3] = [None, None, None];
55        for (i, label) in spec.labels.iter().enumerate() {
56            if let Some(name) = label {
57                let mut mask = vec![false; n];
58                for id in self.nodes_by_label(name)? {
59                    if let Some(&d) = snap.id_to_dense.get(&id) {
60                        mask[d as usize] = true;
61                    }
62                }
63                masks[i] = Some(mask);
64            }
65        }
66        let label_ok = |mask: &Option<Vec<bool>>, d: usize| mask.as_ref().is_none_or(|m| m[d]);
67
68        // Sorted typed adjacency for each hop: hop 1 and hop 2 read forward
69        // rows, hop 3 reads the transpose (edges into `a`). Hop 2 reuses the
70        // hop-1 view when the types coincide.
71        let out1 = typed_out_sorted(&snap, type_ids[0]);
72        let out2_built = if type_ids[1] == type_ids[0] {
73            None
74        } else {
75            Some(typed_out_sorted(&snap, type_ids[1]))
76        };
77        let out2 = out2_built.as_ref().unwrap_or(&out1);
78        let in3 = typed_in_sorted(&snap, type_ids[2]);
79
80        let mut total: u64 = 0;
81        for a in 0..n {
82            if !label_ok(&masks[0], a) {
83                continue;
84            }
85            let in3_row = in3.row(a);
86            if in3_row.is_empty() {
87                continue;
88            }
89            let out1_row = out1.row(a);
90
91            let mut i = 0;
92            while i < out1_row.len() {
93                let b = out1_row[i].0 as usize;
94                let run1_start = i;
95                while i < out1_row.len() && out1_row[i].0 as usize == b {
96                    i += 1;
97                }
98                if !label_ok(&masks[1], b) {
99                    continue;
100                }
101                let m1 = (i - run1_start) as u64;
102                let out2_row = out2.row(b);
103
104                // Sorted merge of the hop-2 candidates from `b` against the
105                // hop-3 sources into `a`; equal runs give parallel-edge
106                // multiplicities.
107                let (mut j, mut k) = (0, 0);
108                let mut pair_count: u64 = 0;
109                while j < out2_row.len() && k < in3_row.len() {
110                    let c2 = out2_row[j].0;
111                    let c3 = in3_row[k].0;
112                    match c2.cmp(&c3) {
113                        std::cmp::Ordering::Less => j += 1,
114                        std::cmp::Ordering::Greater => k += 1,
115                        std::cmp::Ordering::Equal => {
116                            let c = c2 as usize;
117                            let j0 = j;
118                            while j < out2_row.len() && out2_row[j].0 as usize == c {
119                                j += 1;
120                            }
121                            let k0 = k;
122                            while k < in3_row.len() && in3_row[k].0 as usize == c {
123                                k += 1;
124                            }
125                            if !label_ok(&masks[2], c) {
126                                continue;
127                            }
128                            if a == b && c == a {
129                                // Every hop is a self-loop at `a`, the one shape
130                                // where two hops can bind the same relationship.
131                                // Enumerate ordered triples of pairwise-distinct
132                                // edge IDs explicitly; this term replaces the
133                                // multiplicity product for this cell, so it is
134                                // not scaled by `m1`.
135                                for &(_, e1) in &out1_row[run1_start..run1_start + m1 as usize] {
136                                    for &(_, e2) in &out2_row[j0..j] {
137                                        if e2 == e1 {
138                                            continue;
139                                        }
140                                        for &(_, e3) in &in3_row[k0..k] {
141                                            if e3 != e1 && e3 != e2 {
142                                                total += 1;
143                                            }
144                                        }
145                                    }
146                                }
147                            } else {
148                                pair_count += ((j - j0) * (k - k0)) as u64;
149                            }
150                        }
151                    }
152                }
153                total += m1 * pair_count;
154            }
155        }
156        Ok(total)
157    }
158
159    /// Counts variable assignments of an open directed path of one or two hops
160    /// under `spec`'s per-hop relationship types and per-variable labels, with
161    /// no materialization of the matched rows.
162    ///
163    /// The count follows Cypher MATCH semantics: each distinct assignment of
164    /// the node and relationship variables is one match, nodes may repeat,
165    /// parallel edges multiply, and for the two-hop pattern the two
166    /// relationships must be distinct (relationship uniqueness). That
167    /// uniqueness only removes assignments where a single edge could fill both
168    /// hops, which requires a self-loop shared by both hops.
169    ///
170    /// The Cypher optimizer lowers a grouping-free `count` over a one-hop or
171    /// two-hop directed expansion to this kernel via the `PathCount` physical operator.
172    pub fn count_linear_paths(&self, spec: &PathCountSpec) -> Result<u64, Error> {
173        let hops = spec.rel_types.len();
174        debug_assert!(hops == 1 || hops == 2, "count_linear_paths: 1 or 2 hops");
175        debug_assert_eq!(spec.labels.len(), hops + 1, "labels must be hops + 1");
176
177        self.ensure_csr_fresh()?;
178        let snap = self.csr_cache.snapshot.load();
179        let n = snap.dense_to_id.len();
180        if n == 0 {
181            return Ok(0);
182        }
183
184        // A named but unregistered relationship type matches nothing.
185        let mut type_ids: Vec<Option<TypeId>> = vec![None; hops];
186        {
187            let rtxn = self.storage.env.read_txn()?;
188            for (i, name) in spec.rel_types.iter().enumerate() {
189                if let Some(name) = name {
190                    match get_type(&self.storage, &rtxn, name)? {
191                        Some(tid) => type_ids[i] = Some(tid),
192                        None => return Ok(0),
193                    }
194                }
195            }
196        }
197
198        // Dense-index masks for the per-variable labels; `None` is
199        // unconstrained. An unknown label yields an all-false mask, counting
200        // zero without a special case.
201        let mut masks: Vec<Option<Vec<bool>>> = vec![None; hops + 1];
202        for (i, label) in spec.labels.iter().enumerate() {
203            if let Some(name) = label {
204                let mut mask = vec![false; n];
205                for id in self.nodes_by_label(name)? {
206                    if let Some(&d) = snap.id_to_dense.get(&id) {
207                        mask[d as usize] = true;
208                    }
209                }
210                masks[i] = Some(mask);
211            }
212        }
213        // Per-variable allow-sets from pushed-down property predicates. A
214        // present set intersects with the label mask (a node passes only when it
215        // is in both); a node id absent from the snapshot maps to no dense index
216        // and is simply dropped, counting zero without a special case. An empty
217        // `vertex_allow` (the default) leaves every mask as the label mask, so an
218        // unfiltered path count is unchanged.
219        for (i, allow) in spec.vertex_allow.iter().enumerate() {
220            let Some(ids) = allow else { continue };
221            let mut amask = vec![false; n];
222            for &id in ids {
223                if let Some(&d) = snap.id_to_dense.get(&id) {
224                    amask[d as usize] = true;
225                }
226            }
227            match &mut masks[i] {
228                Some(m) => {
229                    for (slot, &keep) in m.iter_mut().zip(amask.iter()) {
230                        *slot = *slot && keep;
231                    }
232                }
233                None => masks[i] = Some(amask),
234            }
235        }
236        let label_ok = |mask: &Option<Vec<bool>>, d: usize| mask.as_ref().is_none_or(|m| m[d]);
237
238        if hops == 1 {
239            // Count typed edges `v0 -> v1` with `v0` and `v1` inside their masks.
240            let out1 = typed_out_sorted(&snap, type_ids[0]);
241            let mut total: u64 = 0;
242            for v0 in 0..n {
243                if !label_ok(&masks[0], v0) {
244                    continue;
245                }
246                for &(dst, _e) in out1.row(v0) {
247                    if label_ok(&masks[1], dst as usize) {
248                        total += 1;
249                    }
250                }
251            }
252            return Ok(total);
253        }
254
255        // Two hops `(v0:m0)-[t1]->(v1:m1)-[t2]->(v2:m2)`. The path count
256        // factors through the middle node: for each `v1`, the number of
257        // matches is the count of qualifying hop-1 in-edges times the count of
258        // qualifying hop-2 out-edges. Relationship uniqueness then removes the
259        // assignments where hop 1 and hop 2 bind the same edge, which is only
260        // possible for a self-loop at `v1` that satisfies both hops.
261        let in1 = typed_in_sorted(&snap, type_ids[0]); // edges into v1, type t1
262        let out2 = typed_out_sorted(&snap, type_ids[1]); // edges out of v1, type t2
263        let mut total: u64 = 0;
264        for b in 0..n {
265            if !label_ok(&masks[1], b) {
266                continue;
267            }
268            let in_row = in1.row(b);
269            let indeg = in_row
270                .iter()
271                .filter(|&&(src, _)| label_ok(&masks[0], src as usize))
272                .count() as u64;
273            if indeg == 0 {
274                continue;
275            }
276            let out_row = out2.row(b);
277            let outdeg = out_row
278                .iter()
279                .filter(|&&(dst, _)| label_ok(&masks[2], dst as usize))
280                .count() as u64;
281            total += indeg * outdeg;
282
283            // Relationship-uniqueness correction. A single edge can fill both
284            // hops only when it is a self-loop at `b` and `b` satisfies the
285            // first and last masks. Such an edge appears in both rows with
286            // neighbor `b`; intersect those self-loop entries by edge id. Rows
287            // are sorted by `(neighbor, edge id)`, so the self-loop entries for
288            // each row are a contiguous, edge-id-ascending run.
289            if label_ok(&masks[0], b) && label_ok(&masks[2], b) {
290                let in_self: Vec<EdgeId> = in_row
291                    .iter()
292                    .filter(|&&(src, _)| src as usize == b)
293                    .map(|&(_, e)| e)
294                    .collect();
295                if !in_self.is_empty() {
296                    let shared = out_row
297                        .iter()
298                        .filter(|&&(dst, e)| dst as usize == b && in_self.binary_search(&e).is_ok())
299                        .count() as u64;
300                    total = total.saturating_sub(shared);
301                }
302            }
303        }
304        Ok(total)
305    }
306
307    /// Counts typed edges grouped by one endpoint, returning `(group node id, count)`
308    /// for every group node with a non-zero count. See [`GroupedDegreeSpec`]
309    /// for the grouping and filtering semantics.
310    ///
311    /// This scans the CSR snapshot's outgoing adjacency once, incrementing a
312    /// per-node counter, so it is `O(nodes + edges)` with no per-edge row
313    /// materialization. It is the kernel the Cypher optimizer lowers a
314    /// `count` aggregation grouped by one endpoint of a single directed hop
315    /// to (the `GroupedDegree` physical operator), turning what would be a
316    /// full expansion-and-fold into an integer pass over adjacency.
317    pub fn grouped_edge_counts(
318        &self,
319        spec: &GroupedDegreeSpec,
320    ) -> Result<Vec<(NodeId, u64)>, Error> {
321        self.ensure_csr_fresh()?;
322        let snap = self.csr_cache.snapshot.load();
323        let n = snap.dense_to_id.len();
324        if n == 0 {
325            return Ok(Vec::new());
326        }
327
328        // A named but unregistered relationship type matches nothing.
329        let type_id = match spec.rel_type {
330            Some(name) => {
331                let rtxn = self.storage.env.read_txn()?;
332                match get_type(&self.storage, &rtxn, name)? {
333                    Some(tid) => Some(tid),
334                    None => return Ok(Vec::new()),
335                }
336            }
337            None => None,
338        };
339
340        // Dense label masks; an unknown label yields an all-false mask, which
341        // counts zero without a special case.
342        let label_mask = |label: Option<&str>| -> Result<Option<Vec<bool>>, Error> {
343            match label {
344                Some(name) => {
345                    let mut mask = vec![false; n];
346                    for id in self.nodes_by_label(name)? {
347                        if let Some(&d) = snap.id_to_dense.get(&id) {
348                            mask[d as usize] = true;
349                        }
350                    }
351                    Ok(Some(mask))
352                }
353                None => Ok(None),
354            }
355        };
356        let group_mask = label_mask(spec.group_label)?;
357        // The endpoints usually carry the same label (e.g. `(:Person)->(:Person)`);
358        // reuse the mask instead of scanning that label a second time.
359        let counted_mask = if spec.counted_label == spec.group_label {
360            group_mask.clone()
361        } else {
362            label_mask(spec.counted_label)?
363        };
364
365        // Non-null mask for the counted endpoint's property: dense index `d` is
366        // true when the property is present on `dense_to_id[d]`. The property
367        // columns carry their own dense mapping, so resolve each CSR node id
368        // through it. A missing column (no such property anywhere) leaves the
369        // mask all-false, so `count(v.prop)` over an absent property counts
370        // zero, matching the row pipeline.
371        let nonnull_mask: Option<Vec<bool>> = match spec.counted_nonnull_prop {
372            Some(prop) => Some(self.prop_columns.with_fresh(&self.storage, |cols| {
373                let mut mask = vec![false; n];
374                if let Some(col) = cols.cols.get(prop) {
375                    for (d, id) in snap.dense_to_id.iter().enumerate() {
376                        if let Some(&cd) = cols.id_to_dense.get(id) {
377                            mask[d] = col.is_present(cd as usize);
378                        }
379                    }
380                }
381                mask
382            })?),
383            None => None,
384        };
385
386        let ok = |mask: &Option<Vec<bool>>, d: usize| mask.as_ref().is_none_or(|m| m[d]);
387
388        // `present` marks a group node with at least one label-qualifying edge,
389        // so it produces a MATCH row and therefore a group. `counts` is the
390        // number of those edges whose counted endpoint also passes the non-null
391        // filter. The two differ for `count(v.prop)`: a group can exist (an edge
392        // reaches it) while its count is zero (every counted source has a null
393        // property), and that group must still appear with count zero, exactly
394        // as the row pipeline emits it.
395        let mut counts = vec![0u64; n];
396        let mut present = vec![false; n];
397        for v0 in 0..n {
398            for k in snap.row_ptr[v0]..snap.row_ptr[v0 + 1] {
399                if let Some(tid) = type_id {
400                    if snap.edge_type[k] != tid {
401                        continue;
402                    }
403                }
404                let v1 = snap.col_idx[k] as usize;
405                // Map the stored edge `v0 -> v1` to the group and counted
406                // endpoints per the grouping direction.
407                let (group_d, counted_d) = if spec.group_is_dst {
408                    (v1, v0)
409                } else {
410                    (v0, v1)
411                };
412                // Label constraints decide which edges match (existence); the
413                // non-null property filter only narrows the count within them.
414                if !ok(&group_mask, group_d) || !ok(&counted_mask, counted_d) {
415                    continue;
416                }
417                present[group_d] = true;
418                if ok(&nonnull_mask, counted_d) {
419                    counts[group_d] += 1;
420                }
421            }
422        }
423
424        let mut out = Vec::new();
425        for (d, &p) in present.iter().enumerate() {
426            if p {
427                out.push((snap.dense_to_id[d], counts[d]));
428            }
429        }
430        Ok(out)
431    }
432
433    /// Detects if there is at least one directed cycle in the graph.
434    pub fn detect_cycle(&self) -> Result<bool, Error> {
435        self.ensure_csr_fresh()?;
436        let guard = self.matrices.read();
437        let m = guard
438            .as_ref()
439            .ok_or(Error::Corrupt("matrices not initialized"))?;
440        let snap = self.csr_cache.snapshot.load();
441        self.detect_cycle_graphblas(m, &snap)
442    }
443
444    /// Returns directed neighbor entries for all outgoing and incoming edges of `node`.
445    pub fn all_neighbors(&self, node: NodeId) -> Result<Vec<DirectedNeighborEntry>, Error> {
446        let rtxn = self.storage.env.read_txn()?;
447        let mut neighbors = Vec::new();
448        for ne in self.out_neighbors_impl(&rtxn, node)? {
449            neighbors.push(DirectedNeighborEntry {
450                node: ne.node,
451                edge: ne.edge,
452                edge_type: ne.edge_type,
453                outgoing: true,
454            });
455        }
456        for ne in self.in_neighbors_impl(&rtxn, node)? {
457            neighbors.push(DirectedNeighborEntry {
458                node: ne.node,
459                edge: ne.edge,
460                edge_type: ne.edge_type,
461                outgoing: false,
462            });
463        }
464        Ok(neighbors)
465    }
466
467    /// Returns all simple paths (no repeated nodes) between `src` and `dst`.
468    pub fn all_paths(&self, src: NodeId, dst: NodeId) -> Result<Vec<Vec<NodeId>>, Error> {
469        self.ensure_csr_fresh()?;
470        let guard = self.matrices.read();
471        let m = guard
472            .as_ref()
473            .ok_or(Error::Corrupt("matrices not initialized"))?;
474        let snap = self.csr_cache.snapshot.load();
475        self.all_paths_graphblas(m, &snap, src, dst)
476    }
477
478    /// Returns all unweighted shortest paths between `src` and `dst`.
479    pub fn all_shortest_paths(&self, src: NodeId, dst: NodeId) -> Result<Vec<Vec<NodeId>>, Error> {
480        self.ensure_csr_fresh()?;
481        let guard = self.matrices.read();
482        let m = guard
483            .as_ref()
484            .ok_or(Error::Corrupt("matrices not initialized"))?;
485        let snap = self.csr_cache.snapshot.load();
486        self.all_shortest_paths_graphblas(m, &snap, src, dst)
487    }
488
489    /// Returns the longest simple path (no repeated nodes) between `src` and `dst`.
490    pub fn longest_path(&self, src: NodeId, dst: NodeId) -> Result<Option<Vec<NodeId>>, Error> {
491        self.ensure_csr_fresh()?;
492        let guard = self.matrices.read();
493        let m = guard
494            .as_ref()
495            .ok_or(Error::Corrupt("matrices not initialized"))?;
496        let snap = self.csr_cache.snapshot.load();
497        self.longest_path_graphblas(m, &snap, src, dst)
498    }
499
500    /// Computes the weighted shortest path between `src` and `dst` using Dijkstra's algorithm.
501    ///
502    /// Edge weights come from the materialized CSR snapshot, which reads the
503    /// first present of the `weight`, `cost`, `capacity`, or `cap` edge
504    /// properties, defaulting to `1.0`. The weight source is fixed: unlike
505    /// `shortest_path_top_k` and `spanning_forest`, this method does not take a
506    /// weight-property argument.
507    pub fn shortest_path_dijkstra(
508        &self,
509        src: NodeId,
510        dst: NodeId,
511    ) -> Result<Option<WeightedPath>, Error> {
512        self.ensure_csr_fresh()?;
513        let guard = self.matrices.read();
514        let m = guard
515            .as_ref()
516            .ok_or(Error::Corrupt("matrices not initialized"))?;
517        let snap = self.csr_cache.snapshot.load();
518        self.shortest_path_dijkstra_graphblas(m, &snap, src, dst)
519    }
520
521    /// Computes the Minimum or Maximum Spanning Forest (MSF) of the graph.
522    pub fn spanning_forest(
523        &self,
524        weight_property: &str,
525        maximum: bool,
526    ) -> Result<Vec<EdgeId>, Error> {
527        self.ensure_csr_fresh()?;
528        let guard = self.matrices.read();
529        let m = guard
530            .as_ref()
531            .ok_or(Error::Corrupt("matrices not initialized"))?;
532        let snap = self.csr_cache.snapshot.load();
533        self.spanning_forest_graphblas(m, &snap, weight_property, maximum)
534    }
535
536    /// Computes community detection on the graph using the Label Propagation Algorithm (LPA / CDLP).
537    pub fn label_propagation(&self, max_iterations: usize) -> Result<HashMap<NodeId, u64>, Error> {
538        self.ensure_csr_fresh()?;
539        let guard = self.matrices.read();
540        let m = guard
541            .as_ref()
542            .ok_or(Error::Corrupt("matrices not initialized"))?;
543        let snap = self.csr_cache.snapshot.load();
544        self.label_propagation_graphblas(m, &snap, max_iterations)
545    }
546
547    /// Computes the harmonic closeness centrality for all nodes in the graph.
548    pub fn harmonic_centrality(&self) -> Result<HashMap<NodeId, f64>, Error> {
549        self.ensure_csr_fresh()?;
550        let guard = self.matrices.read();
551        let m = guard
552            .as_ref()
553            .ok_or(Error::Corrupt("matrices not initialized"))?;
554        let snap = self.csr_cache.snapshot.load();
555        self.harmonic_centrality_graphblas(m, &snap)
556    }
557
558    /// Computes the betweenness centrality for all nodes in the graph.
559    pub fn betweenness_centrality(&self) -> Result<HashMap<NodeId, f64>, Error> {
560        self.ensure_csr_fresh()?;
561        let guard = self.matrices.read();
562        let m = guard
563            .as_ref()
564            .ok_or(Error::Corrupt("matrices not initialized"))?;
565        let snap = self.csr_cache.snapshot.load();
566        self.betweenness_centrality_graphblas(m, &snap)
567    }
568
569    /// Computes the strongly connected components (SCC) of the graph using Tarjan's algorithm.
570    pub fn strongly_connected_components(&self) -> Result<HashMap<NodeId, u64>, Error> {
571        self.ensure_csr_fresh()?;
572        let guard = self.matrices.read();
573        let m = guard
574            .as_ref()
575            .ok_or(Error::Corrupt("matrices not initialized"))?;
576        let snap = self.csr_cache.snapshot.load();
577        self.strongly_connected_components_graphblas(m, &snap)
578    }
579
580    /// Computes the degree centrality for all nodes in the graph based on the specified direction.
581    pub fn degree_centrality(
582        &self,
583        direction: DegreeDirection,
584    ) -> Result<HashMap<NodeId, u64>, Error> {
585        self.ensure_matrix_view()?;
586        let guard = self.matrices.read();
587        let m = guard
588            .as_ref()
589            .ok_or(Error::Corrupt("matrices not initialized"))?;
590        self.degree_centrality_graphblas(m, direction)
591    }
592
593    /// Computes the maximum flow from a source node to a sink node.
594    pub fn maximum_flow(
595        &self,
596        source: NodeId,
597        sink: NodeId,
598        capacity_property: &str,
599    ) -> Result<f64, Error> {
600        self.ensure_csr_fresh()?;
601        let guard = self.matrices.read();
602        let m = guard
603            .as_ref()
604            .ok_or(Error::Corrupt("matrices not initialized"))?;
605        let snap = self.csr_cache.snapshot.load();
606        self.maximum_flow_graphblas(m, &snap, source, sink, capacity_property)
607    }
608
609    /// Computes the K shortest paths from a source node to a destination node using Yen's algorithm.
610    pub fn shortest_path_top_k(
611        &self,
612        src: NodeId,
613        dst: NodeId,
614        k: usize,
615        weight_property: &str,
616    ) -> Result<Vec<WeightedPath>, Error> {
617        self.ensure_csr_fresh()?;
618        let guard = self.matrices.read();
619        let m = guard
620            .as_ref()
621            .ok_or(Error::Corrupt("matrices not initialized"))?;
622        let snap = self.csr_cache.snapshot.load();
623        let paths = self.shortest_path_top_k_graphblas(m, &snap, src, dst, k, weight_property)?;
624        Ok(paths
625            .into_iter()
626            .map(|(nodes, total_weight)| WeightedPath {
627                nodes,
628                total_weight,
629            })
630            .collect())
631    }
632
633    /// Breadth-first search outward from `start` up to `hops` levels deep.
634    pub fn bfs(&self, start: NodeId, hops: u8) -> Result<Vec<NodeId>, Error> {
635        self.ensure_matrix_view()?;
636        self.bfs_graphblas(start, hops)
637    }
638
639    /// Unweighted shortest path from `src` to `dst` by BFS.
640    pub fn shortest_path(&self, src: NodeId, dst: NodeId) -> Result<Option<Vec<NodeId>>, Error> {
641        self.ensure_csr_fresh()?;
642        self.shortest_path_graphblas(src, dst)
643    }
644
645    /// Iterative PageRank over the current CSR snapshot.
646    pub fn page_rank(&self, iterations: u32, damping: f32) -> Result<HashMap<NodeId, f32>, Error> {
647        self.ensure_csr_fresh()?;
648        self.page_rank_graphblas(iterations, damping)
649    }
650
651    /// Freshness gate for consumers that read the CSR snapshot: the native-CSR
652    /// algorithms (`dfs`, `strongly_connected_components`, `maximum_flow`,
653    /// `spanning_forest`, `shortest_path_top_k`, `all_paths`, `longest_path`,
654    /// `detect_cycle`) and the hybrid SpMV-plus-path-reconstruction algorithms
655    /// (`shortest_path_dijkstra`, `betweenness_centrality`, `harmonic_centrality`,
656    /// `all_shortest_paths`, `page_rank`). A full rebuild refreshes both the
657    /// snapshot and all matrices. Gated by the write generation, so it catches
658    /// edge-only drift, not just node-count changes.
659    pub(crate) fn ensure_csr_fresh(&self) -> Result<(), Error> {
660        if self.matrices.read().is_none() || self.csr_cache.snapshot_is_stale() {
661            self.rebuild_csr()?;
662        } else {
663            // A snapshot-only refresh (`ensure_snapshot_fresh`) leaves the
664            // structural delta pending, so a fresh snapshot generation does
665            // not imply fresh matrices; drain the delta into them.
666            self.ensure_matrix_view()?;
667        }
668        Ok(())
669    }
670
671    /// Freshness gate for consumers that read only the CSR snapshot (typed
672    /// expansion). Rebuilds the snapshot alone when it lags committed writes,
673    /// skipping GraphBLAS matrix materialization; the pending structural delta
674    /// stays in place for `ensure_matrix_view` to drain later.
675    pub(crate) fn ensure_snapshot_fresh(&self) -> Result<(), Error> {
676        if self.csr_cache.snapshot_is_stale() {
677            let built_gen = self.csr_cache.current_gen();
678            let snap = CsrSnapshot::build(&self.storage)?;
679            self.csr_cache.install_snapshot(snap, built_gen);
680        }
681        Ok(())
682    }
683
684    /// Freshness gate for the pure-adjacency consumers (`bfs`,
685    /// `bfs_multi_source`, untyped `expand`, `degree_centrality`,
686    /// `connected_components`), which read only `adjacency`/`adjacency_t` and the
687    /// dense mapping carried on `MatrixSet`. Applies the pending structural delta
688    /// to the cached matrices in place (resize plus per-element set/drop) in
689    /// O(delta), falling back to a full rebuild when a node was deleted (the
690    /// dense-index mapping is reshuffled) or the matrices are not yet
691    /// materialized. The take-and-apply runs under the matrices write lock, so a
692    /// reader's subsequent `matrices.read()` never observes a partial apply.
693    pub(crate) fn ensure_matrix_view(&self) -> Result<(), Error> {
694        // A node deletion or an unmaterialized matrix set needs a full rebuild,
695        // which refreshes the snapshot and all matrices from LMDB.
696        if self.matrices.read().is_none() || self.csr_cache.pending_force_full() {
697            return self.rebuild_csr();
698        }
699        // Cheap pre-check: skip the exclusive lock when nothing is pending.
700        if !self.csr_cache.has_pending() {
701            return Ok(());
702        }
703
704        let mut guard = self.matrices.write();
705        let delta = self.csr_cache.take_delta();
706        if delta.force_full {
707            // A node deletion raced in after the peek above. Drop the guard
708            // (rebuild_csr re-acquires the write lock) and rebuild from LMDB; the
709            // taken delta is superseded.
710            drop(guard);
711            return self.rebuild_csr();
712        }
713        if delta.is_empty() {
714            return Ok(());
715        }
716
717        // A removed edge clears the boolean adjacency bit only when no parallel
718        // edge between the same endpoints remains. LMDB is the fresh truth.
719        let mut clear_edges = Vec::new();
720        {
721            let rtxn = self.storage.env.read_txn()?;
722            for &(src, dst) in &delta.removed_edges {
723                let still_connected = self
724                    .out_neighbors_impl(&rtxn, src)?
725                    .into_iter()
726                    .any(|ne| ne.node == dst);
727                if !still_connected {
728                    clear_edges.push((src, dst));
729                }
730            }
731        }
732
733        if let Some(m) = guard.as_mut() {
734            m.apply_delta(&delta.added_nodes, &delta.added_edges, &clear_edges)?;
735        }
736        Ok(())
737    }
738
739    /// Returns all node IDs in the graph in ascending order.
740    pub fn all_nodes(&self) -> Result<Vec<NodeId>, Error> {
741        let rtxn = self.storage.env.read_txn()?;
742        self.all_nodes_impl(&rtxn)
743    }
744
745    pub(super) fn all_nodes_impl(&self, rtxn: &heed::RoTxn) -> Result<Vec<NodeId>, Error> {
746        let mut ids = self
747            .storage
748            .nodes
749            .iter(rtxn)?
750            .map(|r| r.map(|(k, _)| k))
751            .collect::<Result<Vec<_>, _>>()?;
752        ids.sort_unstable();
753        Ok(ids)
754    }
755
756    /// Weakly connected components via BFS treating all edges as undirected.
757    ///
758    /// Returns a map from each node ID to a component ID. Component IDs are
759    /// assigned in ascending order of first discovery and have no guaranteed
760    /// relationship to node IDs.
761    pub fn connected_components(&self) -> Result<HashMap<NodeId, u64>, Error> {
762        self.ensure_matrix_view()?;
763        {
764            let guard = self.matrices.read();
765            if let Some(m) = guard.as_ref() {
766                if m.n_nodes > 0 {
767                    return self.connected_components_graphblas(m);
768                }
769            }
770        }
771        let nodes: Vec<NodeId> = {
772            let rtxn = self.storage.env.read_txn()?;
773            self.storage
774                .nodes
775                .iter(&rtxn)?
776                .map(|r| r.map(|(k, _)| k))
777                .collect::<Result<Vec<_>, _>>()?
778        };
779
780        let mut component: HashMap<NodeId, u64> = HashMap::with_capacity(nodes.len());
781        let mut next_id: u64 = 0;
782
783        for &start in &nodes {
784            if component.contains_key(&start) {
785                continue;
786            }
787            let comp_id = next_id;
788            next_id += 1;
789            component.insert(start, comp_id);
790            let mut queue = vec![start];
791            while let Some(node) = queue.pop() {
792                for ne in self.out_neighbors(node)? {
793                    if component.insert(ne.node, comp_id).is_none() {
794                        queue.push(ne.node);
795                    }
796                }
797                for ne in self.in_neighbors(node)? {
798                    if component.insert(ne.node, comp_id).is_none() {
799                        queue.push(ne.node);
800                    }
801                }
802            }
803        }
804
805        Ok(component)
806    }
807
808    // ------------------------------------------------------------------
809    // Internals
810    // ------------------------------------------------------------------
811
812    /// Increment the dirty counter and, if the threshold is crossed and no
813    /// rebuild is already running, spawn a background thread to rebuild the
814    /// CSR snapshot from LMDB.
815    pub(super) fn maybe_spawn_rebuild(&self) {
816        self.maybe_spawn_rebuild_n(1);
817    }
818
819    pub(super) fn maybe_spawn_rebuild_n(&self, count: usize) {
820        if self.csr_cache.mark_dirty_n(count as u64) {
821            let cache = Arc::clone(&self.csr_cache);
822            let storage = Arc::clone(&self.storage);
823            let matrices = Arc::clone(&self.matrices);
824            let thread_count = Arc::clone(&self.n_threads);
825            std::thread::spawn(move || {
826                // Rebuild until the dirty count drops below the threshold: writes
827                // that commit while a rebuild runs keep the count above zero, and
828                // `install` retains the claim and asks for another pass so the
829                // snapshot does not silently lag behind LMDB.
830                loop {
831                    // Capture the generation before reading LMDB; writes that
832                    // commit during the build leave the snapshot stale until the
833                    // next pass, which the dirty-count loop already drives.
834                    let built_gen = cache.current_gen();
835                    // Clear before reading LMDB so writes during the build are
836                    // retained in the emptied delta for a later incremental apply.
837                    cache.clear_delta();
838                    match CsrSnapshot::build(&storage) {
839                        Ok(snap) => {
840                            if let Ok(m) = MatrixSet::materialize(
841                                &snap,
842                                thread_count.load(std::sync::atomic::Ordering::Acquire),
843                            ) {
844                                *matrices.write() = Some(m);
845                            }
846                            if !cache.install(snap, built_gen) {
847                                break;
848                            }
849                        }
850                        Err(_) => {
851                            cache.cancel_rebuild();
852                            break;
853                        }
854                    }
855                }
856            });
857        }
858    }
859
860    /// Append one `AdjEntry` as a new LMDB duplicate value: O(log n), no blob read.
861    pub(super) fn append_adj(
862        &self,
863        wtxn: &mut heed::RwTxn,
864        node: NodeId,
865        other: NodeId,
866        edge_type: u32,
867        edge_id: EdgeId,
868        outgoing: bool,
869    ) -> Result<(), Error> {
870        let entry = AdjEntry {
871            edge_type,
872            other,
873            edge_id,
874        };
875        let db = if outgoing {
876            &self.storage.out_adj
877        } else {
878            &self.storage.in_adj
879        };
880        db.put(wtxn, &node, entry.as_bytes())?;
881        Ok(())
882    }
883
884    /// Iterate all duplicate `AdjEntry` values for `node` via LMDB cursor.
885    pub(super) fn adj_entries(
886        &self,
887        node: NodeId,
888        outgoing: bool,
889    ) -> Result<Vec<NeighborEntry>, Error> {
890        let rtxn = self.storage.env.read_txn()?;
891        self.adj_entries_impl(&rtxn, node, outgoing)
892    }
893
894    pub(super) fn adj_entries_impl(
895        &self,
896        rtxn: &heed::RoTxn,
897        node: NodeId,
898        outgoing: bool,
899    ) -> Result<Vec<NeighborEntry>, Error> {
900        let db = if outgoing {
901            &self.storage.out_adj
902        } else {
903            &self.storage.in_adj
904        };
905
906        let iter = match db.get_duplicates(rtxn, &node)? {
907            Some(iter) => iter,
908            None => return Ok(vec![]),
909        };
910
911        let mut out = Vec::new();
912        for result in iter {
913            let (_, bytes) = result?;
914            let entry = AdjEntry::read_from_bytes(bytes)
915                .ok()
916                .ok_or(Error::Corrupt("AdjEntry value is not exactly 20 bytes"))?;
917            out.push(NeighborEntry {
918                node: entry.other,
919                edge: entry.edge_id,
920                edge_type: entry.edge_type,
921            });
922        }
923        Ok(out)
924    }
925}
926
927/// Per-row adjacency restricted to one relationship type, with each row
928/// sorted by `(neighbor, edge id)` so intersections run as sorted merges and
929/// parallel edges form contiguous runs.
930struct TypedSortedAdj {
931    ptr: Vec<usize>,
932    adj: Vec<(u32, EdgeId)>,
933}
934
935impl TypedSortedAdj {
936    fn row(&self, d: usize) -> &[(u32, EdgeId)] {
937        &self.adj[self.ptr[d]..self.ptr[d + 1]]
938    }
939}
940
941/// Forward adjacency from the CSR snapshot filtered to `type_id` (`None`
942/// keeps every edge), rows sorted by `(dst, edge id)`.
943fn typed_out_sorted(snap: &CsrSnapshot, type_id: Option<TypeId>) -> TypedSortedAdj {
944    let n = snap.dense_to_id.len();
945    let keep = |idx: usize| type_id.is_none_or(|t| snap.edge_type[idx] == t);
946
947    let mut ptr = vec![0usize; n + 1];
948    for row in 0..n {
949        let mut count = 0;
950        for idx in snap.row_ptr[row]..snap.row_ptr[row + 1] {
951            if keep(idx) {
952                count += 1;
953            }
954        }
955        ptr[row + 1] = ptr[row] + count;
956    }
957
958    let mut adj = vec![(0u32, 0u64); ptr[n]];
959    for row in 0..n {
960        let mut at = ptr[row];
961        for idx in snap.row_ptr[row]..snap.row_ptr[row + 1] {
962            if keep(idx) {
963                adj[at] = (snap.col_idx[idx], snap.edge_id[idx]);
964                at += 1;
965            }
966        }
967        adj[ptr[row]..at].sort_unstable();
968    }
969    TypedSortedAdj { ptr, adj }
970}
971
972/// Transposed adjacency (edges grouped by destination) filtered to
973/// `type_id`, rows sorted by `(src, edge id)`.
974fn typed_in_sorted(snap: &CsrSnapshot, type_id: Option<TypeId>) -> TypedSortedAdj {
975    let n = snap.dense_to_id.len();
976    let keep = |idx: usize| type_id.is_none_or(|t| snap.edge_type[idx] == t);
977
978    let mut ptr = vec![0usize; n + 1];
979    for idx in 0..snap.col_idx.len() {
980        if keep(idx) {
981            ptr[snap.col_idx[idx] as usize + 1] += 1;
982        }
983    }
984    for d in 0..n {
985        ptr[d + 1] += ptr[d];
986    }
987
988    let mut at = ptr.clone();
989    let mut adj = vec![(0u32, 0u64); ptr[n]];
990    for row in 0..n {
991        for idx in snap.row_ptr[row]..snap.row_ptr[row + 1] {
992            if keep(idx) {
993                let dst = snap.col_idx[idx] as usize;
994                adj[at[dst]] = (row as u32, snap.edge_id[idx]);
995                at[dst] += 1;
996            }
997        }
998    }
999    for d in 0..n {
1000        adj[ptr[d]..ptr[d + 1]].sort_unstable();
1001    }
1002    TypedSortedAdj { ptr, adj }
1003}
1004
1005#[cfg(test)]
1006mod incremental_matrix_tests {
1007    use issundb_graphblas::Matrix;
1008    use serde_json::json;
1009    use tempfile::TempDir;
1010
1011    use std::collections::{BTreeMap, HashMap};
1012
1013    use crate::Graph;
1014    use crate::graph::DegreeDirection;
1015    use crate::schema::NodeId;
1016
1017    /// Adjacency coordinates, transpose coordinates, and the dense-index mapping:
1018    /// the matrix-view state the incremental path maintains.
1019    type MatrixView = (Vec<(usize, usize)>, Vec<(usize, usize)>, Vec<NodeId>);
1020
1021    /// Canonicalize a component map to its underlying partition (each node mapped
1022    /// to the smallest node id in its component), so two results compare equal
1023    /// regardless of the arbitrary component-id numbering.
1024    fn canonical_partition(cc: &HashMap<NodeId, u64>) -> BTreeMap<NodeId, NodeId> {
1025        let mut groups: HashMap<u64, Vec<NodeId>> = HashMap::new();
1026        for (&node, &comp) in cc {
1027            groups.entry(comp).or_default().push(node);
1028        }
1029        let mut out = BTreeMap::new();
1030        for members in groups.into_values() {
1031            let rep = *members.iter().min().unwrap();
1032            for n in members {
1033                out.insert(n, rep);
1034            }
1035        }
1036        out
1037    }
1038
1039    /// Sorted, deduplicated `(row, col)` coordinates of a boolean adjacency
1040    /// matrix, for set comparison independent of internal storage order.
1041    fn matrix_coords(m: &Matrix<i32>) -> Vec<(usize, usize)> {
1042        let mut out: Vec<(usize, usize)> = m
1043            .triples()
1044            .expect("triples")
1045            .into_iter()
1046            .map(|(r, c, _)| (r, c))
1047            .collect();
1048        out.sort_unstable();
1049        out.dedup();
1050        out
1051    }
1052
1053    /// Snapshot the matrix-view state that the incremental path maintains:
1054    /// adjacency coordinates, transpose coordinates, and the dense-index mapping.
1055    fn extract(graph: &Graph) -> MatrixView {
1056        let guard = graph.matrices.read();
1057        let m = guard.as_ref().expect("matrices materialized");
1058        (
1059            matrix_coords(&m.adjacency),
1060            matrix_coords(&m.adjacency_t),
1061            m.dense_to_id.clone(),
1062        )
1063    }
1064
1065    /// The incrementally-maintained matrices must be byte-identical (as element
1066    /// sets and dense mapping) to a full rebuild over the same final LMDB state.
1067    /// Because the incremental matrices equal the freshly-built ones, any
1068    /// consumer reading them sees every committed mutation: this is the freshness
1069    /// proof as well as the correctness proof.
1070    #[test]
1071    fn incremental_matrices_match_full_rebuild() {
1072        let dir = TempDir::new().unwrap();
1073        let g = Graph::open(dir.path(), 1).unwrap();
1074
1075        // Base graph: a 20-node ring.
1076        let ids: Vec<NodeId> = (0..20)
1077            .map(|i| g.add_node("N", &json!({ "v": i })).unwrap())
1078            .collect();
1079        let mut base_edges = Vec::new();
1080        for i in 0..20 {
1081            base_edges.push(
1082                g.add_edge(ids[i], ids[(i + 1) % 20], "R", &json!({}))
1083                    .unwrap(),
1084            );
1085        }
1086        // Establish the base matrices and clear the pending delta.
1087        g.rebuild_csr().unwrap();
1088
1089        // Mutations recorded into the delta:
1090        // 1. New edges among existing nodes.
1091        g.add_edge(ids[0], ids[5], "R", &json!({})).unwrap();
1092        g.add_edge(ids[3], ids[10], "R", &json!({})).unwrap();
1093        // 2. Parallel edges, then remove one: the adjacency bit must stay set.
1094        let par_a = g.add_edge(ids[2], ids[4], "R", &json!({})).unwrap();
1095        let _par_b = g.add_edge(ids[2], ids[4], "R", &json!({})).unwrap();
1096        // 3. New nodes with edges (matrix must grow).
1097        let n20 = g.add_node("N", &json!({ "v": 20 })).unwrap();
1098        let n21 = g.add_node("N", &json!({ "v": 21 })).unwrap();
1099        g.add_edge(n20, n21, "R", &json!({})).unwrap();
1100        g.add_edge(ids[1], n20, "R", &json!({})).unwrap();
1101        // 4. Remove an edge with no parallel: the adjacency bit must clear.
1102        g.delete_edge(base_edges[7]).unwrap();
1103        // 5. Remove one of the parallel pair (the other still connects the pair).
1104        g.delete_edge(par_a).unwrap();
1105
1106        // Incremental refresh, then snapshot.
1107        g.ensure_matrix_view().unwrap();
1108        let incremental = extract(&g);
1109
1110        // Full rebuild over the same LMDB state, then snapshot.
1111        g.rebuild_csr().unwrap();
1112        let full = extract(&g);
1113
1114        assert_eq!(incremental.0, full.0, "adjacency element sets differ");
1115        assert_eq!(incremental.1, full.1, "adjacency_t element sets differ");
1116        assert_eq!(incremental.2, full.2, "dense-index mapping differs");
1117    }
1118
1119    /// A node deletion reshuffles dense indices, so the refresh must fall back to
1120    /// a full rebuild and still match.
1121    #[test]
1122    fn node_deletion_forces_full_rebuild_and_matches() {
1123        let dir = TempDir::new().unwrap();
1124        let g = Graph::open(dir.path(), 1).unwrap();
1125        let ids: Vec<NodeId> = (0..10)
1126            .map(|i| g.add_node("N", &json!({ "v": i })).unwrap())
1127            .collect();
1128        for i in 0..10 {
1129            g.add_edge(ids[i], ids[(i + 1) % 10], "R", &json!({}))
1130                .unwrap();
1131        }
1132        g.rebuild_csr().unwrap();
1133
1134        // Delete a node (cascades its edges) and add a fresh edge.
1135        g.delete_node(ids[3]).unwrap();
1136        g.add_edge(ids[5], ids[7], "R", &json!({})).unwrap();
1137
1138        g.ensure_matrix_view().unwrap();
1139        let incremental = extract(&g);
1140        g.rebuild_csr().unwrap();
1141        let full = extract(&g);
1142
1143        assert_eq!(incremental.0, full.0, "adjacency element sets differ");
1144        assert_eq!(incremental.1, full.1, "adjacency_t element sets differ");
1145        assert_eq!(incremental.2, full.2, "dense-index mapping differs");
1146    }
1147
1148    /// Go/no-go measurement (ignored by default; the build dominates runtime).
1149    /// Run with:
1150    /// `cargo test -p issundb-core --release incremental_apply_cost -- --ignored --nocapture`
1151    #[test]
1152    #[ignore = "measurement: prints incremental-apply vs full-rebuild timings"]
1153    fn incremental_apply_cost() {
1154        use std::time::Instant;
1155
1156        fn measure(n_nodes: usize, out_degree: usize, k_added: usize) {
1157            let dir = TempDir::new().unwrap();
1158            let g = Graph::open(dir.path(), 4).unwrap();
1159            // Build the base graph in one batched transaction: individual commits
1160            // would dominate the runtime and swamp the measurement.
1161            let ids: Vec<NodeId> = g
1162                .update(|txn| {
1163                    let ids: Vec<NodeId> = (0..n_nodes)
1164                        .map(|i| txn.add_node("N", &json!({ "v": i })).unwrap())
1165                        .collect();
1166                    for i in 0..n_nodes {
1167                        for k in 0..out_degree {
1168                            let off = 1 + k * 7;
1169                            txn.add_edge(ids[i], ids[(i + off) % n_nodes], "R", &json!({}))
1170                                .unwrap();
1171                        }
1172                    }
1173                    Ok(ids)
1174                })
1175                .unwrap();
1176            g.rebuild_csr().unwrap();
1177
1178            // Stage `k_added` new edges among existing nodes, then time the
1179            // incremental apply of exactly that delta.
1180            for j in 0..k_added {
1181                let a = (j * 31) % n_nodes;
1182                let b = (j * 97 + 5) % n_nodes;
1183                g.add_edge(ids[a], ids[b], "R", &json!({})).unwrap();
1184            }
1185            let t = Instant::now();
1186            g.ensure_matrix_view().unwrap();
1187            let incr = t.elapsed();
1188
1189            // Full rebuild is independent of the delta size: it is the cost the
1190            // incremental path replaces.
1191            let mut best_full = std::time::Duration::from_secs(3600);
1192            for _ in 0..3 {
1193                let t = Instant::now();
1194                g.rebuild_csr().unwrap();
1195                let e = t.elapsed();
1196                if e < best_full {
1197                    best_full = e;
1198                }
1199            }
1200            let n_edges = n_nodes * out_degree + k_added;
1201            println!(
1202                "{:>7} nodes, {:>9} edges: incremental apply of {} edges = {:>8.3} ms; full rebuild = {:>8.2} ms",
1203                n_nodes,
1204                n_edges,
1205                k_added,
1206                incr.as_secs_f64() * 1e3,
1207                best_full.as_secs_f64() * 1e3,
1208            );
1209        }
1210
1211        measure(10_000, 5, 1_000);
1212        measure(50_000, 5, 1_000);
1213        measure(100_000, 5, 1_000);
1214    }
1215
1216    /// End-to-end differential check: the migrated matrix-view consumers (`bfs`,
1217    /// `degree_centrality`, `connected_components`) must return identical results
1218    /// whether refreshed incrementally or via a forced full rebuild, over a
1219    /// mutation battery including a new node reached through a new edge.
1220    #[test]
1221    fn incremental_consumers_match_full_rebuild() {
1222        let dir = TempDir::new().unwrap();
1223        let g = Graph::open(dir.path(), 1).unwrap();
1224        let ids: Vec<NodeId> = (0..15)
1225            .map(|i| g.add_node("N", &json!({ "v": i })).unwrap())
1226            .collect();
1227        for i in 0..15 {
1228            g.add_edge(ids[i], ids[(i + 1) % 15], "R", &json!({}))
1229                .unwrap();
1230        }
1231        g.rebuild_csr().unwrap();
1232
1233        // Mutations recorded into the delta, with no rebuild in between.
1234        g.add_edge(ids[0], ids[7], "R", &json!({})).unwrap();
1235        let n15 = g.add_node("N", &json!({ "v": 15 })).unwrap();
1236        g.add_edge(ids[2], n15, "R", &json!({})).unwrap();
1237        g.add_edge(n15, ids[5], "R", &json!({})).unwrap();
1238
1239        // Results via the incremental matrix-view path.
1240        let bfs_incr = {
1241            let mut v = g.bfs(ids[0], 3).unwrap();
1242            v.sort_unstable();
1243            v
1244        };
1245        let deg_incr = g.degree_centrality(DegreeDirection::Both).unwrap();
1246        let cc_incr = canonical_partition(&g.connected_components().unwrap());
1247
1248        // Results via a forced full rebuild over the same LMDB state.
1249        g.rebuild_csr().unwrap();
1250        let bfs_full = {
1251            let mut v = g.bfs(ids[0], 3).unwrap();
1252            v.sort_unstable();
1253            v
1254        };
1255        let deg_full = g.degree_centrality(DegreeDirection::Both).unwrap();
1256        let cc_full = canonical_partition(&g.connected_components().unwrap());
1257
1258        assert_eq!(bfs_incr, bfs_full, "bfs: incremental vs full rebuild");
1259        assert_eq!(deg_incr, deg_full, "degree: incremental vs full rebuild");
1260        assert_eq!(cc_incr, cc_full, "components: incremental vs full rebuild");
1261    }
1262
1263    /// Freshness: a matrix-view consumer reflects an edge, and a brand-new node
1264    /// reached through a new edge, with no explicit `rebuild_csr` between the
1265    /// write and the read. This is the edge-drift bug the migration closes.
1266    #[test]
1267    fn matrix_view_consumers_reflect_writes_without_rebuild() {
1268        let dir = TempDir::new().unwrap();
1269        let g = Graph::open(dir.path(), 1).unwrap();
1270        let a = g.add_node("N", &json!({})).unwrap();
1271        let b = g.add_node("N", &json!({})).unwrap();
1272        g.rebuild_csr().unwrap();
1273        assert!(
1274            !g.bfs(a, 5).unwrap().contains(&b),
1275            "b is unreachable before the edge exists"
1276        );
1277
1278        // Edge between existing nodes, no rebuild: the incremental view sees it.
1279        g.add_edge(a, b, "R", &json!({})).unwrap();
1280        assert!(
1281            g.bfs(a, 1).unwrap().contains(&b),
1282            "b reachable from a after the edge, without a rebuild"
1283        );
1284
1285        // A brand-new node reached through a new edge, still no rebuild: this
1286        // exercises the matrix resize plus dense-mapping extension end to end.
1287        let c = g.add_node("N", &json!({})).unwrap();
1288        g.add_edge(b, c, "R", &json!({})).unwrap();
1289        assert!(
1290            g.bfs(a, 2).unwrap().contains(&c),
1291            "new node c reachable two hops from a, without a rebuild"
1292        );
1293    }
1294
1295    /// Freshness for the CSR-snapshot consumers: a generation-gated rebuild makes
1296    /// a native-CSR algorithm (`all_paths`) reflect an edge added with no explicit
1297    /// `rebuild_csr`.
1298    #[test]
1299    fn csr_consumers_reflect_writes_without_rebuild() {
1300        let dir = TempDir::new().unwrap();
1301        let g = Graph::open(dir.path(), 1).unwrap();
1302        let a = g.add_node("N", &json!({})).unwrap();
1303        let b = g.add_node("N", &json!({})).unwrap();
1304        let c = g.add_node("N", &json!({})).unwrap();
1305        g.add_edge(a, b, "R", &json!({})).unwrap();
1306        g.rebuild_csr().unwrap();
1307        assert!(
1308            g.all_paths(a, c).unwrap().is_empty(),
1309            "no path a..c before the edge exists"
1310        );
1311
1312        // Edge b->c, no rebuild: the write-generation gate forces a refresh.
1313        g.add_edge(b, c, "R", &json!({})).unwrap();
1314        assert!(
1315            !g.all_paths(a, c).unwrap().is_empty(),
1316            "path a->b->c reflected without an explicit rebuild"
1317        );
1318    }
1319
1320    /// After a write, `ensure_matrix_view` applies the delta with
1321    /// `GrB_Matrix_setElement` (lazy in non-blocking mode), then drops the write
1322    /// lock. Multiple `bfs` calls then take the shared `matrices.read()` lock and
1323    /// run `mxv` concurrently. If the pending operations were not materialized
1324    /// under the write lock, the first `mxv` triggers GraphBLAS lazy completion,
1325    /// which mutates the shared matrix's internal representation while other
1326    /// readers race on it: undefined behavior. With the fix (`apply_delta`
1327    /// materializes the adjacency matrices before releasing the write lock),
1328    /// every concurrent `bfs` returns the full reachable set deterministically.
1329    #[test]
1330    fn concurrent_bfs_after_incremental_write_is_consistent() {
1331        use std::sync::Barrier;
1332
1333        let dir = TempDir::new().unwrap();
1334        let g = Graph::open(dir.path(), 1).unwrap();
1335
1336        // A chain 0 -> 1 -> ... -> 29: bfs from node 0 reaches all 30 nodes.
1337        const N: usize = 30;
1338        let start = g.add_node("N", &json!({ "v": 0 })).unwrap();
1339        let mut prev = start;
1340        for i in 1..N {
1341            let node = g.add_node("N", &json!({ "v": i })).unwrap();
1342            g.add_edge(prev, node, "R", &json!({})).unwrap();
1343            prev = node;
1344        }
1345        g.rebuild_csr().unwrap();
1346
1347        const THREADS: usize = 6;
1348        const ROUNDS: usize = 200;
1349        let mut expected = N;
1350        for r in 0..ROUNDS {
1351            // Attach a fresh node directly to `start`. The edge start -> new is a
1352            // brand-new matrix coordinate, so `apply_delta` records a pending
1353            // `setElement` (lazy in non-blocking mode), re-opening the
1354            // lazy-completion race window. The reachable set from `start` grows by
1355            // exactly one, keeping the expected count deterministic.
1356            let leaf = g.add_node("N", &json!({ "leaf": r })).unwrap();
1357            g.add_edge(start, leaf, "R", &json!({})).unwrap();
1358            expected += 1;
1359
1360            let barrier = Barrier::new(THREADS);
1361            std::thread::scope(|s| {
1362                for _ in 0..THREADS {
1363                    let g = &g;
1364                    let barrier = &barrier;
1365                    s.spawn(move || {
1366                        // Synchronize so the threads reach the shared-read `mxv`
1367                        // together, maximizing the overlap on the pending matrix.
1368                        barrier.wait();
1369                        let reached = g.bfs(start, u8::MAX).unwrap();
1370                        assert_eq!(
1371                            reached.len(),
1372                            expected,
1373                            "concurrent bfs saw a partially materialized matrix"
1374                        );
1375                    });
1376                }
1377            });
1378        }
1379    }
1380}
1381
1382#[cfg(test)]
1383mod linear_path_count_tests {
1384    use serde_json::json;
1385    use tempfile::TempDir;
1386
1387    use crate::{Graph, PathCountSpec};
1388
1389    fn open_tmp() -> (TempDir, Graph) {
1390        let dir = TempDir::new().unwrap();
1391        let g = Graph::open(dir.path(), 1).unwrap();
1392        (dir, g)
1393    }
1394
1395    fn spec(
1396        rels: &[Option<&'static str>],
1397        labels: &[Option<&'static str>],
1398    ) -> PathCountSpec<'static> {
1399        PathCountSpec {
1400            rel_types: rels.to_vec(),
1401            labels: labels.to_vec(),
1402            vertex_allow: Vec::new(),
1403        }
1404    }
1405
1406    /// A per-variable allow-set intersects with the label, restricting the
1407    /// counted paths to the supplied node ids exactly as a brute-force count
1408    /// over the same restriction does.
1409    #[test]
1410    fn two_hop_allow_set_restricts_middle_and_dest() {
1411        let (_dir, g) = open_tmp();
1412        // Five people; ages drive the allow-sets below.
1413        let p: Vec<_> = (0..5)
1414            .map(|i| {
1415                g.add_node("Person", &json!({ "age": 20 + i * 10 }))
1416                    .unwrap()
1417            })
1418            .collect();
1419        // A small FOLLOWS web with two-hop paths through several middles.
1420        let edges = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (2, 4), (3, 4)];
1421        for &(s, d) in &edges {
1422            g.add_edge(p[s], p[d], "FOLLOWS", &json!({})).unwrap();
1423        }
1424
1425        // Allow middles {p1, p2} and destinations {p3, p4}. Brute-force the
1426        // count of (a)-[FOLLOWS]->(b)-[FOLLOWS]->(c) with b in the middle set
1427        // and c in the dest set.
1428        let mid = [p[1], p[2]];
1429        let dst = [p[3], p[4]];
1430        let mut expected = 0u64;
1431        for &(_s1, d1) in &edges {
1432            if !mid.contains(&p[d1]) {
1433                continue;
1434            }
1435            for &(s2, d2) in &edges {
1436                if p[s2] == p[d1] && dst.contains(&p[d2]) {
1437                    expected += 1;
1438                }
1439            }
1440        }
1441        assert!(expected > 0, "test graph must have qualifying paths");
1442
1443        let filtered = PathCountSpec {
1444            rel_types: vec![Some("FOLLOWS"), Some("FOLLOWS")],
1445            labels: vec![Some("Person"), Some("Person"), Some("Person")],
1446            vertex_allow: vec![None, Some(mid.to_vec()), Some(dst.to_vec())],
1447        };
1448        assert_eq!(g.count_linear_paths(&filtered).unwrap(), expected);
1449
1450        // The same pattern with no allow-sets counts every two-hop path, so the
1451        // restriction strictly reduces the count.
1452        let unfiltered = g
1453            .count_linear_paths(&spec(
1454                &[Some("FOLLOWS"), Some("FOLLOWS")],
1455                &[Some("Person"); 3],
1456            ))
1457            .unwrap();
1458        assert!(unfiltered > expected);
1459    }
1460
1461    /// One hop counts typed edges whose endpoints carry the required labels.
1462    #[test]
1463    fn one_hop_counts_typed_edges() {
1464        let (_dir, g) = open_tmp();
1465        let a = g.add_node("Person", &json!({})).unwrap();
1466        let b = g.add_node("Person", &json!({})).unwrap();
1467        let c = g.add_node("Person", &json!({})).unwrap();
1468        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1469        g.add_edge(a, c, "KNOWS", &json!({})).unwrap();
1470
1471        let n = g
1472            .count_linear_paths(&spec(&[Some("KNOWS")], &[Some("Person"), Some("Person")]))
1473            .unwrap();
1474        assert_eq!(n, 2);
1475    }
1476
1477    /// A one-hop label predicate on the far endpoint excludes mismatched
1478    /// targets.
1479    #[test]
1480    fn one_hop_label_filter_excludes_endpoint() {
1481        let (_dir, g) = open_tmp();
1482        let a = g.add_node("Person", &json!({})).unwrap();
1483        let b = g.add_node("Person", &json!({})).unwrap();
1484        let c = g.add_node("City", &json!({})).unwrap();
1485        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1486        g.add_edge(a, c, "KNOWS", &json!({})).unwrap();
1487
1488        let n = g
1489            .count_linear_paths(&spec(&[Some("KNOWS")], &[Some("Person"), Some("Person")]))
1490            .unwrap();
1491        assert_eq!(n, 1);
1492    }
1493
1494    /// Two distinct hops over distinct nodes count once.
1495    #[test]
1496    fn two_hop_distinct_nodes_count_once() {
1497        let (_dir, g) = open_tmp();
1498        let a = g.add_node("Person", &json!({})).unwrap();
1499        let b = g.add_node("Person", &json!({})).unwrap();
1500        let c = g.add_node("Person", &json!({})).unwrap();
1501        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1502        g.add_edge(b, c, "KNOWS", &json!({})).unwrap();
1503
1504        let n = g
1505            .count_linear_paths(&spec(
1506                &[Some("KNOWS"), Some("KNOWS")],
1507                &[Some("Person"), Some("Person"), Some("Person")],
1508            ))
1509            .unwrap();
1510        assert_eq!(n, 1);
1511    }
1512
1513    /// Parallel edges on one hop multiply the assignment count.
1514    #[test]
1515    fn two_hop_parallel_edges_multiply() {
1516        let (_dir, g) = open_tmp();
1517        let a = g.add_node("Person", &json!({})).unwrap();
1518        let b = g.add_node("Person", &json!({})).unwrap();
1519        let c = g.add_node("Person", &json!({})).unwrap();
1520        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1521        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1522        g.add_edge(b, c, "KNOWS", &json!({})).unwrap();
1523
1524        let n = g
1525            .count_linear_paths(&spec(
1526                &[Some("KNOWS"), Some("KNOWS")],
1527                &[Some("Person"), Some("Person"), Some("Person")],
1528            ))
1529            .unwrap();
1530        assert_eq!(n, 2);
1531    }
1532
1533    /// Relationship uniqueness removes the assignment where one self-loop edge
1534    /// would fill both hops, while keeping the path that leaves the self-loop.
1535    #[test]
1536    fn two_hop_self_loop_respects_relationship_uniqueness() {
1537        let (_dir, g) = open_tmp();
1538        let x = g.add_node("Person", &json!({})).unwrap();
1539        let y = g.add_node("Person", &json!({})).unwrap();
1540        g.add_edge(x, x, "KNOWS", &json!({})).unwrap(); // self-loop
1541        g.add_edge(x, y, "KNOWS", &json!({})).unwrap();
1542
1543        // Without the uniqueness rule the middle-node product would be 2
1544        // (in-degree 1 times out-degree 2 at x); the shared self-loop edge is
1545        // the one excluded pair, leaving the single (self-loop, x->y) path.
1546        let n = g
1547            .count_linear_paths(&spec(
1548                &[Some("KNOWS"), Some("KNOWS")],
1549                &[Some("Person"), Some("Person"), Some("Person")],
1550            ))
1551            .unwrap();
1552        assert_eq!(n, 1);
1553    }
1554
1555    /// An unregistered relationship type matches nothing.
1556    #[test]
1557    fn unknown_relationship_type_counts_zero() {
1558        let (_dir, g) = open_tmp();
1559        let a = g.add_node("Person", &json!({})).unwrap();
1560        let b = g.add_node("Person", &json!({})).unwrap();
1561        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1562
1563        let n = g
1564            .count_linear_paths(&spec(&[Some("LIKES")], &[Some("Person"), Some("Person")]))
1565            .unwrap();
1566        assert_eq!(n, 0);
1567    }
1568}
1569
1570#[cfg(test)]
1571mod triangle_cycle_count_tests {
1572    use serde_json::json;
1573    use tempfile::TempDir;
1574
1575    use crate::{Graph, TriangleCountSpec};
1576
1577    fn open_tmp() -> (TempDir, Graph) {
1578        let dir = TempDir::new().unwrap();
1579        let g = Graph::open(dir.path(), 1).unwrap();
1580        (dir, g)
1581    }
1582
1583    fn spec_all<'a>(rel: &'a str, label: &'a str) -> TriangleCountSpec<'a> {
1584        TriangleCountSpec {
1585            rel_types: [Some(rel); 3],
1586            labels: [Some(label); 3],
1587        }
1588    }
1589
1590    /// One directed 3-cycle of distinct nodes matches once per rotation of
1591    /// `a`: three assignments, exactly what MATCH row semantics produce.
1592    #[test]
1593    fn single_cycle_counts_one_per_rotation() {
1594        let (_dir, g) = open_tmp();
1595        let a = g.add_node("Person", &json!({})).unwrap();
1596        let b = g.add_node("Person", &json!({})).unwrap();
1597        let c = g.add_node("Person", &json!({})).unwrap();
1598        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1599        g.add_edge(b, c, "KNOWS", &json!({})).unwrap();
1600        g.add_edge(c, a, "KNOWS", &json!({})).unwrap();
1601
1602        let n = g
1603            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1604            .unwrap();
1605        assert_eq!(n, 3);
1606    }
1607
1608    /// A non-cycle triangle orientation (two edges out of one node) is not a
1609    /// directed cycle and must not count.
1610    #[test]
1611    fn non_cyclic_orientation_does_not_count() {
1612        let (_dir, g) = open_tmp();
1613        let a = g.add_node("Person", &json!({})).unwrap();
1614        let b = g.add_node("Person", &json!({})).unwrap();
1615        let c = g.add_node("Person", &json!({})).unwrap();
1616        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1617        g.add_edge(b, c, "KNOWS", &json!({})).unwrap();
1618        g.add_edge(a, c, "KNOWS", &json!({})).unwrap();
1619
1620        let n = g
1621            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1622            .unwrap();
1623        assert_eq!(n, 0);
1624    }
1625
1626    /// Parallel edges are distinct relationships; doubling one hop doubles
1627    /// every assignment that uses it.
1628    #[test]
1629    fn parallel_edges_multiply() {
1630        let (_dir, g) = open_tmp();
1631        let a = g.add_node("Person", &json!({})).unwrap();
1632        let b = g.add_node("Person", &json!({})).unwrap();
1633        let c = g.add_node("Person", &json!({})).unwrap();
1634        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1635        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1636        g.add_edge(b, c, "KNOWS", &json!({})).unwrap();
1637        g.add_edge(c, a, "KNOWS", &json!({})).unwrap();
1638
1639        let n = g
1640            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1641            .unwrap();
1642        assert_eq!(n, 6);
1643    }
1644
1645    /// Per-hop types are positional: a cycle whose third edge has a different
1646    /// type matches only the rotation whose hop order lines up with the spec.
1647    #[test]
1648    fn per_hop_types_are_positional() {
1649        let (_dir, g) = open_tmp();
1650        let a = g.add_node("Person", &json!({})).unwrap();
1651        let b = g.add_node("Person", &json!({})).unwrap();
1652        let c = g.add_node("Person", &json!({})).unwrap();
1653        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1654        g.add_edge(b, c, "KNOWS", &json!({})).unwrap();
1655        g.add_edge(c, a, "LIKES", &json!({})).unwrap();
1656
1657        let homogeneous = g
1658            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1659            .unwrap();
1660        assert_eq!(homogeneous, 0);
1661
1662        let mixed = g
1663            .count_triangle_cycles(&TriangleCountSpec {
1664                rel_types: [Some("KNOWS"), Some("KNOWS"), Some("LIKES")],
1665                labels: [Some("Person"); 3],
1666            })
1667            .unwrap();
1668        assert_eq!(mixed, 1);
1669    }
1670
1671    /// Untyped hops match any relationship type.
1672    #[test]
1673    fn untyped_hops_match_any_type() {
1674        let (_dir, g) = open_tmp();
1675        let a = g.add_node("Person", &json!({})).unwrap();
1676        let b = g.add_node("Person", &json!({})).unwrap();
1677        let c = g.add_node("Person", &json!({})).unwrap();
1678        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1679        g.add_edge(b, c, "LIKES", &json!({})).unwrap();
1680        g.add_edge(c, a, "FOLLOWS", &json!({})).unwrap();
1681
1682        let n = g
1683            .count_triangle_cycles(&TriangleCountSpec {
1684                rel_types: [None; 3],
1685                labels: [Some("Person"); 3],
1686            })
1687            .unwrap();
1688        assert_eq!(n, 3);
1689    }
1690
1691    /// A node missing the required label excludes every assignment that
1692    /// binds it; a multi-label node still qualifies.
1693    #[test]
1694    fn label_filter_applies_per_variable() {
1695        let (_dir, g) = open_tmp();
1696        let a = g.add_node("Person", &json!({})).unwrap();
1697        let b = g.add_node("Person", &json!({})).unwrap();
1698        let c = g.add_node("Robot", &json!({})).unwrap();
1699        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1700        g.add_edge(b, c, "KNOWS", &json!({})).unwrap();
1701        g.add_edge(c, a, "KNOWS", &json!({})).unwrap();
1702
1703        let strict = g
1704            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1705            .unwrap();
1706        assert_eq!(strict, 0);
1707
1708        // With the label added, the node carries both labels and qualifies.
1709        g.add_label(c, "Person").unwrap();
1710        let after = g
1711            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1712            .unwrap();
1713        assert_eq!(after, 3);
1714
1715        let unlabeled = g
1716            .count_triangle_cycles(&TriangleCountSpec {
1717                rel_types: [Some("KNOWS"); 3],
1718                labels: [None; 3],
1719            })
1720            .unwrap();
1721        assert_eq!(unlabeled, 3);
1722    }
1723
1724    /// Relationship uniqueness: with `a == b == c` every hop is a self-loop,
1725    /// so matches are ordered triples of pairwise-distinct self-loop edges.
1726    /// Three self-loops give 3! = 6; two give none.
1727    #[test]
1728    fn self_loop_assignments_respect_relationship_uniqueness() {
1729        let (_dir, g) = open_tmp();
1730        let a = g.add_node("Person", &json!({})).unwrap();
1731        g.add_edge(a, a, "KNOWS", &json!({})).unwrap();
1732        g.add_edge(a, a, "KNOWS", &json!({})).unwrap();
1733
1734        let two = g
1735            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1736            .unwrap();
1737        assert_eq!(two, 0);
1738
1739        g.add_edge(a, a, "KNOWS", &json!({})).unwrap();
1740        let three = g
1741            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1742            .unwrap();
1743        assert_eq!(three, 6);
1744    }
1745
1746    /// A self-loop combined with a 2-cycle yields one assignment per choice
1747    /// of the variable bound to the looped node: a=b, b=c, or c=a.
1748    #[test]
1749    fn self_loop_with_two_cycle_counts_each_position() {
1750        let (_dir, g) = open_tmp();
1751        let x = g.add_node("Person", &json!({})).unwrap();
1752        let y = g.add_node("Person", &json!({})).unwrap();
1753        g.add_edge(x, x, "KNOWS", &json!({})).unwrap();
1754        g.add_edge(x, y, "KNOWS", &json!({})).unwrap();
1755        g.add_edge(y, x, "KNOWS", &json!({})).unwrap();
1756
1757        let n = g
1758            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1759            .unwrap();
1760        assert_eq!(n, 3);
1761    }
1762
1763    /// Unknown relationship types and labels match nothing instead of
1764    /// erroring: the query layer maps absent registry entries to empty scans.
1765    #[test]
1766    fn unknown_type_or_label_counts_zero() {
1767        let (_dir, g) = open_tmp();
1768        let a = g.add_node("Person", &json!({})).unwrap();
1769        let b = g.add_node("Person", &json!({})).unwrap();
1770        let c = g.add_node("Person", &json!({})).unwrap();
1771        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1772        g.add_edge(b, c, "KNOWS", &json!({})).unwrap();
1773        g.add_edge(c, a, "KNOWS", &json!({})).unwrap();
1774
1775        assert_eq!(
1776            g.count_triangle_cycles(&spec_all("NOPE", "Person"))
1777                .unwrap(),
1778            0
1779        );
1780        assert_eq!(
1781            g.count_triangle_cycles(&spec_all("KNOWS", "Ghost"))
1782                .unwrap(),
1783            0
1784        );
1785    }
1786
1787    /// The count must reflect committed writes without an explicit
1788    /// `rebuild_csr`: the freshness gate covers this consumer.
1789    #[test]
1790    fn count_is_fresh_after_writes() {
1791        let (_dir, g) = open_tmp();
1792        let a = g.add_node("Person", &json!({})).unwrap();
1793        let b = g.add_node("Person", &json!({})).unwrap();
1794        let c = g.add_node("Person", &json!({})).unwrap();
1795        g.add_edge(a, b, "KNOWS", &json!({})).unwrap();
1796        g.add_edge(b, c, "KNOWS", &json!({})).unwrap();
1797
1798        let before = g
1799            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1800            .unwrap();
1801        assert_eq!(before, 0);
1802
1803        g.add_edge(c, a, "KNOWS", &json!({})).unwrap();
1804        let after = g
1805            .count_triangle_cycles(&spec_all("KNOWS", "Person"))
1806            .unwrap();
1807        assert_eq!(after, 3);
1808    }
1809
1810    /// An empty graph counts zero without erroring on unmaterialized state.
1811    #[test]
1812    fn empty_graph_counts_zero() {
1813        let (_dir, g) = open_tmp();
1814        assert_eq!(
1815            g.count_triangle_cycles(&spec_all("KNOWS", "Person"))
1816                .unwrap(),
1817            0
1818        );
1819    }
1820}