Skip to main content

graphrefly_core/
handle.rs

1//! Identifier newtypes — the handle-protocol's core type vocabulary.
2//!
3//! Mirrors `~/src/graphrefly-ts/src/__experiments__/handle-core/core.ts:52–57`
4//! under Rust's stricter type discipline (CLAUDE.md Rust invariant 8 — no raw
5//! integers in public APIs).
6//!
7//! # Cleaving plane
8//!
9//! - [`NodeId`] — identifies a node in the graph. Allocated by the Core; opaque
10//!   to bindings.
11//! - [`HandleId`] — identifies a user value `T` in the binding-side registry.
12//!   The Core never sees `T`; equals-substitution under `equals: identity` is
13//!   a `u64` compare on `HandleId` (zero FFI). Custom equals crosses the
14//!   boundary explicitly via [`crate::boundary::BindingBoundary::custom_equals`].
15//! - [`FnId`] — identifies a user function (or a custom-equals oracle) in the
16//!   binding-side registry.
17//! - [`LockId`] — identifies a pause-lock. Multiple pausers can hold distinct
18//!   locks on the same node; the node remains paused until the lockset is
19//!   empty (R1.2.6, R2.6).
20//!
21//! All four are `u64` newtypes for cheap hashing, atomic increments, and
22//! lock-free version counters. They are intentionally NOT
23//! interconvertible — `NodeId(7)` and `HandleId(7)` are different things.
24
25/// Identifier for a node in the Core's dispatcher.
26#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
27pub struct NodeId(u64);
28
29impl NodeId {
30    /// Wrap a raw `u64` as a `NodeId`. Production code allocates via the Core's
31    /// id counter; this constructor exists for tests and for the binding-side
32    /// registry to round-trip ids through serialization.
33    #[must_use]
34    pub const fn new(raw: u64) -> Self {
35        Self(raw)
36    }
37
38    /// Unwrap to the underlying `u64`. Use for hashing, logging, or
39    /// FFI marshalling — never for arithmetic that mixes id spaces.
40    #[must_use]
41    pub const fn raw(self) -> u64 {
42        self.0
43    }
44}
45
46/// Identifier for a user value `T` in the binding-side registry.
47///
48/// The Core stores `HandleId` everywhere user values would otherwise sit:
49/// `cache`, `prevData`, `Data`/`Error` payloads, dep records. Equals-substitution
50/// under `EqualsMode::Identity` compares `HandleId` directly (a `u64` ==).
51#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
52pub struct HandleId(u64);
53
54impl HandleId {
55    /// Wrap a raw `u64`. Real handles come from the binding-side registry
56    /// (`bindings.ts` `valueRegistry.intern(value)`); this constructor is for
57    /// tests and FFI round-tripping.
58    #[must_use]
59    pub const fn new(raw: u64) -> Self {
60        Self(raw)
61    }
62
63    /// Unwrap to the underlying `u64`.
64    #[must_use]
65    pub const fn raw(self) -> u64 {
66        self.0
67    }
68
69    /// True if this is the [`NO_HANDLE`] sentinel (handle id 0 is never
70    /// assigned to a real value). Equivalent to `self == NO_HANDLE`.
71    #[must_use]
72    pub const fn is_sentinel(self) -> bool {
73        self.0 == NO_HANDLE.0
74    }
75}
76
77/// Sentinel "no handle" — distinct from any valid handle (which start at 1).
78///
79/// Mirrors `core.ts:57` `NO_HANDLE = 0`. Used for:
80/// - `cache` of compute nodes that haven't fired yet.
81/// - `prevData[i]` for deps that haven't delivered DATA yet.
82/// - First-run gate condition (R2.5.3).
83///
84/// Per the handle-protocol cleaving plane, the binding-side registry refuses to
85/// intern `undefined`/`None` (the global SENTINEL per R1.2.4 / Lock 5.A);
86/// no real handle ever collides with this.
87pub const NO_HANDLE: HandleId = HandleId(0);
88
89/// Identifier for a user function (or a custom-equals oracle) in the
90/// binding-side registry.
91///
92/// The Core invokes user code by sending `(node_id, fn_id, dep_handles)` across
93/// the [`crate::boundary::BindingBoundary`]; the binding-side dereferences
94/// `fn_id` to a callable and `dep_handles` to user values, then registers the
95/// fn's output as a new handle and returns it.
96#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
97pub struct FnId(u64);
98
99impl FnId {
100    #[must_use]
101    pub const fn new(raw: u64) -> Self {
102        Self(raw)
103    }
104
105    #[must_use]
106    pub const fn raw(self) -> u64 {
107        self.0
108    }
109}
110
111/// Identifier for a pause-lock.
112///
113/// Per R1.2.6, `[PAUSE, lockId]` and `[RESUME, lockId]` MUST carry a lock id.
114/// Each node maintains a lockset (`HashSet<LockId>`); `paused` derives from
115/// `lockset.is_empty()`. Unknown-lockId `Resume` is a no-op (idempotent
116/// dispose).
117///
118/// # Allocation ranges (Slice F, A4 — 2026-05-07)
119///
120/// To prevent collision between user-supplied and dispatcher-allocated lock
121/// ids:
122///
123/// - `[0, 1<<32)` — **user range.** Direct callers of [`LockId::new`] (and
124///   the napi-rs binding's `u32 → LockId` marshalling) live here. Pick any
125///   value you like; the dispatcher will not allocate any id in this range.
126/// - `[1<<32, u64::MAX]` — **dispatcher range.** [`crate::Core::alloc_lock_id`]
127///   draws from this range, starting at `1<<32` and incrementing. Allocation
128///   is monotonic; no recycling.
129///
130/// Both constructors are public — the range convention is by construction at
131/// the dispatcher, not by visibility on the type. If a binding marshals lock
132/// ids beyond `u32::MAX` from user-facing input, raise the dispatcher floor
133/// (`CoreState::next_lock_id`) at construction time.
134#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
135pub struct LockId(u64);
136
137impl LockId {
138    #[must_use]
139    pub const fn new(raw: u64) -> Self {
140        Self(raw)
141    }
142
143    #[must_use]
144    pub const fn raw(self) -> u64 {
145        self.0
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn ids_are_distinct_types() {
155        // Compile-time: these would not coexist if NodeId/HandleId/FnId/LockId
156        // were aliases. Run-time: just confirm the round-trip works.
157        let n = NodeId::new(42);
158        let h = HandleId::new(42);
159        let f = FnId::new(42);
160        let l = LockId::new(42);
161        assert_eq!(n.raw(), 42);
162        assert_eq!(h.raw(), 42);
163        assert_eq!(f.raw(), 42);
164        assert_eq!(l.raw(), 42);
165    }
166
167    #[test]
168    fn no_handle_is_sentinel() {
169        assert!(NO_HANDLE.is_sentinel());
170        assert_eq!(NO_HANDLE.raw(), 0);
171        assert!(!HandleId::new(1).is_sentinel());
172    }
173
174    #[test]
175    fn copy_eq_hash() {
176        use std::collections::HashSet;
177        // u64 newtypes round-trip through Copy + Eq + Hash for use in
178        // HashMap / HashSet / DashMap keys.
179        let a = HandleId::new(7);
180        let b = a;
181        assert_eq!(a, b);
182
183        let mut set = HashSet::new();
184        set.insert(NodeId::new(1));
185        set.insert(NodeId::new(2));
186        assert!(set.contains(&NodeId::new(1)));
187        assert!(!set.contains(&NodeId::new(99)));
188    }
189}