Skip to main content

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}