Skip to main content

nodedb_graph/csr/index/
interning.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Node / label string↔id interning and the node-label bitset.
4
5use std::collections::hash_map::Entry;
6
7use super::types::CsrIndex;
8use crate::csr::rebuild::journal::{CsrWriteOp, OpOutcome};
9
10impl CsrIndex {
11    /// Get or create a dense ID for a node.
12    ///
13    /// Returns `Err(GraphError::NodeOverflow)` when the partition already holds
14    /// `MAX_NODES_PER_CSR` nodes and a new name is introduced. The check uses a
15    /// typed `Result` rather than `debug_assert!` so the failure mode is loud
16    /// and deterministic — a silent `u32` wrap would reproduce the same class
17    /// of bug as the label-overflow issue this crate fixed previously.
18    pub(crate) fn ensure_node(&mut self, node: &str) -> Result<u32, crate::GraphError> {
19        match self.node_to_id.entry(node.to_string()) {
20            Entry::Occupied(e) => Ok(*e.get()),
21            Entry::Vacant(e) => {
22                let len = self.id_to_node.len();
23                if len >= crate::MAX_NODES_PER_CSR {
24                    return Err(crate::GraphError::NodeOverflow { used: len });
25                }
26                let id = len as u32;
27                e.insert(id);
28                self.id_to_node.push(node.to_string());
29                // Extend dense offsets (new node has 0 edges in dense part).
30                self.out_offsets
31                    .push(*self.out_offsets.last().unwrap_or(&0));
32                self.in_offsets.push(*self.in_offsets.last().unwrap_or(&0));
33                // Extend buffer and access tracking.
34                self.buffer_out.push(Vec::new());
35                self.buffer_in.push(Vec::new());
36                self.buffer_out_weights.push(Vec::new());
37                self.buffer_in_weights.push(Vec::new());
38                self.buffer_out_collections.push(Vec::new());
39                self.buffer_in_collections.push(Vec::new());
40                self.node_label_bits.push(0);
41                // Surrogate is populated later by the EdgePut handler; start
42                // with the ZERO sentinel so unset nodes are never in a bitmap.
43                self.node_surrogates.push(0);
44                self.access_counts.push(std::cell::Cell::new(0));
45                Ok(id)
46            }
47        }
48    }
49
50    /// Get or create a dense ID for a label.
51    ///
52    /// Returns `Err(GraphError::LabelOverflow)` if the id space is
53    /// exhausted (`MAX_EDGE_LABELS` distinct labels already interned).
54    /// The DSL ingress layer enforces a far tighter user-facing cap,
55    /// so this branch is effectively unreachable in practice — but the
56    /// Result is propagated so the ceiling failure mode is typed and
57    /// loud rather than a silent cast.
58    pub(crate) fn ensure_label(&mut self, label: &str) -> Result<u32, crate::GraphError> {
59        match self.label_to_id.entry(label.to_string()) {
60            Entry::Occupied(e) => Ok(*e.get()),
61            Entry::Vacant(e) => {
62                let len = self.id_to_label.len();
63                if len >= crate::MAX_EDGE_LABELS {
64                    return Err(crate::GraphError::LabelOverflow { used: len });
65                }
66                let id = len as u32;
67                e.insert(id);
68                self.id_to_label.push(label.to_string());
69                Ok(id)
70            }
71        }
72    }
73
74    /// Get or create a dense id for a collection name.
75    ///
76    /// Unlike labels/nodes there is no hard cap: the number of distinct
77    /// collections in a partition is bounded by DDL and far below any `u32`
78    /// concern. `""` (the unscoped sentinel) interns like any other name.
79    pub(crate) fn ensure_collection(&mut self, collection: &str) -> u32 {
80        match self.collection_to_id.entry(collection.to_string()) {
81            Entry::Occupied(e) => *e.get(),
82            Entry::Vacant(e) => {
83                let id = self.id_to_collection.len() as u32;
84                e.insert(id);
85                self.id_to_collection.push(collection.to_string());
86                id
87            }
88        }
89    }
90
91    /// Look up the dense collection id for a collection name, if interned.
92    ///
93    /// Returns `None` when no edge in this partition was inserted under
94    /// `collection` — the collection-scoped read paths (MATCH `IN '<c>'`,
95    /// RAG) treat that as "no edges in this collection" (empty result).
96    pub fn collection_id(&self, collection: &str) -> Option<u32> {
97        self.collection_to_id.get(collection).copied()
98    }
99
100    /// Collection name for a dense collection id.
101    pub fn collection_name(&self, collection_id: u32) -> Option<&str> {
102        self.id_to_collection
103            .get(collection_id as usize)
104            .map(String::as_str)
105    }
106
107    // ── Surrogate methods ──
108
109    /// Set the surrogate for a node, identified by its user-visible name.
110    ///
111    /// Call once per node after `add_edge` / `add_edge_weighted` has allocated
112    /// a CSR-local id for it. If the node is not yet interned this is a no-op
113    /// (the edge insertion that follows will intern it and a subsequent call
114    /// with the correct surrogate will set it). Calling with `Surrogate::ZERO`
115    /// is a no-op — the zero sentinel is the initial state and has no meaning.
116    pub fn set_node_surrogate(&mut self, node: &str, surrogate: nodedb_types::Surrogate) {
117        let raw = surrogate.as_u32();
118        self.apply_set_node_surrogate(node, raw);
119        self.journal_record(
120            || CsrWriteOp::SetNodeSurrogate {
121                node: node.to_string(),
122                surrogate: raw,
123            },
124            OpOutcome::Applied,
125        );
126    }
127
128    /// The surrogate bind itself, unjournaled. `raw == 0` is a no-op.
129    pub(crate) fn apply_set_node_surrogate(&mut self, node: &str, raw: u32) {
130        if raw == 0 {
131            return;
132        }
133        if let Some(&id) = self.node_to_id.get(node)
134            && let Some(slot) = self.node_surrogates.get_mut(id as usize)
135        {
136            *slot = raw;
137            self.surrogate_to_local.insert(raw, id);
138        }
139    }
140
141    /// Forward lookup: the `Surrogate` bound to a node by name, or `None` if the
142    /// node is unknown to this partition or has no surrogate set (ZERO sentinel).
143    ///
144    /// A graph node and its same-pk document share one global surrogate, so this
145    /// is the bridge from a MATCH binding's node name to the document storage key
146    /// (`StorageKey::for_surrogate`) used to fetch the node's properties.
147    pub fn node_surrogate(&self, node: &str) -> Option<nodedb_types::Surrogate> {
148        let &local_id = self.node_to_id.get(node)?;
149        let raw = self.node_surrogate_raw(local_id);
150        if raw == 0 {
151            None
152        } else {
153            Some(nodedb_types::Surrogate::new(raw))
154        }
155    }
156
157    /// Return the raw surrogate `u32` for a CSR-local node id.
158    ///
159    /// Returns `0` (the ZERO sentinel) when the node has no surrogate set.
160    pub fn node_surrogate_raw(&self, local_id: u32) -> u32 {
161        self.node_surrogates
162            .get(local_id as usize)
163            .copied()
164            .unwrap_or(0)
165    }
166
167    /// Resolve a node name to its CSR-local id.
168    ///
169    /// The name table is rebuilt from durable storage on every open, so this
170    /// resolution always works; the surrogate table is not, which is why a
171    /// name-seeded read must not be routed through
172    /// [`Self::local_id_for_surrogate`].
173    pub fn local_id_for_node(&self, node: &str) -> Option<u32> {
174        self.node_to_id.get(node).copied()
175    }
176
177    /// Resolve a `Surrogate` to its CSR-local node id without touching the
178    /// node-name table.
179    ///
180    /// This is the entry point for reads that stay in the surrogate domain:
181    /// seeding a traversal through [`Self::node_id_for_surrogate`] would
182    /// materialize a name only to hash it straight back to this same id.
183    /// Returns `None` for `Surrogate::ZERO` and for surrogates never bound
184    /// in this partition.
185    pub fn local_id_for_surrogate(&self, surrogate: nodedb_types::Surrogate) -> Option<u32> {
186        let raw = surrogate.as_u32();
187        if raw == 0 {
188            return None;
189        }
190        self.surrogate_to_local.get(&raw).copied()
191    }
192
193    /// Look up the user-visible node name bound to a `Surrogate`.
194    ///
195    /// Returns `None` for `Surrogate::ZERO` and for surrogates that have
196    /// never been bound to a node in this partition. Used by cross-engine
197    /// fusion (graph RAG) to translate a vector-side surrogate into a
198    /// graph BFS seed name.
199    pub fn node_id_for_surrogate(&self, surrogate: nodedb_types::Surrogate) -> Option<&str> {
200        let raw = surrogate.as_u32();
201        if raw == 0 {
202            return None;
203        }
204        let local_id = *self.surrogate_to_local.get(&raw)?;
205        self.id_to_node.get(local_id as usize).map(String::as_str)
206    }
207
208    // ── Node label methods ──
209
210    /// Get or create a node label ID (max 64 labels).
211    fn ensure_node_label(&mut self, label: &str) -> Option<u8> {
212        if let Some(&id) = self.node_label_to_id.get(label) {
213            return Some(id);
214        }
215        let id = self.node_label_names.len();
216        if id >= 64 {
217            return None; // bitset limit
218        }
219        let id = id as u8;
220        self.node_label_to_id.insert(label.to_string(), id);
221        self.node_label_names.push(label.to_string());
222        Some(id)
223    }
224
225    /// Add a label to a node.
226    ///
227    /// Returns `Ok(false)` if the 64 distinct node-label limit is hit (the
228    /// label is silently ignored). Returns `Err(GraphError::NodeOverflow)` if
229    /// the node is new and the partition's node-id space is exhausted.
230    pub fn add_node_label(&mut self, node: &str, label: &str) -> Result<bool, crate::GraphError> {
231        let result = self.apply_add_node_label(node, label);
232        self.journal_record(
233            || CsrWriteOp::AddNodeLabel {
234                node: node.to_string(),
235                label: label.to_string(),
236            },
237            OpOutcome::of_label(&result),
238        );
239        result
240    }
241
242    /// The label add itself, unjournaled.
243    pub(crate) fn apply_add_node_label(
244        &mut self,
245        node: &str,
246        label: &str,
247    ) -> Result<bool, crate::GraphError> {
248        let node_id = self.ensure_node(node)?;
249        let Some(label_id) = self.ensure_node_label(label) else {
250            return Ok(false);
251        };
252        self.node_label_bits[node_id as usize] |= 1u64 << label_id;
253        Ok(true)
254    }
255
256    /// Remove a label from a node.
257    pub fn remove_node_label(&mut self, node: &str, label: &str) {
258        self.apply_remove_node_label(node, label);
259        self.journal_record(
260            || CsrWriteOp::RemoveNodeLabel {
261                node: node.to_string(),
262                label: label.to_string(),
263            },
264            OpOutcome::Applied,
265        );
266    }
267
268    /// The label removal itself, unjournaled.
269    pub(crate) fn apply_remove_node_label(&mut self, node: &str, label: &str) {
270        let Some(&node_id) = self.node_to_id.get(node) else {
271            return;
272        };
273        let Some(&label_id) = self.node_label_to_id.get(label) else {
274            return;
275        };
276        self.node_label_bits[node_id as usize] &= !(1u64 << label_id);
277    }
278
279    /// Check if a node has a specific label.
280    pub fn node_has_label(&self, node_id: u32, label: &str) -> bool {
281        let Some(&label_id) = self.node_label_to_id.get(label) else {
282            return false;
283        };
284        let bits = self
285            .node_label_bits
286            .get(node_id as usize)
287            .copied()
288            .unwrap_or(0);
289        bits & (1u64 << label_id) != 0
290    }
291
292    /// Every node carrying at least one label, as `(node_name, label_names)`.
293    ///
294    /// Node NAMES, not local ids: the local id of a node is assigned by
295    /// whatever order the CSR was built in (the `EdgeStore` scan on a rebuild,
296    /// or `ensure_node` vivification order on a replay), so it is not stable
297    /// across restarts. Any consumer that persists this state and installs it
298    /// back through `add_node_label` therefore reattaches each label to the
299    /// node it actually belonged to, instead of to whichever node happens to
300    /// hold that id in the next process.
301    ///
302    /// Unlabeled nodes are skipped — their bitset is zero and carries no
303    /// information.
304    pub fn labeled_nodes(&self) -> Vec<(&str, Vec<&str>)> {
305        let mut out = Vec::new();
306        for (local_id, &bits) in self.node_label_bits.iter().enumerate() {
307            if bits == 0 {
308                continue;
309            }
310            let Some(name) = self.id_to_node.get(local_id) else {
311                continue;
312            };
313            let labels = self.node_labels(local_id as u32);
314            if labels.is_empty() {
315                continue;
316            }
317            out.push((name.as_str(), labels));
318        }
319        out
320    }
321
322    /// Get all label names for a node.
323    pub fn node_labels(&self, node_id: u32) -> Vec<&str> {
324        let bits = self
325            .node_label_bits
326            .get(node_id as usize)
327            .copied()
328            .unwrap_or(0);
329        if bits == 0 {
330            return Vec::new();
331        }
332        let mut labels = Vec::new();
333        for (i, name) in self.node_label_names.iter().enumerate() {
334            if bits & (1u64 << i) != 0 {
335                labels.push(name.as_str());
336            }
337        }
338        labels
339    }
340}