Skip to main content

nodedb_graph/csr/index/
mutation.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Edge insert / remove paths and node-edge cleanup.
4
5use super::types::CsrIndex;
6use crate::csr::rebuild::journal::{CsrWriteOp, OpOutcome};
7
8impl CsrIndex {
9    /// Incrementally add an unweighted edge (goes into mutable buffer).
10    /// Uses weight 1.0 if the graph already has weighted edges.
11    ///
12    /// Returns `Err(GraphError::LabelOverflow)` if the label id space is
13    /// exhausted. Production callers should always surface this to the
14    /// client; silently ignoring it reproduces the silent-wrap bug the
15    /// `u32` widening was meant to fix.
16    pub fn add_edge(&mut self, src: &str, label: &str, dst: &str) -> Result<(), crate::GraphError> {
17        self.add_edge_internal(src, label, dst, "", 1.0, false)
18    }
19
20    /// Add an unweighted edge scoped to `collection`.
21    ///
22    /// The collection tag is what lets a collection-scoped MATCH / RAG read
23    /// (`... IN '<collection>'`) see only its own edges even though every
24    /// collection's edges share one per-`(database, tenant)` CSR partition
25    /// (nodes, node-labels and surrogates are shared across collections; only
26    /// edges carry the collection axis).
27    pub fn add_edge_in_collection(
28        &mut self,
29        src: &str,
30        label: &str,
31        dst: &str,
32        collection: &str,
33    ) -> Result<(), crate::GraphError> {
34        self.add_edge_internal(src, label, dst, collection, 1.0, false)
35    }
36
37    /// Add a weighted edge scoped to `collection`. See
38    /// [`Self::add_edge_in_collection`] and [`Self::add_edge_weighted`].
39    pub fn add_edge_weighted_in_collection(
40        &mut self,
41        src: &str,
42        label: &str,
43        dst: &str,
44        collection: &str,
45        weight: f64,
46    ) -> Result<(), crate::GraphError> {
47        self.add_edge_internal(src, label, dst, collection, weight, weight != 1.0)
48    }
49
50    /// Incrementally add a weighted edge (goes into mutable buffer).
51    ///
52    /// If this is the first weighted edge (weight != 1.0), initializes
53    /// the weight tracking infrastructure (backfills existing buffer
54    /// entries with 1.0).
55    pub fn add_edge_weighted(
56        &mut self,
57        src: &str,
58        label: &str,
59        dst: &str,
60        weight: f64,
61    ) -> Result<(), crate::GraphError> {
62        self.add_edge_internal(src, label, dst, "", weight, weight != 1.0)
63    }
64
65    fn add_edge_internal(
66        &mut self,
67        src: &str,
68        label: &str,
69        dst: &str,
70        collection: &str,
71        weight: f64,
72        force_weights: bool,
73    ) -> Result<(), crate::GraphError> {
74        let result = self.apply_add_edge(src, label, dst, collection, weight, force_weights);
75        self.journal_record(
76            || CsrWriteOp::AddEdge {
77                src: src.to_string(),
78                label: label.to_string(),
79                dst: dst.to_string(),
80                collection: collection.to_string(),
81                weight,
82                force_weights,
83            },
84            OpOutcome::of(&result),
85        );
86        result
87    }
88
89    /// The edge insert itself, unjournaled.
90    pub(crate) fn apply_add_edge(
91        &mut self,
92        src: &str,
93        label: &str,
94        dst: &str,
95        collection: &str,
96        weight: f64,
97        force_weights: bool,
98    ) -> Result<(), crate::GraphError> {
99        let src_id = self.ensure_node(src)?;
100        let dst_id = self.ensure_node(dst)?;
101        let label_id = self.ensure_label(label)?;
102        let collection_id = self.ensure_collection(collection);
103
104        // Check for duplicates in buffer. Edge identity is collection-aware:
105        // the same `(label, dst)` under a DIFFERENT collection is a distinct
106        // edge and must NOT be deduplicated away.
107        let out = &self.buffer_out[src_id as usize];
108        let out_colls = &self.buffer_out_collections[src_id as usize];
109        if out
110            .iter()
111            .zip(out_colls.iter())
112            .any(|(&(l, d), &c)| l == label_id && d == dst_id && c == collection_id)
113        {
114            return Ok(());
115        }
116        // A dense copy is the edge itself: a deleted one comes back.
117        if self.dense_has_edge(src_id, label_id, dst_id, collection_id) {
118            self.deleted_edges
119                .remove(&(src_id, label_id, dst_id, collection_id));
120            return Ok(());
121        }
122
123        // Initialize weight tracking on first non-default weight.
124        if force_weights && !self.has_weights {
125            self.enable_weights();
126        }
127
128        self.buffer_out[src_id as usize].push((label_id, dst_id));
129        self.buffer_in[dst_id as usize].push((label_id, src_id));
130        self.buffer_out_collections[src_id as usize].push(collection_id);
131        self.buffer_in_collections[dst_id as usize].push(collection_id);
132
133        if self.has_weights {
134            self.buffer_out_weights[src_id as usize].push(weight);
135            self.buffer_in_weights[dst_id as usize].push(weight);
136        }
137
138        // A node-edge removal marks buffered edges deleted too. With no dense
139        // copy that mark names this edge only, so it goes.
140        self.deleted_edges
141            .remove(&(src_id, label_id, dst_id, collection_id));
142        Ok(())
143    }
144
145    /// Incrementally remove an edge under the unscoped (`""`) collection.
146    ///
147    /// Kept for callers that operate on a single-collection CSR (NodeDB-Lite
148    /// keys one `CsrIndex` per collection) or add edges via the collection-less
149    /// [`Self::add_edge`]. Origin's collection-scoped delete path uses
150    /// [`Self::remove_edge_in_collection`].
151    pub fn remove_edge(&mut self, src: &str, label: &str, dst: &str) {
152        self.remove_edge_in_collection(src, label, dst, "");
153    }
154
155    /// Incrementally remove the `(src, label, dst, collection)` edge.
156    ///
157    /// Only the copy tagged with `collection` is removed — an identical triple
158    /// under a different collection is left intact.
159    pub fn remove_edge_in_collection(
160        &mut self,
161        src: &str,
162        label: &str,
163        dst: &str,
164        collection: &str,
165    ) {
166        self.apply_remove_edge(src, label, dst, collection);
167        self.journal_record(
168            || CsrWriteOp::RemoveEdge {
169                src: src.to_string(),
170                label: label.to_string(),
171                dst: dst.to_string(),
172                collection: collection.to_string(),
173            },
174            OpOutcome::Applied,
175        );
176    }
177
178    /// The edge removal itself, unjournaled.
179    pub(crate) fn apply_remove_edge(
180        &mut self,
181        src: &str,
182        label: &str,
183        dst: &str,
184        collection: &str,
185    ) {
186        let (Some(&src_id), Some(&dst_id)) = (self.node_to_id.get(src), self.node_to_id.get(dst))
187        else {
188            return;
189        };
190        let Some(&label_id) = self.label_to_id.get(label) else {
191            return;
192        };
193        let Some(&collection_id) = self.collection_to_id.get(collection) else {
194            return;
195        };
196
197        // Remove from buffer if present (keep parallel buffers in sync).
198        let out_buf = &self.buffer_out[src_id as usize];
199        let out_colls = &self.buffer_out_collections[src_id as usize];
200        if let Some(pos) = out_buf
201            .iter()
202            .zip(out_colls.iter())
203            .position(|(&(l, d), &c)| l == label_id && d == dst_id && c == collection_id)
204        {
205            self.buffer_out[src_id as usize].swap_remove(pos);
206            self.buffer_out_collections[src_id as usize].swap_remove(pos);
207            if self.has_weights {
208                self.buffer_out_weights[src_id as usize].swap_remove(pos);
209            }
210        }
211        let in_buf = &self.buffer_in[dst_id as usize];
212        let in_colls = &self.buffer_in_collections[dst_id as usize];
213        if let Some(pos) = in_buf
214            .iter()
215            .zip(in_colls.iter())
216            .position(|(&(l, s), &c)| l == label_id && s == src_id && c == collection_id)
217        {
218            self.buffer_in[dst_id as usize].swap_remove(pos);
219            self.buffer_in_collections[dst_id as usize].swap_remove(pos);
220            if self.has_weights {
221                self.buffer_in_weights[dst_id as usize].swap_remove(pos);
222            }
223        }
224
225        // Mark as deleted in dense CSR.
226        if self.dense_has_edge(src_id, label_id, dst_id, collection_id) {
227            self.deleted_edges
228                .insert((src_id, label_id, dst_id, collection_id));
229        }
230    }
231
232    /// Remove ALL edges touching a node. Returns the number of edges removed.
233    pub fn remove_node_edges(&mut self, node: &str) -> usize {
234        let removed = self.apply_remove_node_edges(node);
235        self.journal_record(
236            || CsrWriteOp::RemoveNodeEdges {
237                node: node.to_string(),
238            },
239            OpOutcome::Applied,
240        );
241        removed
242    }
243
244    /// The node-edge removal itself, unjournaled.
245    pub(crate) fn apply_remove_node_edges(&mut self, node: &str) -> usize {
246        let Some(&node_id) = self.node_to_id.get(node) else {
247            return 0;
248        };
249        let mut removed = 0;
250
251        // Collect outgoing edges (collection-tagged) then remove reverse
252        // references. Every collection's copy of an edge is removed — node
253        // deletion cascades across all collections.
254        let out_edges = self.dense_iter_out_coll(node_id);
255        for (label_id, dst_id, coll_id) in &out_edges {
256            let in_buf = &self.buffer_in[*dst_id as usize];
257            let in_colls = &self.buffer_in_collections[*dst_id as usize];
258            if let Some(pos) = in_buf
259                .iter()
260                .zip(in_colls.iter())
261                .position(|(&(l, s), &c)| l == *label_id && s == node_id && c == *coll_id)
262            {
263                self.buffer_in[*dst_id as usize].swap_remove(pos);
264                self.buffer_in_collections[*dst_id as usize].swap_remove(pos);
265                if self.has_weights {
266                    self.buffer_in_weights[*dst_id as usize].swap_remove(pos);
267                }
268            }
269            self.deleted_edges
270                .insert((node_id, *label_id, *dst_id, *coll_id));
271            removed += 1;
272        }
273        self.buffer_out[node_id as usize].clear();
274        self.buffer_out_collections[node_id as usize].clear();
275        if self.has_weights {
276            self.buffer_out_weights[node_id as usize].clear();
277        }
278
279        // Collect incoming edges (collection-tagged) then remove reverse
280        // references.
281        let in_edges = self.dense_iter_in_coll(node_id);
282        for (label_id, src_id, coll_id) in &in_edges {
283            let out_buf = &self.buffer_out[*src_id as usize];
284            let out_colls = &self.buffer_out_collections[*src_id as usize];
285            if let Some(pos) = out_buf
286                .iter()
287                .zip(out_colls.iter())
288                .position(|(&(l, d), &c)| l == *label_id && d == node_id && c == *coll_id)
289            {
290                self.buffer_out[*src_id as usize].swap_remove(pos);
291                self.buffer_out_collections[*src_id as usize].swap_remove(pos);
292                if self.has_weights {
293                    self.buffer_out_weights[*src_id as usize].swap_remove(pos);
294                }
295            }
296            self.deleted_edges
297                .insert((*src_id, *label_id, node_id, *coll_id));
298            removed += 1;
299        }
300        self.buffer_in[node_id as usize].clear();
301        self.buffer_in_collections[node_id as usize].clear();
302        if self.has_weights {
303            self.buffer_in_weights[node_id as usize].clear();
304        }
305
306        removed
307    }
308
309    /// Remove all edges touching any node whose ID starts with `prefix`.
310    ///
311    /// Used for tenant purge: `prefix = "{tenant_id}:"` removes all
312    /// edges belonging to that tenant.
313    pub fn remove_nodes_with_prefix(&mut self, prefix: &str) {
314        let matching_nodes: Vec<String> = self
315            .node_to_id
316            .keys()
317            .filter(|k| k.starts_with(prefix))
318            .cloned()
319            .collect();
320        for node in &matching_nodes {
321            self.remove_node_edges(node);
322        }
323    }
324}