Skip to main content

sqry_core/graph/unified/node/
id.rs

1//! `NodeId` with generational index for the unified graph architecture.
2//!
3//! This module implements `NodeId`, an opaque handle type that uses generational
4//! indices to detect stale references after node deletion.
5//!
6//! # Design
7//!
8//! - **u32 index**: Supports up to 4 billion nodes (sufficient for any project)
9//! - **u64 generation**: Prevents wrap-around in practical use (~9 quintillion operations)
10//! - **Stale detection**: Lookups validate generation matches arena slot
11//! - **Overflow guard**: `checked_add` returns error if overflow imminent
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use sqry_core::graph::unified::node::NodeId;
17//!
18//! let id = NodeId::new(42, 1);
19//! assert!(!id.is_invalid());
20//! assert_eq!(id.index(), 42);
21//! assert_eq!(id.generation(), 1);
22//! ```
23
24use std::fmt;
25use std::hash::{Hash, Hasher};
26
27use serde::{Deserialize, Serialize};
28
29/// Error returned when generation counter would overflow.
30///
31/// This is a safety mechanism to prevent generation wrap-around which could
32/// cause a new node to appear valid when checked against a stale `NodeId`.
33/// In practice, this should never occur as u64 provides ~9 quintillion operations.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct GenerationOverflowError {
36    /// The index of the arena slot that overflowed
37    pub index: u32,
38    /// The generation value at overflow
39    pub generation: u64,
40}
41
42impl fmt::Display for GenerationOverflowError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(
45            f,
46            "generation overflow at index {}: generation {} would exceed MAX_GENERATION",
47            self.index, self.generation
48        )
49    }
50}
51
52impl std::error::Error for GenerationOverflowError {}
53
54/// Generational node identifier with overflow protection.
55///
56/// `NodeId` provides a type-safe, generational index into the `NodeArena`.
57/// The generation counter ensures that stale references (to deleted nodes)
58/// are detected and rejected.
59///
60/// # Memory Layout
61///
62/// ```text
63/// NodeId (12 bytes on 64-bit systems):
64/// ┌──────────────────────────────────────────┐
65/// │ index: u32      │ generation: u64        │
66/// │ (4 bytes)       │ (8 bytes)              │
67/// └──────────────────────────────────────────┘
68/// ```
69///
70/// # Generational Index Pattern
71///
72/// When a node is removed from the arena, the slot's generation is incremented.
73/// Any `NodeId` holding the old generation will fail validation when used for lookup.
74///
75/// ```text
76/// Time 0: Allocate slot 5, generation 1 → NodeId { index: 5, generation: 1 }
77/// Time 1: Remove slot 5 → slot generation becomes 2
78/// Time 2: Lookup with NodeId { index: 5, generation: 1 } → FAILS (1 ≠ 2)
79/// Time 3: Allocate slot 5, generation 2 → NodeId { index: 5, generation: 2 }
80/// Time 4: Lookup with NodeId { index: 5, generation: 2 } → SUCCEEDS
81/// ```
82#[derive(Clone, Copy, Serialize, Deserialize, Ord, PartialOrd)]
83pub struct NodeId {
84    /// Index into `NodeArena` slots vector.
85    index: u32,
86    /// Generation counter - incremented on slot reuse.
87    generation: u64,
88}
89
90impl NodeId {
91    /// Invalid sentinel value used to represent "no node".
92    ///
93    /// Uses `u32::MAX` as index which is reserved and never allocated.
94    pub const INVALID: NodeId = NodeId {
95        index: u32::MAX,
96        generation: 0,
97    };
98
99    /// Maximum safe generation value before overflow protection kicks in.
100    ///
101    /// Set to `u64::MAX / 2` (~9.2 quintillion) to provide a safety margin.
102    /// This allows for detection before actual overflow occurs.
103    pub const MAX_GENERATION: u64 = u64::MAX / 2;
104
105    /// Creates a new `NodeId` with the given index and generation.
106    ///
107    /// # Arguments
108    ///
109    /// * `index` - The slot index in the arena
110    /// * `generation` - The generation counter for this slot
111    ///
112    /// # Example
113    ///
114    /// ```rust,ignore
115    /// let id = NodeId::new(0, 1);
116    /// assert_eq!(id.index(), 0);
117    /// assert_eq!(id.generation(), 1);
118    /// ```
119    #[inline]
120    #[must_use]
121    pub const fn new(index: u32, generation: u64) -> Self {
122        Self { index, generation }
123    }
124
125    /// Returns the slot index.
126    #[inline]
127    #[must_use]
128    pub const fn index(self) -> u32 {
129        self.index
130    }
131
132    /// Returns the generation counter.
133    #[inline]
134    #[must_use]
135    pub const fn generation(self) -> u64 {
136        self.generation
137    }
138
139    /// Checks if this is the invalid sentinel value.
140    ///
141    /// # Example
142    ///
143    /// ```rust,ignore
144    /// assert!(NodeId::INVALID.is_invalid());
145    /// assert!(!NodeId::new(0, 1).is_invalid());
146    /// ```
147    #[inline]
148    #[must_use]
149    pub const fn is_invalid(self) -> bool {
150        self.index == u32::MAX
151    }
152
153    /// Checks if this is a valid (non-sentinel) ID.
154    ///
155    /// Note: This only checks the sentinel value, not whether the ID
156    /// actually refers to a live node in an arena.
157    #[inline]
158    #[must_use]
159    pub const fn is_valid(self) -> bool {
160        self.index != u32::MAX
161    }
162
163    /// Attempts to increment the generation, returning an error if overflow would occur.
164    ///
165    /// This is used by `NodeArena` when a slot is freed and will be reused.
166    ///
167    /// # Errors
168    ///
169    /// Returns `GenerationOverflowError` if the generation would exceed `MAX_GENERATION`.
170    ///
171    /// # Example
172    ///
173    /// ```rust,ignore
174    /// let id = NodeId::new(5, 1);
175    /// let next_gen = id.try_increment_generation()?;
176    /// assert_eq!(next_gen, 2);
177    /// ```
178    pub fn try_increment_generation(self) -> Result<u64, GenerationOverflowError> {
179        let next = self
180            .generation
181            .checked_add(1)
182            .ok_or(GenerationOverflowError {
183                index: self.index,
184                generation: self.generation,
185            })?;
186
187        if next > Self::MAX_GENERATION {
188            return Err(GenerationOverflowError {
189                index: self.index,
190                generation: self.generation,
191            });
192        }
193
194        Ok(next)
195    }
196
197    /// Checks if the generation is approaching the overflow limit.
198    ///
199    /// Returns `true` if generation is within 1000 of `MAX_GENERATION`.
200    /// This can be used for proactive warnings.
201    #[inline]
202    #[must_use]
203    pub const fn is_near_overflow(self) -> bool {
204        self.generation > Self::MAX_GENERATION.saturating_sub(1000)
205    }
206}
207
208impl PartialEq for NodeId {
209    #[inline]
210    fn eq(&self, other: &Self) -> bool {
211        self.index == other.index && self.generation == other.generation
212    }
213}
214
215impl Eq for NodeId {}
216
217impl Hash for NodeId {
218    #[inline]
219    fn hash<H: Hasher>(&self, state: &mut H) {
220        // Combine index and generation into hash for good distribution
221        self.index.hash(state);
222        self.generation.hash(state);
223    }
224}
225
226impl fmt::Debug for NodeId {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        if self.is_invalid() {
229            write!(f, "NodeId(INVALID)")
230        } else {
231            write!(f, "NodeId({}:{})", self.index, self.generation)
232        }
233    }
234}
235
236impl fmt::Display for NodeId {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        if self.is_invalid() {
239            write!(f, "INVALID")
240        } else {
241            write!(f, "{}:{}", self.index, self.generation)
242        }
243    }
244}
245
246impl Default for NodeId {
247    /// Returns `NodeId::INVALID` as the default value.
248    #[inline]
249    fn default() -> Self {
250        Self::INVALID
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_node_id_creation() {
260        let id = NodeId::new(42, 1);
261        assert_eq!(id.index(), 42);
262        assert_eq!(id.generation(), 1);
263        assert!(!id.is_invalid());
264        assert!(id.is_valid());
265    }
266
267    #[test]
268    fn test_node_id_invalid_sentinel() {
269        assert!(NodeId::INVALID.is_invalid());
270        assert!(!NodeId::INVALID.is_valid());
271        assert_eq!(NodeId::INVALID.index(), u32::MAX);
272        assert_eq!(NodeId::INVALID.generation(), 0);
273    }
274
275    #[test]
276    fn test_node_id_default() {
277        let default_id: NodeId = NodeId::default();
278        assert_eq!(default_id, NodeId::INVALID);
279    }
280
281    #[test]
282    fn test_node_id_equality() {
283        let id1 = NodeId::new(5, 10);
284        let id2 = NodeId::new(5, 10);
285        let id3 = NodeId::new(5, 11);
286        let id4 = NodeId::new(6, 10);
287
288        assert_eq!(id1, id2);
289        assert_ne!(id1, id3); // Different generation
290        assert_ne!(id1, id4); // Different index
291    }
292
293    #[test]
294    fn test_node_id_hash() {
295        use std::collections::HashSet;
296
297        let mut set = HashSet::new();
298        set.insert(NodeId::new(1, 1));
299        set.insert(NodeId::new(1, 2));
300        set.insert(NodeId::new(2, 1));
301
302        assert!(set.contains(&NodeId::new(1, 1)));
303        assert!(set.contains(&NodeId::new(1, 2)));
304        assert!(set.contains(&NodeId::new(2, 1)));
305        assert!(!set.contains(&NodeId::new(3, 1)));
306        assert_eq!(set.len(), 3);
307    }
308
309    #[test]
310    #[allow(clippy::clone_on_copy)] // Intentionally testing Clone trait
311    fn test_node_id_copy_clone() {
312        let id = NodeId::new(10, 20);
313        let copied = id;
314        let cloned = id.clone();
315
316        assert_eq!(id, copied);
317        assert_eq!(id, cloned);
318    }
319
320    #[test]
321    fn test_generation_increment_success() {
322        let id = NodeId::new(5, 1);
323        let next_gen = id.try_increment_generation().unwrap();
324        assert_eq!(next_gen, 2);
325    }
326
327    #[test]
328    fn test_generation_increment_at_max() {
329        let id = NodeId::new(5, NodeId::MAX_GENERATION);
330        let result = id.try_increment_generation();
331        assert!(result.is_err());
332
333        let err = result.unwrap_err();
334        assert_eq!(err.index, 5);
335        assert_eq!(err.generation, NodeId::MAX_GENERATION);
336    }
337
338    #[test]
339    fn test_generation_overflow_at_u64_max() {
340        let id = NodeId::new(5, u64::MAX);
341        let result = id.try_increment_generation();
342        assert!(result.is_err());
343    }
344
345    #[test]
346    fn test_is_near_overflow() {
347        let safe_id = NodeId::new(5, 1000);
348        assert!(!safe_id.is_near_overflow());
349
350        let near_limit = NodeId::new(5, NodeId::MAX_GENERATION - 500);
351        assert!(near_limit.is_near_overflow());
352
353        let at_limit = NodeId::new(5, NodeId::MAX_GENERATION);
354        assert!(at_limit.is_near_overflow());
355    }
356
357    #[test]
358    fn test_debug_display_format() {
359        let id = NodeId::new(42, 7);
360        assert_eq!(format!("{id:?}"), "NodeId(42:7)");
361        assert_eq!(format!("{id}"), "42:7");
362
363        assert_eq!(format!("{:?}", NodeId::INVALID), "NodeId(INVALID)");
364        assert_eq!(format!("{}", NodeId::INVALID), "INVALID");
365    }
366
367    #[test]
368    fn test_serde_roundtrip() {
369        let original = NodeId::new(123, 456);
370
371        // Test JSON serialization
372        let json = serde_json::to_string(&original).unwrap();
373        let deserialized: NodeId = serde_json::from_str(&json).unwrap();
374        assert_eq!(original, deserialized);
375
376        // Test postcard serialization
377        let bytes = postcard::to_allocvec(&original).unwrap();
378        let deserialized: NodeId = postcard::from_bytes(&bytes).unwrap();
379        assert_eq!(original, deserialized);
380    }
381
382    #[test]
383    fn test_size_of_node_id() {
384        // Verify memory layout: u32 (4 bytes) + u64 (8 bytes) = 12 bytes
385        // With padding, this may be 16 bytes on some platforms
386        assert!(std::mem::size_of::<NodeId>() <= 16);
387        assert!(std::mem::size_of::<NodeId>() >= 12);
388    }
389}