praxis-runtime 0.2.0

GC ABI types, type descriptors, and runtime context for Praxis.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
//! Type descriptors: the vtable-equivalent for runtime objects (§11.4).
//!
//! Every GC object carries a pointer to a [`TypeDescriptor`] that centralizes
//! all payload-aware operations — tracing, dropping, formatting, equality, and
//! hashing. The compiler generates one descriptor per type and emits code that
//! reaches these function pointers through the object header. The point of the
//! design (§11.4) is that there are no scattered type switches in generated or
//! runtime code: every operation routes through a descriptor.

use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::hash::{Hash, Hasher};

/// The closed set of built-in runtime types (§11.4).
///
/// This enum *is* the type-id registry: a descriptor's [`TypeId`] is derived
/// from its variant, so two built-ins cannot be labelled with the same id.
/// Uniqueness reduces to enum-discriminant uniqueness, which rustc already
/// enforces.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
#[repr(u32)]
pub enum BuiltinTypeId {
    Unit = 0,
    Bool,
    Int,
    Byte,
    Char,
    Float,
    Text,
    Vec,
    Deque,
    Grid,
    Map,
    Set,
    Counter,
    MinHeap,
    MaxHeap,
    BitSet,
    Tuple,
    Record,
    Enum,
    Closure,
    VarCell,
    /// `Range` (§4.11, ADR-059). New variants are appended, so no existing id
    /// ever moves.
    Range,
}

impl BuiltinTypeId {
    /// Number of built-in types. Kept honest by [`BUILTINS`]'s array length and
    /// by `builtins_are_indexed_by_their_id`.
    pub const COUNT: usize = 22;

    /// Total inverse of the discriminant. A `match` rather than a `transmute`,
    /// so an out-of-range word yields `None` instead of an invalid enum value.
    pub const fn from_u32(v: u32) -> Option<BuiltinTypeId> {
        use BuiltinTypeId::*;
        Some(match v {
            0 => Unit,
            1 => Bool,
            2 => Int,
            3 => Byte,
            4 => Char,
            5 => Float,
            6 => Text,
            7 => Vec,
            8 => Deque,
            9 => Grid,
            10 => Map,
            11 => Set,
            12 => Counter,
            13 => MinHeap,
            14 => MaxHeap,
            15 => BitSet,
            16 => Tuple,
            17 => Record,
            18 => Enum,
            19 => Closure,
            20 => VarCell,
            21 => Range,
            _ => return None,
        })
    }

    /// This built-in's descriptor. The inverse of
    /// [`TypeDescriptor::as_builtin`].
    pub fn descriptor(self) -> &'static TypeDescriptor {
        BUILTINS[self as usize]
    }
}

/// An opaque, interned identifier for a type. Equality on `TypeId` *is* type
/// identity for descriptor-table lookups.
///
/// The inner word is **private**: the only producers are
/// [`TypeDescriptor::builtin`] (which derives it from a [`BuiltinTypeId`]) and
/// the test-only escape hatch, so a hand-written integer literal cannot
/// impersonate a built-in.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct TypeId(u32);

impl TypeId {
    #[inline]
    pub const fn to_u32(self) -> u32 {
        self.0
    }

    /// The built-in this id names, or `None` for a non-built-in (today: only
    /// the test descriptors, which live at the top of the `u32` range).
    #[inline]
    pub const fn as_builtin(self) -> Option<BuiltinTypeId> {
        BuiltinTypeId::from_u32(self.0)
    }
}

/// The tracer a descriptor's `trace` function receives during GC. The collector
/// supplies a concrete implementation whose own `trace` method enqueues child
/// references onto the mark worklist (ADR-011).
pub trait Tracer {
    /// Mark a `GcRef` as reachable and arrange for it to be traced.
    fn trace(&mut self, reference: crate::GcRef);
}

/// A hashing sink used by structural hash descriptors (§5.5). Concrete
/// implementations feed bytes into a hash state; [`StructHasher`] is the
/// built-in implementation used by the scalar and collection descriptors.
pub trait DynamicHasher {
    fn write_bytes(&mut self, bytes: &[u8]);
    fn finish(&self) -> u64;
}

/// The built-in [`DynamicHasher`] backed by [`DefaultHasher`]. Used by every
/// descriptor's `hash` callback.
pub struct StructHasher(DefaultHasher);

impl StructHasher {
    pub fn new() -> Self {
        StructHasher(DefaultHasher::new())
    }
}

impl Default for StructHasher {
    fn default() -> Self {
        Self::new()
    }
}

impl DynamicHasher for StructHasher {
    fn write_bytes(&mut self, bytes: &[u8]) {
        // `Hasher::write` consumes the bytes into the hash state.
        self.0.write(bytes);
    }

    fn finish(&self) -> u64 {
        self.0.finish()
    }
}

/// Convenience: feed any `Hash` value into a [`DynamicHasher`] byte-wise.
pub(crate) fn hash_value<H: DynamicHasher + ?Sized, T: Hash + ?Sized>(hasher: &mut H, value: &T) {
    // Route through a shim Hasher so we don't re-implement Hash for each scalar.
    struct HasherShim<'a, H: ?Sized>(&'a mut H);
    impl<H: DynamicHasher + ?Sized> Hasher for HasherShim<'_, H> {
        #[inline]
        fn write(&mut self, bytes: &[u8]) {
            self.0.write_bytes(bytes);
        }
        #[inline]
        fn finish(&self) -> u64 {
            self.0.finish()
        }
    }
    value.hash(&mut HasherShim(hasher));
}

/// `trace` callback shape: receive a pointer to the object payload (the bytes
/// after the header) plus a tracer, and report any `GcRef`s stored inside.
///
/// # Safety
/// The `payload` pointer must point at a value of the descriptor's type for the
/// duration of the call.
pub type TraceFn = unsafe fn(payload: *mut u8, tracer: &mut dyn Tracer);

/// `drop_value` callback shape: release Rust-owned resources held in the
/// payload (e.g. the backing `Vec<GcRef>` of a `Vec[T]`). Invoked during sweep
/// (§12.5).
///
/// # Safety
/// `payload` must point at a value of the descriptor's type, and afterwards the
/// memory is no longer valid.
pub type DropFn = unsafe fn(payload: *mut u8);

/// Which of the two renderings a `format` callback is producing.
///
/// Exactly one type reads this — `Text` — and it is one bit rather than a second
/// callback per descriptor because of *nesting*: a `Vec[Text]` renders its
/// elements through the element descriptor's `format`, so whatever distinguishes
/// the two renderings has to travel down that recursion. A `format_debug` field
/// beside `format` would not: `vec_format` would have to know which of the two
/// it was itself running as in order to pick the right one for its elements, and
/// that knowledge is exactly this enum.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FormatStyle {
    /// The program's own rendering: `out(v)`, `"{v}"` interpolation, `to_text()`
    /// and `praxis run`'s result line. A `Text` is its characters, because that
    /// is what a program printing a string means (§16.1, and §8.1 for the
    /// interpolation that shares the callback).
    Display,
    /// The **debugger's** rendering: a locals row, a TUI pane cell, `p EXPR`. A
    /// `Text` is a quoted literal here, because a display that gives each value
    /// one line and no other context cannot afford a value that renders as zero
    /// characters, as a newline, or as something an adjacent `"` could have
    /// ended.
    Debug,
}

/// The writer a `format` callback appends to, carrying the [`FormatStyle`] it is
/// rendering under.
///
/// A wrapper rather than an extra parameter, so that a container passing its
/// writer to an element descriptor passes the style with it and cannot forget
/// to.
pub struct FormatSink<'a> {
    out: &'a mut dyn fmt::Write,
    style: FormatStyle,
}

impl<'a> FormatSink<'a> {
    /// A sink in the program's own rendering.
    pub fn display(out: &'a mut dyn fmt::Write) -> FormatSink<'a> {
        FormatSink {
            out,
            style: FormatStyle::Display,
        }
    }

    /// A sink in the debugger's rendering.
    pub fn debug(out: &'a mut dyn fmt::Write) -> FormatSink<'a> {
        FormatSink {
            out,
            style: FormatStyle::Debug,
        }
    }

    /// The style this sink renders under.
    #[must_use]
    pub fn style(&self) -> FormatStyle {
        self.style
    }

    /// A sink over `out` in a style read off another one.
    ///
    /// For the callbacks that render a part into a scratch `String` before
    /// placing it — `map_format` orders its entries by key and has to have them
    /// rendered to place them (ADR-138 decision 4). Such a callback holds its
    /// [`style`](Self::style) across the buffer and rebuilds a sink around it,
    /// which is what keeps a `Map[Text, Text]` quoting its keys and values in
    /// the debugger, exactly as a `Vec[Text]` does without needing a buffer at
    /// all.
    ///
    /// Every scratch buffer is a place the style could be dropped, and dropping
    /// it is silent — the value still renders, just in the other rendering. The
    /// style is `Copy` so that carrying it across is the easy thing to write.
    pub fn styled(out: &'a mut dyn fmt::Write, style: FormatStyle) -> FormatSink<'a> {
        FormatSink { out, style }
    }
}

impl fmt::Write for FormatSink<'_> {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        self.out.write_str(s)
    }
}

/// `format` callback shape: append the user-visible representation of the value
/// to the given writer, in that writer's [`FormatStyle`].
///
/// # Safety
/// `payload` must point at a value of the descriptor's type.
pub type FormatFn = unsafe fn(payload: *const u8, out: &mut FormatSink<'_>);

/// `equals` callback shape: structural equality between two values of the same
/// descriptor. `None` on the descriptor means the type is not equatable.
///
/// # Safety
/// Both pointers must point at values of the descriptor's type.
pub type EqualsFn = unsafe fn(a: *const u8, b: *const u8) -> bool;

/// `hash` callback shape: feed the value's structural identity into a hasher.
/// `None` on the descriptor means the type is not hashable.
///
/// # Safety
/// `payload` must point at a value of the descriptor's type.
pub type HashFn = unsafe fn(payload: *const u8, hasher: &mut dyn DynamicHasher);

/// `owned_bytes` callback shape: how many bytes *outside* the object's
/// `[header|payload]` block this value owns — the `Box<str>` behind a `Text`,
/// the `Vec`'s buffer behind a `Vec[T]`, the `HashMap`'s table behind a
/// `Map[K,V]`.
///
/// `None` on the descriptor means "nothing beyond the payload", which is the
/// truth for every scalar and the reason this is opt-in rather than a required
/// constructor argument.
///
/// The collector's pacing counter reads it at allocation. Without it a 1 MiB
/// `Text` would charge the same 40 bytes as an `Int`, and a text-heavy program
/// would under-report its own pressure by essentially its whole footprint.
///
/// # Safety
/// `payload` must point at a value of the descriptor's type.
pub type OwnedBytesFn = unsafe fn(payload: *const u8) -> usize;

/// `compare` callback shape: total ordering between two values of the same
/// descriptor. `None` on the descriptor means the type has no container order.
///
/// This is the ordering a **container** imposes — a heap's `Ord`, a sort, the
/// sequence a `Map` or `Set` prints and iterates in — and it is total, including
/// over `Float` NaN (which sorts last and equals itself). The source-level `<`
/// on a `Float` keeps IEEE semantics and is a different operation; see ADR-045.
///
/// Populated on every type a `Map` key or `Set` member can be: `Int`, `Byte`,
/// `Char`, `Float`, `Text`, `Bool`, `Unit`, `Range`, and tuples, records and
/// enums recursing through their element types. `None` on the eleven that can
/// never be one — the nine collections, `Closure` and `VarCell` (ADR-138
/// decision 1). That is deliberately a *different* set from
/// `praxis_hir::capability::supports_ord`, which is the source language's `<`
/// and `sorted()`: a tuple has a container order and no `<`, and
/// `(1, 2) < (1, 3)` is still `Y006` (ADR-138 decision 3).
///
/// # Safety
/// Both pointers must point at values of the descriptor's type.
pub type CompareFn = unsafe fn(a: *const u8, b: *const u8) -> std::cmp::Ordering;

/// Centralized table of operations on a value's payload (§11.4).
///
/// Exact Rust types may evolve, but all payload-aware operations must live here
/// rather than in scattered type switches.
///
/// `id`, `size` and `align` are private and *derived*: a built-in descriptor is
/// constructible only through [`TypeDescriptor::builtin`], which takes the
/// [`BuiltinTypeId`] the id comes from and the payload type the layout comes
/// from. "A descriptor whose id names a different type" and "a descriptor whose
/// size disagrees with its payload" are therefore unrepresentable.
#[derive(Clone, Copy)]
pub struct TypeDescriptor {
    id: TypeId,
    pub name: &'static str,
    size: usize,
    align: usize,
    pub trace: TraceFn,
    pub drop_value: DropFn,
    pub format: FormatFn,
    pub equals: Option<EqualsFn>,
    pub hash: Option<HashFn>,
    pub compare: Option<CompareFn>,
    /// Bytes this value owns outside its allocation block, for GC pacing.
    /// `None` means none — the scalar case, and the default. Set with
    /// [`TypeDescriptor::with_owned_bytes`].
    pub owned_bytes: Option<OwnedBytesFn>,
}

impl TypeDescriptor {
    /// The only constructor for a built-in descriptor. `id` is derived from
    /// `builtin`; `size`/`align` are derived from the payload type `P`.
    ///
    /// Built-in descriptors must be declared as `static`, never `const`: a
    /// `const` reference is a promoted rvalue with no guaranteed unique
    /// address, and descriptor *pointer* identity is what the runtime compares.
    #[allow(clippy::too_many_arguments)]
    pub const fn builtin<P>(
        builtin: BuiltinTypeId,
        name: &'static str,
        trace: TraceFn,
        drop_value: DropFn,
        format: FormatFn,
        equals: Option<EqualsFn>,
        hash: Option<HashFn>,
        compare: Option<CompareFn>,
    ) -> TypeDescriptor {
        TypeDescriptor {
            id: TypeId(builtin as u32),
            name,
            size: std::mem::size_of::<P>(),
            align: std::mem::align_of::<P>(),
            trace,
            drop_value,
            format,
            equals,
            hash,
            compare,
            owned_bytes: None,
        }
    }

    /// Test-only descriptor whose id is outside the built-in range by
    /// construction, so a fixture can never collide with a real type.
    #[cfg(test)]
    #[allow(clippy::too_many_arguments)]
    pub const fn for_test<P>(
        n: u32,
        name: &'static str,
        trace: TraceFn,
        drop_value: DropFn,
        format: FormatFn,
        equals: Option<EqualsFn>,
        hash: Option<HashFn>,
        compare: Option<CompareFn>,
    ) -> TypeDescriptor {
        TypeDescriptor {
            id: TypeId(u32::MAX - n),
            name,
            size: std::mem::size_of::<P>(),
            align: std::mem::align_of::<P>(),
            trace,
            drop_value,
            format,
            equals,
            hash,
            compare,
            owned_bytes: None,
        }
    }

    /// Declare that this type owns memory outside its allocation block, and how
    /// to measure it.
    ///
    /// A builder rather than a constructor argument because the default —
    /// "nothing beyond the payload" — is right for every scalar and for
    /// `VarCell` and `Range`, and a required argument would make each of those
    /// declarations spell out the same `None`.
    #[must_use]
    pub const fn with_owned_bytes(self, owned_bytes: OwnedBytesFn) -> TypeDescriptor {
        TypeDescriptor {
            id: self.id,
            name: self.name,
            size: self.size,
            align: self.align,
            trace: self.trace,
            drop_value: self.drop_value,
            format: self.format,
            equals: self.equals,
            hash: self.hash,
            compare: self.compare,
            owned_bytes: Some(owned_bytes),
        }
    }

    /// Bytes `payload` owns outside its allocation block, or 0 if this type
    /// owns nothing beyond its payload.
    ///
    /// # Safety
    /// `payload` must point at a value of this descriptor's type.
    #[inline]
    pub unsafe fn owned_bytes_of(&self, payload: *const u8) -> usize {
        match self.owned_bytes {
            // SAFETY: forwarded from this function's contract.
            Some(f) => unsafe { f(payload) },
            None => 0,
        }
    }

    /// This descriptor's type identity.
    #[inline]
    pub const fn id(&self) -> TypeId {
        self.id
    }

    /// Which built-in this descriptor is, if any.
    #[inline]
    pub const fn as_builtin(&self) -> Option<BuiltinTypeId> {
        self.id.as_builtin()
    }

    /// Size in bytes of this type's payload.
    #[inline]
    pub const fn size(&self) -> usize {
        self.size
    }

    /// Alignment in bytes of this type's payload.
    #[inline]
    pub const fn align(&self) -> usize {
        self.align
    }

    /// True iff values of this type participate in structural equality (§5.5).
    #[inline]
    pub fn is_equatable(&self) -> bool {
        self.equals.is_some()
    }

    /// True iff values of this type have a structural hash (§5.5).
    ///
    /// Not the same as "may be a `Map` key": a `Vec` hashes and can never be a
    /// key (ADR-057 D4). That question is
    /// `praxis_hir::capability::supports_hash_stable`.
    #[inline]
    pub fn is_hashable(&self) -> bool {
        self.hash.is_some()
    }

    /// True iff values of this type have a **container** order — the sequence a
    /// `Map`, `Set`, `Counter` or heap puts them in (ADR-138).
    ///
    /// Not the source language's `<`: that is
    /// `praxis_hir::capability::supports_ord`, and it is a strictly smaller set
    /// on purpose. A tuple answers `true` here and is still refused by `<`.
    #[inline]
    pub fn is_orderable(&self) -> bool {
        self.compare.is_some()
    }
}

/// A descriptor together with the Rust type of the payload it describes.
///
/// [`TypeDescriptor::builtin`] takes the payload type `P`, derives `size`/`align`
/// from it, and then **erases it**. An allocator that took the payload as a bare
/// generic could therefore only compare widths at *runtime* —
/// `gc_alloc(ctx, &scalars::INT, 0)` passes an `i32`, because Rust's default
/// integer type is not `i64`, and aborts the process with "payload size mismatch
/// for descriptor Int" from inside `extern "C"`. That is the non-unwinding panic
/// across the ABI §10.4 forbids, and it cannot fire until the wrong call runs.
///
/// `Payload<T>` re-attaches the type. The pairing is checked once, where the
/// handle is declared — [`Payload::new`] is a `const fn` whose assertions run
/// during const evaluation, so a `static`/`const` handle whose `T` is not its
/// descriptor's payload **fails to compile**. And because the allocators take
/// the handle and the value together, the value's type is checked at every call
/// site by ordinary type inference. Neither mistake reaches a runtime assert.
pub struct Payload<T: Copy> {
    descriptor: &'static TypeDescriptor,
    /// `fn() -> T` rather than `T`: invariance is not wanted here, and this
    /// marker leaves `Payload<T>` `Copy`/`Send`/`Sync` whatever `T` is.
    _payload: std::marker::PhantomData<fn() -> T>,
}

// Derived impls would demand `T: Clone`/`T: Copy` bounds that the marker makes
// unnecessary — a handle is two words of shared metadata, not a value.
impl<T: Copy> Clone for Payload<T> {
    fn clone(&self) -> Self {
        *self
    }
}
impl<T: Copy> Copy for Payload<T> {}

impl<T: Copy> Payload<T> {
    /// Pair `descriptor` with the payload type `T`.
    ///
    /// Declare the result as a `const` or `static` — that is what makes the
    /// check a compile-time one. Called in a runtime expression the assertions
    /// are ordinary ones, which is the situation this type exists to remove.
    ///
    /// # Panics
    /// During const evaluation, if `T`'s layout is not the one `descriptor`
    /// declares.
    #[must_use]
    pub const fn new(descriptor: &'static TypeDescriptor) -> Payload<T> {
        assert!(
            std::mem::size_of::<T>() == descriptor.size(),
            "payload type is not this descriptor's width"
        );
        assert!(
            std::mem::align_of::<T>() == descriptor.align(),
            "payload type is not this descriptor's alignment"
        );
        Payload {
            descriptor,
            _payload: std::marker::PhantomData,
        }
    }

    /// The descriptor this handle carries. Its *address* is the type's identity,
    /// and the handle holds the one `static`, so that identity survives.
    #[must_use]
    pub const fn descriptor(self) -> &'static TypeDescriptor {
        self.descriptor
    }

    /// Read the payload at `payload` as this handle's `T`.
    ///
    /// The **width is the compiler's**: it is `size_of::<T>()`, and
    /// [`Payload::new`] proved during const evaluation that that is exactly the
    /// descriptor's declared width. A caller therefore cannot pick a width, and
    /// cannot pick the wrong one — a hand-written read of a one-byte `Bool`
    /// through an `i64` consumes seven bytes of arena padding the allocator
    /// never initialized.
    ///
    /// This is the read half of what `Payload<T>` already does for allocation.
    /// It does **not** check the object's descriptor — a handle names a type but
    /// a raw payload pointer carries no header — so callers that hold a `GcRef`
    /// should reach for the wrapper that checks identity first.
    ///
    /// # Safety
    /// `payload` must point at an initialized payload of this handle's type,
    /// aligned for `T`.
    #[must_use]
    #[inline]
    pub unsafe fn read(self, payload: *const u8) -> T {
        // SAFETY: the caller guarantees `payload` is an initialized, aligned
        // payload of this descriptor's type, and `Payload::new` already proved
        // `T`'s layout is that type's layout.
        unsafe { payload.cast::<T>().read() }
    }
}

impl<T: Copy> fmt::Debug for Payload<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Payload")
            .field("descriptor", &self.descriptor.name)
            .field("size", &std::mem::size_of::<T>())
            .finish()
    }
}

impl fmt::Debug for TypeDescriptor {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("TypeDescriptor")
            .field("id", &self.id)
            .field("name", &self.name)
            .field("size", &self.size)
            .field("align", &self.align)
            .field("equatable", &self.is_equatable())
            .field("hashable", &self.is_hashable())
            .finish()
    }
}

/// Every built-in descriptor, indexed by its [`BuiltinTypeId`] discriminant.
///
/// This is the registry `BuiltinTypeId::descriptor` reads and the array
/// `builtins_are_indexed_by_their_id` walks; adding a variant without adding an
/// entry here is a compile error on the array length.
pub static BUILTINS: [&TypeDescriptor; BuiltinTypeId::COUNT] = [
    &crate::scalars::UNIT,
    &crate::scalars::BOOL,
    &crate::scalars::INT,
    &crate::scalars::BYTE,
    &crate::scalars::CHAR,
    &crate::scalars::FLOAT,
    &crate::text::TEXT,
    &crate::collections::VEC,
    &crate::collections::DEQUE,
    &crate::collections::GRID,
    &crate::maps::MAP,
    &crate::maps::SET,
    &crate::maps::COUNTER,
    &crate::heaps::MIN_HEAP,
    &crate::heaps::MAX_HEAP,
    &crate::bitset::BITSET,
    &crate::tuples::TUPLE,
    &crate::records::RECORD,
    &crate::enums::ENUM,
    &crate::closures::CLOSURE,
    &crate::var_cell::VAR_CELL,
    &crate::range::RANGE,
];

/// [`BUILTINS`] as raw addresses, for
/// [`RuntimeContext::descriptors`](crate::RuntimeContext::descriptors) to hold
/// by value (ADR-116).
///
/// **Derived rather than written out, which is the point.** Generated code
/// proves a value's type by loading slot `id` of that array and comparing it
/// against the header's descriptor word (ADR-102), so a slot holding a
/// neighbour's descriptor would be a proof of the wrong type. Mapping
/// `BUILTINS` here leaves the registry as the one place the index-to-descriptor
/// correspondence is stated, and `builtins_are_indexed_by_their_id` as the one
/// gate on it.
///
/// A `fn` and not a `const fn`: const evaluation may not read a `static`, and
/// `BUILTINS` is one deliberately — the addresses *are* the identities
/// (`builtin_descriptors_have_a_stable_address`). It is called once per
/// [`Runtime::context`](crate::Runtime::context), which is once per program
/// run, not per call into generated code.
#[must_use]
pub fn builtin_descriptor_addresses() -> [*const TypeDescriptor; BuiltinTypeId::COUNT] {
    BUILTINS.map(|d| d as *const TypeDescriptor)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Smoke test: a descriptor can be constructed and copied, and the
    /// `is_equatable` / `is_hashable` flags reflect the optional callbacks.
    /// The function pointers here are dummies that must never be called — the
    /// point is that the *type* is well-formed.
    unsafe fn dummy_trace(_: *mut u8, _: &mut dyn Tracer) {}
    unsafe fn dummy_drop(_: *mut u8) {}
    unsafe fn dummy_format(_: *const u8, _: &mut FormatSink<'_>) {}
    unsafe fn dummy_eq(a: *const u8, b: *const u8) -> bool {
        a == b
    }
    unsafe fn dummy_hash(_: *const u8, _: &mut dyn DynamicHasher) {}

    #[test]
    fn descriptor_constructs_and_reports_capabilities() {
        static EQUATABLE_ONLY: TypeDescriptor = TypeDescriptor::for_test::<i64>(
            0,
            "EquatableOnly",
            dummy_trace,
            dummy_drop,
            dummy_format,
            Some(dummy_eq),
            None,
            None,
        );
        assert!(EQUATABLE_ONLY.is_equatable());
        assert!(!EQUATABLE_ONLY.is_hashable());
        assert!(!EQUATABLE_ONLY.is_orderable());

        static HASHABLE: TypeDescriptor = TypeDescriptor::for_test::<[u64; 2]>(
            1,
            "Key",
            dummy_trace,
            dummy_drop,
            dummy_format,
            Some(dummy_eq),
            Some(dummy_hash),
            None,
        );
        assert!(HASHABLE.is_equatable());
        assert!(HASHABLE.is_hashable());
        assert_eq!(HASHABLE.size(), 16);
        assert_eq!(HASHABLE.align(), 8);
    }

    /// A test descriptor's id is outside the built-in range by construction, so
    /// a fixture can never be mistaken for a real type.
    #[test]
    fn test_descriptor_ids_are_not_builtins() {
        static PROBE: TypeDescriptor = TypeDescriptor::for_test::<u8>(
            0,
            "Probe",
            dummy_trace,
            dummy_drop,
            dummy_format,
            None,
            None,
            None,
        );
        assert_eq!(PROBE.as_builtin(), None);
    }

    #[test]
    fn builtin_type_ids_are_globally_unique() {
        let mut by_id = std::collections::BTreeMap::new();

        for descriptor in BUILTINS {
            if let Some(previous) = by_id.insert(descriptor.id(), descriptor.name) {
                panic!(
                    "built-in descriptors {previous} and {} share {:?}; descriptor IDs are runtime type identity",
                    descriptor.name,
                    descriptor.id()
                );
            }
        }
        assert_eq!(by_id.len(), BuiltinTypeId::COUNT);
    }

    /// The registry is a lookup table: `BUILTINS[b as usize]` must be the
    /// descriptor whose id *is* `b`. Without this, `BuiltinTypeId::descriptor`
    /// would silently return a neighbour.
    #[test]
    fn builtins_are_indexed_by_their_id() {
        for (index, descriptor) in BUILTINS.iter().enumerate() {
            assert_eq!(
                descriptor.id().to_u32(),
                index as u32,
                "BUILTINS[{index}] is {} whose id is {:?}",
                descriptor.name,
                descriptor.id()
            );
            let builtin = BuiltinTypeId::from_u32(index as u32).expect("index is in range");
            assert!(std::ptr::eq(builtin.descriptor(), *descriptor));
        }
        assert!(BuiltinTypeId::from_u32(BuiltinTypeId::COUNT as u32).is_none());
    }

    /// Built-in descriptors are `static`, so their address is their identity.
    /// Two reads of the same descriptor must produce the same pointer — this is
    /// what lets the runtime compare descriptors by pointer rather than by id.
    #[test]
    fn builtin_descriptors_have_a_stable_address() {
        assert!(std::ptr::eq(&crate::scalars::INT, &crate::scalars::INT));
        assert!(std::ptr::eq(
            BuiltinTypeId::Int.descriptor(),
            &crate::scalars::INT
        ));
        assert!(!std::ptr::eq(
            &crate::scalars::FLOAT,
            &crate::text::TEXT as &TypeDescriptor
        ));
    }
}