Skip to main content

shape_value/
shape_graph.rs

1//! Shape graph for HashMap hidden classes.
2//!
3//! A "shape" describes the ordered property layout of a HashMap, enabling
4//! O(1) index-based property access instead of hash-based lookup. This is
5//! the same concept as V8's "hidden classes" or "maps".
6//!
7//! Shapes form a transition tree: adding a property transitions to a child
8//! shape. Multiple HashMaps with the same property insertion order share
9//! the same shape, enabling inline caching.
10
11use std::collections::HashMap;
12use std::fmt;
13
14/// Unique identifier for a shape in the transition graph.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct ShapeId(pub u32);
17
18impl fmt::Display for ShapeId {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        write!(f, "Shape({})", self.0)
21    }
22}
23
24/// A shape describes the ordered property layout of a HashMap.
25///
26/// Properties are identified by u32 "string IDs" (hashed property names).
27/// The index of a property in the `properties` vec is its slot index for
28/// direct access.
29#[derive(Debug, Clone)]
30pub struct Shape {
31    pub id: ShapeId,
32    /// Ordered property names (as string IDs/hashes).
33    pub properties: Vec<u32>,
34    /// Parent shape in the transition chain (None for root).
35    pub parent: Option<ShapeId>,
36    /// Number of properties (== properties.len(), cached for speed).
37    pub property_count: u16,
38}
39
40/// Manages shape creation and transitions.
41///
42/// Thread-safety: This is NOT thread-safe. For multi-threaded use,
43/// wrap in a Mutex or use thread-local instances.
44pub struct ShapeTransitionTable {
45    /// Transition edges: (parent_shape, property_name_id) -> child_shape
46    transitions: HashMap<(ShapeId, u32), ShapeId>,
47    /// All shapes, indexed by ShapeId
48    shapes: Vec<Shape>,
49    /// Next shape ID to assign
50    next_id: u32,
51}
52
53impl ShapeTransitionTable {
54    /// Create a new transition table with a root shape (id=0, no properties).
55    pub fn new() -> Self {
56        let root = Shape {
57            id: ShapeId(0),
58            properties: Vec::new(),
59            parent: None,
60            property_count: 0,
61        };
62        Self {
63            transitions: HashMap::new(),
64            shapes: vec![root],
65            next_id: 1,
66        }
67    }
68
69    /// Returns the root shape ID (always ShapeId(0)).
70    #[inline]
71    pub fn root() -> ShapeId {
72        ShapeId(0)
73    }
74
75    /// Get a shape by its ID. Panics if the ID is invalid.
76    #[inline]
77    pub fn get_shape(&self, id: ShapeId) -> &Shape {
78        &self.shapes[id.0 as usize]
79    }
80
81    /// Get a shape by its ID, returning `None` if the ID is not present in
82    /// this table.
83    ///
84    /// Use this when a `ShapeId` may have originated from a *different*
85    /// transition table (for example, a `HashMapData::shape_id` computed
86    /// under the process-default ambient handle but observed inside a
87    /// per-VM scope). In that case callers should degrade to the
88    /// hash-based slow path rather than panic on an out-of-range index.
89    #[inline]
90    pub fn try_get_shape(&self, id: ShapeId) -> Option<&Shape> {
91        self.shapes.get(id.0 as usize)
92    }
93
94    /// Transition from `from` shape by adding `property_name`.
95    ///
96    /// Returns the existing child shape if the transition already exists,
97    /// otherwise creates a new shape with the property appended.
98    pub fn transition(&mut self, from: ShapeId, property_name: u32) -> ShapeId {
99        if let Some(&existing) = self.transitions.get(&(from, property_name)) {
100            return existing;
101        }
102
103        let parent_shape = &self.shapes[from.0 as usize];
104        let mut properties = parent_shape.properties.clone();
105        properties.push(property_name);
106
107        let new_id = ShapeId(self.next_id);
108        self.next_id += 1;
109
110        let new_shape = Shape {
111            id: new_id,
112            properties,
113            parent: Some(from),
114            property_count: self.shapes[from.0 as usize].property_count + 1,
115        };
116
117        self.shapes.push(new_shape);
118        self.transitions.insert((from, property_name), new_id);
119        new_id
120    }
121
122    /// Build a shape by transitioning from root through all keys in order.
123    pub fn shape_for_keys(&mut self, keys: &[u32]) -> ShapeId {
124        let mut current = Self::root();
125        for &key in keys {
126            current = self.transition(current, key);
127        }
128        current
129    }
130
131    /// Find the slot index of `property_name` in the given shape.
132    ///
133    /// Returns `None` if the property is not part of this shape, or if
134    /// `shape_id` does not belong to this table (e.g. a stale ID carried
135    /// over from a different ambient handle — see `try_get_shape`).
136    /// O(n) scan of the shape's properties list.
137    pub fn property_index(&self, shape_id: ShapeId, property_name: u32) -> Option<usize> {
138        let shape = self.shapes.get(shape_id.0 as usize)?;
139        shape.properties.iter().position(|&p| p == property_name)
140    }
141
142    /// Total number of shapes in the table.
143    #[inline]
144    pub fn shape_count(&self) -> usize {
145        self.shapes.len()
146    }
147}
148
149// ── Ambient shape table (task-local / thread-local) ────────────────────────
150//
151// Pre-B5 this module owned a process-global
152// `LazyLock<Mutex<ShapeTransitionTable>>` plus a sibling
153// `LazyLock<Mutex<Vec<(ShapeId, ShapeId)>>>` transition log. Those
154// statics were the single source of truth for every HashMap shape
155// transition and for the JIT tier-manager's shape-guard invalidation
156// drain.
157//
158// B5 replaces those statics with a per-VM `ShapeTableHandle` installed
159// via `shape_graph_current` (see module docs there). These free
160// functions now look up the ambient handle on entry — if one is
161// installed (i.e. we're inside a VM execution scope, either via
162// `SyncShapeTableScope` or `with_async_shape_table_scope`) they
163// operate against that per-VM table; otherwise they return the same
164// "degrade gracefully" values (`None` / empty) that the old impl
165// returned on lock poisoning, so callers outside any VM scope (e.g.
166// unit tests that build a `HashMapData` directly) continue to work.
167
168/// Drain all pending shape transition events.
169///
170/// Returns the events accumulated since the last drain. Each event is
171/// `(parent_shape_id, new_child_shape_id)`.
172///
173/// Called by `TierManager::check_shape_invalidations()` during `poll_completions()`.
174/// When no shape-table scope is active returns an empty `Vec` (no transitions
175/// are tracked in that state).
176pub fn drain_shape_transitions() -> Vec<(ShapeId, ShapeId)> {
177    let Some(handle) = crate::shape_graph_current::try_current_shape_table() else {
178        return Vec::new();
179    };
180    handle
181        .transition_log()
182        .lock()
183        .map(|mut log| std::mem::take(&mut *log))
184        .unwrap_or_default()
185}
186
187/// Compute a ShapeId for a HashMap with the given key hashes (in insertion order).
188///
189/// Consults the ambient shape table (see module docs). Returns `None` if no
190/// scope is active, if the lock is poisoned, or if there are more than 64
191/// properties (dictionary mode threshold).
192pub fn shape_for_hashmap_keys(key_hashes: &[u32]) -> Option<ShapeId> {
193    if key_hashes.len() > 64 {
194        return None; // Dictionary mode: too many properties
195    }
196    let handle = crate::shape_graph_current::try_current_shape_table()?;
197    let mut table = handle.table().lock().ok()?;
198    Some(table.shape_for_keys(key_hashes))
199}
200
201/// Look up the slot index of a property in a shape.
202///
203/// Consults the ambient shape table. Returns `None` if no scope is active.
204pub fn shape_property_index(shape_id: ShapeId, property_hash: u32) -> Option<usize> {
205    let handle = crate::shape_graph_current::try_current_shape_table()?;
206    let table = handle.table().lock().ok()?;
207    table.property_index(shape_id, property_hash)
208}
209
210/// Transition a shape by adding a new property.
211///
212/// Consults the ambient shape table. Returns `None` if no scope is active,
213/// if the dictionary-mode threshold (>64 properties) would be exceeded, or
214/// if `from` is not present in the active table (e.g. a stale shape_id
215/// carried over from a different ambient handle — a `HashMapData` built
216/// under the process-default handle and later mutated inside a per-VM
217/// scope). When a transition is recorded, it is also appended to the
218/// ambient table's transition log for JIT shape-guard invalidation.
219pub fn shape_transition(from: ShapeId, property_hash: u32) -> Option<ShapeId> {
220    let handle = crate::shape_graph_current::try_current_shape_table()?;
221    let mut table = handle.table().lock().ok()?;
222    let shape = table.try_get_shape(from)?;
223    if shape.property_count >= 64 {
224        return None; // Dictionary mode threshold
225    }
226    let new_id = table.transition(from, property_hash);
227    drop(table);
228    // Log the transition for JIT shape-guard invalidation.
229    if let Ok(mut log) = handle.transition_log().lock() {
230        log.push((from, new_id));
231    }
232    Some(new_id)
233}
234
235/// Hash a property name string to a u32 for shape transition keys.
236///
237/// Simple FNV-1a hash truncated to u32.
238#[inline]
239pub fn hash_property_name(name: &str) -> u32 {
240    let mut hash: u32 = 0x811c_9dc5;
241    for byte in name.as_bytes() {
242        hash ^= *byte as u32;
243        hash = hash.wrapping_mul(0x0100_0193);
244    }
245    hash
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_root_shape_exists() {
254        let table = ShapeTransitionTable::new();
255        let root = table.get_shape(ShapeTransitionTable::root());
256        assert_eq!(root.id, ShapeId(0));
257        assert!(root.properties.is_empty());
258        assert_eq!(root.parent, None);
259        assert_eq!(root.property_count, 0);
260    }
261
262    #[test]
263    fn test_transition_creates_new_shape() {
264        let mut table = ShapeTransitionTable::new();
265        let child = table.transition(ShapeTransitionTable::root(), 42);
266        assert_ne!(child, ShapeTransitionTable::root());
267
268        let shape = table.get_shape(child);
269        assert_eq!(shape.properties, vec![42]);
270        assert_eq!(shape.property_count, 1);
271        assert_eq!(shape.parent, Some(ShapeTransitionTable::root()));
272    }
273
274    #[test]
275    fn test_transition_deduplication() {
276        let mut table = ShapeTransitionTable::new();
277        let child1 = table.transition(ShapeTransitionTable::root(), 42);
278        let child2 = table.transition(ShapeTransitionTable::root(), 42);
279        assert_eq!(child1, child2);
280        // Only root + one child should exist
281        assert_eq!(table.shape_count(), 2);
282    }
283
284    #[test]
285    fn test_shape_for_keys() {
286        let mut table = ShapeTransitionTable::new();
287        let shape_id = table.shape_for_keys(&[10, 20, 30]);
288        let shape = table.get_shape(shape_id);
289        assert_eq!(shape.properties, vec![10, 20, 30]);
290        assert_eq!(shape.property_count, 3);
291    }
292
293    #[test]
294    fn test_property_index() {
295        let mut table = ShapeTransitionTable::new();
296        let shape_id = table.shape_for_keys(&[10, 20, 30]);
297        assert_eq!(table.property_index(shape_id, 10), Some(0));
298        assert_eq!(table.property_index(shape_id, 20), Some(1));
299        assert_eq!(table.property_index(shape_id, 30), Some(2));
300    }
301
302    #[test]
303    fn test_property_index_missing() {
304        let mut table = ShapeTransitionTable::new();
305        let shape_id = table.shape_for_keys(&[10, 20]);
306        assert_eq!(table.property_index(shape_id, 99), None);
307    }
308
309    #[test]
310    fn test_multiple_transition_paths() {
311        let mut table = ShapeTransitionTable::new();
312        // Path 1: root -> a -> b
313        let ab = table.shape_for_keys(&[1, 2]);
314        // Path 2: root -> b -> a
315        let ba = table.shape_for_keys(&[2, 1]);
316        // Different property orders must produce different shapes
317        assert_ne!(ab, ba);
318        let shape_ab = table.get_shape(ab);
319        let shape_ba = table.get_shape(ba);
320        assert_eq!(shape_ab.properties, vec![1, 2]);
321        assert_eq!(shape_ba.properties, vec![2, 1]);
322    }
323
324    #[test]
325    fn test_shape_count() {
326        let mut table = ShapeTransitionTable::new();
327        assert_eq!(table.shape_count(), 1); // root only
328        table.transition(ShapeTransitionTable::root(), 1);
329        assert_eq!(table.shape_count(), 2);
330        table.transition(ShapeTransitionTable::root(), 2);
331        assert_eq!(table.shape_count(), 3);
332        // Duplicate transition should not create new shape
333        table.transition(ShapeTransitionTable::root(), 1);
334        assert_eq!(table.shape_count(), 3);
335    }
336
337    #[test]
338    fn test_parent_chain() {
339        let mut table = ShapeTransitionTable::new();
340        let a = table.transition(ShapeTransitionTable::root(), 10);
341        let ab = table.transition(a, 20);
342        let abc = table.transition(ab, 30);
343
344        let shape_abc = table.get_shape(abc);
345        assert_eq!(shape_abc.parent, Some(ab));
346
347        let shape_ab = table.get_shape(ab);
348        assert_eq!(shape_ab.parent, Some(a));
349
350        let shape_a = table.get_shape(a);
351        assert_eq!(shape_a.parent, Some(ShapeTransitionTable::root()));
352    }
353
354    #[test]
355    fn test_shape_id_display() {
356        assert_eq!(format!("{}", ShapeId(0)), "Shape(0)");
357        assert_eq!(format!("{}", ShapeId(42)), "Shape(42)");
358    }
359
360    #[test]
361    fn test_hash_property_name_consistency() {
362        let h1 = hash_property_name("foo");
363        let h2 = hash_property_name("foo");
364        assert_eq!(h1, h2);
365        assert_ne!(hash_property_name("foo"), hash_property_name("bar"));
366    }
367
368    #[test]
369    fn test_global_shape_for_keys() {
370        let handle = crate::shape_graph_current::ShapeTableHandle::new();
371        let _scope = crate::shape_graph_current::SyncShapeTableScope::enter(handle);
372        let keys = &[hash_property_name("x"), hash_property_name("y")];
373        let id1 = shape_for_hashmap_keys(keys).unwrap();
374        let id2 = shape_for_hashmap_keys(keys).unwrap();
375        assert_eq!(id1, id2); // Same keys → same shape
376    }
377
378    #[test]
379    fn test_global_shape_transition() {
380        let handle = crate::shape_graph_current::ShapeTableHandle::new();
381        let _scope = crate::shape_graph_current::SyncShapeTableScope::enter(handle);
382        let root = ShapeTransitionTable::root();
383        let prop = hash_property_name("test_prop");
384        let child = shape_transition(root, prop).unwrap();
385        assert_ne!(child, root);
386    }
387
388    #[test]
389    fn test_dictionary_mode_threshold() {
390        let handle = crate::shape_graph_current::ShapeTableHandle::new();
391        let _scope = crate::shape_graph_current::SyncShapeTableScope::enter(handle);
392        // More than 64 properties → dictionary mode (None)
393        let keys: Vec<u32> = (0..65).collect();
394        assert!(shape_for_hashmap_keys(&keys).is_none());
395    }
396
397    #[test]
398    fn test_free_funcs_degrade_without_scope() {
399        // Post-B5-followup (1e5a924), scopeless callers no longer get
400        // `None` from the free functions — `try_current_shape_table`
401        // falls back to the process-wide `DEFAULT_SHAPE_TABLE`. That
402        // preserves pre-B5 shape-tracking semantics for stdlib / unit-
403        // test callers that poke `HashMapData::compute_shape` without a
404        // VM execution scope (e.g. JSON/YAML/CSV decoders).
405        //
406        // The fallback handle is shared process-wide, so we cannot assert
407        // specific shape IDs without racing other tests; instead we just
408        // check that the calls succeed and return sensible values under
409        // the process-default handle.
410        assert!(shape_for_hashmap_keys(&[1, 2]).is_some());
411        let root = ShapeTransitionTable::root();
412        assert!(shape_transition(root, 42).is_some());
413        // The root shape is always present and empty; looking up a
414        // hash that was never registered under root returns None
415        // independently of whatever other tests have populated the
416        // default table.
417        assert_eq!(shape_property_index(root, u32::MAX), None);
418        // `drain_shape_transitions` returns whatever has accumulated in
419        // the default handle; just exercise the call path.
420        let _ = drain_shape_transitions();
421    }
422
423    /// Regression test for the B5 cross-table stale-ShapeId bug: a
424    /// `ShapeId` minted under one transition table must not panic when
425    /// fed to another. This can happen in real workloads when a
426    /// `HashMapData::shape_id` is computed under the process-default
427    /// ambient handle (via `ValueWord::from_hashmap_pairs` called
428    /// outside any VM scope) and later observed inside a per-VM scope
429    /// whose fresh table has no knowledge of that id. Both
430    /// `property_index` and `shape_transition` must degrade to `None`
431    /// instead of indexing out of bounds.
432    #[test]
433    fn cross_table_stale_shape_id_degrades_gracefully() {
434        let table = ShapeTransitionTable::new();
435        let huge = ShapeId(9_999);
436        assert_eq!(table.property_index(huge, 42), None);
437        assert!(table.try_get_shape(huge).is_none());
438
439        // Free-function path via a fresh scoped handle must also degrade.
440        let handle = crate::shape_graph_current::ShapeTableHandle::new();
441        let _scope = crate::shape_graph_current::SyncShapeTableScope::enter(handle);
442        assert_eq!(shape_property_index(huge, 42), None);
443        assert_eq!(shape_transition(huge, 42), None);
444    }
445}