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
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
//! The `Record` descriptor (§7.8).
//!
//! A record is a fixed set of named fields, each a `GcRef`. It backs both
//! declared record types and the anonymous structural records the input
//! parser's named-capture templates produce, e.g. `lines(`{x:int},{y:int}`)` →
//! `Vec[{x:Int,y:Int}]`.
//!
//! Each distinct record *shape* (field names + element descriptors) gets a
//! [`RecordSchema`]. The schema is leaked to `&'static` (one per parser plan)
//! because a record's descriptor callbacks need a type-stable home for the
//! field descriptors; this matches how the JIT leaks function-name strings.
//!
//! The descriptor dispatches element-wise through the schema (§11.4) — there are
//! no scattered type switches in formatting/tracing. A single `RECORD`-shaped
//! descriptor serves every record because the per-shape knowledge lives in the
//! schema referenced from the payload.

use std::fmt::Write as _;

use crate::GcRef;
use crate::descriptor::{BuiltinTypeId, DynamicHasher, FormatSink, Tracer, TypeDescriptor};

/// One field of a record shape: its source name plus the descriptor for the
/// values stored at that field. The descriptor pointer is `const` data shared
/// across all records of this shape.
#[repr(C)]
pub struct RecordField {
    pub name: &'static str,
    pub descriptor: *const TypeDescriptor,
}

/// Which *type* a record schema describes — the half of a record's identity
/// that its field list cannot express.
///
/// `struct Point { x: Int, y: Int }` and `struct Vector { x: Int, y: Int }` are
/// different types with one shape, and §5.6's anonymous records are the
/// opposite case: the same shape *is* the same type, however many times it is
/// built. One enum distinguishes them.
///
/// A nominal identity is the declared *name*, so it is compared alongside the
/// shape (see [`RecordSchema::same_type`]) to keep a generic record's two
/// instantiations from colliding.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[repr(C)]
pub enum SchemaIdentity {
    /// A structural record (§5.6): identity is the field shape alone. What the
    /// input parser's named-capture templates produce.
    Anonymous,
    /// A declared record type. Two schemas are the same type only if they name
    /// the same one.
    Nominal(&'static str),
}

impl SchemaIdentity {
    /// A deterministic sort key over type identity, for the container ordering
    /// (ADR-138). Anonymous shapes come first, then nominal ones by name.
    ///
    /// Derived `Ord` would do the same thing, but it would also make identity
    /// *silently* orderable everywhere and would move whenever a variant is
    /// added. This is the one place an order over it is wanted, so it is spelled
    /// once, here, and the reason travels with it. The name is the key rather
    /// than the address because a schema is interned per producer — the JIT
    /// generation, the parser registry, the runtime — and two `Point` schemas
    /// from different producers must order identically.
    pub(crate) fn order_key(self) -> (u8, &'static str) {
        match self {
            SchemaIdentity::Anonymous => (0, ""),
            SchemaIdentity::Nominal(name) => (1, name),
        }
    }
}

/// The static shape of a record: what type it is, plus an ordered list of named
/// fields, each with its value descriptor. Allocated in the JIT generation that
/// built it (or, for parser templates, in the runtime's schema registry).
#[repr(C)]
pub struct RecordSchema {
    pub identity: SchemaIdentity,
    pub fields: &'static [RecordField],
}

impl RecordSchema {
    /// The number of fields in this record shape.
    pub fn arity(&self) -> usize {
        self.fields.len()
    }

    /// The descriptor to dispatch field `i` through for `value`: the static one
    /// when the producer had it, and the value's own otherwise.
    ///
    /// The same rule [`TupleSchema::descriptor_at`](crate::tuples::TupleSchema)
    /// states — an object always knows what it is — so a producer that had no
    /// static type for a field leaves a null rather than guessing one.
    fn descriptor_at(&self, i: usize, value: GcRef) -> &'static TypeDescriptor {
        match self.fields.get(i).map(|f| f.descriptor) {
            Some(d) if !d.is_null() => {
                // SAFETY: a non-null slot is a `'static` descriptor pointer.
                unsafe { &*d }
            }
            _ => value.descriptor(),
        }
    }

    /// Whether two schemas describe the *same record type* — the same identity
    /// and the same field shape.
    ///
    /// Type identity, not allocation identity. Schemas are interned per def
    /// *within a generation*, and there are three producers — every JIT
    /// generation, the runtime's parser registry, and test fixtures — so a
    /// `pa.schema != pb.schema` test would call two records of one type unequal
    /// as soon as they came from different compiles. The debugger depends on
    /// this directly: `p` evaluates in its own module, and its result is
    /// compared against program values.
    ///
    /// The shape is compared even for a `Nominal` pair, which the name alone
    /// would settle. It costs an arity check and a slice walk, and it is what
    /// keeps two instantiations of a generic record (one name, different field
    /// descriptors) apart, and what stops a debugger session that reloaded a
    /// *changed* definition from comparing old values field-wise through new
    /// descriptors.
    #[must_use]
    pub fn same_type(&self, other: &RecordSchema) -> bool {
        if self.identity != other.identity {
            return false;
        }
        self.fields.len() == other.fields.len()
            && self
                .fields
                .iter()
                .zip(other.fields.iter())
                .all(|(a, b)| a.name == b.name && std::ptr::eq(a.descriptor, b.descriptor))
    }
}

/// The `Record` payload: a pointer to the static schema plus the field values
/// (one `GcRef` per field, in schema order).
#[repr(C)]
pub struct RecordPayload {
    /// The static field shape. `items.len()` must equal `schema.arity()`.
    pub schema: *const RecordSchema,
    /// Field values in schema order.
    pub items: Vec<GcRef>,
}

unsafe fn record_trace(payload: *mut u8, tracer: &mut dyn Tracer) {
    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
    let p = unsafe { &*(payload as *const RecordPayload) };
    for item in p.items.iter() {
        tracer.trace(*item);
    }
}

unsafe fn record_drop(payload: *mut u8) {
    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
    // `drop_in_place` frees the items Vec; the schema is static and not owned.
    unsafe { std::ptr::drop_in_place(payload as *mut RecordPayload) };
}

unsafe fn record_format(payload: *const u8, out: &mut FormatSink<'_>) {
    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
    let p = unsafe { &*(payload as *const RecordPayload) };
    let schema = unsafe { &*p.schema };
    let _ = out.write_str("{ ");
    for (i, item) in p.items.iter().enumerate() {
        if i > 0 {
            let _ = out.write_str(", ");
        }
        let field = &schema.fields[i];
        let _ = out.write_str(field.name);
        let _ = out.write_str(": ");
        let elem_desc = unsafe { &*field.descriptor };
        // SAFETY: the descriptor came from the schema for this slot, so the slot's
        // payload is the type its `format` expects.
        unsafe { (elem_desc.format)(item.payload::<u8>() as *const u8, out) };
    }
    let _ = out.write_str(" }");
}

unsafe fn record_equals(a: *const u8, b: *const u8) -> bool {
    // SAFETY: caller guarantees both pointers point at initialized RecordPayloads
    // with compatible schemas.
    let pa = unsafe { &*(a as *const RecordPayload) };
    let pb = unsafe { &*(b as *const RecordPayload) };
    // Equality is same-type + field-wise equality (§5.5). "Same type" is the
    // schema's identity and shape, not its *address*: each JIT generation
    // interns its own schemas, so comparing pointers would call two
    // `Point { x: 1, y: 2 }`s from different compiles unequal.
    if pa.schema.is_null() || pb.schema.is_null() {
        return false;
    }
    if !unsafe { (*pa.schema).same_type(&*pb.schema) } {
        return false;
    }
    if pa.items.len() != pb.items.len() {
        return false;
    }
    let schema = unsafe { &*pa.schema };
    // Field-wise equality through each field's descriptor (§11.4), short-circuiting
    // on the first non-equal field. If a field type is not equatable, the record is
    // not equatable (§5.5).
    for (i, (x, y)) in pa.items.iter().zip(pb.items.iter()).enumerate() {
        let Some(eq) = unsafe { &*schema.fields[i].descriptor }.equals else {
            return false;
        };
        let xe = x.payload::<u8>() as *const u8;
        let ye = y.payload::<u8>() as *const u8;
        // SAFETY: both slots were just checked to carry the same descriptor, and it
        // is the one whose `equals` this is.
        if !unsafe { eq(xe, ye) } {
            return false;
        }
    }
    true
}

unsafe fn record_hash(payload: *const u8, hasher: &mut dyn DynamicHasher) {
    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
    let p = unsafe { &*(payload as *const RecordPayload) };
    let schema = unsafe { &*p.schema };
    // Everything `same_type` compares is hashed, so `Eq` and `Hash` agree: two
    // records that differ only in which type they are must be free to land in
    // different buckets.
    match schema.identity {
        SchemaIdentity::Anonymous => hasher.write_bytes(b"anon"),
        SchemaIdentity::Nominal(name) => {
            hasher.write_bytes(b"nom");
            hasher.write_bytes(name.as_bytes());
        }
    }
    // Arity first to distinguish records of different field counts.
    hasher.write_bytes(&(p.items.len() as u64).to_le_bytes());
    for (i, item) in p.items.iter().enumerate() {
        hasher.write_bytes(schema.fields[i].name.as_bytes());
        let field_desc = unsafe { &*schema.fields[i].descriptor };
        hasher.write_bytes(&field_desc.id().to_u32().to_le_bytes());
        // If the field type is not hashable, the record is not hashable (§5.5).
        let Some(hash_field) = field_desc.hash else {
            return;
        };
        let elem_payload = item.payload::<u8>() as *const u8;
        // SAFETY: the descriptor came from the schema for this slot, so the slot's
        // payload is the type its `hash` expects.
        unsafe { hash_field(elem_payload, hasher) };
    }
}

unsafe fn record_compare(a: *const u8, b: *const u8) -> std::cmp::Ordering {
    use std::cmp::Ordering;
    // SAFETY: caller guarantees both pointers point at initialized RecordPayloads.
    let pa = unsafe { &*(a as *const RecordPayload) };
    let pb = unsafe { &*(b as *const RecordPayload) };
    // A null schema is a producer bug and not a user-reachable state, but it
    // still needs a deterministic answer rather than a hash-order one (ADR-138).
    match (pa.schema.is_null(), pb.schema.is_null()) {
        (true, true) => return Ordering::Equal,
        (true, false) => return Ordering::Less,
        (false, true) => return Ordering::Greater,
        (false, false) => {}
    }
    // SAFETY: both checked non-null above.
    let (schema_a, schema_b) = unsafe { (&*pa.schema, &*pb.schema) };
    // Type identity first, so two record *types* in one collection never
    // interleave — and by name, never by schema address, for the reason
    // `same_type` gives: there are three producers of a schema and their
    // addresses differ.
    match schema_a
        .identity
        .order_key()
        .cmp(&schema_b.identity.order_key())
    {
        Ordering::Equal => {}
        other => return other,
    }
    match pa.items.len().cmp(&pb.items.len()) {
        Ordering::Equal => {}
        other => return other,
    }
    // Field-wise in schema order, short-circuiting at the first difference. The
    // field *name* participates because two anonymous shapes with one arity are
    // different types, and `record_hash` already mixes the names for the same
    // reason.
    for (i, (x, y)) in pa.items.iter().zip(pb.items.iter()).enumerate() {
        let (na, nb) = (schema_a.fields[i].name, schema_b.fields[i].name);
        match na.cmp(nb) {
            Ordering::Equal => {}
            other => return other,
        }
        let dx = schema_a.descriptor_at(i, *x);
        let dy = schema_b.descriptor_at(i, *y);
        // SAFETY: each field's payload matches the descriptor its schema slot
        // names, or its own header's when that slot is null.
        match unsafe { crate::ordering::slot_cmp(*x, *y, dx, dy) } {
            Ordering::Equal => {}
            other => return other,
        }
    }
    Ordering::Equal
}

/// Descriptor for the structural `Record` type (§4.5/§7.8). Structural equality,
/// hashing (§5.5) and the container ordering (ADR-138) recurse field-wise
/// through the per-shape schema's field descriptors. A record is
/// equatable/hashable iff every field is; functions never are, so a record
/// containing a function field is neither. This lets records serve as map/set
/// keys — which is why a container has to be able to order one.
pub static RECORD: TypeDescriptor = TypeDescriptor::builtin::<RecordPayload>(
    BuiltinTypeId::Record,
    "Record",
    record_trace,
    record_drop,
    record_format,
    Some(record_equals),
    Some(record_hash),
    // A record can be a key, so a container orders one (ADR-138). `p < q` on
    // two records is still Y006: that is `capability::supports_ord`'s question.
    Some(record_compare),
)
.with_owned_bytes(record_owned_bytes);

// --- the grid neighbourhood records (§6.4) ---------------------------------
//
// `Around4` and `Around8` are the two nominal records the *runtime* builds:
// `g.around4(p)` and `g.around8(p)` answer one, a field per direction, each an
// `Option[(Int, Int)]` whose `None` is a direction that leaves the grid. They
// live here rather than in `abi.rs` because the field order is the schema's,
// and the schema is a record.

/// One direction of a neighbourhood record: the field's name and the `(dx, dy)`
/// step it names.
///
/// Name and offset are **one tuple** on purpose. Two parallel lists — field
/// names here, offsets in the wrapper — is exactly the shape that lets slot *i*
/// hold the neighbour of direction *j*, which no test of either list alone
/// would catch.
pub struct Direction {
    /// The record field's name, and therefore the schema's slot at this index.
    pub name: &'static str,
    /// Column step. `y` grows downward, so `down` is `+1`.
    pub dx: i64,
    /// Row step.
    pub dy: i64,
}

/// `Around4`'s directions, **in field order**: the plus read off the page,
/// centre skipped — up, left, right, down.
///
/// # The order is load-bearing
///
/// A field read compiles to a slot index taken from the *static* type's field
/// order (the method catalog's `Around4` row), while a value built here is laid
/// out in *this* order. ADR-152's permutation into a first-written canonical
/// order applies only to anonymous shapes, so `Around4` is nominal and these
/// two lists are simply required to agree. `around_schemas_match_the_catalog`
/// is what holds them together; a disagreement reads the wrong field and says
/// nothing.
///
/// Note this is **not** `praxis_grid_neighbors4`'s order, which is up, down,
/// left, right. That wrapper answers a clipped `Vec` in which position carries
/// no meaning, so nothing depended on its order and nothing changes it.
pub static AROUND4_DIRECTIONS: &[Direction] = &[
    Direction {
        name: "up",
        dx: 0,
        dy: -1,
    },
    Direction {
        name: "left",
        dx: -1,
        dy: 0,
    },
    Direction {
        name: "right",
        dx: 1,
        dy: 0,
    },
    Direction {
        name: "down",
        dx: 0,
        dy: 1,
    },
];

/// `Around8`'s directions, **in field order**: the eight cells of a 3×3 block
/// in reading order, centre skipped. Already `praxis_grid_neighbors8`'s order,
/// which is what makes a printed `Around8` look like the block it describes.
///
/// See [`AROUND4_DIRECTIONS`] for why the order is load-bearing.
pub static AROUND8_DIRECTIONS: &[Direction] = &[
    Direction {
        name: "up_left",
        dx: -1,
        dy: -1,
    },
    Direction {
        name: "up",
        dx: 0,
        dy: -1,
    },
    Direction {
        name: "up_right",
        dx: 1,
        dy: -1,
    },
    Direction {
        name: "left",
        dx: -1,
        dy: 0,
    },
    Direction {
        name: "right",
        dx: 1,
        dy: 0,
    },
    Direction {
        name: "down_left",
        dx: -1,
        dy: 1,
    },
    Direction {
        name: "down",
        dx: 0,
        dy: 1,
    },
    Direction {
        name: "down_right",
        dx: 1,
        dy: 1,
    },
];

/// Leak the `'static` schema for a neighbourhood record: one field per
/// direction, in `directions` order, every field an `Option[(Int, Int)]`.
///
/// The field descriptor is [`crate::enums::ENUM`] rather than a null. A null is
/// legal in a *tuple* slot and [`RecordSchema::descriptor_at`] honours it, but
/// `record_format` and `record_equals` dereference `fields[i].descriptor`
/// directly — and here there is nothing to be honest about anyway: every field
/// of both shapes is an `Option`, statically, for every value ever built.
fn leak_around_schema(
    name: &'static str,
    directions: &'static [Direction],
) -> &'static RecordSchema {
    let fields: Vec<RecordField> = directions
        .iter()
        .map(|d| RecordField {
            name: d.name,
            descriptor: &crate::enums::ENUM,
        })
        .collect();
    Box::leak(Box::new(RecordSchema {
        identity: SchemaIdentity::Nominal(name),
        fields: Box::leak(fields.into_boxed_slice()),
    }))
}

/// The runtime's own `'static` schema for `Around4` (§6.4).
///
/// Nominal, so [`RecordSchema::same_type`] compares the name as well as the
/// shape — the runtime is the only producer of an `Around4`, and it stays that
/// way because no source syntax declares one.
///
/// A plain `static` will not do: `*const TypeDescriptor` is neither `Send` nor
/// `Sync`. This is the `OnceLock<SyncPtr>` + `Box::leak` idiom
/// [`crate::enums::option_schema`] and `tuples::point_schema` already use.
#[must_use]
pub fn around4_schema() -> &'static RecordSchema {
    use std::sync::OnceLock;
    struct SyncPtr(&'static RecordSchema);
    // SAFETY: the leaked schema and every descriptor it points at are immutable
    // and outlive every thread.
    unsafe impl Send for SyncPtr {}
    unsafe impl Sync for SyncPtr {}
    static AROUND4: OnceLock<SyncPtr> = OnceLock::new();
    AROUND4
        .get_or_init(|| SyncPtr(leak_around_schema("Around4", AROUND4_DIRECTIONS)))
        .0
}

/// The runtime's own `'static` schema for `Around8` (§6.4). See
/// [`around4_schema`].
#[must_use]
pub fn around8_schema() -> &'static RecordSchema {
    use std::sync::OnceLock;
    struct SyncPtr(&'static RecordSchema);
    // SAFETY: as `around4_schema`.
    unsafe impl Send for SyncPtr {}
    unsafe impl Sync for SyncPtr {}
    static AROUND8: OnceLock<SyncPtr> = OnceLock::new();
    AROUND8
        .get_or_init(|| SyncPtr(leak_around_schema("Around8", AROUND8_DIRECTIONS)))
        .0
}

/// The heap bytes a record owns beyond its payload, for GC pacing.
/// `capacity`, not `len`: the buffer's real footprint is what the collector is
/// paced against.
///
/// # Safety
/// `payload` must point at an initialized `RecordPayload`.
unsafe fn record_owned_bytes(payload: *const u8) -> usize {
    // SAFETY: caller guarantees `payload` points at an initialized RecordPayload.
    let p = unsafe { &*(payload as *const RecordPayload) };
    p.items.capacity() * std::mem::size_of::<GcRef>()
}

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

    #[test]
    fn record_descriptor_reports_capabilities() {
        assert!(RECORD.is_equatable());
        assert!(RECORD.is_hashable());
        assert_eq!(RECORD.name, "Record");
        assert_eq!(RECORD.as_builtin(), Some(BuiltinTypeId::Record));
    }

    #[test]
    fn grid_descriptor_reports_capabilities() {
        // A grid is equatable and hashable, so it can be a map key.
        assert!(crate::collections::GRID.is_equatable());
        assert!(crate::collections::GRID.is_hashable());
        assert_eq!(crate::collections::GRID.name, "Grid");
        assert_eq!(
            crate::collections::GRID.as_builtin(),
            Some(BuiltinTypeId::Grid)
        );
    }

    /// A leaked schema of `(name, Int)` fields, standing in for one a JIT
    /// generation or the parser registry would build. Each call leaks its own,
    /// which is the point wherever two are compared: same shape, different
    /// address.
    fn leak_schema(identity: SchemaIdentity, names: &[&'static str]) -> &'static RecordSchema {
        let fields: Vec<RecordField> = names
            .iter()
            .map(|name| RecordField {
                name,
                descriptor: &crate::scalars::INT,
            })
            .collect();
        Box::leak(Box::new(RecordSchema {
            identity,
            fields: Box::leak(fields.into_boxed_slice()),
        }))
    }

    /// Allocate a record of `schema` and fill it with `values` as `Int`s.
    fn record_of(
        ctx: &mut crate::RuntimeContext,
        schema: &'static RecordSchema,
        values: &[i64],
    ) -> GcRef {
        let r = unsafe { crate::abi::praxis_alloc_record(ctx, schema) };
        for (i, v) in values.iter().enumerate() {
            let boxed = unsafe { crate::abi::praxis_alloc_int(ctx, *v) };
            unsafe { crate::abi::praxis_record_set_field(ctx, r, i as u32, boxed) };
        }
        r
    }

    fn equal(a: GcRef, b: GcRef) -> bool {
        unsafe {
            record_equals(
                a.payload::<u8>() as *const u8,
                b.payload::<u8>() as *const u8,
            )
        }
    }

    fn hash_of(r: GcRef) -> u64 {
        let mut h = crate::descriptor::StructHasher::new();
        unsafe { record_hash(r.payload::<u8>() as *const u8, &mut h) };
        h.finish()
    }

    /// Two schemas of one anonymous shape, separately allocated — what two JIT
    /// generations, or a generation and the parser registry, produce for the
    /// same `{x: Int, y: Int}`. Records built through them are one value, so
    /// equality cannot rest on the schema *address*.
    #[test]
    fn anonymous_records_of_one_shape_are_equal_across_schema_allocations() {
        let mut rt = crate::Runtime::new();
        let mut ctx = rt.context();
        let first = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);
        let second = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);
        assert!(
            !std::ptr::eq(first, second),
            "the two schemas must really be distinct allocations"
        );

        let a = record_of(&mut ctx, first, &[1, 2]);
        let b = record_of(&mut ctx, second, &[1, 2]);
        assert!(equal(a, b));
        assert_eq!(hash_of(a), hash_of(b), "equal records must hash equally");

        // Same shape, different values: still not equal.
        let c = record_of(&mut ctx, second, &[1, 3]);
        assert!(!equal(a, c));
    }

    /// The ordering analogue of the test above (ADR-138). A record's container
    /// order is its type identity, then its fields — and identity is compared
    /// by *name*, never by schema address, for the same reason equality is:
    /// there are three producers of a schema and their allocations differ, so an
    /// address order would sort two `Point`s from two generations differently
    /// between runs.
    #[test]
    fn record_compare_is_identity_then_fields() {
        let mut rt = crate::Runtime::new();
        let mut ctx = rt.context();
        let cmp = |a: GcRef, b: GcRef| unsafe {
            record_compare(
                a.payload::<u8>() as *const u8,
                b.payload::<u8>() as *const u8,
            )
        };

        let first = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);
        let second = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);
        assert!(!std::ptr::eq(first, second));
        let a = record_of(&mut ctx, first, &[1, 2]);
        let same = record_of(&mut ctx, second, &[1, 2]);
        assert_eq!(
            cmp(a, same),
            std::cmp::Ordering::Equal,
            "one shape, one value"
        );

        // Fields decide, left to right, through each field's own order — so
        // `2` precedes `10` rather than trailing it as `"10"` would.
        let bigger = record_of(&mut ctx, second, &[1, 10]);
        let smaller = record_of(&mut ctx, second, &[1, 2]);
        assert_eq!(cmp(smaller, bigger), std::cmp::Ordering::Less);

        // Identity comes first: an anonymous shape sorts before a nominal one,
        // whatever its fields say.
        let point = leak_schema(SchemaIdentity::Nominal("Point"), &["x", "y"]);
        let p = record_of(&mut ctx, point, &[0, 0]);
        assert_eq!(cmp(a, p), std::cmp::Ordering::Less);
        assert_eq!(cmp(p, a), std::cmp::Ordering::Greater);
    }

    /// A *nominal* record is its declared type, so two records with identical
    /// fields and different type names are not equal — and a nominal record is
    /// never equal to a structural one of the same shape (§5.6).
    #[test]
    fn nominal_records_of_different_types_are_never_equal() {
        let mut rt = crate::Runtime::new();
        let mut ctx = rt.context();
        let point = leak_schema(SchemaIdentity::Nominal("Point"), &["x", "y"]);
        let vector = leak_schema(SchemaIdentity::Nominal("Vector"), &["x", "y"]);
        let anon = leak_schema(SchemaIdentity::Anonymous, &["x", "y"]);

        let p = record_of(&mut ctx, point, &[1, 2]);
        let v = record_of(&mut ctx, vector, &[1, 2]);
        let a = record_of(&mut ctx, anon, &[1, 2]);
        assert!(!equal(p, v), "two record types are not one type");
        assert!(!equal(p, a), "a declared type is not a structural shape");

        // And the same nominal type from two generations *is* one type.
        let point_again = leak_schema(SchemaIdentity::Nominal("Point"), &["x", "y"]);
        let p2 = record_of(&mut ctx, point_again, &[1, 2]);
        assert!(equal(p, p2));
        assert_eq!(hash_of(p), hash_of(p2));
    }

    /// A shape check rides along with the name, so one nominal name over two
    /// different field shapes — a generic record's instantiations, or a
    /// debugger session that reloaded a changed definition — does not compare
    /// field-wise through the wrong descriptors.
    #[test]
    fn one_nominal_name_over_two_shapes_is_two_types() {
        let mut rt = crate::Runtime::new();
        let mut ctx = rt.context();
        let two_fields = leak_schema(SchemaIdentity::Nominal("P"), &["x", "y"]);
        let renamed = leak_schema(SchemaIdentity::Nominal("P"), &["x", "z"]);

        let a = record_of(&mut ctx, two_fields, &[1, 2]);
        let b = record_of(&mut ctx, renamed, &[1, 2]);
        assert!(!equal(a, b));
    }

    /// **ADR-152, and the reason `Around4`/`Around8` are nominal.**
    ///
    /// A field read compiles to a slot index taken from the *catalog* row's
    /// field order; a value is assembled in the *schema's*. Nothing derives one
    /// from the other — the permutation into a canonical order that keeps the
    /// two honest for an anonymous shape applies only when the def has no name
    /// — so the agreement has to be asserted, and this is where.
    ///
    /// A drift here is silent: `a.up` would answer the neighbour to the left,
    /// with no diagnostic anywhere and no crash. That is the whole failure mode
    /// ADR-152 exists about.
    #[test]
    fn around_schemas_match_the_catalog() {
        use praxis_stdlib::type_pattern::{CollectionCtor, TypePattern};

        let catalog = praxis_stdlib::builtin_catalog();
        let grid = TypePattern::Collection {
            ctor: CollectionCtor::Grid,
            args: vec![TypePattern::var("T")],
        };

        for (method, schema, directions) in [
            (
                "around4",
                super::around4_schema(),
                super::AROUND4_DIRECTIONS,
            ),
            (
                "around8",
                super::around8_schema(),
                super::AROUND8_DIRECTIONS,
            ),
        ] {
            let entry = catalog
                .by_receiver_and_name(&grid, method)
                .next()
                .unwrap_or_else(|| panic!("`Grid[T].{method}` is a catalog row"));
            let TypePattern::Record { name, fields } = &entry.result else {
                panic!("`Grid[T].{method}` answers a nominal record");
            };

            // The name is the record's identity, and a `Nominal` schema is what
            // keeps two `Around4`s from comparing equal to two `Around8`s.
            assert_eq!(
                schema.identity,
                SchemaIdentity::Nominal(name),
                "`{method}`'s schema must name the type its row does"
            );

            let from_catalog: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
            let from_schema: Vec<&str> = schema.fields.iter().map(|f| f.name).collect();
            assert_eq!(
                from_catalog, from_schema,
                "`{method}`'s catalog field order is the slot index a field read \
                 compiles to, and the schema's is where the value's fields land"
            );

            // …and the direction table is the third list that must agree: it is
            // what the wrapper iterates, so slot *i* holds `directions[i]`.
            let from_directions: Vec<&str> = directions.iter().map(|d| d.name).collect();
            assert_eq!(from_directions, from_schema);

            // Every field is an `Option[(Int, Int)]`, which is what makes
            // `crate::enums::ENUM` the right descriptor for all of them.
            for (fname, fpat) in fields {
                assert!(
                    matches!(fpat, TypePattern::Option(_)),
                    "`{method}.{fname}` must be an Option: a direction that \
                     leaves the grid has no point"
                );
            }
            for field in schema.fields {
                assert!(
                    std::ptr::eq(field.descriptor, &crate::enums::ENUM),
                    "`{method}.{}` holds an Option, so its slot dispatches \
                     through ENUM",
                    field.name
                );
            }
        }
    }

    /// The two shapes are different *types*, not one shape at two arities.
    ///
    /// `same_type` compares the identity before the field list, so this is
    /// really a check that the identities were not copy-pasted — an `Around8`
    /// schema calling itself `Around4` would make an eight-field record compare
    /// against a four-field one, and `record_equals` would answer on the arity
    /// rather than on the type.
    #[test]
    fn around4_and_around8_are_two_types() {
        let four = super::around4_schema();
        let eight = super::around8_schema();
        assert_eq!(four.identity, SchemaIdentity::Nominal("Around4"));
        assert_eq!(eight.identity, SchemaIdentity::Nominal("Around8"));
        assert_eq!(four.arity(), 4);
        assert_eq!(eight.arity(), 8);
        assert!(!four.same_type(eight));
        // Each is interned once, so a second call is the same allocation and
        // two values built in one process are one type by pointer as well as by
        // name.
        assert!(std::ptr::eq(four, super::around4_schema()));
        assert!(std::ptr::eq(eight, super::around8_schema()));
    }

    #[test]
    fn record_equals_identical_int_fields() {
        // Build two records with the same schema and equal Int fields; their
        // structural equals must be true, and unequal fields must be false.
        let mut rt = crate::Runtime::new();
        let mut ctx = rt.context();
        let descriptors: &'static [*const TypeDescriptor] =
            Box::leak(vec![&crate::scalars::INT as *const TypeDescriptor; 2].into_boxed_slice());
        let schema = Box::leak(Box::new(RecordSchema {
            identity: SchemaIdentity::Anonymous,
            fields: Box::leak(
                vec![
                    RecordField {
                        name: "x",
                        descriptor: descriptors[0],
                    },
                    RecordField {
                        name: "y",
                        descriptor: descriptors[1],
                    },
                ]
                .into_boxed_slice(),
            ),
        }));
        // Allocate two records and fill with Int 1, 2.
        let a = unsafe { crate::abi::praxis_alloc_record(&mut ctx, schema) };
        let b = unsafe { crate::abi::praxis_alloc_record(&mut ctx, schema) };
        let one = unsafe { crate::abi::praxis_alloc_int(&mut ctx, 1) };
        let two = unsafe { crate::abi::praxis_alloc_int(&mut ctx, 2) };
        unsafe {
            crate::abi::praxis_record_set_field(&mut ctx, a, 0, one);
            crate::abi::praxis_record_set_field(&mut ctx, a, 1, two);
            crate::abi::praxis_record_set_field(&mut ctx, b, 0, one);
            crate::abi::praxis_record_set_field(&mut ctx, b, 1, two);
        }
        assert!(unsafe {
            record_equals(
                a.payload::<u8>() as *const u8,
                b.payload::<u8>() as *const u8,
            )
        });

        // Now make b's second field differ (3) → not equal.
        let three = unsafe { crate::abi::praxis_alloc_int(&mut ctx, 3) };
        unsafe { crate::abi::praxis_record_set_field(&mut ctx, b, 1, three) };
        assert!(!unsafe {
            record_equals(
                a.payload::<u8>() as *const u8,
                b.payload::<u8>() as *const u8,
            )
        });
    }
}