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}