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}