Skip to main content

velesdb_core/collection/graph/
label_table.rs

1//! String interning for graph labels (EPIC-019 US-004).
2//!
3//! Provides memory-efficient storage for repetitive labels in knowledge graphs.
4//! With 10M edges having only ~20 distinct labels, this can save ~200MB of memory.
5
6// Reason: Numeric casts in label table are intentional:
7// - usize->u32 for LabelId: Table capacity is bounded (typically < 10K labels)
8// - LabelId is validated against table bounds on lookup
9#![allow(clippy::cast_possible_truncation)]
10
11use std::collections::HashMap;
12use thiserror::Error;
13
14/// Error type for LabelTable operations.
15#[derive(Debug, Error)]
16#[non_exhaustive]
17pub enum LabelTableError {
18    /// The label table has reached its maximum capacity.
19    #[error("LabelTable overflow: cannot intern more than {max_labels} labels")]
20    Overflow {
21        /// Maximum number of labels supported.
22        max_labels: u32,
23    },
24}
25
26/// ID for an interned label string.
27///
28/// Using u32 allows ~4 billion unique labels while saving memory
29/// compared to storing String on each node/edge.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
31pub struct LabelId(u32);
32
33impl LabelId {
34    /// Returns the raw ID value.
35    #[must_use]
36    pub fn as_u32(self) -> u32 {
37        self.0
38    }
39
40    /// Creates a LabelId from a raw value (for deserialization).
41    #[must_use]
42    pub fn from_u32(id: u32) -> Self {
43        Self(id)
44    }
45}
46
47/// String interning table for graph labels.
48///
49/// Stores each unique label string once and returns a compact `LabelId`
50/// that can be used for efficient comparison and storage.
51///
52/// # Example
53///
54/// ```rust,ignore
55/// use velesdb_core::collection::graph::LabelTable;
56///
57/// let mut table = LabelTable::new();
58///
59/// // Intern same label multiple times - returns same ID
60/// let id1 = table.intern("Person");
61/// let id2 = table.intern("Person");
62/// assert_eq!(id1, id2);
63///
64/// // Resolve ID back to string
65/// assert_eq!(table.resolve(id1), Some("Person"));
66/// ```
67#[derive(Debug, Default)]
68pub struct LabelTable {
69    /// Stored strings indexed by LabelId
70    strings: Vec<String>,
71    /// Reverse lookup: string -> LabelId
72    ids: HashMap<String, LabelId>,
73}
74
75impl LabelTable {
76    /// Creates a new empty label table.
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Creates a label table with pre-allocated capacity.
83    ///
84    /// # Arguments
85    ///
86    /// * `expected_labels` - Expected number of unique labels
87    #[must_use]
88    pub fn with_capacity(expected_labels: usize) -> Self {
89        Self {
90            strings: Vec::with_capacity(expected_labels),
91            ids: HashMap::with_capacity(expected_labels),
92        }
93    }
94
95    /// Interns a string and returns its ID.
96    ///
97    /// If the string was already interned, returns the existing ID.
98    /// Otherwise, stores the string and returns a new ID.
99    ///
100    /// # Arguments
101    ///
102    /// * `s` - The string to intern
103    ///
104    /// # Returns
105    ///
106    /// The `LabelId` for this string (existing or newly created), or an error
107    /// if the table has reached capacity (4 billion labels).
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the number of interned strings would exceed `u32::MAX`.
112    pub fn intern(&mut self, s: &str) -> Result<LabelId, LabelTableError> {
113        if let Some(&id) = self.ids.get(s) {
114            return Ok(id);
115        }
116        let len = self.strings.len();
117        if len >= u32::MAX as usize {
118            return Err(LabelTableError::Overflow {
119                max_labels: u32::MAX,
120            });
121        }
122        // Reason: len checked against u32::MAX above, truncation impossible
123        #[allow(clippy::cast_possible_truncation)]
124        let id = LabelId(len as u32);
125        let owned = s.to_owned();
126        self.strings.push(owned.clone());
127        self.ids.insert(owned, id);
128        Ok(id)
129    }
130
131    /// Resolves a LabelId back to its original string.
132    ///
133    /// # Arguments
134    ///
135    /// * `id` - The LabelId to resolve
136    ///
137    /// # Returns
138    ///
139    /// The original string, or `None` if the ID is invalid
140    #[must_use]
141    pub fn resolve(&self, id: LabelId) -> Option<&str> {
142        self.strings.get(id.0 as usize).map(String::as_str)
143    }
144
145    /// Returns the number of unique labels in the table.
146    #[must_use]
147    pub fn len(&self) -> usize {
148        self.strings.len()
149    }
150
151    /// Returns true if no labels have been interned.
152    #[must_use]
153    pub fn is_empty(&self) -> bool {
154        self.strings.is_empty()
155    }
156
157    /// Returns an iterator over all interned labels.
158    pub fn iter(&self) -> impl Iterator<Item = (LabelId, &str)> {
159        self.strings
160            .iter()
161            .enumerate()
162            .map(|(i, s)| (LabelId(i as u32), s.as_str()))
163    }
164
165    /// Gets the ID for a label if it exists, without interning.
166    ///
167    /// Useful for lookup operations where you don't want to add new labels.
168    #[must_use]
169    pub fn get_id(&self, s: &str) -> Option<LabelId> {
170        self.ids.get(s).copied()
171    }
172
173    /// Checks if a label is already interned.
174    #[must_use]
175    pub fn contains(&self, s: &str) -> bool {
176        self.ids.contains_key(s)
177    }
178}
179
180// Tests moved to label_table_tests.rs per project rules