velesdb_core/collection/graph/label_index.rs
1//! Label index for fast graph node lookups by label.
2//!
3//! Provides O(1) label-to-node-id lookups using `RoaringBitmap`, enabling
4//! `find_start_nodes()` to skip the O(N) full scan when a MATCH pattern
5//! specifies node labels.
6
7use super::helpers::safe_bitmap_id;
8use roaring::RoaringBitmap;
9use std::collections::HashMap;
10
11/// Index mapping label names to the set of node IDs carrying that label.
12///
13/// Each label maps to a `RoaringBitmap` of node IDs (u32). When a MATCH
14/// query specifies `(n:Person)`, the index returns the bitmap of all
15/// `Person`-labeled nodes in O(1), avoiding a full payload scan.
16///
17/// # Limitations
18///
19/// `RoaringBitmap` only supports u32 IDs. Node IDs exceeding `u32::MAX`
20/// are silently skipped (logged at warn level on insert).
21///
22/// # Example
23///
24/// ```rust,ignore
25/// let mut index = LabelIndex::new();
26/// index.insert("Person", 1);
27/// index.insert("Person", 2);
28/// index.insert("Company", 3);
29///
30/// let persons = index.lookup("Person");
31/// assert!(persons.map_or(false, |b| b.contains(1)));
32/// assert!(persons.map_or(false, |b| b.contains(2)));
33/// ```
34#[derive(Debug, Default)]
35pub struct LabelIndex {
36 /// label_name -> set of node IDs with that label.
37 labels: HashMap<String, RoaringBitmap>,
38 /// Set to `true` when any `index_from_payload` call encounters a node ID
39 /// exceeding `u32::MAX`. Callers should fall back to a full scan when this
40 /// flag is set and the bitmap lookup returns no results.
41 has_large_ids: bool,
42}
43
44impl LabelIndex {
45 /// Creates a new empty label index.
46 #[must_use]
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 /// Indexes a node under one or more labels.
52 ///
53 /// Extracts `_labels` from the JSON payload (expected to be an array of
54 /// strings) and inserts the node ID into the bitmap for each label.
55 ///
56 /// Returns the number of labels successfully indexed (0 if payload has
57 /// no `_labels` array or node ID exceeds `u32::MAX`).
58 pub fn index_from_payload(&mut self, node_id: u64, payload: &serde_json::Value) -> usize {
59 let Some(safe_id) = safe_bitmap_id(node_id) else {
60 tracing::warn!(
61 node_id,
62 "LabelIndex: node_id exceeds u32::MAX, cannot index"
63 );
64 // Track that we have unindexable IDs so callers can fall back
65 // to a full scan instead of returning silently incomplete results.
66 if payload.get("_labels").and_then(|v| v.as_array()).is_some() {
67 self.has_large_ids = true;
68 }
69 return 0;
70 };
71
72 let Some(labels_arr) = payload.get("_labels").and_then(|v| v.as_array()) else {
73 return 0;
74 };
75
76 let mut count = 0usize;
77 for label_val in labels_arr {
78 if let Some(label_str) = label_val.as_str() {
79 self.labels
80 .entry(label_str.to_string())
81 .or_default()
82 .insert(safe_id);
83 count += 1;
84 }
85 }
86 count
87 }
88
89 /// Inserts a single `(label, node_id)` pair into the index.
90 ///
91 /// Returns `true` if the node was added (new entry). Returns `false` if
92 /// the node ID exceeds `u32::MAX` or was already present.
93 pub fn insert(&mut self, label: &str, node_id: u64) -> bool {
94 let Some(safe_id) = safe_bitmap_id(node_id) else {
95 return false;
96 };
97 self.labels
98 .entry(label.to_string())
99 .or_default()
100 .insert(safe_id)
101 }
102
103 /// Removes a node from all label bitmaps.
104 ///
105 /// Call this before removing a node to keep the index consistent.
106 pub fn remove_from_payload(&mut self, node_id: u64, payload: &serde_json::Value) {
107 let Some(safe_id) = safe_bitmap_id(node_id) else {
108 return;
109 };
110
111 let Some(labels_arr) = payload.get("_labels").and_then(|v| v.as_array()) else {
112 return;
113 };
114
115 for label_val in labels_arr {
116 if let Some(label_str) = label_val.as_str() {
117 if let Some(bitmap) = self.labels.get_mut(label_str) {
118 bitmap.remove(safe_id);
119 if bitmap.is_empty() {
120 self.labels.remove(label_str);
121 }
122 }
123 }
124 }
125 }
126
127 /// Returns `true` if any node with `_labels` had an ID exceeding `u32::MAX`
128 /// and could not be indexed. Callers should fall back to a full scan when
129 /// this is `true` and the bitmap lookup returns empty results.
130 #[must_use]
131 pub fn has_large_ids(&self) -> bool {
132 self.has_large_ids
133 }
134
135 /// Returns the bitmap of node IDs carrying the given label.
136 ///
137 /// Returns `None` if no nodes have been indexed with this label.
138 #[must_use]
139 pub fn lookup(&self, label: &str) -> Option<&RoaringBitmap> {
140 self.labels.get(label)
141 }
142
143 /// Returns the intersection of bitmaps for all required labels.
144 ///
145 /// When a MATCH pattern requires multiple labels (e.g., `(n:Person:Employee)`),
146 /// only nodes carrying ALL labels should match. Returns `None` if any
147 /// required label has no indexed nodes (empty intersection).
148 #[must_use]
149 pub fn lookup_intersection(&self, labels: &[String]) -> Option<RoaringBitmap> {
150 let mut iter = labels.iter();
151 let first = iter.next()?;
152 let mut result = self.labels.get(first.as_str())?.clone();
153
154 for label in iter {
155 match self.labels.get(label.as_str()) {
156 Some(bitmap) => result &= bitmap,
157 None => return None, // Label has no nodes → empty intersection
158 }
159 if result.is_empty() {
160 return None;
161 }
162 }
163
164 if result.is_empty() {
165 None
166 } else {
167 Some(result)
168 }
169 }
170
171 /// Returns the number of distinct labels in the index.
172 #[must_use]
173 pub fn label_count(&self) -> usize {
174 self.labels.len()
175 }
176
177 /// Returns `true` if the index contains no entries.
178 #[must_use]
179 pub fn is_empty(&self) -> bool {
180 self.labels.is_empty()
181 }
182
183 /// Clears all entries from the index.
184 pub fn clear(&mut self) {
185 self.labels.clear();
186 }
187
188 /// Returns an estimated memory usage in bytes.
189 #[must_use]
190 pub fn memory_usage(&self) -> usize {
191 let mut total = std::mem::size_of::<Self>();
192 for (label, bitmap) in &self.labels {
193 total += label.len() + std::mem::size_of::<String>();
194 total += bitmap.serialized_size();
195 }
196 total
197 }
198}