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}