Skip to main content

nodedb_graph/csr/index/
lookup.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Read-side queries: neighbor lookup, counters, degree, iterators,
4//! dense-array helpers, and the `add_node` / `build_dense` utilities.
5//!
6//! Public entry points take [`LocalNodeId`] so that cross-partition id
7//! use panics at the boundary. Crate-internal iteration over dense
8//! ranges uses raw `u32` via `dense_out_edges` / `dense_in_edges`.
9
10use std::mem::size_of;
11
12use nodedb_mem::ScopedMemory;
13
14use super::types::{CsrIndex, Direction};
15use crate::GraphError;
16use crate::csr::LocalNodeId;
17use crate::csr::rebuild::journal::{CsrWriteOp, OpOutcome};
18
19/// Contiguous CSR adjacency arrays produced by [`CsrIndex::build_dense`].
20pub(crate) struct DenseAdjacency {
21    pub(crate) offsets: Vec<u32>,
22    pub(crate) targets: Vec<u32>,
23    pub(crate) labels: Vec<u32>,
24    pub(crate) collections: Vec<u32>,
25}
26
27impl CsrIndex {
28    /// Partition tag assigned at construction. Embedded in every `LocalNodeId` this index produces.
29    #[inline]
30    pub fn partition_tag(&self) -> u32 {
31        self.partition_tag
32    }
33
34    /// Mint a `LocalNodeId` for this partition from a raw dense index.
35    /// Used by algorithm code that iterates `0..node_count` and needs
36    /// to call `LocalNodeId`-taking APIs.
37    #[inline]
38    pub fn local(&self, id: u32) -> LocalNodeId {
39        LocalNodeId::new(id, self.partition_tag)
40    }
41
42    /// Get immediate neighbors by string name. An empty `label_filter` keeps
43    /// every edge. Otherwise an edge whose label is any listed label passes.
44    pub fn neighbors(
45        &self,
46        node: &str,
47        label_filter: &[&str],
48        direction: Direction,
49    ) -> Vec<(String, String)> {
50        let Some(&node_id) = self.node_to_id.get(node) else {
51            return Vec::new();
52        };
53        self.record_access(node_id);
54        let labels = self.label_filter(label_filter);
55
56        let mut result = Vec::new();
57
58        if matches!(direction, Direction::Out | Direction::Both) {
59            for (lid, dst) in self.dense_iter_out(node_id) {
60                if labels.keeps(lid) {
61                    result.push((
62                        self.id_to_label[lid as usize].clone(),
63                        self.id_to_node[dst as usize].clone(),
64                    ));
65                }
66            }
67        }
68        if matches!(direction, Direction::In | Direction::Both) {
69            for (lid, src) in self.dense_iter_in(node_id) {
70                if labels.keeps(lid) {
71                    result.push((
72                        self.id_to_label[lid as usize].clone(),
73                        self.id_to_node[src as usize].clone(),
74                    ));
75                }
76            }
77        }
78
79        result
80    }
81
82    /// Add a node without any edges. Idempotent — returns the existing
83    /// tagged id if the name is already present.
84    ///
85    /// Returns `Err(GraphError::NodeOverflow)` when the partition's node-id
86    /// space is exhausted (more than `MAX_NODES_PER_CSR` distinct nodes).
87    pub fn add_node(&mut self, name: &str) -> Result<LocalNodeId, crate::GraphError> {
88        let result = self.ensure_node(name);
89        self.journal_record(
90            || CsrWriteOp::AddNode {
91                name: name.to_string(),
92            },
93            OpOutcome::of(&result),
94        );
95        Ok(LocalNodeId::new(result?, self.partition_tag))
96    }
97
98    pub fn node_count(&self) -> usize {
99        self.id_to_node.len()
100    }
101
102    pub fn contains_node(&self, node: &str) -> bool {
103        self.node_to_id.contains_key(node)
104    }
105
106    /// Get the string name for a tagged node id.
107    pub fn node_name(&self, id: LocalNodeId) -> &str {
108        &self.id_to_node[id.raw(self.partition_tag) as usize]
109    }
110
111    /// Look up the tagged node id for a string name.
112    pub fn node_id(&self, name: &str) -> Option<LocalNodeId> {
113        self.node_to_id
114            .get(name)
115            .copied()
116            .map(|raw| LocalNodeId::new(raw, self.partition_tag))
117    }
118
119    /// Get the string label for a dense label index.
120    pub fn label_name(&self, label_id: u32) -> &str {
121        &self.id_to_label[label_id as usize]
122    }
123
124    /// Look up the dense label id for a string label.
125    pub fn label_id(&self, name: &str) -> Option<u32> {
126        self.label_to_id.get(name).copied()
127    }
128
129    /// Out-degree of a node (including buffer, excluding deleted).
130    pub fn out_degree(&self, id: LocalNodeId) -> usize {
131        self.dense_iter_out(id.raw(self.partition_tag)).count()
132    }
133
134    /// In-degree of a node.
135    pub fn in_degree(&self, id: LocalNodeId) -> usize {
136        self.dense_iter_in(id.raw(self.partition_tag)).count()
137    }
138
139    /// Total edge count (dense + buffer - deleted). O(V).
140    pub fn edge_count(&self) -> usize {
141        let n = self.id_to_node.len();
142        (0..n as u32).map(|i| self.out_degree(self.local(i))).sum()
143    }
144
145    // ── Internal helpers ──
146
147    /// Build contiguous offset/target/label arrays from per-node edge lists.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`GraphError::MemoryBudget`] if the reservation for the three
152    /// output arrays exceeds the `Graph` engine budget.
153    pub(crate) fn build_dense(
154        edges: &[Vec<(u32, u32)>],
155        collections: &[Vec<u32>],
156        memory: &ScopedMemory,
157    ) -> Result<DenseAdjacency, GraphError> {
158        let n = edges.len();
159        let total: usize = edges.iter().map(|e| e.len()).sum();
160        // Reserve budget for offsets (n+1 u32s), targets/labels/collections (total u32s each).
161        let reserve_bytes = (n + 1 + 3 * total) * size_of::<u32>();
162        let _budget_guard = memory.reserve(reserve_bytes)?;
163        let mut offsets = Vec::with_capacity(n + 1);
164        let mut targets = Vec::with_capacity(total);
165        let mut labels = Vec::with_capacity(total);
166        let mut out_collections = Vec::with_capacity(total);
167
168        let mut offset = 0u32;
169        for (node, node_edges) in edges.iter().enumerate() {
170            offsets.push(offset);
171            let node_colls = collections.get(node);
172            for (k, &(lid, target)) in node_edges.iter().enumerate() {
173                targets.push(target);
174                labels.push(lid);
175                out_collections.push(node_colls.and_then(|c| c.get(k)).copied().unwrap_or(0));
176            }
177            offset += node_edges.len() as u32;
178        }
179        offsets.push(offset);
180
181        Ok(DenseAdjacency {
182            offsets,
183            targets,
184            labels,
185            collections: out_collections,
186        })
187    }
188
189    /// Check if a specific `(src, label, dst, collection)` edge exists in the
190    /// dense CSR. Edge identity is collection-aware: the same triple under two
191    /// collections is two distinct edges, so dedup / re-insert must key on the
192    /// collection too.
193    pub(crate) fn dense_has_edge(&self, src: u32, label: u32, dst: u32, collection: u32) -> bool {
194        for (lid, target, coll) in self.dense_out_edges(src) {
195            if lid == label && target == dst && coll == collection {
196                return true;
197            }
198        }
199        false
200    }
201
202    /// Iterate dense outbound edges for a node as `(label, dst, collection)`
203    /// (raw u32, no tag check, no deletion filter).
204    pub(crate) fn dense_out_edges(&self, node: u32) -> impl Iterator<Item = (u32, u32, u32)> + '_ {
205        let range = self
206            .out_offsets
207            .get(node as usize..)
208            .and_then(|offsets| offsets.first().zip(offsets.get(1)))
209            .map_or(0..0, |(&start, &end)| start as usize..end as usize);
210        range.map(move |i| {
211            (
212                self.out_labels[i],
213                self.out_targets[i],
214                self.out_collections.get(i).copied().unwrap_or(0),
215            )
216        })
217    }
218
219    /// Iterate dense inbound edges for a node as `(label, src, collection)`
220    /// (raw u32, no tag check, no deletion filter).
221    pub(crate) fn dense_in_edges(&self, node: u32) -> impl Iterator<Item = (u32, u32, u32)> + '_ {
222        let range = self
223            .in_offsets
224            .get(node as usize..)
225            .and_then(|offsets| offsets.first().zip(offsets.get(1)))
226            .map_or(0..0, |(&start, &end)| start as usize..end as usize);
227        range.map(move |i| {
228            (
229                self.in_labels[i],
230                self.in_targets[i],
231                self.in_collections.get(i).copied().unwrap_or(0),
232            )
233        })
234    }
235
236    /// Raw u32 iteration over outbound edges (dense + buffer - deleted),
237    /// yielding `(label, dst)`. The deletion filter is collection-aware: only
238    /// the `(node, label, dst, collection)` copy that was deleted is hidden.
239    /// Crate-internal: used by label-dispatching helpers and algorithms
240    /// that already hold a validated partition borrow.
241    pub(crate) fn dense_iter_out(&self, node: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
242        let dense = self
243            .dense_out_edges(node)
244            .filter(move |&(lid, dst, coll)| !self.deleted_edges.contains(&(node, lid, dst, coll)))
245            .map(|(lid, dst, _coll)| (lid, dst));
246        dense.chain(self.buffer_out_iter(node))
247    }
248
249    /// Raw u32 iteration over inbound edges (dense + buffer - deleted),
250    /// yielding `(label, src)`. Collection-aware deletion filter.
251    pub(crate) fn dense_iter_in(&self, node: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
252        let dense = self
253            .dense_in_edges(node)
254            .filter(move |&(lid, src, coll)| !self.deleted_edges.contains(&(src, lid, node, coll)))
255            .map(|(lid, src, _coll)| (lid, src));
256        dense.chain(self.buffer_in_iter(node))
257    }
258
259    /// Collection-tagged iteration over live outbound edges (dense + buffer -
260    /// deleted), yielding `(label, dst, collection)`. Used by node-edge removal
261    /// so each edge is tombstoned under its own collection identity.
262    pub(crate) fn dense_iter_out_coll(&self, node: u32) -> Vec<(u32, u32, u32)> {
263        let mut out: Vec<(u32, u32, u32)> = self
264            .dense_out_edges(node)
265            .filter(|&(lid, dst, coll)| !self.deleted_edges.contains(&(node, lid, dst, coll)))
266            .collect();
267        let idx = node as usize;
268        if idx < self.buffer_out.len() {
269            for (k, &(lid, dst)) in self.buffer_out[idx].iter().enumerate() {
270                let coll = self
271                    .buffer_out_collections
272                    .get(idx)
273                    .and_then(|c| c.get(k))
274                    .copied()
275                    .unwrap_or(0);
276                out.push((lid, dst, coll));
277            }
278        }
279        out
280    }
281
282    /// Collection-tagged iteration over live inbound edges (see
283    /// [`Self::dense_iter_out_coll`]), yielding `(label, src, collection)`.
284    pub(crate) fn dense_iter_in_coll(&self, node: u32) -> Vec<(u32, u32, u32)> {
285        let mut out: Vec<(u32, u32, u32)> = self
286            .dense_in_edges(node)
287            .filter(|&(lid, src, coll)| !self.deleted_edges.contains(&(src, lid, node, coll)))
288            .collect();
289        let idx = node as usize;
290        if idx < self.buffer_in.len() {
291            for (k, &(lid, src)) in self.buffer_in[idx].iter().enumerate() {
292                let coll = self
293                    .buffer_in_collections
294                    .get(idx)
295                    .and_then(|c| c.get(k))
296                    .copied()
297                    .unwrap_or(0);
298                out.push((lid, src, coll));
299            }
300        }
301        out
302    }
303
304    /// Buffer-only iteration over outbound edges for a node.
305    pub(crate) fn buffer_out_iter(&self, node: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
306        self.buffer_out
307            .get(node as usize)
308            .map_or(&[][..], Vec::as_slice)
309            .iter()
310            .copied()
311    }
312
313    /// Buffer-only iteration over inbound edges for a node.
314    pub(crate) fn buffer_in_iter(&self, node: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
315        self.buffer_in
316            .get(node as usize)
317            .map_or(&[][..], Vec::as_slice)
318            .iter()
319            .copied()
320    }
321
322    /// Iterate all outbound edges for a tagged node. Yields
323    /// `(label_id, dst)` with `dst` tagged to this partition.
324    pub fn iter_out_edges(
325        &self,
326        node: LocalNodeId,
327    ) -> impl Iterator<Item = (u32, LocalNodeId)> + '_ {
328        let raw = node.raw(self.partition_tag);
329        let tag = self.partition_tag;
330        self.dense_iter_out(raw)
331            .map(move |(lid, dst)| (lid, LocalNodeId::new(dst, tag)))
332    }
333
334    /// Iterate all inbound edges for a tagged node.
335    pub fn iter_in_edges(
336        &self,
337        node: LocalNodeId,
338    ) -> impl Iterator<Item = (u32, LocalNodeId)> + '_ {
339        let raw = node.raw(self.partition_tag);
340        let tag = self.partition_tag;
341        self.dense_iter_in(raw)
342            .map(move |(lid, src)| (lid, LocalNodeId::new(src, tag)))
343    }
344
345    // ── Raw u32 helpers for in-partition algorithm use ──
346    //
347    // The tagged `LocalNodeId` API catches cross-partition id leakage
348    // at runtime. In-partition algorithms that iterate dense ranges
349    // within a single `&CsrIndex` borrow cannot produce a cross-
350    // partition id by construction — no other partition is reachable
351    // from the borrow. These helpers expose the underlying raw u32
352    // iteration at zero cost for that case.
353
354    /// Raw dense out-edges iteration. In-partition algorithm use only.
355    pub fn iter_out_edges_raw(&self, node: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
356        self.dense_iter_out(node)
357    }
358
359    /// Raw dense in-edges iteration. In-partition algorithm use only.
360    pub fn iter_in_edges_raw(&self, node: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
361        self.dense_iter_in(node)
362    }
363
364    /// Raw out-degree by dense index.
365    pub fn out_degree_raw(&self, node: u32) -> usize {
366        self.dense_iter_out(node).count()
367    }
368
369    /// Raw in-degree by dense index.
370    pub fn in_degree_raw(&self, node: u32) -> usize {
371        self.dense_iter_in(node).count()
372    }
373
374    /// String name for a raw dense index. In-partition algorithm use only.
375    pub fn node_name_raw(&self, id: u32) -> &str {
376        &self.id_to_node[id as usize]
377    }
378
379    /// Whether a raw dense index names a node of this partition.
380    ///
381    /// Read paths that accept caller-supplied local ids gate on this: an id
382    /// past the end is not a node, and admitting it would put an entry in a
383    /// result set that resolves to no name at all.
384    pub fn is_local_node(&self, id: u32) -> bool {
385        (id as usize) < self.id_to_node.len()
386    }
387
388    /// String name for a raw dense index, or `None` when the index is out of
389    /// range. Same domain as [`Self::node_name_raw`], without the panic: read
390    /// paths that resolve a whole traversal frontier use this so one torn id
391    /// narrows the answer instead of killing the core.
392    pub fn node_name_checked(&self, id: u32) -> Option<&str> {
393        self.id_to_node.get(id as usize).map(String::as_str)
394    }
395
396    /// Raw dense index lookup by name. In-partition algorithm use only.
397    pub fn node_id_raw(&self, name: &str) -> Option<u32> {
398        self.node_to_id.get(name).copied()
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    // Live adjacency iteration across dense and buffered edges.
405
406    use super::CsrIndex;
407    use crate::test_support::test_memory;
408
409    fn names(csr: &CsrIndex, node: &str) -> Vec<(String, String)> {
410        csr.iter_out_edges(csr.node_id(node).unwrap())
411            .map(|(label, target)| (csr.label_name(label).into(), csr.node_name(target).into()))
412            .collect()
413    }
414
415    #[test]
416    fn live_iterators_preserve_dense_then_buffer_order() {
417        let mut csr = CsrIndex::new(test_memory());
418        csr.add_edge("a", "FIRST", "b").unwrap();
419        assert_eq!(names(&csr, "a"), vec![("FIRST".into(), "b".into())]);
420        csr.compact().unwrap();
421        assert_eq!(names(&csr, "a"), vec![("FIRST".into(), "b".into())]);
422        csr.add_edge("a", "SECOND", "c").unwrap();
423        assert_eq!(
424            names(&csr, "a"),
425            vec![("FIRST".into(), "b".into()), ("SECOND".into(), "c".into())]
426        );
427        let a = csr.node_id_raw("a").unwrap();
428        assert_eq!(csr.iter_out_edges_raw(a).count(), 2);
429        for target in ["b", "c"] {
430            let tagged = csr.node_id(target).unwrap();
431            let inbound: Vec<_> = csr
432                .iter_in_edges(tagged)
433                .map(|(_, source)| csr.node_name(source))
434                .collect();
435            assert_eq!(inbound, vec!["a"]);
436            assert_eq!(
437                csr.iter_in_edges_raw(csr.node_id_raw(target).unwrap())
438                    .count(),
439                1
440            );
441        }
442        assert_eq!(csr.iter_out_edges_raw(u32::MAX).count(), 0);
443        assert_eq!(csr.iter_in_edges_raw(u32::MAX).count(), 0);
444    }
445
446    #[test]
447    fn collection_tombstones_remove_only_the_matching_copy() {
448        let mut csr = CsrIndex::new(test_memory());
449        csr.add_edge_in_collection("a", "LINK", "b", "first")
450            .unwrap();
451        csr.add_edge_in_collection("a", "LINK", "b", "second")
452            .unwrap();
453        csr.compact().unwrap();
454        csr.remove_edge_in_collection("a", "LINK", "b", "first");
455        csr.add_edge("a", "STAGED", "c").unwrap();
456        csr.remove_edge("a", "STAGED", "c");
457        assert_eq!(names(&csr, "a"), vec![("LINK".into(), "b".into())]);
458        assert_eq!(csr.iter_in_edges(csr.node_id("b").unwrap()).count(), 1);
459        assert_eq!(csr.iter_in_edges(csr.node_id("c").unwrap()).count(), 0);
460    }
461}