graph_explorer_style/interner.rs
1//! String ↔ u32 interning for the per-frame path.
2//!
3//! Node ids and label texts are interned ONCE, at scene-build time (per
4//! navigation / arrival — never per frame). Everything downstream — Scene,
5//! Frame, hit targets, screen positions, sim sync — is keyed by these dense
6//! indices, which is what lets every per-frame structure be a Vec of Copy
7//! elements refilled in place.
8//!
9//! Indices are **append-only and never reused** for the life of the session.
10//! The animator correlates `from`/`to` scenes by index; reusing a dead node's
11//! slot would alias its mid-flight animation state onto an unrelated new
12//! node. The cost is index space growing with nodes-ever-seen rather than
13//! currently-alive — accepted in the spec (a few hundred KB of slack at
14//! tens-of-thousands scale).
15//!
16//! `NodeIndex` and `LabelId` are deliberately kept as bare `u32` aliases
17//! rather than newtypes: the two index spaces are already separated by
18//! receiver type (`Interner` vs `LabelInterner`), so a newtype would add
19//! ceremony without adding safety, and consumers keep plain-integer
20//! indexing idioms (`Vec` indexing, arithmetic, etc.) for free.
21
22use std::collections::HashMap;
23
24/// Dense index of an interned node id. Stable for the session.
25pub type NodeIndex = u32;
26/// Dense index of an interned label text. `EMPTY_LABEL` (0) is reserved for
27/// the empty string, so "no label" is a plain compare, not an Option.
28pub type LabelId = u32;
29pub const EMPTY_LABEL: LabelId = 0;
30
31/// What an interned id denotes — classified once, at intern time, replacing
32/// the per-hit `starts_with("__")` string tests.
33///
34/// This preserves TWO distinct pre-existing behaviors that must not
35/// collapse into one another:
36/// - `graph-explorer-wasm`'s `node_screen_positions` excludes ANY id starting with
37/// `"__"` from the projection API (see `crates/graph-explorer-wasm/src/lib.rs`).
38/// - `graph-explorer-render`'s hit-test `pick` only special-cases `"__more"`,
39/// `"__agg:"`-prefixed ids, and skips exactly `"__loading"` as
40/// pick-inert; every other `"__"`-prefixed id is hit-tested as an
41/// ordinary node.
42///
43/// `OtherSynthetic` exists to keep both behaviors intact under one
44/// classification: it is synthetic for the screen-positions exclusion, but
45/// `graph-explorer-wasm`'s `node_at` maps it to hit-test kind "node" (not skipped) —
46/// only `Loading` is pick-inert.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum IdKind {
49 Node,
50 More,
51 Aggregate,
52 Loading,
53 /// Any `"__"`-prefixed id that is not `"__more"`, `"__loading"`, or
54 /// `"__agg:"`-prefixed (e.g. a near-miss like `"__agg"` with no colon,
55 /// or an unrecognized `"__x"`). Synthetic for screen-position purposes
56 /// (matches the old blanket `starts_with("__")` filter), but hit-testing
57 /// treats it as an ordinary hittable node — `graph-explorer-wasm`'s `node_at`
58 /// maps it to hit kind `"node"`, honoring this contract.
59 OtherSynthetic,
60}
61
62impl IdKind {
63 pub fn is_synthetic(self) -> bool { !matches!(self, IdKind::Node) }
64 fn classify(id: &str) -> Self {
65 if id == "__more" { IdKind::More }
66 else if id == "__loading" { IdKind::Loading }
67 else if id.starts_with("__agg:") { IdKind::Aggregate }
68 else if id.starts_with("__") { IdKind::OtherSynthetic }
69 else { IdKind::Node }
70 }
71}
72
73/// Node-id interner. `to_id` is the single place id Strings live.
74#[derive(Debug, Default)]
75pub struct Interner {
76 to_index: HashMap<String, NodeIndex>,
77 to_id: Vec<String>,
78 kinds: Vec<IdKind>,
79}
80
81impl Interner {
82 pub fn new() -> Self { Self::default() }
83
84 pub fn intern(&mut self, id: &str) -> NodeIndex {
85 if let Some(&ix) = self.to_index.get(id) { return ix; }
86 // Scene-build-time only (never per frame), so the check is free
87 // where it matters; it turns a theoretical silent index-wrap at
88 // u32::MAX ids-ever-seen into a loud panic instead.
89 assert!(self.to_id.len() < u32::MAX as usize, "interner full");
90 let ix = self.to_id.len() as NodeIndex;
91 self.to_index.insert(id.to_string(), ix);
92 self.to_id.push(id.to_string());
93 self.kinds.push(IdKind::classify(id));
94 ix
95 }
96
97 pub fn get(&self, id: &str) -> Option<NodeIndex> { self.to_index.get(id).copied() }
98
99 /// Resolve an index back to its id string.
100 ///
101 /// # Panics
102 /// Panics if `ix` did not come from this interner's `intern`/`get`.
103 /// Indices are dense and append-only within one `Interner` instance;
104 /// they are not portable across instances, and an out-of-range index
105 /// panics rather than silently returning a wrong or empty string.
106 pub fn resolve(&self, ix: NodeIndex) -> &str { &self.to_id[ix as usize] }
107
108 /// Look up the kind classified for `ix` at intern time.
109 ///
110 /// # Panics
111 /// Panics if `ix` did not come from this interner's `intern`/`get` —
112 /// see [`Interner::resolve`].
113 pub fn kind(&self, ix: NodeIndex) -> IdKind { self.kinds[ix as usize] }
114 pub fn len(&self) -> usize { self.to_id.len() }
115 pub fn is_empty(&self) -> bool { self.to_id.is_empty() }
116}
117
118/// Label-text interner. Same shape, separate index space, with slot 0
119/// pre-seeded as the empty string.
120#[derive(Debug)]
121pub struct LabelInterner {
122 to_index: HashMap<String, LabelId>,
123 to_text: Vec<String>,
124}
125
126impl Default for LabelInterner { fn default() -> Self { Self::new() } }
127
128impl LabelInterner {
129 pub fn new() -> Self {
130 let mut s = Self { to_index: HashMap::new(), to_text: Vec::new() };
131 let zero = s.raw_intern("");
132 debug_assert_eq!(zero, EMPTY_LABEL);
133 s
134 }
135 fn raw_intern(&mut self, text: &str) -> LabelId {
136 if let Some(&ix) = self.to_index.get(text) { return ix; }
137 // Scene-build-time only (never per frame); see Interner::intern for
138 // why this assert is free where it matters.
139 assert!(self.to_text.len() < u32::MAX as usize, "interner full");
140 let ix = self.to_text.len() as LabelId;
141 self.to_index.insert(text.to_string(), ix);
142 self.to_text.push(text.to_string());
143 ix
144 }
145 pub fn intern(&mut self, text: &str) -> LabelId { self.raw_intern(text) }
146
147 /// Resolve an index back to its label text.
148 ///
149 /// # Panics
150 /// Panics if `ix` did not come from this interner's `intern`. Indices
151 /// are dense and append-only within one `LabelInterner` instance; they
152 /// are not portable across instances, and an out-of-range index panics
153 /// rather than silently returning a wrong or empty string.
154 pub fn resolve(&self, ix: LabelId) -> &str { &self.to_text[ix as usize] }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn intern_is_idempotent_and_stable() {
163 let mut i = Interner::new();
164 let a = i.intern("alpha");
165 let b = i.intern("beta");
166 assert_ne!(a, b);
167 assert_eq!(i.intern("alpha"), a, "re-intern returns the same index");
168 assert_eq!(i.resolve(a), "alpha");
169 assert_eq!(i.resolve(b), "beta");
170 assert_eq!(i.len(), 2);
171 }
172
173 #[test]
174 fn indices_are_append_only_never_reused() {
175 let mut i = Interner::new();
176 let a = i.intern("a");
177 let b = i.intern("b");
178 let c = i.intern("c");
179 assert_eq!((a, b, c), (0, 1, 2));
180 assert_eq!(i.intern("d"), 3);
181 }
182
183 #[test]
184 fn kind_is_classified_once_at_intern_time() {
185 let mut i = Interner::new();
186 let n = i.intern("node-1");
187 let m = i.intern("__more");
188 let g = i.intern("__agg:Sorcery");
189 let l = i.intern("__loading");
190 assert_eq!(i.kind(n), IdKind::Node);
191 assert_eq!(i.kind(m), IdKind::More);
192 assert_eq!(i.kind(g), IdKind::Aggregate);
193 assert_eq!(i.kind(l), IdKind::Loading);
194 assert!(!i.kind(n).is_synthetic());
195 assert!(i.kind(m).is_synthetic());
196 assert!(i.kind(g).is_synthetic());
197 assert!(i.kind(l).is_synthetic());
198 }
199
200 #[test]
201 fn near_miss_synthetic_ids_fall_back_to_other_synthetic() {
202 // These all start with "__" but match none of the three named
203 // prefixes. The old `node_screen_positions` filter excluded ANY
204 // "__"-prefixed id (see graph-explorer-wasm), so these must stay synthetic
205 // even though the old hit-test `kind_of` would have called them
206 // ordinary nodes.
207 let mut i = Interner::new();
208 let no_colon_agg = i.intern("__agg"); // no trailing colon
209 let near_more = i.intern("__morex");
210 let bare = i.intern("__x");
211 assert_eq!(i.kind(no_colon_agg), IdKind::OtherSynthetic);
212 assert_eq!(i.kind(near_more), IdKind::OtherSynthetic);
213 assert_eq!(i.kind(bare), IdKind::OtherSynthetic);
214 assert!(i.kind(no_colon_agg).is_synthetic());
215 assert!(i.kind(near_more).is_synthetic());
216 assert!(i.kind(bare).is_synthetic());
217 }
218
219 #[test]
220 fn lookup_without_interning() {
221 let mut i = Interner::new();
222 let a = i.intern("a");
223 assert_eq!(i.get("a"), Some(a));
224 assert_eq!(i.get("nope"), None);
225 }
226
227 #[test]
228 fn label_interner_reserves_zero_for_empty() {
229 let mut l = LabelInterner::new();
230 assert_eq!(l.intern(""), EMPTY_LABEL);
231 let x = l.intern("Sorcery");
232 assert_ne!(x, EMPTY_LABEL);
233 assert_eq!(l.intern("Sorcery"), x);
234 assert_eq!(l.resolve(x), "Sorcery");
235 assert_eq!(l.resolve(EMPTY_LABEL), "");
236 }
237}