Skip to main content

praxis_runtime/
dynamic_key.rs

1//! The `DynamicKey` wrapper for hash-based collections (§11.3).
2//!
3//! `Map[K, V]`, `Set[T]`, and `Counter[T]` reuse Rust's `HashMap`/`HashSet`
4//! behind opaque GC objects. Rust needs `Hash` + `Eq` on its key type, but a
5//! Praxis key is a uniform `GcRef` whose structural identity is defined by the
6//! *value's* type descriptor (§5.5, §11.3): `DynamicKey` is the bridge.
7//!
8//! A `DynamicKey` stores the rooted `GcRef` plus its descriptor. Its Rust
9//! `Hash`/`Eq` delegate to the descriptor's `hash`/`equals` callbacks (§11.3:
10//! "Its Rust `Hash` and `Eq` implementations delegate to descriptor functions
11//! generated or selected by the compiler"). The static type checker guarantees
12//! one collection instance receives only its declared key type, so all keys in
13//! one map share a descriptor; a non-hashable type (e.g. a closure) has
14//! `hash`/`equals == None` and is rejected at the capability layer
15//! (`supports_hash`, §5.5) before reaching here.
16//!
17//! `DynamicKey` is a Rust-internal type: it never crosses the ABI and has no
18//! `TypeId`. The GC traces the underlying values through the collection's own
19//! `trace` callback (which iterates the map/set entries), so `DynamicKey`
20//! itself carries no GC-rooting responsibility.
21
22use std::hash::{Hash, Hasher};
23
24use crate::GcRef;
25use crate::descriptor::{DynamicHasher, TypeDescriptor};
26
27/// A Praxis value used as a hash-collection key, paired with its descriptor so
28/// Rust's `HashMap`/`HashSet` can hash and compare it structurally (§11.3).
29///
30/// Two keys are equal iff they carry the *same* descriptor and that descriptor's
31/// `equals` callback reports them structurally equal (§5.5). The `GcRef` is the
32/// rooted value; the descriptor is the `&'static TypeDescriptor` the value's own
33/// header names.
34///
35/// Both fields are private and the descriptor is *derived* from the value, so
36/// "a key whose descriptor names a different type than its payload" is
37/// unrepresentable: [`DynamicKey::new`] is the only way in, and it reads the
38/// descriptor out of the object's header.
39#[derive(Clone, Copy)]
40pub struct DynamicKey {
41    /// The rooted key value. Stable for the object's lifetime (non-moving GC,
42    /// ADR-011), so its address is a valid hash-collection identity anchor.
43    value: GcRef,
44    /// The key type's descriptor. Selects the `hash`/`equals` callbacks.
45    descriptor: &'static TypeDescriptor,
46}
47
48impl DynamicKey {
49    /// Wrap a `GcRef` as a key, pairing it with its descriptor.
50    #[must_use]
51    pub fn new(value: GcRef) -> Self {
52        // The value's own descriptor IS the key-type descriptor: the type
53        // checker guarantees the collection receives only its declared key type,
54        // and every GcRef already carries its descriptor in its header.
55        let descriptor = value.descriptor();
56        Self { value, descriptor }
57    }
58
59    /// The wrapped value.
60    #[inline]
61    #[must_use]
62    pub fn value(&self) -> GcRef {
63        self.value
64    }
65
66    /// The descriptor selecting this key's `hash`/`equals` callbacks. Always the
67    /// value's own descriptor.
68    #[inline]
69    #[must_use]
70    pub fn descriptor(&self) -> &'static TypeDescriptor {
71        self.descriptor
72    }
73}
74
75impl PartialEq for DynamicKey {
76    fn eq(&self, other: &Self) -> bool {
77        // Runtime type identity comes first. Without it a key of one type
78        // dispatches the *left* descriptor's `equals` against the *right*
79        // payload — a read through the wrong layout — and the result can also
80        // disagree with `Hash`, which is keyed on the descriptor below.
81        // Descriptors are `static`, so pointer identity is the authoritative
82        // test (ADR-038); `TypeId` is the other correct spelling, kept for
83        // diagnostics and for readability at comparison sites.
84        if !std::ptr::eq(self.descriptor, other.descriptor) {
85            return false;
86        }
87        // Fast path: the same object. Cheaper than the structural callback, and
88        // reflexive for a type whose `equals` is not (a future NaN payload).
89        if self.value == other.value {
90            return true;
91        }
92        // The callbacks are `None` only for non-equatable types (closures), which
93        // the capability layer rejects before construction. Defensively treat a
94        // missing callback as pointer inequality so a malformed key never matches.
95        let Some(equals) = self.descriptor.equals else {
96            return false;
97        };
98        // SAFETY: both `value`s are live GcRefs whose payloads match the
99        // descriptor — checked pointer-equal just above, and each descriptor is
100        // read from its own object's header. The non-moving GC keeps the
101        // payloads stable for the call's duration.
102        unsafe {
103            let a = self.value.payload::<u8>() as *const u8;
104            let b = other.value.payload::<u8>() as *const u8;
105            equals(a, b)
106        }
107    }
108}
109
110impl Eq for DynamicKey {}
111
112impl std::fmt::Debug for DynamicKey {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        // Render the value through its descriptor's `format` callback so the
115        // debug output shows the user-visible value, not just a raw pointer.
116        let mut s = String::new();
117        // SAFETY: `value` is a live GcRef matching the descriptor.
118        let payload = self.value.payload::<u8>() as *const u8;
119        // The format callback returns fmt::Result; discard it (debug rendering
120        // is best-effort). The sink is in the debug style, which is the one a
121        // Rust `Debug` impl means: a `Text` key renders quoted.
122        unsafe {
123            (self.descriptor.format)(payload, &mut crate::FormatSink::debug(&mut s));
124        }
125        write!(f, "DynamicKey({}:{})", self.descriptor.name, s)
126    }
127}
128
129impl Hash for DynamicKey {
130    fn hash<H: Hasher>(&self, state: &mut H) {
131        // The descriptor id leads, mirroring `eq`'s descriptor check: keys that
132        // can never be equal because they are of different types are then also
133        // unlikely to share a bucket. Ids are globally unique and deterministic
134        // (ADR-038), unlike a descriptor's address.
135        self.descriptor.id().hash(state);
136        // Delegate to the descriptor's structural `hash` callback (§11.3),
137        // routing its bytes through a `DynamicHasher` shim into Rust's `Hasher`.
138        // A missing callback (non-hashable type) hashes the descriptor id alone,
139        // so two such keys never spuriously collide on content they don't have;
140        // such keys are rejected at the capability layer before reaching here.
141        match self.descriptor.hash {
142            Some(hash_fn) => {
143                // `value` is a live GcRef whose payload matches the descriptor;
144                // the non-moving GC keeps it stable. `payload()` is a safe accessor.
145                let payload = self.value.payload::<u8>() as *const u8;
146                let mut shim = HasherShim(state);
147                // SAFETY: `hash_fn` reads the payload per the descriptor contract.
148                unsafe { hash_fn(payload, &mut shim) };
149            }
150            None => {
151                // Defensive: the id hashed above is the whole hash. Should not
152                // happen for a well-typed program (the capability check rejects
153                // non-hashable keys).
154            }
155        }
156    }
157}
158
159/// A [`DynamicHasher`] that feeds bytes into a borrowed Rust [`Hasher`]. Used by
160/// [`DynamicKey::hash`] to route the descriptor's structural hash into the
161/// `HashMap`/`HashSet`'s own `Hasher`.
162struct HasherShim<'a, H: Hasher + ?Sized>(&'a mut H);
163
164impl<H: Hasher + ?Sized> DynamicHasher for HasherShim<'_, H> {
165    fn write_bytes(&mut self, bytes: &[u8]) {
166        self.0.write(bytes);
167    }
168
169    fn finish(&self) -> u64 {
170        self.0.finish()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::abi::praxis_alloc_int;
178    use crate::context::{Runtime, RuntimeContext};
179    use crate::descriptor::TypeDescriptor;
180    use crate::{Heap, Tracer};
181
182    unsafe fn test_trace(_: *mut u8, _: &mut dyn Tracer) {}
183    unsafe fn test_drop(_: *mut u8) {}
184    unsafe fn test_format(payload: *const u8, out: &mut crate::FormatSink<'_>) {
185        use std::fmt::Write as _;
186        let value = unsafe { *(payload as *const i64) };
187        let _ = write!(out, "{value}");
188    }
189    unsafe fn test_equals(a: *const u8, b: *const u8) -> bool {
190        unsafe { *(a as *const i64) == *(b as *const i64) }
191    }
192    unsafe fn test_format_u8(payload: *const u8, out: &mut crate::FormatSink<'_>) {
193        use std::fmt::Write as _;
194        let value = unsafe { *payload };
195        let _ = write!(out, "{value}");
196    }
197    unsafe fn test_equals_u8(a: *const u8, b: *const u8) -> bool {
198        unsafe { *a == *b }
199    }
200
201    static LOGICAL_A: TypeDescriptor = TypeDescriptor::for_test::<i64>(
202        10,
203        "LogicalA",
204        test_trace,
205        test_drop,
206        test_format,
207        Some(test_equals),
208        None,
209        None,
210    );
211    static LOGICAL_B: TypeDescriptor = TypeDescriptor::for_test::<i64>(
212        11,
213        "LogicalB",
214        test_trace,
215        test_drop,
216        test_format,
217        Some(test_equals),
218        None,
219        None,
220    );
221    /// A one-byte payload, so dispatching `LOGICAL_A`'s eight-byte `equals`
222    /// against it would read past the object.
223    static LOGICAL_C: TypeDescriptor = TypeDescriptor::for_test::<u8>(
224        12,
225        "LogicalC",
226        test_trace,
227        test_drop,
228        test_format_u8,
229        Some(test_equals_u8),
230        None,
231        None,
232    );
233
234    // The payload handles for the three fixtures. Declared as `static`s, which
235    // is what makes `Payload::new`'s layout check a compile-time one — a fixture
236    // whose type argument disagreed with its `for_test::<P>` payload would not
237    // build.
238    static A_PAYLOAD: crate::descriptor::Payload<i64> = crate::descriptor::Payload::new(&LOGICAL_A);
239    static B_PAYLOAD: crate::descriptor::Payload<i64> = crate::descriptor::Payload::new(&LOGICAL_B);
240    static C_PAYLOAD: crate::descriptor::Payload<u8> = crate::descriptor::Payload::new(&LOGICAL_C);
241
242    /// Wire a fresh runtime and return its context pointer (test helper).
243    fn wired_ctx(rt: &mut Runtime) -> *mut RuntimeContext {
244        let ctx = Box::leak(Box::new(rt.context()));
245        ctx as *mut RuntimeContext
246    }
247
248    /// A value the runtime does not intern, so two allocations of it really are
249    /// two objects. The tests below that say "structurally equal" mean equal
250    /// *without* being the same object; an interned `Int` would make them true
251    /// by the pointer fast path at the top of [`DynamicKey::eq`] and stop
252    /// exercising `int_equals` at all.
253    const UNINTERNED: i64 = crate::small_int::SMALL_INT_MAX + 1;
254
255    #[test]
256    fn dynamic_key_equal_for_identical_scalar_values() {
257        // Two equal Ints from two allocations are structurally equal keys.
258        let mut rt = Runtime::new();
259        let ctx = wired_ctx(&mut rt);
260        // SAFETY: ctx is wired; praxis_alloc_int produces a valid Int.
261        let a = unsafe { praxis_alloc_int(ctx, UNINTERNED) };
262        let b = unsafe { praxis_alloc_int(ctx, UNINTERNED) };
263        assert_ne!(a, b, "distinct allocations");
264        let ka = DynamicKey::new(a);
265        let kb = DynamicKey::new(b);
266        assert_eq!(ka, kb, "equal Ints are equal keys structurally");
267    }
268
269    #[test]
270    fn dynamic_key_equal_for_the_same_interned_scalar() {
271        // The other half: a small `Int` *is* one object per value, and the
272        // pointer fast path must agree with `int_equals` rather than shortcut
273        // past it to a different answer. This is the executable form of "sharing
274        // an `Int` is unobservable" — a shared key behaves exactly like a
275        // distinct one (crate::small_int).
276        let mut rt = Runtime::new();
277        let ctx = wired_ctx(&mut rt);
278        // SAFETY: ctx is wired; praxis_alloc_int produces a valid Int.
279        let a = unsafe { praxis_alloc_int(ctx, 5) };
280        let b = unsafe { praxis_alloc_int(ctx, 5) };
281        assert_eq!(a.as_ptr(), b.as_ptr(), "a small Int is interned");
282        assert_eq!(DynamicKey::new(a), DynamicKey::new(b));
283        // And an interned key is still unequal to a different value, interned
284        // or not — sharing must not collapse two values into one slot.
285        let c = unsafe { praxis_alloc_int(ctx, 6) };
286        let d = unsafe { praxis_alloc_int(ctx, UNINTERNED) };
287        assert_ne!(DynamicKey::new(a), DynamicKey::new(c));
288        assert_ne!(DynamicKey::new(a), DynamicKey::new(d));
289    }
290
291    #[test]
292    fn dynamic_key_unequal_for_different_scalar_values() {
293        let mut rt = Runtime::new();
294        let ctx = wired_ctx(&mut rt);
295        let a = unsafe { praxis_alloc_int(ctx, 5) };
296        let b = unsafe { praxis_alloc_int(ctx, 7) };
297        assert_ne!(DynamicKey::new(a), DynamicKey::new(b));
298    }
299
300    #[test]
301    fn dynamic_key_hash_matches_for_equal_values() {
302        // Equal keys must hash equal (the HashMap invariant).
303        let mut rt = Runtime::new();
304        let ctx = wired_ctx(&mut rt);
305        // Uninterned, so this really is "two objects hash the same" rather than
306        // "one object hashes the same as itself".
307        let a = unsafe { praxis_alloc_int(ctx, UNINTERNED) };
308        let b = unsafe { praxis_alloc_int(ctx, UNINTERNED) };
309        let ha = {
310            let mut h = std::collections::hash_map::DefaultHasher::new();
311            DynamicKey::new(a).hash(&mut h);
312            h.finish()
313        };
314        let hb = {
315            let mut h = std::collections::hash_map::DefaultHasher::new();
316            DynamicKey::new(b).hash(&mut h);
317            h.finish()
318        };
319        assert_eq!(ha, hb, "equal keys hash equal");
320    }
321
322    #[test]
323    fn dynamic_keys_with_different_descriptors_are_never_equal() {
324        let heap = Heap::new();
325        let a = heap.alloc_unpaced(A_PAYLOAD, 7_i64);
326        let b = heap.alloc_unpaced(B_PAYLOAD, 7_i64);
327
328        assert_ne!(
329            DynamicKey::new(a),
330            DynamicKey::new(b),
331            "runtime type identity is part of structural key equality"
332        );
333    }
334
335    /// The `equals` callback must not run at all against a foreign payload —
336    /// the descriptor check has to short-circuit before dispatch, not merely
337    /// discard the answer. `LOGICAL_C` reads eight bytes; its payload is one.
338    #[test]
339    fn a_mismatched_key_never_dispatches_the_equality_callback() {
340        let heap = Heap::new();
341        let wide = heap.alloc_unpaced(A_PAYLOAD, 7_i64);
342        let narrow = heap.alloc_unpaced(C_PAYLOAD, 7_u8);
343
344        // `LOGICAL_A::equals` would read eight bytes out of a one-byte payload.
345        // Equality must answer `false` from the descriptors alone.
346        assert_ne!(DynamicKey::new(wide), DynamicKey::new(narrow));
347        assert_ne!(DynamicKey::new(narrow), DynamicKey::new(wide));
348    }
349
350    /// `Hash`'s contract is one-directional — equal keys hash equal — and the
351    /// descriptor is part of both. Distinct types are free to collide, but
352    /// they must never be *equal*, which is what would corrupt a bucket.
353    #[test]
354    fn keys_of_different_types_are_unequal_in_a_real_hash_set() {
355        use std::collections::HashSet;
356
357        let heap = Heap::new();
358        let a = heap.alloc_unpaced(A_PAYLOAD, 7_i64);
359        let b = heap.alloc_unpaced(B_PAYLOAD, 7_i64);
360
361        let mut set = HashSet::new();
362        assert!(set.insert(DynamicKey::new(a)));
363        assert!(
364            set.insert(DynamicKey::new(b)),
365            "a same-valued key of another type is a distinct entry"
366        );
367        assert_eq!(set.len(), 2);
368    }
369
370    /// A `DynamicKey` hashes by the value's *contents*, so mutating a stored
371    /// key really does move its bucket. **D4 rejects the state** rather than
372    /// trying to make a mutated key stay findable, which no structural hash can
373    /// deliver; `a_mutable_collection_is_not_a_key` (`infer_tests.rs`) is the
374    /// compile-time half, and this is why that half has to exist.
375    ///
376    /// The hashes are compared directly, with the same `RandomState` a
377    /// `HashMap` builds its hasher from, rather than by asking
378    /// `!set.contains(&wrapped)`. `wrapped` is the *same* `GcRef`, so
379    /// `DynamicKey`'s equality is trivially true and such an assertion would
380    /// rest on the mutated key's new hash not probing the stored entry's slot:
381    /// a one-element hashbrown table is a single 16-byte control group, so a
382    /// new hash whose top seven bits match the stored tag lands on that slot
383    /// and `contains` answers `true` — about one run in 128. Two 64-bit hashes
384    /// colliding is not a number this suite has to care about, so comparing
385    /// them measures the property in the sentence rather than a consequence
386    /// of it.
387    #[test]
388    fn a_structural_key_hashes_by_contents_so_mutating_it_moves_its_bucket() {
389        use std::collections::HashSet;
390        use std::collections::hash_map::RandomState;
391        use std::hash::BuildHasher;
392
393        let rt = Runtime::new();
394        let state = RandomState::new();
395        let hash_of = |k: &DynamicKey| state.hash_one(*k);
396
397        let key = rt.alloc_vec(&crate::scalars::INT, Vec::new());
398        let wrapped = DynamicKey::new(key);
399        let before = hash_of(&wrapped);
400
401        // Contents, not identity: a *different* empty `Vec` hashes the same.
402        let twin = DynamicKey::new(rt.alloc_vec(&crate::scalars::INT, Vec::new()));
403        assert_eq!(hash_of(&twin), before, "the hash is over the contents");
404
405        let mut set = HashSet::new();
406        assert!(set.insert(wrapped));
407
408        // One push is enough: the hash is over the contents, and the contents
409        // are different.
410        let item = rt.alloc_int(1);
411        unsafe {
412            (*key.payload::<crate::collections::VecPayload>())
413                .items
414                .push(item);
415        }
416        assert_ne!(
417            hash_of(&wrapped),
418            before,
419            "a mutated key hashes elsewhere — which is exactly why the type \
420             checker refuses one (D4, Y014)"
421        );
422        // …and the entry is still in the table, filed under the hash it no
423        // longer has. That is the shape of the corruption: not a lost value, an
424        // unfindable one.
425        assert_eq!(set.len(), 1);
426    }
427}